Compare commits
10 Commits
f2fce9097b
...
81b59fd309
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81b59fd309 | ||
|
|
a90069d340 | ||
|
|
8b401fa50b | ||
|
|
f765b87336 | ||
|
|
669954b496 | ||
|
|
9146ebaba1 | ||
|
|
874d6c010f | ||
|
|
dd8566eabc | ||
|
|
da318223c1 | ||
|
|
4edd417dab |
@@ -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 |
|
||||
|
||||
46
AGENTS.md
46
AGENTS.md
@@ -49,8 +49,8 @@ flow:
|
||||
component: dashfront
|
||||
runtime: React 19 (PureComponent & Function Components)
|
||||
state_layers:
|
||||
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat]
|
||||
transport: src/api (http(), ApiClient)
|
||||
automats: [artikelAutomat, articleSearchAutomat, articleIndexAutomat, deliveryEditorAutomat, authAutomat, sseAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat, deliveryAutomat, quoteAutomat, orderAutomat, entityAutomat, customergroupAutomat, numberAutomat]
|
||||
transport: src/api (http(), named namespaces)
|
||||
egress:
|
||||
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
|
||||
proxy: Vite dev server (/api -> http://localhost:4208)
|
||||
@@ -84,7 +84,7 @@ flow:
|
||||
| **Build & Dev** | Vite 8 (`type: module`) | Dev server on `4209`, HTTPS on `4308`, proxies `/api` → `http://localhost:4208` |
|
||||
| **Framework** | React 19 (`react`, `react-dom`) | Uses `PureComponent` (with Automats & dashgui) and modern function components |
|
||||
| **Routing** | `react-router-dom` v7 | Configured in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) with HMR router preservation |
|
||||
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#c5ddd8` in `src/colors.js` |
|
||||
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#85b7ad` in `src/colors.js` |
|
||||
| **Shared GUI** | `dashgui` (`file:../dashgui`) | `PanelTile`, `RecordList`, `Tile`, `SingleLineTextEdit`, `ElevatedPill` |
|
||||
| **State Management** | `react-automat` | Single state layer for all domain & configuration data (articles, categories, company, brands, units, POS terminals, session) |
|
||||
| **Notifications** | `notistack` | `useSnackbar()` / `<SnackbarProvider>` in [src/main.jsx](file:///home/seb/src/dashfront/src/main.jsx) |
|
||||
@@ -99,10 +99,7 @@ flow:
|
||||
```
|
||||
src/
|
||||
├── api/ # Backend communication layer
|
||||
│ ├── index.js # Canonical API module exports (article, category, delivery, etc.)
|
||||
│ ├── ApiClient.js # Object-oriented API client (this.article, this.delivery, etc.)
|
||||
│ ├── StorageManager.js # Client-side cache / local storage management
|
||||
│ └── storageManagerContext.js # React context provider for StorageManager
|
||||
│ └── index.js # Canonical API module (article, category, delivery, entity, …)
|
||||
├── automats/ # react-automat state modules (fine-grained subscriptions)
|
||||
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
|
||||
│ ├── authAutomat.js # Authentication state, session bootstrap, company selection
|
||||
@@ -191,9 +188,15 @@ In `dashfront`, two state management solutions coexist harmoniously:
|
||||
|
||||
### B. Configuration Automats (`src/automats/`)
|
||||
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
|
||||
- Company-scoped **lists** (article index, deliveries, quotes, orders, entities, customer groups, number sequences) live in list automats built by `createListAutomat`.
|
||||
- Reads use the same fine-grained `subscribe`/selector pattern as the domain automats.
|
||||
- Cross-automat coordination is done by subscribing to `authAutomat`: when it reaches `authed` with a `selectedCompanyId`, these automats load themselves and reset on `anon` (see [§10](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)).
|
||||
|
||||
### C. No React Context for Data (`src/api` has no context layer)
|
||||
- There is **no** store/context provider in this app. Components import the automat or `src/api` namespace they need directly. There is deliberately no `static contextType` for data access.
|
||||
- Call the API through the namespace functions (`article.getBySku(...)`, `delivery.list()`), and keep shared/cached state in an automat.
|
||||
- Barrel exports live in [src/automats/index.js](file:///home/seb/src/dashfront/src/automats/index.js); `src/main.jsx` imports it for its side effects so every store is registered before the session bootstraps.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend Communication & Authentication Rules
|
||||
@@ -267,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`).
|
||||
@@ -320,6 +323,33 @@ All domain Automats are located in [src/automats/](file:///home/seb/src/dashfron
|
||||
### 6. `userSettingsAutomat` ([src/automats/userSettingsAutomat.js](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js))
|
||||
- **Role**: Stores client-side user layout preferences such as viewer panel coordinates and drawer positions.
|
||||
|
||||
### 7. `sseAutomat` ([src/automats/sseAutomat.js](file:///home/seb/src/dashfront/src/automats/sseAutomat.js))
|
||||
- **Role**: Owns the **single** `EventSource` for `/api/events` and fans signals out into the domain automats. Reconnects whenever the selected company changes; disconnects on logout.
|
||||
- **Built-in handlers**: `category` → `categoryAutomat.loadTree()` + `invalidateAllCategoryViews()`; `articles` → `articleIndexAutomat.reload()` + category-view invalidation; `warehouse` → `warehouseAutomat.reload()`; `attributename` / `propertyname` → `attributeAutomat.load()` / `propertyAutomat.load()`; `supplier` → `supplierAutomat.reload()`.
|
||||
- **Extension API**:
|
||||
- `sseAutomat.actions.on(event, handler)` — register a handler for a named backend event. Returns an unsubscribe callback. Handlers registered while disconnected attach on the next connect.
|
||||
- `sseAutomat.actions.watchArticle(sku, handler)` — subscribe to `article-<sku>` for one SKU. Exactly one listener is bound per SKU while at least one caller is subscribed; the listener resolves its handler set at call time, so an unwatched SKU is a no-op.
|
||||
- `invalidateArticle(sku)` — reloads the article plus its keyed history/supplier views. Background refreshes stay silent.
|
||||
|
||||
### 8. Company-Scoped List Automats
|
||||
Built with the shared [src/automats/createListAutomat.js](file:///home/seb/src/dashfront/src/automats/createListAutomat.js) factory, which follows the brand/unit/warehouse pattern (`status: idle | loading | ready | failed`):
|
||||
|
||||
| Automat | Backs |
|
||||
|---|---|
|
||||
| `articleIndexAutomat` | Article search index (`GET /api/articleIndex`), mapped to the recordset shape; also patched optimistically on rename |
|
||||
| `deliveryAutomat` | Delivery list |
|
||||
| `quoteAutomat` / `orderAutomat` | Quote / order lists |
|
||||
| `entityAutomat` / `customergroupAutomat` | Customer list and customer groups |
|
||||
| `numberAutomat` | Document number sequences |
|
||||
|
||||
Each exposes `load()` (deduplicated), `reload()` (silent), `replace()`, `ensureLoaded()`, and `reset()`. They subscribe to `authAutomat`, so they load on `authed` with a `selectedCompanyId` and reset to `idle` on `anon`.
|
||||
|
||||
### 9. `articleSearchAutomat` ([src/automats/articleSearchAutomat.js](file:///home/seb/src/dashfront/src/automats/articleSearchAutomat.js))
|
||||
- **Role**: Client-side search over the cached article index for the `/artikel` overview. `search(target)` returns `{ categoryFacets, totalCount, recordset, error }`; the page subscribes to `result` instead of receiving a callback.
|
||||
|
||||
### 10. `articleDetailAutomat` ([src/automats/articleDetailAutomat.js](file:///home/seb/src/dashfront/src/automats/articleDetailAutomat.js))
|
||||
- **Role**: Two `KeyedAutomat` stores keyed by SKU — `articleHistoryAutomat` (`GET /api/article/{sku}/history`, paginated client-side) and `articleSupplierAutomat` (`GET /api/article/{sku}/supplier`). Both reload automatically when `sseAutomat` reports an `article-<sku>` signal.
|
||||
|
||||
---
|
||||
|
||||
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
|
||||
|
||||
@@ -30,7 +30,8 @@ In `dashfront`, state is managed outside the React component lifecycle using **A
|
||||
| **`artikelAutomat`** | [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js) | [artikelAutomat.md](./artikelAutomat.md) | Cache and reactive distributor for single article detail payloads (`GET /api/article/:sku`) with inflight request deduplication. |
|
||||
| **`authAutomat`** | [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js) | [authAutomat.md](./authAutomat.md) | Manages authentication lifecycle, session bootstrap, Google One Tap login, and multi-tenant company selection. |
|
||||
| **`brandAutomat`** | [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) | [brandAutomat.md](./brandAutomat.md) | Company-scoped catalog brands / manufacturers list (`GET /api/brand`, upsert). Auto-loads on company auth. |
|
||||
| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), route view article automats, tree ordering, drag-and-drop moves, and image uploads. |
|
||||
| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), tree ordering, drag-and-drop moves, and image uploads. Triggers the bulk category-view preload once the tree succeeds. |
|
||||
| **`categoryViewAutomat`** | [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js) | [categoryViewAutomat.md](./categoryViewAutomat.md) | Per-category article views (`KeyedAutomat`) keyed by slug path; all views are warmed right after the tree loads. |
|
||||
| **`companyAutomat`** | [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js) | [companyAutomat.md](./companyAutomat.md) | Active company configuration, tax rates (standard, reduced), root category ID, and company profile info. |
|
||||
| **`deliveryEditorAutomat`** | [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js) | [deliveryEditorAutomat.md](./deliveryEditorAutomat.md) | Multi-line inbound delivery editor, OCR/PDF receipt reconciliation, supplier article auto-matching, and draft badge tracking. |
|
||||
| **`navigationAutomat`** | [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js) | [navigationAutomat.md](./navigationAutomat.md) | Tracks and restores last visited article SKU and delivery paths across top-level section navigation. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
# `categoryAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)
|
||||
> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`)
|
||||
> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`)
|
||||
> **Related**: [`categoryViewAutomat`](./categoryViewAutomat.md) — the keyed per-category article views, extracted into their own module.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images, POS category visibility flags, and dynamic per-route category article view automats.
|
||||
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images and POS category visibility flags. It owns the tree and all category/article **mutations**; the per-category *article views* live in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and are attached to this automat via `attachCategoryTree()`.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Hierarchical Category Tree**: Normalized `id → node` dictionary (`categories`) representing the parent-child relational tree.
|
||||
- **Dynamic Per-Route Category View Automats**: Instantiates lightweight route-scoped automats (`category-view:<slug>`) that load category articles on-demand with automatic cache cleanup and disposal.
|
||||
- **Bulk View Preload**: after `GET /api/category/tree` succeeds, it triggers `loadAllCategoryViews()` so every category's articles are already cached when a route mounts.
|
||||
- **Mutations & Tree Reordering**: Category creation, batch creation, sibling reordering (`sort`), drag-and-drop hierarchy moves, and in-place renaming.
|
||||
- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the active route automat's article list.
|
||||
- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the matching category views' article lists.
|
||||
- **Media Management**: Uploading and deleting category cover images and updating AVIF full and thumbnail hashes (`fullHash`, `thumbHash`).
|
||||
- **POS Integration**: Toggling POS visibility and hardware terminal IDs per category.
|
||||
|
||||
@@ -50,6 +51,8 @@ interface CategoryState {
|
||||
treeStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
|
||||
/** Last tree error message. */
|
||||
error: string | null;
|
||||
/** True while the post-tree preload of all category views is running. */
|
||||
viewsLoading: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -63,29 +66,35 @@ const initialState = {
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
error: null,
|
||||
viewsLoading: false,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Dynamic Category View Automats (`KeyedAutomat`)
|
||||
## 3. Category View Automats (delegated)
|
||||
|
||||
Category views are powered by a `KeyedAutomat` instance (`categoryViews`) indexed by route **slug path** (e.g. `''` for root, `'getraenke'`, `'getraenke/softdrinks'`).
|
||||
The keyed per-category article views are **no longer defined in this file**. They are implemented in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and re-exported here for backward compatibility:
|
||||
|
||||
| Symbol | Source |
|
||||
|---|---|
|
||||
| `categoryViews` | `categoryViewAutomat.js` |
|
||||
| `getOrCreateCategoryAutomat({ slugPath })` | `categoryViewAutomat.js` |
|
||||
| `invalidateAllCategoryViews()` | `categoryViewAutomat.js` |
|
||||
| `disposeAllCategoryViewAutomats()` | `categoryViewAutomat.js` |
|
||||
|
||||
The following facades remain on the tree automat and delegate to `categoryViews`:
|
||||
|
||||
### `categoryAutomat.key(slugPath): KeyHandle`
|
||||
Returns a scoped `KeyHandle` proxy for the given route slug.
|
||||
- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories` (all child categories having the current as their root/parent).
|
||||
- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories`.
|
||||
- **Handle State**: `{ data: { slug, categoryId, articles, childCategories }, loaded: boolean, loading: boolean, error: string | null }`.
|
||||
- **`handle.load(options?)`**: Deduplicated background fetch across concurrent mounts.
|
||||
- **`handle.read()`**: React Suspense-compatible read (throws in-flight promise if unready).
|
||||
- **`handle.reload()`**: Re-fetches articles and child categories.
|
||||
- **`handle.subscribe(target, selector)`**: Fine-grained subscription to this slug view with zero cross-talk between different category routes.
|
||||
|
||||
### `invalidateAllCategoryViews(): void`
|
||||
Triggers `reload()` across all active slug view handles.
|
||||
|
||||
### `disposeAllCategoryViewAutomats(): void`
|
||||
Clears all active view handles and frees memory on company switch or logout.
|
||||
See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +103,8 @@ Clears all active view handles and frees memory on company switch or logout.
|
||||
Access actions via `categoryAutomat.actions.<method>()`.
|
||||
|
||||
### Tree Operations
|
||||
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, and updates state.
|
||||
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, updates state, and then **awaits `loadAllCategoryViews()`** with `viewsLoading: true → false`. Individual category failures are logged as a warning and do not fail the tree load.
|
||||
- **`loadAllCategoryViews(): Promise<{ loaded: number, failed: Array<{ slug, error }> }>`**: Re-runs the bulk view preload on demand (idempotent — already cached views resolve immediately).
|
||||
- **`setRenameMode(enabled: boolean): void`**: Toggles inline renaming mode for the category explorer.
|
||||
- **`reset(): void`**: Disposes all route automats and resets tree state to `initialState`.
|
||||
|
||||
|
||||
136
docs/categoryViewAutomat.md
Normal file
136
docs/categoryViewAutomat.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# `categoryViewAutomat`
|
||||
|
||||
> **Source File**: [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js)
|
||||
> **Registration Key**: `'category-views'` (`Automat.get('category-views')` / `window.__AUTOMATS__.get('category-views')`)
|
||||
> **Related**: [`categoryAutomat`](./categoryAutomat.md) — owns the category **tree** this module resolves against.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview & Purpose
|
||||
|
||||
`categoryViewAutomat` owns the **per-category article views**: a `KeyedAutomat` (`categoryViews`) indexed by route **slug path** (`''` for root, `'getraenke'`, `'getraenke/softdrinks'`). Each entry holds one category's articles plus its child categories.
|
||||
|
||||
This module was extracted from [`categoryAutomat.js`](../src/automats/categoryAutomat.js) so the tree and the views can evolve independently. The dependency stays **one-directional**:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
CA[categoryAutomat<br/>tree + mutations] -->|attachCategoryTree| CV[categoryViewAutomat<br/>KeyedAutomat views]
|
||||
CV -->|loadAllCategoryViews| API[/api/category/:id/articles]
|
||||
CV -->|collectLoadedCategoryViewData| AI[articleIndexAutomat<br/>derived search index]
|
||||
```
|
||||
|
||||
`categoryAutomat` injects itself via `attachCategoryTree(automat)`; `categoryViewAutomat` never imports the tree, which avoids a circular import.
|
||||
|
||||
### Core Responsibilities
|
||||
- **Slug → Category Resolution**: Resolves a slug path against the loaded tree to a `categoryId`.
|
||||
- **Per-Category Article Payloads**: `GET /api/category/:id/articles` for the resolved category.
|
||||
- **Bulk Warm-Up**: `loadAllCategoryViews()` loads **every** category in the tree (concurrency-bounded), triggered by `categoryAutomat.loadTree()` the moment the tree succeeds.
|
||||
- **Child Category Derivation**: Recomputes `childCategories` for all loaded views whenever the tree changes.
|
||||
- **Incremental Patches**: Appends created articles to, and removes deleted articles from, the affected views.
|
||||
|
||||
---
|
||||
|
||||
## 2. View Data Structure
|
||||
|
||||
```ts
|
||||
interface CategoryViewData {
|
||||
/** Cleaned slug path this view is keyed by. */
|
||||
slug: string;
|
||||
/** Resolved category id. */
|
||||
categoryId: number | string;
|
||||
/** Articles in that category. */
|
||||
articles: Array<ArticleRow>;
|
||||
/** Direct child category nodes, sorted by `sort`. */
|
||||
childCategories: Array<CategoryNode>;
|
||||
}
|
||||
```
|
||||
|
||||
Each keyed entry wraps this as `ItemState<CategoryViewData>`:
|
||||
`{ data, loading, loaded, error }`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Loading Strategy
|
||||
|
||||
### `loadAllCategoryViews({ concurrency }): Promise<{ loaded, failed }>`
|
||||
Builds the slug path of **every** category node from its breadcrumb trail
|
||||
(`categoryViewSlugPath()`), de-duplicates them, then loads each with
|
||||
`categoryViews.load(slug)`.
|
||||
|
||||
- **Already cached views resolve immediately** (`load()` short-circuits when `loaded && !error`), so the call is idempotent.
|
||||
- Concurrency defaults to **6**; failures are captured per category and returned in `failed` rather than rejecting the batch.
|
||||
- **Triggered by**: `categoryAutomat.actions.loadTree()` right after `treeStatus === 'succeeded'`, and available on demand via `categoryAutomat.actions.loadAllCategoryViews()`.
|
||||
|
||||
### `reloadLoadedCategoryViews(): Promise<void>`
|
||||
Re-fetches **only the already cached** views (`setDirty(undefined, false, true)`), keeping the cached set small for SSE-driven refreshes.
|
||||
|
||||
### `invalidateAllCategoryViews(): Promise<unknown[]>`
|
||||
Background-invalidates all loaded views. Called by `sseAutomat` on `'category'` and `'articles'` events.
|
||||
|
||||
### `disposeAllCategoryViewAutomats(): void`
|
||||
Clears every view and frees memory on company switch or logout.
|
||||
|
||||
### `waitForCategoryTree(): Promise<CategoryState>`
|
||||
Resolves once the attached tree reports `treeStatus === 'succeeded'`. Rejects on tree failure or when `authAutomat` turns `'anon'`, so a pending view never hangs forever.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exported API
|
||||
|
||||
| Export | Description |
|
||||
|---|---|
|
||||
| `categoryViews` (default) | The `KeyedAutomat` keyed by slug path. |
|
||||
| `attachCategoryTree(automat)` | Injects the tree automat (called once by `categoryAutomat`). |
|
||||
| `categoryViewSlugPath(categories, rootCategoryId, categoryId)` | Breadcrumb-derived slug path usable as a key. |
|
||||
| `collectLoadedCategoryViewData()` | All loaded view payloads — the source for the derived article index. |
|
||||
| `loadAllCategoryViews({ concurrency })` | Bulk warm-up of every category in the tree. |
|
||||
| `reloadLoadedCategoryViews()` | Re-fetch only the cached views. |
|
||||
| `invalidateAllCategoryViews()` | Background-invalidate all loaded views. |
|
||||
| `disposeAllCategoryViewAutomats()` | Clear all views. |
|
||||
| `getOrCreateCategoryAutomat({ slugPath })` | Returns the `KeyHandle` for a slug path. |
|
||||
| `refreshCategoryViewChildren(categories)` | Re-derive `childCategories` for every loaded view. |
|
||||
| `appendArticlesToCategoryViews({ categoryId, articles })` | Append newly created articles. |
|
||||
| `removeArticleBySku(sku)` | Strip a deleted article from every view. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Handle Usage
|
||||
|
||||
```js
|
||||
import categoryAutomat from '../automats/categoryAutomat';
|
||||
|
||||
const handle = categoryAutomat.key('getraenke/softdrinks');
|
||||
|
||||
handle.subscribe(this, (item) => ({
|
||||
articles: item.data?.articles ?? [],
|
||||
childCategories: item.data?.childCategories ?? [],
|
||||
}));
|
||||
|
||||
const data = await handle.load(); // deduplicated across concurrent mounts
|
||||
const data2 = handle.read(); // Suspense: throws the in-flight promise when unready
|
||||
handle.reload();
|
||||
handle.setDirty();
|
||||
```
|
||||
|
||||
All four facades (`key`, `read`, `load`, `views`) are attached to `categoryAutomat`
|
||||
as thin delegates to `categoryViews`, so existing import sites keep working.
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-Automat Coordination
|
||||
|
||||
| Automat | Interaction |
|
||||
|---|---|
|
||||
| `categoryAutomat` | `attachCategoryTree()`, `loadAllCategoryViews()` after the tree succeeds, `refreshCategoryViewChildren()` on every tree mutation, `disposeAllCategoryViewAutomats()` on reset. |
|
||||
| `authAutomat` | `waitForCategoryTree()` rejects pending views on `'anon'`. |
|
||||
| `sseAutomat` | `invalidateAllCategoryViews()` on `'category'` / `'articles'` events. |
|
||||
| `articleIndexAutomat` | Subscribes to `categoryViews` and re-derives the search index from `collectLoadedCategoryViewData()`. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Bulk warm-up cost**: `loadTree()` now issues one request per category. On very large trees this is many requests — `concurrency` (default 6) is the throttle to tune. Individual failures are non-fatal.
|
||||
- **`viewsLoading`**: While it is `true`, the tree is ready but the views are still warming. Use it to decide between "render a spinner" and "render an empty grid".
|
||||
- **Keys are slug paths, not ids**: A rename or move changes the slug, so the view key changes and the old entry should be evicted; `refreshCategoryViewChildren()` handles the child lists, but stale keys may linger until the next `disposeAllCategoryViewAutomats()`.
|
||||
- **Do not import `categoryAutomat` from this file** — that would create the circular dependency the `attachCategoryTree()` injection exists to avoid.
|
||||
105
index.html
105
index.html
@@ -1,56 +1,59 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<meta name="theme-color" content="#3b5bdb" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Dashfront</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background-color: #c5ddd8;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<meta name="theme-color" content="#3b5bdb" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Dashfront</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background-color: #85b7ad;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var host = location.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
var hadSw = false;
|
||||
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||
hadSw = regs.length > 0;
|
||||
return Promise.all(regs.map(function (r) { return r.unregister(); }));
|
||||
}).then(function () {
|
||||
if (!window.caches) return hadSw;
|
||||
return caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
|
||||
.then(function () { return hadSw; });
|
||||
});
|
||||
}).then(function (reload) {
|
||||
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
|
||||
sessionStorage.setItem('dashfront-sw-cleared', '1');
|
||||
location.reload();
|
||||
}
|
||||
html,
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
(function () {
|
||||
var host = location.hostname;
|
||||
if (host !== 'localhost' && host !== '127.0.0.1') return;
|
||||
if (!navigator.serviceWorker) return;
|
||||
var hadSw = false;
|
||||
navigator.serviceWorker.getRegistrations().then(function (regs) {
|
||||
hadSw = regs.length > 0;
|
||||
return Promise.all(regs.map(function (r) { return r.unregister(); }));
|
||||
}).then(function () {
|
||||
if (!window.caches) return hadSw;
|
||||
return caches.keys().then(function (keys) {
|
||||
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
|
||||
.then(function () { return hadSw; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
}).then(function (reload) {
|
||||
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
|
||||
sessionStorage.setItem('dashfront-sw-cleared', '1');
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
174
package-lock.json
generated
174
package-lock.json
generated
@@ -34,12 +34,12 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"globals": "^17.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vite": "^8.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"../automat": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"vite": "^8.3.1"
|
||||
"vite": "^8.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
@@ -2931,9 +2931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.150.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz",
|
||||
"integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==",
|
||||
"version": "0.152.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2951,9 +2951,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz",
|
||||
"integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2968,9 +2968,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2985,9 +2985,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3002,9 +3002,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz",
|
||||
"integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3019,9 +3019,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz",
|
||||
"integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3036,9 +3036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz",
|
||||
"integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3053,9 +3053,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3073,9 +3073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz",
|
||||
"integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3093,9 +3093,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -3113,9 +3113,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -3133,9 +3133,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz",
|
||||
"integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3153,9 +3153,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz",
|
||||
"integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3173,9 +3173,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz",
|
||||
"integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3190,9 +3190,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz",
|
||||
"integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3207,9 +3207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz",
|
||||
"integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4926,9 +4926,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.11.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
|
||||
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
|
||||
"version": "10.12.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
|
||||
"integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -5528,9 +5528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
|
||||
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7666,13 +7666,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz",
|
||||
"integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==",
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.150.0",
|
||||
"@oxc-project/types": "=0.152.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -7682,21 +7682,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.9",
|
||||
"@rolldown/binding-android-arm64": "1.2.9",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.9",
|
||||
"@rolldown/binding-darwin-x64": "1.2.9",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.9",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.9",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.9",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.9",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.9",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.9",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.9",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.9"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||
"@rolldown/binding-android-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -8736,16 +8736,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
|
||||
"integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.9",
|
||||
"rolldown": "~1.2.11",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -8814,9 +8814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
||||
"integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-2.0.0.tgz",
|
||||
"integrity": "sha512-6G9dsPKT9QKYpeTuTLJM28zZL3HsXUTUM/QUZBEgQk2jbfrPJDq2i8gTewAKqi1c9Mt6MiidrL4SFI+HthiQMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8827,13 +8827,13 @@
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vite-pwa/assets-generator": "^1.0.0",
|
||||
"@vite-pwa/assets-generator": "^1.0.0 || ^2.0.0",
|
||||
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
|
||||
@@ -41,12 +41,12 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"globals": "^17.13.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vite": "^8.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"canvas@3.2.3": true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
|
||||
import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
|
||||
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
function enc(value) {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
function buildUrl(baseUrl, path) {
|
||||
return new URL(path, baseUrl || window.location.origin).href;
|
||||
}
|
||||
|
||||
function buildPath(path, query) {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null) continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty).
|
||||
*/
|
||||
export class ApiClient {
|
||||
#baseUrl;
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
this.#baseUrl = baseUrl;
|
||||
|
||||
this.auth = {
|
||||
/** POST /api/auth/login */
|
||||
login: (body) => this.#json('POST', '/api/auth/login', { body }),
|
||||
/** POST /api/auth/logout */
|
||||
logout: () => this.#json('POST', '/api/auth/logout'),
|
||||
/** GET /api/auth/me */
|
||||
me: () => this.#json('GET', '/api/auth/me'),
|
||||
/** PUT /api/auth/selected-company */
|
||||
selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
|
||||
};
|
||||
|
||||
this.health = {
|
||||
/** GET /api/health */
|
||||
get: () => this.#json('GET', '/api/health'),
|
||||
};
|
||||
|
||||
this.company = {
|
||||
/** GET /api/company */
|
||||
list: () => this.#json('GET', '/api/company'),
|
||||
/** POST /api/company */
|
||||
create: (body) => this.#json('POST', '/api/company', { body }),
|
||||
/** PUT /api/company/{id} */
|
||||
update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
|
||||
/** DELETE /api/company/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.unit = {
|
||||
/** GET /api/unit */
|
||||
list: () => this.#json('GET', '/api/unit'),
|
||||
/** PUT /api/unit */
|
||||
upsert: (body) => this.#json('PUT', '/api/unit', { body }),
|
||||
/** DELETE /api/unit?si=… */
|
||||
delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
|
||||
};
|
||||
|
||||
this.brand = {
|
||||
/** GET /api/brand */
|
||||
list: () => this.#json('GET', '/api/brand'),
|
||||
/** PUT /api/brand */
|
||||
upsert: (body) => this.#json('PUT', '/api/brand', { body }),
|
||||
/** DELETE /api/brand/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.attributename = {
|
||||
/** GET /api/attributename */
|
||||
list: () => this.#json('GET', '/api/attributename'),
|
||||
/** POST /api/attributename */
|
||||
create: (body) => this.#json('POST', '/api/attributename', { body }),
|
||||
/** PATCH /api/attributename/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/attributename/${enc(id)}`, { body }),
|
||||
/** DELETE /api/attributename/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/attributename/${enc(id)}`),
|
||||
/** PUT /api/attributename/order */
|
||||
order: (body) => this.#json('PUT', '/api/attributename/order', { body }),
|
||||
};
|
||||
|
||||
this.attributevalue = {
|
||||
/** GET /api/attributename/{attributeId}/value */
|
||||
listByAttributeId: (attributeId) =>
|
||||
this.#json('GET', `/api/attributename/${enc(attributeId)}/value`),
|
||||
/** POST /api/attributename/{attributeId}/value */
|
||||
create: (attributeId, body) =>
|
||||
this.#json('POST', `/api/attributename/${enc(attributeId)}/value`, { body }),
|
||||
/** PATCH /api/attributevalue/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/attributevalue/${enc(id)}`, { body }),
|
||||
/** DELETE /api/attributevalue/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/attributevalue/${enc(id)}`),
|
||||
/** PUT /api/attributename/{attributeId}/value/order */
|
||||
order: (attributeId, body) =>
|
||||
this.#json('PUT', `/api/attributename/${enc(attributeId)}/value/order`, { body }),
|
||||
};
|
||||
|
||||
this.propertyname = {
|
||||
/** GET /api/propertyname */
|
||||
list: () => this.#json('GET', '/api/propertyname'),
|
||||
/** POST /api/propertyname */
|
||||
create: (body) => this.#json('POST', '/api/propertyname', { body }),
|
||||
/** PATCH /api/propertyname/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/propertyname/${enc(id)}`, { body }),
|
||||
/** DELETE /api/propertyname/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/propertyname/${enc(id)}`),
|
||||
/** PUT /api/propertyname/order */
|
||||
order: (body) => this.#json('PUT', '/api/propertyname/order', { body }),
|
||||
};
|
||||
|
||||
this.supplier = {
|
||||
/** GET /api/supplier → { suppliers: Supplier[] } */
|
||||
list: () => this.#json('GET', '/api/supplier'),
|
||||
/** POST /api/supplier → 201 { supplier } */
|
||||
create: (body) => this.#json('POST', '/api/supplier', { body }),
|
||||
/** PATCH /api/supplier/{id} → { supplier } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
|
||||
/** DELETE /api/supplier/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
|
||||
/** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */
|
||||
getArticles: (id, skus) => {
|
||||
let query = '';
|
||||
if (skus != null) {
|
||||
const list = Array.isArray(skus) ? skus : [skus];
|
||||
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
|
||||
if (filtered.length > 0) {
|
||||
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
|
||||
}
|
||||
}
|
||||
return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`);
|
||||
},
|
||||
};
|
||||
|
||||
this.warehouse = {
|
||||
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
||||
list: () => this.#json('GET', '/api/warehouse'),
|
||||
/** GET /api/warehouse/{id} → { warehouse: Warehouse } */
|
||||
getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`),
|
||||
/** POST /api/warehouse → 201 { warehouse: Warehouse } */
|
||||
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
||||
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
||||
/** PUT /api/warehouse/order → 200 { warehouseIds: number[] } */
|
||||
order: (body) => this.#json('PUT', '/api/warehouse/order', { body }),
|
||||
/** DELETE /api/warehouse/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.stock = {
|
||||
/** POST /api/stock/adjust → 201 { manifest, lines, ledger } */
|
||||
adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }),
|
||||
};
|
||||
|
||||
this.delivery = {
|
||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
||||
list: () => this.#json('GET', '/api/delivery'),
|
||||
/** GET /api/delivery/{id} → { delivery: Delivery } */
|
||||
get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
|
||||
/** POST /api/delivery → 201 { delivery: Delivery } */
|
||||
create: (body) => this.#json('POST', '/api/delivery', { body }),
|
||||
/** PATCH /api/delivery/{id} → { delivery: Delivery } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
||||
/** DELETE /api/delivery/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
||||
/** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */
|
||||
getFile: (id, fileId, { download = false } = {}) =>
|
||||
this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, {
|
||||
query: download ? { download: 'true' } : undefined,
|
||||
}),
|
||||
/**
|
||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
||||
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename
|
||||
* Returns an async generator that yields SSE event objects:
|
||||
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
||||
*/
|
||||
parseSse: async function* (file, { signal } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name);
|
||||
const res = await fetch('/api/delivery/parse', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
let currentEvent = 'message';
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
reader.cancel();
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop(); // keep incomplete last line
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
||||
yield { event: currentEvent, data };
|
||||
currentEvent = 'message';
|
||||
}
|
||||
// blank line or comment: reset
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal?.aborted || err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
if (signal) {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
parsePdfSse(file, opts) {
|
||||
return this.parseSse(file, opts);
|
||||
},
|
||||
};
|
||||
|
||||
this.ledger = {
|
||||
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
|
||||
add: (body) => this.#json('POST', '/api/ledger', { body }),
|
||||
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
|
||||
listByManifestarticleId: (manifestarticleId, transactionType) =>
|
||||
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
|
||||
query: transactionType ? { transaction_type: transactionType } : undefined,
|
||||
}),
|
||||
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
|
||||
listBySku: (sku, transactionType) =>
|
||||
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
|
||||
query: transactionType ? { transaction_type: transactionType } : undefined,
|
||||
}),
|
||||
/** GET /api/ledger → { ledger: LedgerEntry[] } */
|
||||
list: (query) => this.#json('GET', '/api/ledger', { query }),
|
||||
};
|
||||
|
||||
this.quote = {
|
||||
/** GET /api/quote → { quotes } */
|
||||
list: () => this.#json('GET', '/api/quote'),
|
||||
/** GET /api/quote/{id} → { quote } */
|
||||
get: (id) => this.#json('GET', `/api/quote/${enc(id)}`),
|
||||
/** POST /api/quote → 201 { quote } */
|
||||
create: (body) => this.#json('POST', '/api/quote', { body }),
|
||||
/** PATCH /api/quote/{id} → { quote } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }),
|
||||
/** DELETE /api/quote/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.order = {
|
||||
/** GET /api/order → { orders } */
|
||||
list: () => this.#json('GET', '/api/order'),
|
||||
/** GET /api/order/{id} → { order } */
|
||||
get: (id) => this.#json('GET', `/api/order/${enc(id)}`),
|
||||
/** POST /api/order → 201 { order } */
|
||||
create: (body) => this.#json('POST', '/api/order', { body }),
|
||||
/** PATCH /api/order/{id} → { order } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }),
|
||||
/** DELETE /api/order/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.customergroup = {
|
||||
/** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */
|
||||
list: () => this.#json('GET', '/api/customergroup'),
|
||||
/** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */
|
||||
create: (body) => this.#json('POST', '/api/customergroup', { body }),
|
||||
/** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */
|
||||
update: (id, body) =>
|
||||
this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }),
|
||||
/** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */
|
||||
order: (body) => this.#json('PUT', '/api/customergroup/order', { body }),
|
||||
/** DELETE /api/customergroup/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.entity = {
|
||||
/** GET /api/entity → { entities } · optional query customergroupId */
|
||||
list: (query) => this.#json('GET', '/api/entity', { query }),
|
||||
/** GET /api/entity/{id} → { entity } */
|
||||
get: (id) => this.#json('GET', `/api/entity/${enc(id)}`),
|
||||
/** POST /api/entity → 201 { entity } */
|
||||
create: (body) => this.#json('POST', '/api/entity', { body }),
|
||||
/** PATCH /api/entity/{id} → { entity } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
|
||||
/** DELETE /api/entity/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
|
||||
/** GET /api/entity/next-number → { number: string } */
|
||||
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
|
||||
/** GET /api/entity/{entityId}/address → { addresses } */
|
||||
listAddresses: (entityId) =>
|
||||
this.#json('GET', `/api/entity/${enc(entityId)}/address`),
|
||||
/** POST /api/entity/{entityId}/address → 201 { address } */
|
||||
createAddress: (entityId, body) =>
|
||||
this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }),
|
||||
/** PATCH /api/entity/{entityId}/address/{id} → { address } */
|
||||
updateAddress: (entityId, id, body) =>
|
||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, {
|
||||
body,
|
||||
}),
|
||||
/** DELETE /api/entity/{entityId}/address/{id} → 204 */
|
||||
deleteAddress: (entityId, id) =>
|
||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`),
|
||||
/** GET /api/entity/{entityId}/contact → { contacts } */
|
||||
listContacts: (entityId) =>
|
||||
this.#json('GET', `/api/entity/${enc(entityId)}/contact`),
|
||||
/** POST /api/entity/{entityId}/contact → 201 { contact } */
|
||||
createContact: (entityId, body) =>
|
||||
this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }),
|
||||
/** PATCH /api/entity/{entityId}/contact/{id} → { contact } */
|
||||
updateContact: (entityId, id, body) =>
|
||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, {
|
||||
body,
|
||||
}),
|
||||
/** DELETE /api/entity/{entityId}/contact/{id} → 204 */
|
||||
deleteContact: (entityId, id) =>
|
||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`),
|
||||
};
|
||||
|
||||
this.category = {
|
||||
/** GET /api/category/tree */
|
||||
tree: () => this.#json('GET', '/api/category/tree'),
|
||||
/** GET /api/category/export.zip */
|
||||
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
|
||||
/** POST /api/category */
|
||||
create: (body) => this.#json('POST', '/api/category', { body }),
|
||||
/** GET /api/category/{id}/articles */
|
||||
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
|
||||
/** PATCH /api/category/{id} */
|
||||
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
|
||||
/** DELETE /api/category/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
|
||||
/** PUT /api/category/order */
|
||||
order: (body) => this.#json('PUT', '/api/category/order', { body }),
|
||||
/** POST /api/category/{id}/image — raw image body */
|
||||
addImage: (id, imageBytes) =>
|
||||
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
||||
body: imageBytes,
|
||||
contentType: imageBytes?.type || 'image/webp',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/category/{id}/image */
|
||||
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
|
||||
};
|
||||
|
||||
this.article = {
|
||||
/** GET /api/articleIndex */
|
||||
index: () => this.#json('GET', '/api/articleIndex'),
|
||||
/** POST /api/article */
|
||||
create: (body) => this.#json('POST', '/api/article', { body }),
|
||||
/** GET /api/article/export.zip */
|
||||
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
|
||||
/** GET /api/article/{sku} */
|
||||
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
||||
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
||||
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
||||
/**
|
||||
* Load history and refetch on SSE `article-<sku>`.
|
||||
* Paginates in StorageManager until the HTTP API supports skip/offset.
|
||||
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
||||
*/
|
||||
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
||||
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi, track_stock } */
|
||||
update: async (sku, body) => {
|
||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||
if (nameChange) {
|
||||
this.setArticleIndexName(sku, body.name);
|
||||
this.setArticleName(sku, body.name);
|
||||
}
|
||||
try {
|
||||
const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body });
|
||||
this.setArticle(res.article);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
this.clearArticleIndex();
|
||||
if (this.getArticle(sku)) {
|
||||
void this.loadArticle(sku).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
/** DELETE /api/article/{sku} */
|
||||
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
|
||||
/** PUT /api/article/{sku}/prices */
|
||||
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
|
||||
/** POST /api/article/{sku}/image — raw image body */
|
||||
addImage: (sku, imageBytes) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
||||
body: imageBytes,
|
||||
contentType: imageBytes?.type || 'image/webp',
|
||||
raw: true,
|
||||
}),
|
||||
/** DELETE /api/article/{sku}/image/{fullHash} */
|
||||
deleteImage: (sku, fullHash) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
|
||||
/** PUT /api/article/{sku}/images/order */
|
||||
orderImages: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
|
||||
/** PUT /api/article/{sku}/eans */
|
||||
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
|
||||
/** PUT /api/article/{sku}/brand */
|
||||
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
|
||||
/** PUT /api/article/{sku}/weight */
|
||||
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
|
||||
/** PUT /api/article/{sku}/categories */
|
||||
setCategories: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
||||
/** PUT /api/article/{sku}/track_stock */
|
||||
setTrackStock: async (sku, body) => {
|
||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||
const res = await this.#json('PUT', `/api/article/${enc(sku)}/track_stock`, { body: payload });
|
||||
if (res?.article) {
|
||||
this.setArticle(res.article);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
/** GET /api/article/{sku}/supplier */
|
||||
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
||||
/** POST /api/article/{sku}/supplier */
|
||||
addSupplier: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
|
||||
/** PATCH /api/article/{sku}/supplier/{id} */
|
||||
updateSupplier: (sku, id, body) =>
|
||||
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
|
||||
/** PUT /api/article/{sku}/supplier/order */
|
||||
orderSuppliers: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
|
||||
/** DELETE /api/article/{sku}/supplier/{id} */
|
||||
deleteSupplier: (sku, id) =>
|
||||
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
|
||||
/** GET /api/article/{sku}/attributevalue */
|
||||
getAttributeValues: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/attributevalue`),
|
||||
/** PUT /api/article/{sku}/attributevalue */
|
||||
setAttributeValues: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/attributevalue`, { body }),
|
||||
/** GET /api/article/{sku}/property */
|
||||
getProperties: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/property`),
|
||||
/** PUT /api/article/{sku}/property */
|
||||
setProperties: (sku, body) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/property`, { body }),
|
||||
/** DELETE /api/article/{sku}/property/{propertyId} */
|
||||
deleteProperty: (sku, propertyId) =>
|
||||
this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
|
||||
/** POST /api/article/{sku}/father */
|
||||
toggleFather: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/father`, { body }),
|
||||
/** GET /api/article/{sku}/children */
|
||||
getChildren: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/children`),
|
||||
/** POST /api/article/{sku}/children */
|
||||
addChild: (sku, body) =>
|
||||
this.#json('POST', `/api/article/${enc(sku)}/children`, { body }),
|
||||
/** DELETE /api/article/{sku}/children/{childSku} */
|
||||
removeChild: (sku, childSku, { deleteArticle = false } = {}) =>
|
||||
this.#json(
|
||||
'DELETE',
|
||||
`/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
|
||||
),
|
||||
/** GET /api/article/{sku}/articlelist */
|
||||
getArticlelist: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/articlelist`),
|
||||
/** PUT /api/article/{sku}/articlelist */
|
||||
setArticlelist: (sku, items) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/articlelist`, {
|
||||
body: Array.isArray(items) ? { items } : items,
|
||||
}),
|
||||
/** GET /api/article/{sku}/optionslist */
|
||||
getOptionslist: (sku) =>
|
||||
this.#json('GET', `/api/article/${enc(sku)}/optionslist`),
|
||||
/** PUT /api/article/{sku}/optionslist */
|
||||
setOptionslist: (sku, options) =>
|
||||
this.#json('PUT', `/api/article/${enc(sku)}/optionslist`, {
|
||||
body: Array.isArray(options) ? { options } : options,
|
||||
}),
|
||||
};
|
||||
|
||||
this.posterminal = {
|
||||
/** GET /api/posterminal */
|
||||
list: () => this.#json('GET', '/api/posterminal'),
|
||||
/** PUT /api/posterminal */
|
||||
upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
|
||||
/** DELETE /api/posterminal/{id} */
|
||||
delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
|
||||
/** PUT /api/posterminal/{id}/category/{categoryId} */
|
||||
setOn: (id, categoryId) =>
|
||||
this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
||||
/** DELETE /api/posterminal/{id}/category/{categoryId} */
|
||||
setOff: (id, categoryId) =>
|
||||
this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
||||
/** PUT /api/posterminal/{id}/all */
|
||||
setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
|
||||
/** DELETE /api/posterminal/{id}/all */
|
||||
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
|
||||
};
|
||||
|
||||
this.number = {
|
||||
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
|
||||
list: () => this.#json('GET', '/api/number'),
|
||||
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
|
||||
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
||||
};
|
||||
|
||||
this.printer = {
|
||||
/** GET /api/printer → { printers: Printer[] } */
|
||||
list: () => this.#json('GET', '/api/printer'),
|
||||
/** POST /api/printer → 201 { printer } */
|
||||
create: (body) => this.#json('POST', '/api/printer', { body }),
|
||||
/** PATCH /api/printer/{id} → { printer } */
|
||||
update: (id, body) => this.#json('PATCH', `/api/printer/${enc(id)}`, { body }),
|
||||
/** DELETE /api/printer/{id} → 204 */
|
||||
delete: (id) => this.#empty('DELETE', `/api/printer/${enc(id)}`),
|
||||
/** PUT /api/printer/order body { printerIds } → { printerIds } */
|
||||
order: (body) => this.#json('PUT', '/api/printer/order', { body }),
|
||||
};
|
||||
|
||||
this.setting = {
|
||||
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
||||
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
||||
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
|
||||
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
|
||||
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
|
||||
upsert: (key, body) =>
|
||||
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
|
||||
};
|
||||
|
||||
this.image = {
|
||||
/** GET /api/image/{fullHash} → AVIF blob */
|
||||
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
|
||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
|
||||
};
|
||||
|
||||
this.thumb = {
|
||||
/** GET /api/thumb/{fullHash} → AVIF blob */
|
||||
get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
|
||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
|
||||
};
|
||||
|
||||
this.modelconversation = {
|
||||
/** GET /api/modelconversation → { conversations: [...] } */
|
||||
list: () => this.#json('GET', '/api/modelconversation'),
|
||||
/** GET /api/modelconversation/{id} → { conversation } */
|
||||
get: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}`),
|
||||
/** POST /api/modelconversation body { provider, model } → { conversation } */
|
||||
create: (body) => this.#json('POST', '/api/modelconversation', { body }),
|
||||
/** GET /api/modelconversation/{id}/message → { messages: [...] } */
|
||||
listMessages: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}/message`),
|
||||
/** POST /api/modelconversation/{id}/message body { message } → { message } */
|
||||
addMessage: (id, body) => this.#json('POST', `/api/modelconversation/${enc(id)}/message`, { body }),
|
||||
/** DELETE /api/modelconversation/{id} */
|
||||
delete: (id) => this.#json('DELETE', `/api/modelconversation/${enc(id)}`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** Absolute URL for a same-origin API path (e.g. SSE). */
|
||||
urlFor(path) {
|
||||
return buildUrl(this.#baseUrl, path);
|
||||
}
|
||||
|
||||
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const headers = { ...extraHeaders };
|
||||
let payload;
|
||||
if (body !== undefined) {
|
||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
||||
payload = body;
|
||||
} else if (raw) {
|
||||
if (contentType) headers['Content-Type'] = contentType;
|
||||
payload = body;
|
||||
} else {
|
||||
headers['Content-Type'] = contentType || 'application/json';
|
||||
payload = JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: payload,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = url;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async #empty(method, path, { query } = {}) {
|
||||
await this.#json(method, path, { query });
|
||||
return null;
|
||||
}
|
||||
|
||||
async #blob(method, path, { query } = {}) {
|
||||
const url = buildPath(path, query);
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
message = data.error || data.message || message;
|
||||
} catch {
|
||||
message = (await res.text().catch(() => message)) || message;
|
||||
}
|
||||
const err = new Error(message || `Request failed (${res.status})`);
|
||||
err.status = res.status;
|
||||
err.path = url;
|
||||
err.method = method;
|
||||
err.data = data;
|
||||
throw err;
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
@@ -1,819 +0,0 @@
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { ApiClient } from './ApiClient';
|
||||
import artikelAutomat from '../automats/artikelAutomat';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import brandAutomat from '../automats/brandAutomat';
|
||||
import unitAutomat from '../automats/unitAutomat';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||
import warehouseAutomat from '../automats/warehouseAutomat';
|
||||
import attributeAutomat from '../automats/attributeAutomat';
|
||||
import propertyAutomat from '../automats/propertyAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
||||
* swaps only after a successful reload. Failed updates leave the cache intact.
|
||||
* replace() writes local data without a round-trip.
|
||||
*/
|
||||
function createCache(label, load) {
|
||||
/** Settled value, or `undefined` if not loaded yet / last load failed. */
|
||||
let current;
|
||||
let promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
async function update() {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] update ${label} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function replace(data) {
|
||||
current = data;
|
||||
promise = Promise.resolve(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
current = undefined;
|
||||
promise = load()
|
||||
.then((data) => {
|
||||
current = data;
|
||||
return data;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
get promise() {
|
||||
return promise;
|
||||
},
|
||||
/** Loaded value, or `undefined` if the initial load has not settled successfully. */
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
|
||||
*/
|
||||
export class StorageManager extends ApiClient {
|
||||
/** @type {EventSource | null} */
|
||||
#events = null;
|
||||
/** @type {ReturnType<StorageManager['#createDeferredCache']> | null} */
|
||||
#articleIndexCache = null;
|
||||
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
|
||||
#articleWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
||||
#historyWatchers = new Map();
|
||||
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
||||
#supplierWatchers = new Map();
|
||||
|
||||
constructor(baseUrl = window.location.origin) {
|
||||
super(baseUrl);
|
||||
this.#initCaches();
|
||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
||||
this.article.supplierWatch = (sku, onSuppliers) =>
|
||||
this.watchArticleSuppliers(sku, onSuppliers);
|
||||
}
|
||||
|
||||
/** Cached article detail from the shared artikelAutomat, or null. */
|
||||
getArticle(sku) {
|
||||
return artikelAutomat.state.bySku[String(sku)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
|
||||
* notify any StorageManager-level watchers.
|
||||
*/
|
||||
setArticle(article) {
|
||||
requireObject(article, 'article');
|
||||
requireDefined(article.sku, 'article.sku');
|
||||
const key = String(article.sku);
|
||||
const stored = artikelAutomat.actions.setArticle(article);
|
||||
|
||||
const watchers = this.#articleWatchers.get(key);
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(stored);
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
|
||||
* @param {string} sku
|
||||
*/
|
||||
async loadArticle(sku) {
|
||||
const article = await artikelAutomat.actions.loadArticle(sku);
|
||||
const watchers = this.#articleWatchers.get(String(sku));
|
||||
if (watchers) {
|
||||
for (const onArticle of watchers) onArticle(article);
|
||||
}
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
|
||||
* @param {string} sku
|
||||
* @param {(article: object) => void} onArticle
|
||||
* @returns {{ article: object | null, unwatch: () => void }}
|
||||
*/
|
||||
watchArticle(sku, onArticle) {
|
||||
const key = String(sku);
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#articleWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#articleWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(onArticle);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
return {
|
||||
article: this.getArticle(key) ?? null,
|
||||
unwatch: () => {
|
||||
const set = this.#articleWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(onArticle);
|
||||
if (set.size === 0) {
|
||||
this.#articleWatchers.delete(key);
|
||||
if (
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
|
||||
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
|
||||
*/
|
||||
getCategoryState() {
|
||||
const catState = categoryAutomat.state;
|
||||
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
||||
return {
|
||||
categories: catState.categories,
|
||||
rootCategoryId: catState.rootCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
||||
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
|
||||
* @param {string} sku
|
||||
* @param {(history: unknown[], total: number) => void} onHistory
|
||||
* @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
|
||||
*/
|
||||
watchArticleHistory(sku, onHistory) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
let loaded = false;
|
||||
/** @type {unknown[]} */
|
||||
let all = [];
|
||||
let skip = 0;
|
||||
/** Page size (how many rows to emit). */
|
||||
let offset = Number.POSITIVE_INFINITY;
|
||||
|
||||
const emit = () => {
|
||||
if (!active || !loaded) return;
|
||||
const total = all.length;
|
||||
const end =
|
||||
offset === Number.POSITIVE_INFINITY ? total : skip + offset;
|
||||
onHistory(all.slice(skip, end), total);
|
||||
};
|
||||
|
||||
const refresh = () =>
|
||||
this.article.history(key).then((res) => {
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/history');
|
||||
all = requireArray(res.history, 'history');
|
||||
loaded = true;
|
||||
emit();
|
||||
});
|
||||
|
||||
const run = () =>
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#historyWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#historyWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#historyWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#historyWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
setSkipAndOffset: (nextSkip, nextOffset) => {
|
||||
skip = nextSkip;
|
||||
offset = nextOffset;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load suppliers for an article and refetch on SSE `article-<sku>`.
|
||||
* @param {string} sku
|
||||
* @param {(suppliers: Array<object>) => void} onSuppliers
|
||||
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
|
||||
*/
|
||||
watchArticleSuppliers(sku, onSuppliers) {
|
||||
const key = String(sku);
|
||||
let active = true;
|
||||
|
||||
const refresh = async () => {
|
||||
const res = await this.article.getSuppliers(key);
|
||||
if (!active) return;
|
||||
requireObject(res, 'GET /api/article/{sku}/supplier');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
onSuppliers(list);
|
||||
};
|
||||
|
||||
const run = () => {
|
||||
void refresh().catch((err) => {
|
||||
if (!active) return;
|
||||
notifyError(err);
|
||||
});
|
||||
};
|
||||
|
||||
const needListen =
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key) &&
|
||||
!this.#supplierWatchers.has(key);
|
||||
|
||||
let watchers = this.#supplierWatchers.get(key);
|
||||
if (!watchers) {
|
||||
watchers = new Set();
|
||||
this.#supplierWatchers.set(key, watchers);
|
||||
}
|
||||
watchers.add(run);
|
||||
if (needListen) {
|
||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
unwatch: () => {
|
||||
active = false;
|
||||
const set = this.#supplierWatchers.get(key);
|
||||
if (!set) return;
|
||||
set.delete(run);
|
||||
if (set.size === 0) {
|
||||
this.#supplierWatchers.delete(key);
|
||||
if (
|
||||
!this.#articleWatchers.has(key) &&
|
||||
!this.#historyWatchers.has(key)
|
||||
) {
|
||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#onArticleSignal = (event) => {
|
||||
const sku = event.type.slice('article-'.length);
|
||||
|
||||
// Refresh the shared cache; silently when nothing is watching this SKU.
|
||||
if (this.#articleWatchers.has(sku)) {
|
||||
void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
|
||||
} else {
|
||||
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
|
||||
}
|
||||
const historyWatchers = this.#historyWatchers.get(sku);
|
||||
if (historyWatchers) {
|
||||
for (const run of historyWatchers) run();
|
||||
}
|
||||
const supplierWatchers = this.#supplierWatchers.get(sku);
|
||||
if (supplierWatchers) {
|
||||
for (const run of supplierWatchers) run();
|
||||
}
|
||||
};
|
||||
|
||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
||||
setArticleIndexName(sku, name) {
|
||||
const articles = this.#articleIndexCache.value;
|
||||
if (!articles) return;
|
||||
this.#articleIndexCache.replace(
|
||||
articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Patch cached article detail name if already cached; no-op otherwise. */
|
||||
setArticleName(sku, name) {
|
||||
const current = this.getArticle(sku);
|
||||
if (!current) return;
|
||||
this.setArticle({ ...current, name });
|
||||
}
|
||||
|
||||
/** Drop local articleIndex if loaded (forces reload on next read). */
|
||||
clearArticleIndex() {
|
||||
if (!this.#articleIndexCache.value) return;
|
||||
this.#articleIndexCache.clear();
|
||||
}
|
||||
|
||||
#initCaches() {
|
||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
||||
const deliveryCache = this.#createDeferredCache('GET /delivery', () =>
|
||||
this.#loadDeliveries(),
|
||||
);
|
||||
const customergroupCache = this.#createDeferredCache('GET /customergroup', () =>
|
||||
this.#loadCustomergroups(),
|
||||
);
|
||||
const entityCache = this.#createDeferredCache('GET /entity', () =>
|
||||
this.#loadEntities(),
|
||||
);
|
||||
const numberCache = this.#createDeferredCache('GET /number', () =>
|
||||
this.#loadNumbers(),
|
||||
);
|
||||
const quoteCache = this.#createDeferredCache('GET /quote', () =>
|
||||
this.#loadQuotes(),
|
||||
);
|
||||
const orderCache = this.#createDeferredCache('GET /order', () =>
|
||||
this.#loadOrders(),
|
||||
);
|
||||
|
||||
// articleIndex is auth-gated — create a deferred cache that only loads after auth
|
||||
this.#articleIndexCache = this.#createDeferredCache(
|
||||
'GET /articleIndex',
|
||||
() => this.#loadArticleIndex(),
|
||||
);
|
||||
const articleIndexCache = this.#articleIndexCache;
|
||||
|
||||
Object.defineProperties(this, {
|
||||
authMePromise: { get: () => authMeCache.promise },
|
||||
updateAuthMe: { value: authMeCache.update },
|
||||
/** Settled GET /auth/me body, or undefined. */
|
||||
authMe: { get: () => authMeCache.value },
|
||||
|
||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
||||
updateArticleIndex: { value: articleIndexCache.update },
|
||||
|
||||
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
|
||||
/** Brand list from brandAutomat (may be empty until authed). */
|
||||
brands: { get: () => brandAutomat.state.brands },
|
||||
/** Promise that resolves when brands are first loaded. */
|
||||
brandPromise: {
|
||||
get: () => brandAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(brandAutomat.state.brands)
|
||||
: new Promise((resolve) => {
|
||||
const unsub = brandAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload brands from API. */
|
||||
updateBrand: { value: () => brandAutomat.actions.load() },
|
||||
|
||||
/** Unit list from unitAutomat. */
|
||||
units: { get: () => unitAutomat.state.units },
|
||||
/** Promise that resolves when units are first loaded. */
|
||||
unitPromise: {
|
||||
get: () => unitAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(unitAutomat.state.units)
|
||||
: new Promise((resolve) => {
|
||||
const unsub = unitAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload units from API. */
|
||||
updateUnit: { value: () => unitAutomat.actions.load() },
|
||||
|
||||
/** Posterminal list from posterminalAutomat. */
|
||||
posterminals: { get: () => posterminalAutomat.state.terminals },
|
||||
/** Promise that resolves when posterminals are first loaded. */
|
||||
posterminalPromise: {
|
||||
get: () => posterminalAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(posterminalAutomat.state.terminals)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = posterminalAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.terminals); }
|
||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload posterminals from API. */
|
||||
updatePosterminal: { value: () => posterminalAutomat.actions.load() },
|
||||
|
||||
/**
|
||||
* Current company object from companyAutomat, or null.
|
||||
* Previously: companies array from GET /api/company.
|
||||
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
|
||||
*/
|
||||
company: { get: () => companyAutomat.state.current ?? null },
|
||||
/** Promise that resolves when company data is loaded. */
|
||||
companyPromise: {
|
||||
get: () => companyAutomat.state.status === 'succeeded'
|
||||
? Promise.resolve(companyAutomat.state.current)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = companyAutomat.subscribe((s) => {
|
||||
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
|
||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload company data from API. */
|
||||
updateCompany: { value: () => companyAutomat.actions.load() },
|
||||
|
||||
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
|
||||
// Return a single-element array so .find() still works.
|
||||
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
|
||||
|
||||
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
|
||||
/** Category tree promise resolving when categoryAutomat tree is loaded. */
|
||||
categoryTreePromise: {
|
||||
get: () => categoryAutomat.state.treeStatus === 'succeeded'
|
||||
? Promise.resolve(categoryAutomat.state.categories)
|
||||
: new Promise((resolve, reject) => {
|
||||
const unsub = categoryAutomat.subscribe((s) => {
|
||||
if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); }
|
||||
else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); }
|
||||
});
|
||||
}),
|
||||
},
|
||||
/** Reload category tree via categoryAutomat. */
|
||||
updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() },
|
||||
/** Current categories map from categoryAutomat, or null. */
|
||||
categoryTree: { get: () => categoryAutomat.state.categories },
|
||||
|
||||
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
|
||||
deliveryPromise: { get: () => deliveryCache.promise },
|
||||
updateDelivery: { value: deliveryCache.update },
|
||||
/** Settled GET /delivery list, or undefined. */
|
||||
deliveries: { get: () => deliveryCache.value },
|
||||
|
||||
customergroupPromise: { get: () => customergroupCache.promise },
|
||||
updateCustomergroup: { value: customergroupCache.update },
|
||||
/** Settled GET /customergroup list, or undefined. */
|
||||
customergroups: { get: () => customergroupCache.value },
|
||||
|
||||
entityPromise: { get: () => entityCache.promise },
|
||||
updateEntity: { value: entityCache.update },
|
||||
/** Settled GET /entity list, or undefined. */
|
||||
entities: { get: () => entityCache.value },
|
||||
|
||||
numberPromise: { get: () => numberCache.promise },
|
||||
updateNumber: { value: numberCache.update },
|
||||
/** Settled GET /number list, or undefined. */
|
||||
numbers: { get: () => numberCache.value },
|
||||
|
||||
quotePromise: { get: () => quoteCache.promise },
|
||||
updateQuote: { value: quoteCache.update },
|
||||
/** Settled GET /quote list, or undefined. */
|
||||
quotes: { get: () => quoteCache.value },
|
||||
|
||||
orderPromise: { get: () => orderCache.promise },
|
||||
updateOrder: { value: orderCache.update },
|
||||
/** Settled GET /order list, or undefined. */
|
||||
orders: { get: () => orderCache.value },
|
||||
|
||||
/** Close and reopen GET /api/events (required after selected-company change). */
|
||||
reconnectEvents: { value: () => this.#reconnectEvents() },
|
||||
});
|
||||
|
||||
let lastCompanyId = null;
|
||||
|
||||
const syncWithAuth = (auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
|
||||
if (companyId === lastCompanyId) return;
|
||||
const prevCompanyId = lastCompanyId;
|
||||
lastCompanyId = companyId;
|
||||
|
||||
if (!companyId) {
|
||||
// Unauthenticated or logged out
|
||||
this.#disconnectEvents();
|
||||
articleIndexCache.clear();
|
||||
deliveryCache.clear();
|
||||
customergroupCache.clear();
|
||||
entityCache.clear();
|
||||
numberCache.clear();
|
||||
quoteCache.clear();
|
||||
orderCache.clear();
|
||||
artikelAutomat.actions.clear();
|
||||
} else {
|
||||
// Company established or switched
|
||||
if (prevCompanyId) {
|
||||
articleIndexCache.clear();
|
||||
deliveryCache.clear();
|
||||
customergroupCache.clear();
|
||||
entityCache.clear();
|
||||
numberCache.clear();
|
||||
quoteCache.clear();
|
||||
orderCache.clear();
|
||||
artikelAutomat.actions.clear();
|
||||
}
|
||||
this.#reconnectEvents();
|
||||
void articleIndexCache.trigger().catch(() => {});
|
||||
void deliveryCache.trigger().catch(() => {});
|
||||
void customergroupCache.trigger().catch(() => {});
|
||||
void entityCache.trigger().catch(() => {});
|
||||
void numberCache.trigger().catch(() => {});
|
||||
void quoteCache.trigger().catch(() => {});
|
||||
void orderCache.trigger().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a cache that does NOT load immediately but waits for an explicit trigger
|
||||
* or loads on-demand once authenticated.
|
||||
*/
|
||||
#createDeferredCache(label, load) {
|
||||
let current;
|
||||
let loadPromise = null;
|
||||
|
||||
const trigger = () => {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] ${label} failed`, err);
|
||||
loadPromise = null;
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
return loadPromise;
|
||||
};
|
||||
|
||||
const update = async () => {
|
||||
try {
|
||||
const data = await load();
|
||||
current = data;
|
||||
loadPromise = Promise.resolve(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[storageMgr] update ${label} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const replace = (data) => {
|
||||
current = data;
|
||||
loadPromise = Promise.resolve(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
current = undefined;
|
||||
loadPromise = null;
|
||||
};
|
||||
|
||||
return {
|
||||
update,
|
||||
replace,
|
||||
clear,
|
||||
trigger,
|
||||
get promise() {
|
||||
if (!loadPromise) {
|
||||
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
||||
return trigger();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const unsub = authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
unsub();
|
||||
trigger().then(resolve, reject);
|
||||
} else if (auth.status === 'anon') {
|
||||
unsub();
|
||||
reject(new Error(`Cannot load ${label}: unauthenticated`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return loadPromise;
|
||||
},
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/events — SSE signals for the selected company.
|
||||
* Stream is bound at connect time; reconnect after company switch.
|
||||
*/
|
||||
#reconnectEvents() {
|
||||
this.#disconnectEvents();
|
||||
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
||||
es.addEventListener('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[storageMgr] category tree SSE refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
es.addEventListener('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void this.updateArticleIndex().catch((err) => {
|
||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[storageMgr] warehouse SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[storageMgr] attributename SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
es.addEventListener('propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[storageMgr] propertyname SSE refresh failed', err);
|
||||
});
|
||||
});
|
||||
for (const sku of new Set([
|
||||
...this.#articleWatchers.keys(),
|
||||
...this.#historyWatchers.keys(),
|
||||
...this.#supplierWatchers.keys(),
|
||||
])) {
|
||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
||||
}
|
||||
es.onerror = () => {
|
||||
console.error('[storageMgr] events stream error');
|
||||
};
|
||||
this.#events = es;
|
||||
}
|
||||
|
||||
#disconnectEvents() {
|
||||
if (!this.#events) return;
|
||||
this.#events.close();
|
||||
this.#events = null;
|
||||
}
|
||||
|
||||
#syncEvents(user) {
|
||||
if (user?.selectedCompanyId != null) {
|
||||
this.#reconnectEvents();
|
||||
} else {
|
||||
this.#disconnectEvents();
|
||||
}
|
||||
}
|
||||
|
||||
async #loadAuthMe() {
|
||||
try {
|
||||
const data = await this.auth.me();
|
||||
requireObject(data, 'GET /api/auth/me');
|
||||
const session = {
|
||||
user: data.user,
|
||||
googleClientId: data.googleClientId,
|
||||
};
|
||||
this.#syncEvents(session.user);
|
||||
return session;
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
this.#syncEvents(null);
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async #loadArticleIndex() {
|
||||
const data = await this.article.index();
|
||||
requireObject(data, 'GET /api/articleIndex');
|
||||
const articles = requireArray(data.articles, 'articleIndex.articles');
|
||||
// Map backend rows to the recordset shape used by the result list.
|
||||
// `categories` is a comma-joined string (formatCategoryList splits on ',').
|
||||
return articles.map((a) => ({
|
||||
id: a.id,
|
||||
sku: a.sku,
|
||||
name: a.name,
|
||||
eans: a.eans ?? [],
|
||||
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
|
||||
isFather: Boolean(a.isFather ?? a.is_father),
|
||||
}));
|
||||
}
|
||||
|
||||
async #loadDeliveries() {
|
||||
const data = await this.delivery.list();
|
||||
requireObject(data, 'GET /api/delivery');
|
||||
return requireArray(data.deliveries, 'deliveries');
|
||||
}
|
||||
|
||||
async #loadCustomergroups() {
|
||||
const data = await this.customergroup.list();
|
||||
requireObject(data, 'GET /api/customergroup');
|
||||
return requireArray(data.customergroups, 'customergroups');
|
||||
}
|
||||
|
||||
async #loadEntities() {
|
||||
const data = await this.entity.list();
|
||||
requireObject(data, 'GET /api/entity');
|
||||
return requireArray(data.entities, 'entities');
|
||||
}
|
||||
|
||||
async #loadNumbers() {
|
||||
const data = await this.number.list();
|
||||
requireObject(data, 'GET /api/number');
|
||||
return requireArray(data.numbers, 'numbers');
|
||||
}
|
||||
|
||||
async #loadQuotes() {
|
||||
const data = await this.quote.list();
|
||||
requireObject(data, 'GET /api/quote');
|
||||
return requireArray(data.quotes, 'quotes');
|
||||
}
|
||||
|
||||
async #loadOrders() {
|
||||
const data = await this.order.list();
|
||||
requireObject(data, 'GET /api/order');
|
||||
return requireArray(data.orders, 'orders');
|
||||
}
|
||||
|
||||
async doSearch({ target, filter, offset, maxCount, onUpdate }) {
|
||||
const articles = await this.articleIndexPromise;
|
||||
const query = target.trim().toLowerCase();
|
||||
const matches = articles.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.sku.toLowerCase().includes(query) ||
|
||||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
|
||||
);
|
||||
onUpdate({
|
||||
categoryFacets: {},
|
||||
totalCount: matches.length,
|
||||
recordset: matches,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageManager;
|
||||
@@ -1,5 +1,7 @@
|
||||
// Single source of truth for all backend communication.
|
||||
|
||||
|
||||
|
||||
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
||||
function logApi(tag, details) {
|
||||
let text;
|
||||
@@ -188,14 +190,14 @@ async function httpGetBinary(path) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scoped article search index (selectedCompanyId from session).
|
||||
* GET /api/articleIndex → { articles: [...] }
|
||||
* NOTE: there is no `articleIndex` namespace anymore.
|
||||
*
|
||||
* The whole-company search index (`GET /api/articleIndex`) was abandoned. Article
|
||||
* lists are fetched per category via `category.getArticles(categoryId)`
|
||||
* (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every
|
||||
* category view right after the tree loads, and `articleIndexAutomat` derives the
|
||||
* client-side search index from those views.
|
||||
*/
|
||||
export const articleIndex = {
|
||||
list() {
|
||||
return http('/api/articleIndex');
|
||||
},
|
||||
};
|
||||
|
||||
export const article = {
|
||||
search({ target, filter, offset, maxCount } = {}) {
|
||||
@@ -337,6 +339,17 @@ export const article = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight
|
||||
* Body: { weight } → 200 { article }
|
||||
*/
|
||||
setWeight(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand
|
||||
* Body: { brandId: number | null } — null clears the brand.
|
||||
@@ -511,11 +524,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,
|
||||
});
|
||||
@@ -624,6 +637,46 @@ export const printer = {
|
||||
delete(id) {
|
||||
return http(`/api/printer/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/printer/order
|
||||
* Body: { printerIds: number[] }
|
||||
* → 200 { printerIds: number[] }
|
||||
*/
|
||||
order({ printerIds }) {
|
||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped key/value settings (selectedCompanyId from session).
|
||||
* Shape: Setting = { key, value, frontend }
|
||||
*/
|
||||
export const setting = {
|
||||
/**
|
||||
* GET /api/company/setting
|
||||
* Optional query: { frontendOnly } → 200 { settings: Setting[] }
|
||||
*/
|
||||
list({ frontendOnly } = {}) {
|
||||
const qs = frontendOnly ? '?frontendOnly=1' : '';
|
||||
return http(`/api/company/setting${qs}`);
|
||||
},
|
||||
|
||||
/** GET /api/company/setting/:key → { setting: Setting } */
|
||||
get(key) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/company/setting/:key
|
||||
* Body: { value, frontend } → 200 { setting: Setting }
|
||||
*/
|
||||
upsert(key, body) {
|
||||
return http(`/api/company/setting/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1894,7 +1947,6 @@ export const modelconversation = {
|
||||
export default {
|
||||
auth,
|
||||
article,
|
||||
articleIndex,
|
||||
attributename,
|
||||
attributevalue,
|
||||
brand,
|
||||
@@ -1912,6 +1964,7 @@ export default {
|
||||
printer,
|
||||
propertyname,
|
||||
quote,
|
||||
setting,
|
||||
supplier,
|
||||
thumb,
|
||||
unit,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createContext, createElement, useContext, useRef } from 'react';
|
||||
import { StorageManager } from './StorageManager';
|
||||
|
||||
let defaultStorageManager = null;
|
||||
|
||||
export function getStorageManager() {
|
||||
if (!defaultStorageManager) {
|
||||
defaultStorageManager = new StorageManager();
|
||||
}
|
||||
return defaultStorageManager;
|
||||
}
|
||||
|
||||
export const StorageManagerContext = createContext(getStorageManager());
|
||||
|
||||
/**
|
||||
* Provider — mount once near the top of the tree.
|
||||
*/
|
||||
export function StorageManagerProvider({ children }) {
|
||||
const mgrRef = useRef(null);
|
||||
if (!mgrRef.current) {
|
||||
mgrRef.current = getStorageManager();
|
||||
}
|
||||
|
||||
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for function components that need the store manager.
|
||||
*/
|
||||
export function useStorageManager() {
|
||||
const ctx = useContext(StorageManagerContext) || getStorageManager();
|
||||
return ctx;
|
||||
}
|
||||
74
src/automats/articleDetailAutomat.js
Normal file
74
src/automats/articleDetailAutomat.js
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleHistoryEntry
|
||||
* @property {string|number} id History entry id.
|
||||
* @property {string} date Timestamp (ISO).
|
||||
* @property {string} text Human-readable change description.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSupplierRow
|
||||
* @property {string|number} id Supplier link id.
|
||||
* @property {string|number} entityId Supplier id.
|
||||
* @property {string|null} sku Supplier SKU.
|
||||
* @property {string|null} name Supplier name.
|
||||
* @property {number|null} pu Pack unit.
|
||||
* @property {number|null} stock Supplier stock.
|
||||
* @property {number|null} netPrice Net purchase price.
|
||||
* @property {number|null} taxRate Tax rate in percent.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-SKU article change history (`GET /api/article/{sku}/history`).
|
||||
*
|
||||
* The backend returns the full list; pagination happens client-side in
|
||||
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
|
||||
*
|
||||
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
|
||||
*/
|
||||
export const articleHistoryAutomat = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'articleHistory',
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getBySkuHistory(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/history');
|
||||
return requireArray(res.history, 'history');
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||
*
|
||||
* @type {KeyedAutomat<Array<ArticleSupplierRow>>}
|
||||
*/
|
||||
export const articleSupplierAutomat = new KeyedAutomat(
|
||||
null,
|
||||
{},
|
||||
{
|
||||
name: 'articleSuppliers',
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getSuppliers(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||
return requireArray(res.suppliers, 'suppliers');
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
274
src/automats/articleIndexAutomat.js
Normal file
274
src/automats/articleIndexAutomat.js
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* @file articleIndexAutomat.js
|
||||
* @description Company article search index used by the `/artikel` overview,
|
||||
* the manifest picker and the article-list pickers.
|
||||
*
|
||||
* The index is **derived**, not fetched: it is the de-duplicated union of the
|
||||
* articles of every loaded category view. `categoryAutomat.loadTree()` warms
|
||||
* those views right after `GET /api/category/tree` resolves, so the index is
|
||||
* ready without any additional request.
|
||||
*
|
||||
* `GET /api/articleIndex` (whole-company window) and the `/article/:sku/articlelist`
|
||||
* calls are no longer used by this store.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `categoryViewAutomat`: Subscribes to the keyed view store, so a view load,
|
||||
* reload, article creation or article removal re-derives the index.
|
||||
* - `categoryAutomat`: Re-derives once `treeStatus === 'succeeded'` and the
|
||||
* post-tree view preload (`viewsLoading`) settled; `reset()` on company switch.
|
||||
* - `authAutomat`: Resets to idle on `'anon'`.
|
||||
* - `sseAutomat`: `actions.reload()` on an `'articles'` event — now only
|
||||
* re-derives the index from the invalidated category views.
|
||||
* - `artikelAutomat`: `setArticleIndexName(sku, name)` on optimistic renames and
|
||||
* `actions.reset()` on mutation failure rollback.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`.
|
||||
* - `ArticleSearchDialog`, `ArticleViewLists`: `actions.ensureLoaded()` for a
|
||||
* flat article list.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
|
||||
import {
|
||||
categoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
loadAllCategoryViews,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleIndexRecord
|
||||
* @property {string|number} id Article id.
|
||||
* @property {string} sku Article SKU.
|
||||
* @property {string} name Article name.
|
||||
* @property {Array<string>} eans Article EANs (not yet served per category — see below).
|
||||
* @property {string|null} categories Comma-joined category ids.
|
||||
* @property {boolean} isParent Whether the article has child variants.
|
||||
*
|
||||
* @typedef {Object} ArticleIndexState
|
||||
* @property {Array<ArticleIndexRecord>} items Derived index records.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Lifecycle state.
|
||||
* @property {Error|string|null} error Last error.
|
||||
*/
|
||||
|
||||
/** @type {ArticleIndexState} */
|
||||
const initialState = {
|
||||
items: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let articleIndexAutomat;
|
||||
|
||||
/** In-flight `ensureLoaded()` promise so concurrent callers share one rebuild. */
|
||||
let inflightEnsure = null;
|
||||
|
||||
/** Pending coalesced rebuild, scheduled on the microtask queue. */
|
||||
let rebuildScheduled = false;
|
||||
|
||||
/**
|
||||
* Schedules a rebuild on the next microtask.
|
||||
*
|
||||
* Warming every category view fires one notification per view; coalescing keeps
|
||||
* the derivation at roughly O(articles) instead of O(categories × articles).
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function scheduleRebuild() {
|
||||
if (rebuildScheduled) return;
|
||||
rebuildScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
rebuildScheduled = false;
|
||||
actions.rebuild();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a category-view article row into an index record.
|
||||
*
|
||||
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
|
||||
* field stays empty until that endpoint exposes EANs. EAN matching in
|
||||
* `articleSearchAutomat` is therefore not functional yet.
|
||||
*
|
||||
* @param {object} article Raw category-view article row.
|
||||
* @param {number|string|null} categoryId Category the article was loaded from.
|
||||
* @returns {ArticleIndexRecord} Index record.
|
||||
*/
|
||||
function toIndexRecord(article, categoryId) {
|
||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||
return {
|
||||
id: article.id,
|
||||
sku: article.sku,
|
||||
name: article.name,
|
||||
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
||||
categories: categoryId != null ? String(categoryId) : null,
|
||||
isParent: Boolean(article.isParent ?? article.is_parent),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the articles of all loaded category views into unique index records.
|
||||
*
|
||||
* An article assigned to several categories is emitted once, with every category
|
||||
* it was seen in accumulated in `categories`.
|
||||
*
|
||||
* @param {Array<{categoryId: number|string, articles: Array<object>}>} views Loaded view data.
|
||||
* @returns {Array<ArticleIndexRecord>} Deduplicated index records.
|
||||
*/
|
||||
export function buildArticleIndex(views) {
|
||||
const bySku = new Map();
|
||||
|
||||
for (const view of views) {
|
||||
const categoryId = view?.categoryId;
|
||||
for (const article of view?.articles ?? []) {
|
||||
if (article?.sku == null) continue;
|
||||
const key = String(article.sku);
|
||||
const existing = bySku.get(key);
|
||||
if (!existing) {
|
||||
bySku.set(key, toIndexRecord(article, categoryId));
|
||||
continue;
|
||||
}
|
||||
if (categoryId != null) {
|
||||
const ids = new Set(String(existing.categories ?? '').split(',').filter(Boolean));
|
||||
ids.add(String(categoryId));
|
||||
existing.categories = Array.from(ids).join(',');
|
||||
}
|
||||
existing.isParent = Boolean(existing.isParent || (article.isParent ?? article.is_parent));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bySku.values());
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Rebuilds the index from the currently loaded category views.
|
||||
*
|
||||
* Does not hit the network: the data comes from the view cache that
|
||||
* `categoryAutomat.loadTree()` populated.
|
||||
*
|
||||
* @returns {Array<ArticleIndexRecord>} Derived records.
|
||||
*/
|
||||
rebuild() {
|
||||
const items = buildArticleIndex(collectLoadedCategoryViewData());
|
||||
articleIndexAutomat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves once the index holds data, rebuilding it when necessary.
|
||||
*
|
||||
* Waits for the category tree and for the post-tree view preload, so the
|
||||
* returned list is complete rather than a partial early snapshot.
|
||||
*
|
||||
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
|
||||
*/
|
||||
async ensureLoaded() {
|
||||
if (inflightEnsure) return inflightEnsure;
|
||||
if (articleIndexAutomat.state.status === 'ready' && articleIndexAutomat.state.items.length > 0) {
|
||||
return articleIndexAutomat.state.items;
|
||||
}
|
||||
|
||||
articleIndexAutomat.setState({ status: 'loading', error: null });
|
||||
inflightEnsure = (async () => {
|
||||
await whenCategoryTreeReady();
|
||||
// loadTree() already warms every view; this is the idempotent safety net
|
||||
// for a tree that finished loading before this automat subscribed.
|
||||
if (categoryAutomat.state.viewsLoading) {
|
||||
await loadAllCategoryViews();
|
||||
}
|
||||
return actions.rebuild();
|
||||
})()
|
||||
.catch((error) => {
|
||||
articleIndexAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
inflightEnsure = null;
|
||||
});
|
||||
|
||||
return inflightEnsure;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-derives the index after the category views were invalidated by SSE.
|
||||
*
|
||||
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
|
||||
*/
|
||||
async reload() {
|
||||
return actions.ensureLoaded();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without re-deriving it.
|
||||
*
|
||||
* @param {Array<ArticleIndexRecord>} items Records to cache.
|
||||
* @returns {Array<ArticleIndexRecord>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
articleIndexAutomat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/** Reset to idle state (logout / company switch). */
|
||||
reset() {
|
||||
inflightEnsure = null;
|
||||
rebuildScheduled = false;
|
||||
articleIndexAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Derived company article search index.
|
||||
*
|
||||
* @type {Automat<ArticleIndexState>}
|
||||
*/
|
||||
articleIndexAutomat = new Automat(initialState, actions, { name: 'articleIndex' });
|
||||
|
||||
export { articleIndexAutomat };
|
||||
|
||||
// Re-derive whenever a category view loads, reloads or is patched.
|
||||
categoryViews.subscribe(() => {
|
||||
scheduleRebuild();
|
||||
});
|
||||
|
||||
// Re-derive once the tree finished loading and its view preload settled.
|
||||
categoryAutomat.subscribe((state) => {
|
||||
if (state.treeStatus === 'succeeded' && state.viewsLoading === false) {
|
||||
scheduleRebuild();
|
||||
}
|
||||
});
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') actions.reset();
|
||||
});
|
||||
|
||||
/**
|
||||
* Patches a single record's name in the derived index (optimistic update).
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @param {string} name New article name.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function setArticleIndexName(sku, name) {
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items.length) return;
|
||||
articleIndexAutomat.actions.replace(
|
||||
items.map((row) =>
|
||||
String(row.sku) === String(sku) ? { ...row, name } : row,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the derived index records.
|
||||
*
|
||||
* @param {ArticleIndexState} state Automat state.
|
||||
* @returns {Array<ArticleIndexRecord>} Current records.
|
||||
*/
|
||||
export function selectArticleIndexItems(state) {
|
||||
return state.items;
|
||||
}
|
||||
|
||||
export default articleIndexAutomat;
|
||||
120
src/automats/articleSearchAutomat.js
Normal file
120
src/automats/articleSearchAutomat.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @file articleSearchAutomat.js
|
||||
* @description In-memory article search state for the `/artikel` overview and
|
||||
* search pages, querying against the derived `articleIndexAutomat`.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`.
|
||||
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available
|
||||
* before filtering. The index is built from the loaded category views, so a
|
||||
* search never issues a request of its own.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import articleIndexAutomat from './articleIndexAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchResult
|
||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||
* @property {number} totalCount Number of matching records.
|
||||
* @property {Array<object>} recordset Matching article index records.
|
||||
* @property {Error|null} error Search failure, or `null`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchState
|
||||
* @property {ArticleSearchResult} result Latest search outcome.
|
||||
* @property {string} target Active search term.
|
||||
* @property {boolean} loading Whether a search is in flight.
|
||||
*/
|
||||
|
||||
/** @type {ArticleSearchResult} */
|
||||
const emptyResult = {
|
||||
categoryFacets: {},
|
||||
totalCount: 0,
|
||||
recordset: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
/** @type {ArticleSearchState} */
|
||||
const initialState = {
|
||||
result: emptyResult,
|
||||
target: '',
|
||||
loading: false,
|
||||
};
|
||||
|
||||
let articleSearchAutomat;
|
||||
|
||||
/**
|
||||
* Matches one index record against a lowercased search term.
|
||||
*
|
||||
* @param {object} row Article index record.
|
||||
* @param {string} query Lowercased search term.
|
||||
* @returns {boolean} Whether the record matches.
|
||||
*/
|
||||
function matches(row, query) {
|
||||
if (String(row.name ?? '').toLowerCase().includes(query)) return true;
|
||||
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
|
||||
return (row.eans ?? []).some((ean) => String(ean).includes(query));
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Runs a client-side search over the article index.
|
||||
*
|
||||
* The index is already derived from the cached category views, so this does
|
||||
* not hit the network once the tree has loaded.
|
||||
*
|
||||
* @param {string} target Search term; an empty string returns everything.
|
||||
* @returns {Promise<ArticleSearchResult>} Search outcome.
|
||||
*/
|
||||
async search(target) {
|
||||
articleSearchAutomat.setState({ target, loading: true });
|
||||
try {
|
||||
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
||||
const query = String(target ?? '').trim().toLowerCase();
|
||||
const recordset = query
|
||||
? articles.filter((row) => matches(row, query))
|
||||
: articles;
|
||||
const result = {
|
||||
categoryFacets: {},
|
||||
totalCount: recordset.length,
|
||||
recordset,
|
||||
error: null,
|
||||
};
|
||||
articleSearchAutomat.setState({ result, loading: false });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const result = { ...emptyResult, error };
|
||||
articleSearchAutomat.setState({ result, loading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset the result set. */
|
||||
reset() {
|
||||
articleSearchAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Article search state for the `/artikel` overview page.
|
||||
*
|
||||
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
|
||||
* instead of receiving a callback.
|
||||
*
|
||||
* @type {Automat<ArticleSearchState>}
|
||||
*/
|
||||
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
||||
|
||||
/**
|
||||
* Selects the latest search result.
|
||||
*
|
||||
* @param {ArticleSearchState} state Automat state.
|
||||
* @returns {ArticleSearchResult} Search outcome.
|
||||
*/
|
||||
export function selectSearchResult(state) {
|
||||
return state.result;
|
||||
}
|
||||
|
||||
export default articleSearchAutomat;
|
||||
@@ -1,6 +1,26 @@
|
||||
/**
|
||||
* @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';
|
||||
import authAutomat from './authAutomat';
|
||||
import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArtikelState
|
||||
@@ -125,6 +145,117 @@ const actions = {
|
||||
artikelAutomat.setState({ bySku });
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/article/:sku with optimistic name handling.
|
||||
*
|
||||
* A `name` in the body is applied to both caches immediately so the tile
|
||||
* does not flicker, then rolled back from the server on failure.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock? }`).
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async update(sku, body) {
|
||||
const key = String(sku);
|
||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||
if (nameChange) {
|
||||
setArticleIndexName(key, body.name);
|
||||
actions.setArticleName(key, body.name);
|
||||
}
|
||||
try {
|
||||
const res = await articleApi.update(key, body);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||
articleIndexAutomat.actions.reset();
|
||||
if (hasArticle(key)) {
|
||||
void actions.loadArticle(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/eans → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<string>} eans Replacement EAN list.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setEans(sku, eans) {
|
||||
const res = await articleApi.setEansBySku(String(sku), eans);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {{ brandId: number|null }} body Brand payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setBrand(sku, body) {
|
||||
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/categories → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<number>} categoryIds Replacement category ids.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setCategories(sku, categoryIds) {
|
||||
const res = await articleApi.setCategories(String(sku), categoryIds);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/prices → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {Array<object>} prices Net price rows.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setPrices(sku, prices) {
|
||||
const res = await articleApi.setPrices(String(sku), prices);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {object} body Weight payload.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setWeight(sku, body) {
|
||||
const res = await articleApi.setWeight(String(sku), body);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/track_stock → refreshes the cached article.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @param {boolean|object} body Flag or full body.
|
||||
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||
*/
|
||||
async setTrackStock(sku, body) {
|
||||
const res = await articleApi.setTrackStock(String(sku), body);
|
||||
if (res?.article) actions.setArticle(res.article);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets every cached article.
|
||||
*
|
||||
@@ -187,4 +318,12 @@ export function selectArticle(sku) {
|
||||
return artikelAutomat.select(createArticleSelector(sku));
|
||||
}
|
||||
|
||||
// Drop cached articles on logout. A company switch keeps them; the SSE hub
|
||||
// reconnects and revalidates whatever is on screen.
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
actions.clear();
|
||||
}
|
||||
});
|
||||
|
||||
export default artikelAutomat;
|
||||
|
||||
@@ -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,28 @@
|
||||
/**
|
||||
* @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
|
||||
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
|
||||
* the category views once the session is established
|
||||
* - `createListAutomat` instances (`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';
|
||||
|
||||
@@ -191,4 +216,16 @@ const actions = {
|
||||
*/
|
||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
|
||||
/**
|
||||
* Re-reads `GET /api/auth/me` from the server.
|
||||
*
|
||||
* Called after login, logout, and company switches so the automat reflects
|
||||
* the current server session rather than an optimistic guess.
|
||||
*
|
||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
|
||||
*/
|
||||
export function bootstrapAuthMe() {
|
||||
return auth.me();
|
||||
}
|
||||
|
||||
export default authAutomat;
|
||||
|
||||
@@ -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,15 +1,49 @@
|
||||
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.
|
||||
* - `articleIndexAutomat`: Derives its client-side search index from the union of
|
||||
* the loaded category views, so the index costs no requests of its own and
|
||||
* `GET /api/articleIndex` is no longer used.
|
||||
*/
|
||||
|
||||
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,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
cleanCategorySlugPath,
|
||||
resolveCategoryIdFromSlugPath,
|
||||
ROOT_CATEGORY_NAME,
|
||||
} from '../utils/categoryUtils';
|
||||
|
||||
/**
|
||||
@@ -21,6 +55,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} */
|
||||
@@ -32,85 +67,31 @@ const initialState = {
|
||||
renameMode: false,
|
||||
treeStatus: 'idle',
|
||||
error: null,
|
||||
viewsLoading: false,
|
||||
};
|
||||
|
||||
let categoryAutomat;
|
||||
let _treeLoadingPromise = null;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Returns the parsed category map and root id, or `null` while the tree is
|
||||
* still loading. Replaces the former `StorageManager.getCategoryState()`.
|
||||
*
|
||||
* @returns {{ categories: Record<string,object>, rootCategoryId: unknown }|null}
|
||||
*/
|
||||
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') {
|
||||
await categoryAutomat.actions.loadTree();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
export function getCategoryState() {
|
||||
const state = categoryAutomat.state;
|
||||
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
|
||||
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a per-route category view automat for the given slug and categoryId.
|
||||
* (Backward compatibility helper)
|
||||
* Resolves once the category tree has loaded.
|
||||
*
|
||||
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
|
||||
*/
|
||||
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
|
||||
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();
|
||||
export function whenCategoryTreeReady() {
|
||||
return waitForCategoryTree();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||
@@ -118,31 +99,57 @@ export function disposeAllCategoryViewAutomats() {
|
||||
const actions = {
|
||||
/** Load the category tree for the currently selected company. */
|
||||
async loadTree() {
|
||||
const company = companyAutomat.state.current;
|
||||
let company = companyAutomat.state.current;
|
||||
if (!company && companyAutomat.state.status === 'idle') {
|
||||
await companyAutomat.actions.load();
|
||||
company = companyAutomat.state.current;
|
||||
}
|
||||
if (!company) return;
|
||||
const { id: companyId, rootCategoryId: companyRootId } = company;
|
||||
if (!companyRootId) return;
|
||||
|
||||
if (_treeLoadingPromise) return _treeLoadingPromise;
|
||||
|
||||
categoryAutomat.setState({ treeStatus: 'loading', error: null });
|
||||
try {
|
||||
const data = await categoryApi.getTree();
|
||||
const parsed = parseCategoryTreeResponse(data, companyRootId);
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'succeeded',
|
||||
companyId,
|
||||
languageId: parsed.languageId,
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
error: null,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(parsed.categories);
|
||||
} catch (err) {
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'failed',
|
||||
error: err?.message ?? String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
_treeLoadingPromise = (async () => {
|
||||
try {
|
||||
const data = await categoryApi.getTree();
|
||||
const parsed = parseCategoryTreeResponse(data, companyRootId);
|
||||
categoryAutomat.setState({
|
||||
treeStatus: 'succeeded',
|
||||
companyId,
|
||||
languageId: parsed.languageId,
|
||||
categories: parsed.categories,
|
||||
rootCategoryId: parsed.rootCategoryId,
|
||||
error: null,
|
||||
viewsLoading: true,
|
||||
});
|
||||
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;
|
||||
}
|
||||
})().finally(() => {
|
||||
_treeLoadingPromise = null;
|
||||
});
|
||||
|
||||
return _treeLoadingPromise;
|
||||
},
|
||||
|
||||
/** Toggle rename/edit mode for the category grid. */
|
||||
@@ -150,6 +157,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 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -162,7 +178,7 @@ const actions = {
|
||||
const { categories } = categoryAutomat.state;
|
||||
const updated = { ...(categories ?? {}), [String(node.id)]: node };
|
||||
categoryAutomat.setState({ categories: updated });
|
||||
refreshLoadedCategoryViewChildren(updated);
|
||||
refreshCategoryViewChildren(updated);
|
||||
return node;
|
||||
},
|
||||
|
||||
@@ -181,7 +197,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;
|
||||
},
|
||||
|
||||
@@ -192,16 +208,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;
|
||||
},
|
||||
|
||||
@@ -217,16 +224,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;
|
||||
},
|
||||
|
||||
@@ -248,7 +246,7 @@ const actions = {
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(nextCategories);
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return updated;
|
||||
},
|
||||
|
||||
@@ -269,7 +267,7 @@ const actions = {
|
||||
}
|
||||
}
|
||||
categoryAutomat.setState({ categories: patched });
|
||||
refreshLoadedCategoryViewChildren(patched);
|
||||
refreshCategoryViewChildren(patched);
|
||||
return ordered;
|
||||
},
|
||||
|
||||
@@ -290,7 +288,7 @@ const actions = {
|
||||
categoryAutomat.setState({
|
||||
categories: updated,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(updated);
|
||||
refreshCategoryViewChildren(updated);
|
||||
return moved;
|
||||
},
|
||||
|
||||
@@ -305,7 +303,7 @@ const actions = {
|
||||
const next = { ...categories };
|
||||
delete next[String(categoryId)];
|
||||
categoryAutomat.setState({ categories: next });
|
||||
refreshLoadedCategoryViewChildren(next);
|
||||
refreshCategoryViewChildren(next);
|
||||
return categoryId;
|
||||
},
|
||||
|
||||
@@ -323,7 +321,7 @@ const actions = {
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(nextCategories);
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return updated;
|
||||
},
|
||||
|
||||
@@ -341,7 +339,7 @@ const actions = {
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(nextCategories);
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
@@ -362,7 +360,7 @@ const actions = {
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshLoadedCategoryViewChildren(nextCategories);
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
@@ -370,15 +368,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);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -400,12 +390,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);
|
||||
},
|
||||
};
|
||||
@@ -418,6 +409,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));
|
||||
@@ -430,8 +424,11 @@ companyAutomat.subscribe((company) => {
|
||||
const id = company.current?.id ?? null;
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
const isCompanySwitch = _lastCompanyId !== null;
|
||||
_lastCompanyId = id;
|
||||
disposeAllCategoryViewAutomats();
|
||||
if (isCompanySwitch) {
|
||||
disposeAllCategoryViewAutomats();
|
||||
}
|
||||
void categoryAutomat.actions.loadTree();
|
||||
}
|
||||
if (!id && _lastCompanyId) {
|
||||
@@ -460,4 +457,14 @@ export {
|
||||
} from '../utils/categoryUtils';
|
||||
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
|
||||
|
||||
// Re-export category view automats and actions
|
||||
export {
|
||||
categoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
invalidateAllCategoryViews,
|
||||
disposeAllCategoryViewAutomats,
|
||||
loadAllCategoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
} 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';
|
||||
|
||||
165
src/automats/createListAutomat.js
Normal file
165
src/automats/createListAutomat.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @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:
|
||||
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
||||
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
|
||||
*
|
||||
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
|
||||
* category views rather than fetched from a whole-company endpoint.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ListAutomatState
|
||||
* @property {Array<object>} items Loaded records (empty until the first success).
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a company-scoped list store following the brand/unit/warehouse
|
||||
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
||||
* silently, and `reset()` clears on logout.
|
||||
*
|
||||
* This replaces the stale-while-revalidate list caches that previously lived
|
||||
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
||||
* customergroup, number).
|
||||
*
|
||||
* @template T
|
||||
* @param {object} options Factory options.
|
||||
* @param {string} options.name Automat registry name (also used for logging).
|
||||
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
||||
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
||||
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
||||
*/
|
||||
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
||||
const initialState = { items: [], status: 'idle', error: null };
|
||||
|
||||
let automat;
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Fetches and normalizes the record list.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Records from the backend.
|
||||
*/
|
||||
const fetchRecords = async () => {
|
||||
const items = await fetchItems();
|
||||
return sort ? sort(items) : items;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the list, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the list while keeping the current state, so
|
||||
* subscribers do not flash a loading state.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Fresh records.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<T>} items Records to cache.
|
||||
* @returns {Array<T>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-fetches the list once the selected company is known.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
ensureLoaded() {
|
||||
if (automat.state.status === 'ready') {
|
||||
return Promise.resolve(automat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout). */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
automat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name });
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the record list of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {Array<object>} Current records.
|
||||
*/
|
||||
export function selectListItems(automat) {
|
||||
return automat.state.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the load status of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
||||
*/
|
||||
export function selectListStatus(automat) {
|
||||
return automat.state.status;
|
||||
}
|
||||
41
src/automats/customergroupAutomat.js
Normal file
41
src/automats/customergroupAutomat.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomerGroup
|
||||
* @property {string|number} id Customer group id.
|
||||
* @property {string} name Group name.
|
||||
* @property {number} sort Sort index.
|
||||
* @property {boolean} editable False for reserved POS / Lieferanten groups.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company customer groups (`GET /api/customergroup`).
|
||||
*
|
||||
* Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must
|
||||
* not be renamed or deleted by the UI.
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const customergroupAutomat = createListAutomat({
|
||||
name: 'customergroup',
|
||||
fetch: async () => {
|
||||
const data = await customergroupApi.list();
|
||||
return data.customergroups ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)),
|
||||
});
|
||||
|
||||
export default customergroupAutomat;
|
||||
43
src/automats/deliveryAutomat.js
Normal file
43
src/automats/deliveryAutomat.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} DeliverySummary
|
||||
* @property {string|number} id Delivery id.
|
||||
* @property {string} number Server-allocated manifest number.
|
||||
* @property {string|null} externalNumber User-entered reference.
|
||||
* @property {string} type Always `'delivery'`.
|
||||
* @property {string|number|null} supplierId Supplier id.
|
||||
* @property {string} date Delivery date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company goods receipts (`GET /api/delivery`).
|
||||
*
|
||||
* Backs the delivery list page and the quote/order manifest lists share the
|
||||
* same pattern through their own automats.
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const deliveryAutomat = createListAutomat({
|
||||
name: 'delivery',
|
||||
fetch: async () => {
|
||||
const data = await deliveryApi.list();
|
||||
return data.deliveries ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default deliveryAutomat;
|
||||
@@ -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';
|
||||
|
||||
|
||||
42
src/automats/entityAutomat.js
Normal file
42
src/automats/entityAutomat.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Entity
|
||||
* @property {string|number} id Entity id.
|
||||
* @property {string|number} customergroupId Owning customer group id.
|
||||
* @property {string|null} firstName First name.
|
||||
* @property {string} name Last / company name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company customers and contacts (`GET /api/entity`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const entityAutomat = createListAutomat({
|
||||
name: 'entity',
|
||||
fetch: async () => {
|
||||
const data = await entityApi.list();
|
||||
return data.entities ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => {
|
||||
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||||
if (byName !== 0) return byName;
|
||||
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||||
}),
|
||||
});
|
||||
|
||||
export default entityAutomat;
|
||||
@@ -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';
|
||||
@@ -23,6 +29,33 @@ export {
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat';
|
||||
|
||||
/**
|
||||
* Derived article search index: the union of every loaded category view.
|
||||
* Replaces the former `GET /api/articleIndex` list store.
|
||||
*/
|
||||
export {
|
||||
default as articleIndexAutomat,
|
||||
setArticleIndexName,
|
||||
buildArticleIndex,
|
||||
selectArticleIndexItems,
|
||||
} from './articleIndexAutomat';
|
||||
export { default as deliveryAutomat } from './deliveryAutomat';
|
||||
export { default as quoteAutomat } from './quoteAutomat';
|
||||
export { default as orderAutomat } from './orderAutomat';
|
||||
export { default as entityAutomat } from './entityAutomat';
|
||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
/** Per-SKU article detail views keyed by SKU. */
|
||||
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
|
||||
/** Single shared GET /api/events connection. */
|
||||
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
|
||||
|
||||
export { default as artikelAutomat } from './artikelAutomat';
|
||||
export {
|
||||
getArticle,
|
||||
@@ -65,10 +98,27 @@ export {
|
||||
categoryDisplayName,
|
||||
categorySlug,
|
||||
normalizeCategoryMap,
|
||||
getCategoryState,
|
||||
whenCategoryTreeReady,
|
||||
} from './categoryAutomat';
|
||||
|
||||
/**
|
||||
* Per-category article views keyed by slug path, warmed in bulk by
|
||||
* `categoryAutomat.loadTree()` once the tree succeeded.
|
||||
*/
|
||||
export {
|
||||
default as categoryViews,
|
||||
attachCategoryTree,
|
||||
categoryViewSlugPath,
|
||||
collectLoadedCategoryViewData,
|
||||
loadAllCategoryViews,
|
||||
reloadLoadedCategoryViews,
|
||||
getOrCreateCategoryAutomat,
|
||||
disposeAllCategoryViewAutomats,
|
||||
invalidateAllCategoryViews,
|
||||
} from './categoryAutomat';
|
||||
refreshCategoryViewChildren,
|
||||
appendArticlesToCategoryViews,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
||||
@@ -85,4 +135,82 @@ 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`:
|
||||
* - `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
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
|
||||
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
|
||||
* - `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';
|
||||
|
||||
/**
|
||||
|
||||
41
src/automats/numberAutomat.js
Normal file
41
src/automats/numberAutomat.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberSequence
|
||||
* @property {string|number} id Sequence id.
|
||||
* @property {string} type Document type key (e.g. `delivery`, `entity`).
|
||||
* @property {string} prefix Prefix rendered before the counter.
|
||||
* @property {number} padding Zero-padding width.
|
||||
* @property {number} next Next counter value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company document number sequences (`GET /api/number`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const numberAutomat = createListAutomat({
|
||||
name: 'number',
|
||||
fetch: async () => {
|
||||
const data = await numberApi.list();
|
||||
return data.numbers ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
||||
),
|
||||
});
|
||||
|
||||
export default numberAutomat;
|
||||
39
src/automats/orderAutomat.js
Normal file
39
src/automats/orderAutomat.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} OrderSummary
|
||||
* @property {string|number} id Order id.
|
||||
* @property {string} number Order number.
|
||||
* @property {string} type Always `'order'`.
|
||||
* @property {string|number|null} entityId Customer id.
|
||||
* @property {string} date Delivery date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company sales orders (`GET /api/order`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const orderAutomat = createListAutomat({
|
||||
name: 'order',
|
||||
fetch: async () => {
|
||||
const data = await orderApi.list();
|
||||
return data.orders ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default orderAutomat;
|
||||
@@ -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';
|
||||
|
||||
39
src/automats/quoteAutomat.js
Normal file
39
src/automats/quoteAutomat.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* @typedef {Object} QuoteSummary
|
||||
* @property {string|number} id Quote id.
|
||||
* @property {string} number Quote number.
|
||||
* @property {string} type Always `'quote'`.
|
||||
* @property {string|number|null} entityId Customer id.
|
||||
* @property {string} date Expiry date (ISO).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company sales quotes (`GET /api/quote`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const quoteAutomat = createListAutomat({
|
||||
name: 'quote',
|
||||
fetch: async () => {
|
||||
const data = await quoteApi.list();
|
||||
return data.quotes ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||
});
|
||||
|
||||
export default quoteAutomat;
|
||||
293
src/automats/sseAutomat.js
Normal file
293
src/automats/sseAutomat.js
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @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;
|
||||
* the index is derived from the reloaded category views, so no extra request.
|
||||
* - `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';
|
||||
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
import { articleIndexAutomat } from './articleIndexAutomat';
|
||||
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
|
||||
import warehouseAutomat from './warehouseAutomat';
|
||||
import attributeAutomat from './attributeAutomat';
|
||||
import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} SseState
|
||||
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
||||
* @property {string|null} companyId Company the stream is bound to.
|
||||
*/
|
||||
|
||||
/** @type {SseState} */
|
||||
const initialState = {
|
||||
status: 'disconnected',
|
||||
companyId: null,
|
||||
};
|
||||
|
||||
/** Event name prefix for per-article signals. */
|
||||
const ARTICLE_EVENT_PREFIX = 'article-';
|
||||
|
||||
let sseAutomat;
|
||||
/** @type {EventSource | null} */
|
||||
let eventSource = null;
|
||||
|
||||
/**
|
||||
* Handlers for statically named events (`category`, `articles`, …).
|
||||
* @type {Map<string, Set<(event: MessageEvent) => void>>}
|
||||
*/
|
||||
const handlers = new Map();
|
||||
|
||||
/**
|
||||
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
||||
* at least one view is open on that SKU.
|
||||
* @type {Map<string, Set<(sku: string) => void>>}
|
||||
*/
|
||||
const articleWatchers = new Map();
|
||||
|
||||
function disconnect() {
|
||||
if (!eventSource) return;
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches every registered handler to a freshly opened stream.
|
||||
*
|
||||
* @param {EventSource} es Open event stream.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachHandlers(es) {
|
||||
for (const [event, set] of handlers) {
|
||||
for (const handler of set) {
|
||||
es.addEventListener(event, handler);
|
||||
}
|
||||
}
|
||||
for (const [sku] of articleWatchers) {
|
||||
attachArticleListener(sku);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the single `article-<sku>` listener for a watched SKU.
|
||||
*
|
||||
* The listener resolves the handler set at call time, so a SKU that lost all
|
||||
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
||||
* reclaimed whenever the stream is rebuilt on a company switch.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
function attachArticleListener(sku) {
|
||||
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
||||
const set = articleWatchers.get(sku);
|
||||
if (!set || set.size === 0) return;
|
||||
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
||||
for (const handler of set) handler(changed);
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL('/api/events', window.location.origin).href;
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
es.addEventListener('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||
console.error('[sse] category tree refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
|
||||
// Article index changed: refresh the index and open category views.
|
||||
es.addEventListener('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('supplier', () => {
|
||||
void supplierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = () => {
|
||||
console.error('[sse] event stream error');
|
||||
sseAutomat.setState({ status: 'error' });
|
||||
};
|
||||
|
||||
eventSource = es;
|
||||
attachHandlers(es);
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Closes and reopens the event stream. Required after a company switch,
|
||||
* because the stream is scoped to the selected company at connect time.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
reconnect() {
|
||||
disconnect();
|
||||
connect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a handler for a statically named backend event.
|
||||
*
|
||||
* Registration order does not matter: handlers added while disconnected are
|
||||
* attached on the next connect.
|
||||
*
|
||||
* @param {string} event Event name (e.g. `category`, `articles`).
|
||||
* @param {(event: MessageEvent) => void} handler Signal handler.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
on(event, handler) {
|
||||
let set = handlers.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
handlers.set(event, set);
|
||||
}
|
||||
set.add(handler);
|
||||
eventSource?.addEventListener(event, handler);
|
||||
return () => {
|
||||
eventSource?.removeEventListener(event, handler);
|
||||
set.delete(handler);
|
||||
if (set.size === 0) handlers.delete(event);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one SKU.
|
||||
*
|
||||
* The backend emits this event whenever that article changes. Exactly one
|
||||
* listener is attached per SKU while at least one caller is subscribed.
|
||||
*
|
||||
* @param {string} sku Article SKU to watch.
|
||||
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
watchArticle(sku, handler) {
|
||||
const key = String(sku);
|
||||
let set = articleWatchers.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
articleWatchers.set(key, set);
|
||||
attachArticleListener(key);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
return () => {
|
||||
const current = articleWatchers.get(key);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) articleWatchers.delete(key);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global `GET /api/events` connection.
|
||||
*
|
||||
* Owns the single `EventSource` for the tab and dispatches signals into the
|
||||
* domain automats. Reconnects whenever the selected company changes.
|
||||
*
|
||||
* @type {Automat<SseState>}
|
||||
*/
|
||||
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||
|
||||
/**
|
||||
* Refreshes the cached article plus any open history/supplier view.
|
||||
*
|
||||
* Background refreshes stay silent: an article that is not on screen has no
|
||||
* visible subscriber to notify, so failures are swallowed.
|
||||
*
|
||||
* @param {string} sku Article SKU.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function invalidateArticle(sku) {
|
||||
const key = String(sku);
|
||||
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
||||
if (articleHistoryAutomat.has(key)) {
|
||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
if (articleSupplierAutomat.has(key)) {
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
if (companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
});
|
||||
|
||||
export default sseAutomat;
|
||||
@@ -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';
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Mirrored in index.html and index.css for paint before JS loads.
|
||||
export const MAIN_BACKGROUND = '#c5ddd8';
|
||||
export const MAIN_BACKGROUND = '#85b7ad';
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Component } from 'react';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
|
||||
let sessionBootstrapped = false;
|
||||
|
||||
class AuthBootstrap extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
componentDidMount() {
|
||||
if (sessionBootstrapped) return;
|
||||
sessionBootstrapped = true;
|
||||
|
||||
this.context.authMePromise
|
||||
bootstrapAuthMe()
|
||||
.then((session) => {
|
||||
if (session.forbidden) {
|
||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||
|
||||
@@ -25,15 +25,14 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from './BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||
import { whenCategoryTreeReady } from '../automats/categoryAutomat';
|
||||
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
export default class CompanySettingsDialog extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -84,13 +83,7 @@ export default class CompanySettingsDialog extends Component {
|
||||
|
||||
async loadCompany() {
|
||||
try {
|
||||
const session = await this.context.authMePromise;
|
||||
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
||||
const companyData = await this.context.companyPromise;
|
||||
const companyRow = requireDefined(
|
||||
Array.isArray(companyData) ? companyData.find((c) => c.id === companyId) : companyData,
|
||||
'company',
|
||||
);
|
||||
const companyRow = await companyAutomat.actions.load();
|
||||
this.syncFormFromCompany(companyRow);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -99,7 +92,7 @@ export default class CompanySettingsDialog extends Component {
|
||||
|
||||
async loadTerminals() {
|
||||
try {
|
||||
const terminals = await this.context.posterminalPromise;
|
||||
const terminals = await posterminalAutomat.actions.load();
|
||||
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -112,17 +105,17 @@ export default class CompanySettingsDialog extends Component {
|
||||
|
||||
this.setState({ creatingTerminal: true });
|
||||
try {
|
||||
const data = await this.context.posterminal.upsert({ name: trimmed });
|
||||
requireObject(data, 'PUT /api/posterminal');
|
||||
requireObject(data.posterminal, 'posterminal');
|
||||
requireDefined(data.posterminal.id, 'posterminal.id');
|
||||
await posterminalAutomat.actions.create({ name: trimmed });
|
||||
|
||||
if (enableAllForNew) {
|
||||
await this.context.posterminal.setOnAll(data.posterminal.id);
|
||||
await this.context.updateCategoryTree();
|
||||
const created = posterminalAutomat.state.terminals.at(-1);
|
||||
if (created?.id != null) {
|
||||
await posterminalAutomat.actions.enableAll({ terminalId: created.id });
|
||||
await whenCategoryTreeReady();
|
||||
}
|
||||
}
|
||||
|
||||
const terminals = await this.context.updatePosterminal();
|
||||
const terminals = await posterminalAutomat.actions.load();
|
||||
this.setState({ newTerminalName: '', terminals: Array.isArray(terminals) ? terminals : [] });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -133,8 +126,8 @@ export default class CompanySettingsDialog extends Component {
|
||||
|
||||
async handleDeleteTerminal(terminal) {
|
||||
try {
|
||||
await this.context.posterminal.delete(terminal.id);
|
||||
const terminals = await this.context.updatePosterminal();
|
||||
await posterminalAutomat.actions.delete(terminal.id);
|
||||
const terminals = await posterminalAutomat.actions.load();
|
||||
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -160,15 +153,12 @@ export default class CompanySettingsDialog extends Component {
|
||||
|
||||
this.setState({ busy: true, error: null });
|
||||
try {
|
||||
const session = await this.context.authMePromise;
|
||||
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
||||
await this.context.company.update(companyId, {
|
||||
await companyAutomat.actions.update({
|
||||
name: name.trim(),
|
||||
iso639,
|
||||
taxrate: parsedTaxrate,
|
||||
reducedtaxrate: parsedReducedtaxrate,
|
||||
});
|
||||
await this.context.updateCompany();
|
||||
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -34,8 +34,9 @@ import TuneIcon from '@mui/icons-material/Tune';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import BackendError from './BackendError';
|
||||
import { company, number as numberApi } from '../api';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import numberAutomat from '../automats/numberAutomat';
|
||||
import {
|
||||
defaultIso639,
|
||||
ISO639_OPTIONS,
|
||||
@@ -105,8 +106,6 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
|
||||
}
|
||||
|
||||
class CompanySetupDialog extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -149,7 +148,7 @@ class CompanySetupDialog extends Component {
|
||||
this.setState({ busy: true, error: null });
|
||||
try {
|
||||
await authAutomat.actions.selectCompany(companyId);
|
||||
await this.context.updateAuthMe();
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
} finally {
|
||||
@@ -223,9 +222,9 @@ class CompanySetupDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
await this.context.updateAuthMe();
|
||||
await this.context.updateCompany();
|
||||
await this.context.updateNumber?.();
|
||||
await bootstrapAuthMe();
|
||||
await companyAutomat.actions.load();
|
||||
await numberAutomat.actions.reload().catch(() => {});
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
} finally {
|
||||
@@ -236,7 +235,7 @@ class CompanySetupDialog extends Component {
|
||||
async handleLogout() {
|
||||
try {
|
||||
await authAutomat.actions.logout();
|
||||
await this.context.updateAuthMe();
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ import Typography from '@mui/material/Typography';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyErrorMessage } from '../utils/notify';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import {
|
||||
abortGoogleSignIn,
|
||||
FEDCM_BLOCKED_MESSAGE,
|
||||
@@ -20,8 +19,6 @@ import {
|
||||
import quixposLogo from '../assets/quixpos-logo.png';
|
||||
|
||||
class LoginPage extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { ...authAutomat.state, busy: false };
|
||||
@@ -50,7 +47,7 @@ class LoginPage extends PureComponent {
|
||||
});
|
||||
this.setState({ busy: true });
|
||||
await authAutomat.actions.loginWithGoogle(idToken);
|
||||
await this.context.updateAuthMe();
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
if (err instanceof GoogleSignInCancelledError) return;
|
||||
logError('[login] Google sign-in failed', {
|
||||
|
||||
140
src/components/TextInput.jsx
Normal file
140
src/components/TextInput.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Component } from 'react';
|
||||
import TextField from '@mui/material/TextField';
|
||||
|
||||
function normalizeSx(s) {
|
||||
if (!s || typeof s !== 'object' || Array.isArray(s)) return s;
|
||||
const bg = s.bgcolor ?? s.backgroundColor;
|
||||
if (bg && bg !== 'transparent') {
|
||||
const { bgcolor: _bg, backgroundColor: _bgc, ...rest } = s;
|
||||
return {
|
||||
...rest,
|
||||
bgcolor: 'transparent',
|
||||
'& .MuiInputBase-root': {
|
||||
bgcolor: bg,
|
||||
...(typeof rest['& .MuiInputBase-root'] === 'object' ? rest['& .MuiInputBase-root'] : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function resolveSx(s) {
|
||||
if (!s) return undefined;
|
||||
if (Array.isArray(s)) return s.map(resolveSx);
|
||||
return normalizeSx(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* TextInput:
|
||||
* Reusable Class Component wrapping MUI TextField with the harmonized
|
||||
* style of EANs (small outlined, shrink label by default, transparent root)
|
||||
* and complete suppression of browser autocomplete/autofill popups.
|
||||
*/
|
||||
export default class TextInput extends Component {
|
||||
state = {
|
||||
// Starting as readOnly prevents Chrome/Safari/Firefox from attaching autofill popup listeners
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
handleFocus = (e) => {
|
||||
if (this.state.readOnly && !this.props.readOnly && !this.props.slotProps?.htmlInput?.readOnly) {
|
||||
this.setState({ readOnly: false });
|
||||
}
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(e);
|
||||
}
|
||||
};
|
||||
|
||||
handleMouseDown = (e) => {
|
||||
if (this.state.readOnly && !this.props.readOnly && !this.props.slotProps?.htmlInput?.readOnly) {
|
||||
this.setState({ readOnly: false });
|
||||
}
|
||||
if (this.props.onMouseDown) {
|
||||
this.props.onMouseDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
slotProps,
|
||||
inputProps,
|
||||
InputProps,
|
||||
InputLabelProps,
|
||||
inputRef,
|
||||
label,
|
||||
size = 'small',
|
||||
onFocus,
|
||||
onMouseDown,
|
||||
readOnly: propReadOnly,
|
||||
sx,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const isSelect = Boolean(rest.select);
|
||||
const isAutocomplete = Boolean(inputProps?.['aria-autocomplete'] || rest['aria-autocomplete']);
|
||||
const effectiveReadOnly = (isSelect || isAutocomplete)
|
||||
? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false)
|
||||
: (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly);
|
||||
|
||||
const htmlInput = isSelect
|
||||
? {
|
||||
readOnly: effectiveReadOnly,
|
||||
...slotProps?.htmlInput,
|
||||
...inputProps,
|
||||
}
|
||||
: {
|
||||
autoComplete: 'one-time-code',
|
||||
autoCorrect: 'off',
|
||||
autoCapitalize: 'off',
|
||||
spellCheck: false,
|
||||
'aria-autocomplete': 'none',
|
||||
'data-lpignore': 'true',
|
||||
'data-1p-ignore': 'true',
|
||||
'data-bwignore': 'true',
|
||||
'data-form-type': 'other',
|
||||
readOnly: effectiveReadOnly,
|
||||
...slotProps?.htmlInput,
|
||||
...inputProps,
|
||||
};
|
||||
|
||||
const resolvedInputProps = (InputProps || slotProps?.input) ? {
|
||||
...slotProps?.input,
|
||||
...InputProps,
|
||||
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
|
||||
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
|
||||
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
|
||||
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
|
||||
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
|
||||
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
|
||||
} : undefined;
|
||||
|
||||
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
||||
|
||||
const mergedSlotProps = {
|
||||
...restSlotProps,
|
||||
...(resolvedInputProps ? { input: resolvedInputProps } : {}),
|
||||
inputLabel: {
|
||||
...(label ? { shrink: true } : {}),
|
||||
...InputLabelProps,
|
||||
...slotProps?.inputLabel,
|
||||
},
|
||||
htmlInput,
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
size={size}
|
||||
label={label}
|
||||
inputRef={inputRef}
|
||||
autoComplete="one-time-code"
|
||||
onFocus={this.handleFocus}
|
||||
onMouseDown={this.handleMouseDown}
|
||||
slotProps={mergedSlotProps}
|
||||
sx={resolveSx(sx)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { TextInput };
|
||||
@@ -12,16 +12,13 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import { helpAutomat, toggleHelp } from '../automats';
|
||||
import CompanySettingsDialog from './CompanySettingsDialog';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
class UserMenu extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -87,7 +84,7 @@ class UserMenu extends PureComponent {
|
||||
this.setState({ anchorEl: null });
|
||||
try {
|
||||
await authAutomat.actions.logout();
|
||||
await this.context.updateAuthMe();
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
@@ -24,8 +24,7 @@ import BrokenImageIcon from '@mui/icons-material/BrokenImage';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { serper } from '../../api';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { serper, article as articleApi } from '../../api';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
import { articleWithUploadedPhoto } from '../../utils/articleDetail';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
@@ -49,8 +48,6 @@ function computeDefaultQuery(article) {
|
||||
* Dialog that searches Google Images via Serper.dev SSE stream for article photos.
|
||||
*/
|
||||
export default class ArticleImageSearchDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -236,7 +233,7 @@ export default class ArticleImageSearchDialog extends PureComponent {
|
||||
const file = avifBlob instanceof File
|
||||
? avifBlob
|
||||
: new File([avifBlob], 'article-photo.webp', { type: avifBlob.type || 'image/webp' });
|
||||
const res = await this.context.article.addImage(articleData.sku, file);
|
||||
const res = await articleApi.uploadImage(articleData.sku, file);
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.image, 'image');
|
||||
|
||||
@@ -358,9 +355,9 @@ export default class ArticleImageSearchDialog extends PureComponent {
|
||||
}}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
<TextInput
|
||||
fullWidth
|
||||
label="Suchbegriff"
|
||||
value={query}
|
||||
disabled={isSearching || Boolean(processingId)}
|
||||
onChange={(e) => this.setState({ query: e.target.value })}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { thumb } from '../../api';
|
||||
import ThumbHashImage from './ThumbHashImage';
|
||||
|
||||
class ArticlePhoto extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { thumbLoaded: false };
|
||||
@@ -80,7 +78,7 @@ class ArticlePhoto extends Component {
|
||||
{fullHash ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={this.context.thumb.url(fullHash)}
|
||||
src={thumb.url(fullHash)}
|
||||
alt=""
|
||||
onLoad={this.handleThumbLoad}
|
||||
onError={this.handleThumbError}
|
||||
|
||||
@@ -14,7 +14,7 @@ import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { article as articleApi } from '../../api';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||
import {
|
||||
@@ -46,8 +46,6 @@ const overlayIconSx = {
|
||||
};
|
||||
|
||||
class ArticlePhotosDialog extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileInputRef = createRef();
|
||||
@@ -72,7 +70,7 @@ class ArticlePhotosDialog extends Component {
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const uploadStarted = performance.now();
|
||||
const res = await this.context.article.addImage(articleData.sku, file);
|
||||
const res = await articleApi.uploadImage(articleData.sku, file);
|
||||
console.log('[article-photo] upload', {
|
||||
uploadMs: Math.round(performance.now() - uploadStarted),
|
||||
bytes: file.size,
|
||||
@@ -130,7 +128,7 @@ class ArticlePhotosDialog extends Component {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.context.article.deleteImage(articleData.sku, fullHash);
|
||||
await articleApi.deleteImage(articleData.sku, fullHash);
|
||||
const updated = articleWithoutPhoto(articleData, fullHash);
|
||||
artikelAutomat.actions.setArticle(updated);
|
||||
onArticleUpdated?.(updated);
|
||||
@@ -155,7 +153,7 @@ class ArticlePhotosDialog extends Component {
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const res = await this.context.article.orderImages(articleData.sku, { fullHashes });
|
||||
const res = await articleApi.orderImages(articleData.sku, fullHashes);
|
||||
requireObject(res, 'PUT /api/article/:sku/images/order');
|
||||
requireArray(res.fullHashes, 'fullHashes');
|
||||
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
|
||||
|
||||
@@ -9,7 +9,7 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
|
||||
|
||||
@@ -111,27 +111,24 @@ class ArticlePriceTiersDialog extends Component {
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Ab Menge"
|
||||
value={tier.minmultiplier}
|
||||
onChange={(event) =>
|
||||
this.handleChange(index, 'minmultiplier', event.target.value)
|
||||
}
|
||||
size="small"
|
||||
error={tier.minmultiplier !== '' && mult == null}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { inputMode: 'numeric' },
|
||||
}}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Brutto"
|
||||
value={tier.gross}
|
||||
onChange={(event) =>
|
||||
this.handleChange(index, 'gross', event.target.value)
|
||||
}
|
||||
size="small"
|
||||
fullWidth
|
||||
error={tier.gross !== '' && gross == null}
|
||||
slotProps={{
|
||||
|
||||
@@ -16,7 +16,7 @@ import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -27,8 +27,7 @@ import TrendingDownIcon from '@mui/icons-material/TrendingDown';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { stock, article as articleApi } from '../../api';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { stock } from '../../api';
|
||||
import { formatStock } from './searchFormat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
@@ -43,8 +42,6 @@ const REASON_PRESETS = [
|
||||
];
|
||||
|
||||
export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialWhId = this.resolveInitialWarehouseId(props);
|
||||
@@ -214,9 +211,9 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
|
||||
// 1. Update track_stock if changed
|
||||
if (trackStockChanged) {
|
||||
const store = this.context;
|
||||
const setFn = store?.article?.setTrackStock ?? articleApi.setTrackStock;
|
||||
const trackRes = await setFn(article.sku, { track_stock: trackStock });
|
||||
const trackRes = await artikelAutomat.actions.setTrackStock(article.sku, {
|
||||
track_stock: trackStock,
|
||||
});
|
||||
updatedArticle = trackRes?.article || null;
|
||||
}
|
||||
|
||||
@@ -480,9 +477,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
|
||||
{/* Amount input */}
|
||||
{mode === 'diff' ? (
|
||||
<TextField
|
||||
<TextInput
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Menge (+ für Zugang, - für Abgang)"
|
||||
type="number"
|
||||
value={diffValue}
|
||||
@@ -509,9 +505,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
<TextInput
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Tatsächlicher Zählbestand (Ist-Bestand)"
|
||||
type="number"
|
||||
value={targetValue}
|
||||
@@ -560,9 +555,8 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
|
||||
{/* Reason / Memo */}
|
||||
<Box>
|
||||
<TextField
|
||||
<TextInput
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Grund / Buchungstext"
|
||||
value={reason}
|
||||
onChange={this.handleReasonChange}
|
||||
|
||||
@@ -9,7 +9,7 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { companyTaxRateOptions } from '../../utils/articleDetail';
|
||||
import { formatPriceAmount, parsePriceInput } from '../../utils/price';
|
||||
@@ -135,7 +135,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
value={selectedSupplier}
|
||||
onChange={(_e, val) => this.setState({ entityId: val ? val.id : null })}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
<TextInput
|
||||
{...params}
|
||||
label="Lieferant auswählen"
|
||||
required
|
||||
@@ -146,7 +146,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Lieferanten-Artikelnummer (SKU)"
|
||||
value={sku}
|
||||
onChange={(e) => this.setState({ sku: e.target.value })}
|
||||
@@ -155,22 +155,22 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
/>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Verpackungseinheit (VPE / PU)"
|
||||
type="number"
|
||||
required
|
||||
error={!puValid}
|
||||
helperText={!puValid ? 'Muss größer als 0 sein' : undefined}
|
||||
inputProps={{ step: 'any', min: 0.0001 }}
|
||||
slotProps={{ htmlInput: { step: 'any', min: 0.0001 } }}
|
||||
value={pu}
|
||||
onChange={(e) => this.setState({ pu: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Bestand beim Lieferanten"
|
||||
type="number"
|
||||
inputProps={{ step: 'any', min: 0 }}
|
||||
slotProps={{ htmlInput: { step: 'any', min: 0 } }}
|
||||
value={stock}
|
||||
onChange={(e) => this.setState({ stock: e.target.value })}
|
||||
sx={{ flex: 1 }}
|
||||
@@ -179,7 +179,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Einkaufspreis Netto"
|
||||
value={netPrice}
|
||||
onChange={(e) => this.setState({ netPrice: e.target.value })}
|
||||
@@ -193,7 +193,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
helperText="Optionaler Einkaufspreis (EK)"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
<TextInput
|
||||
select
|
||||
label="MwSt.-Satz"
|
||||
value={taxRate}
|
||||
@@ -208,7 +208,7 @@ export default class ArticleSupplierDialog extends PureComponent {
|
||||
{rate} %
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</TextInput>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,7 +3,6 @@ import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import LabelIcon from '@mui/icons-material/Label';
|
||||
@@ -80,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,
|
||||
|
||||
@@ -13,9 +13,10 @@ import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import unitAutomat from '../../automats/unitAutomat';
|
||||
import { unit as unitApi } from '../../api';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { parsePriceInput } from '../../utils/price';
|
||||
@@ -63,8 +64,6 @@ function formValid(form) {
|
||||
}
|
||||
|
||||
export default class ArticleUnitEditorDialog extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -86,7 +85,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
async loadUnits() {
|
||||
this.setState({ unitsLoading: true });
|
||||
try {
|
||||
const units = await this.context.unitPromise;
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({ units, unitsLoading: false });
|
||||
} catch (err) {
|
||||
this.setState({ unitsLoading: false });
|
||||
@@ -117,7 +116,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
if (!formValid(form)) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const data = await this.context.unit.upsert({
|
||||
const data = await unitApi.upsert({
|
||||
si: String(form.si ?? '').trim(),
|
||||
basesi: String(form.basesi ?? '').trim(),
|
||||
basesifactor: parseFactor(form.basesifactor),
|
||||
@@ -126,7 +125,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
});
|
||||
requireObject(data, 'PUT /api/unit');
|
||||
requireObject(data.unit, 'unit');
|
||||
const units = await this.context.updateUnit();
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({
|
||||
busy: false,
|
||||
units,
|
||||
@@ -145,8 +144,8 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.context.unit.delete(selectedSi);
|
||||
const units = await this.context.updateUnit();
|
||||
await unitApi.delete(selectedSi);
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
|
||||
this.props.onUnitDeleted?.(selectedSi);
|
||||
} catch (err) {
|
||||
@@ -241,47 +240,38 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => this.handleField('name', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Symbol"
|
||||
value={form.symbol}
|
||||
onChange={(e) => this.handleField('symbol', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="si"
|
||||
value={form.si}
|
||||
onChange={(e) => this.handleField('si', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="basesi"
|
||||
value={form.basesi}
|
||||
onChange={(e) => this.handleField('basesi', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="basesifactor"
|
||||
value={form.basesifactor}
|
||||
onChange={(e) => this.handleField('basesifactor', e.target.value)}
|
||||
size="small"
|
||||
fullWidth
|
||||
disabled={busy}
|
||||
error={
|
||||
@@ -290,7 +280,6 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
parseFactor(form.basesifactor) <= 0)
|
||||
}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { inputMode: 'decimal' },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { getCategoryState } from '../../automats/categoryAutomat';
|
||||
import withNavigate from '../withNavigate';
|
||||
import {
|
||||
buildCategoryPath,
|
||||
@@ -21,8 +21,6 @@ import ArticleViewLists from './boxes/ArticleViewLists';
|
||||
const PHOTO_TILE_SX = { minHeight: 0 };
|
||||
|
||||
class ArticleView extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
@@ -70,7 +68,7 @@ class ArticleView extends PureComponent {
|
||||
handleArticleDeleted() {
|
||||
const { navigate } = this.props;
|
||||
const { article } = this.state;
|
||||
const categoryState = this.context.getCategoryState();
|
||||
const categoryState = getCategoryState();
|
||||
const categories = categoryState?.categories ?? null;
|
||||
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
||||
const categoryId = article?.categoryIds?.[0];
|
||||
@@ -83,7 +81,7 @@ class ArticleView extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { article } = this.state;
|
||||
const categoryState = this.context.getCategoryState();
|
||||
const categoryState = getCategoryState();
|
||||
const categories = categoryState?.categories ?? null;
|
||||
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export default class CategoryPickerDialog extends PureComponent {
|
||||
componentDidMount() {
|
||||
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
||||
this._unsub = categoryAutomat.subscribe((s) => {
|
||||
this.setState((prev) => ({
|
||||
this.setState(() => ({
|
||||
categories: this.props.categories ?? s.categories,
|
||||
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
||||
}));
|
||||
@@ -132,7 +132,6 @@ export default class CategoryPickerDialog extends PureComponent {
|
||||
const dialogTitle = title || (mode === 'single' ? 'Kategorie auswählen' : 'Kategorie zuweisen');
|
||||
const actionLabel = confirmLabel || (mode === 'single' ? 'Auswählen' : 'Zuweisen');
|
||||
|
||||
const selectedNode = categories && selectedId != null ? categories[String(selectedId)] : null;
|
||||
const isAlreadyAssigned =
|
||||
mode !== 'single' && currentCategoryIds.some((id) => String(id) === String(selectedId));
|
||||
const isSelectedDisabled =
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import { thumbHashToRGBA } from 'thumbhash';
|
||||
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
|
||||
|
||||
const dataUrlCache = (globalThis.__THUMB_DATA_URL_CACHE__ ??= new Map());
|
||||
const loadedImageHashes = (globalThis.__LOADED_IMAGE_HASHES__ ??= new Set());
|
||||
export const imageCache = (globalThis.__IMAGE_CACHE__ ??= new Map());
|
||||
|
||||
export function isImageLoaded(fullHash) {
|
||||
if (!fullHash) return false;
|
||||
return loadedImageHashes.has(fullHash) || imageCache.has(fullHash);
|
||||
}
|
||||
|
||||
export function markImageLoaded(fullHash, img) {
|
||||
if (!fullHash) return;
|
||||
loadedImageHashes.add(fullHash);
|
||||
if (img) {
|
||||
imageCache.set(fullHash, img);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedImage(fullHash) {
|
||||
if (!fullHash) return null;
|
||||
const img = imageCache.get(fullHash);
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
return img;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getThumbHashDataUrl(thumbHash) {
|
||||
if (!thumbHash || typeof thumbHash !== 'string') return null;
|
||||
let url = dataUrlCache.get(thumbHash);
|
||||
if (!url) {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
|
||||
url = thumbHashToDataURL(bytes);
|
||||
dataUrlCache.set(thumbHash, url);
|
||||
} catch (err) {
|
||||
console.warn('[ThumbHashImage] thumbHashToDataURL failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
export function decodeThumbHash(thumbHash) {
|
||||
if (!thumbHash || typeof thumbHash !== 'string') return null;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
import brandAutomat from '../../../automats/brandAutomat';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { requireObject } from '../../../utils/apiShape';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import { BrandLabel, BrandThumb } from '../../brand';
|
||||
@@ -86,22 +87,22 @@ class BrandEdit extends Component {
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
<TextInput
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
size="small"
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
input: {
|
||||
...params.slotProps?.input,
|
||||
startAdornment: matchingBrand ? (
|
||||
<InputAdornment position="start" sx={{ mr: 0.5 }}>
|
||||
<BrandThumb brand={matchingBrand} size={20} />
|
||||
</InputAdornment>
|
||||
) : (
|
||||
params.slotProps?.input?.startAdornment
|
||||
),
|
||||
},
|
||||
label="Marke"
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<>
|
||||
{matchingBrand ? (
|
||||
<InputAdornment position="start" sx={{ mr: 0.5 }}>
|
||||
<BrandThumb brand={matchingBrand} size={20} />
|
||||
</InputAdornment>
|
||||
) : null}
|
||||
{params.InputProps?.startAdornment}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -111,37 +112,29 @@ class BrandEdit extends Component {
|
||||
}
|
||||
|
||||
export default class ArticleViewBrand extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const brands = this.context?.brands;
|
||||
this.state = {
|
||||
brands: brands ?? [],
|
||||
brands: brandAutomat.state.brands ?? [],
|
||||
};
|
||||
this._cancelled = false;
|
||||
this._brandsReady = brands != null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
if (this._brandsReady) return;
|
||||
this.context.brandPromise
|
||||
.then((list) => {
|
||||
if (!this._cancelled) this.setState({ brands: list });
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!this._cancelled) notifyError(err);
|
||||
});
|
||||
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||
brands: state.brands ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsub?.();
|
||||
this.unsub = null;
|
||||
}
|
||||
|
||||
handleSave = async (draft) => {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
const store = this.context;
|
||||
const { brands } = this.state;
|
||||
const name = String(draft.name ?? '').trim();
|
||||
if (!name) {
|
||||
@@ -156,16 +149,16 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
if (existing) {
|
||||
brandId = existing.id;
|
||||
} else {
|
||||
const createdRes = await store.brand.upsert({ name });
|
||||
requireObject(createdRes, 'PUT /api/brand');
|
||||
requireObject(createdRes.brand, 'brand');
|
||||
requireDefined(createdRes.brand.id, 'brand.id');
|
||||
brandId = createdRes.brand.id;
|
||||
const nextBrands = await store.updateBrand();
|
||||
await brandAutomat.actions.upsert({ name });
|
||||
const nextBrands = await brandAutomat.actions.load();
|
||||
this.setState({ brands: nextBrands });
|
||||
brandId = nextBrands.find(
|
||||
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
|
||||
)?.id;
|
||||
if (brandId == null) throw new Error('Marke konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
const res = await store.article.setBrand(articleData.sku, { brandId });
|
||||
const res = await artikelAutomat.actions.setBrand(articleData.sku, { brandId });
|
||||
requireObject(res, 'PUT /api/article/:sku/brand');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
@@ -182,6 +175,7 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
wide={1}
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
color="#e9d5ff"
|
||||
contentAlignItems="flex-start"
|
||||
value={value}
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { requireObject } from '../../../utils/apiShape';
|
||||
import { articleEans } from '../../../utils/articleDetail';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
@@ -117,18 +117,10 @@ class EansEdit extends Component {
|
||||
value={draft.eans ?? []}
|
||||
onChange={(_event, eans) => onChange({ eans })}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
<TextInput
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
label="EANs (Enter zum Hinzufügen)"
|
||||
size="small"
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
inputLabel: {
|
||||
...params.slotProps?.inputLabel,
|
||||
shrink: true,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -139,14 +131,12 @@ class EansEdit extends Component {
|
||||
}
|
||||
|
||||
export default class ArticleViewEANs extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
handleSave = async (draft) => {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
const eans = (draft.eans ?? [])
|
||||
.map((ean) => String(ean).trim())
|
||||
.filter((ean) => ean.length > 0);
|
||||
const res = await this.context.article.setEans(articleData.sku, { eans });
|
||||
const res = await artikelAutomat.actions.setEans(articleData.sku, eans);
|
||||
requireObject(res, 'PUT /api/article/:sku/eans');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { getCategoryState } from '../../../automats/categoryAutomat';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||
import { Box, Chip, IconButton } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
@@ -88,8 +89,6 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
|
||||
}
|
||||
|
||||
class CategoryPills extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { pickerOpen: false, targetPillId: null };
|
||||
@@ -133,7 +132,7 @@ class CategoryPills extends Component {
|
||||
render() {
|
||||
const { categoryIds, editMode } = this.props;
|
||||
const { pickerOpen, targetPillId } = this.state;
|
||||
const { categories, rootCategoryId } = this.context.getCategoryState() ?? {};
|
||||
const { categories, rootCategoryId } = getCategoryState() ?? {};
|
||||
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||
|
||||
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
||||
@@ -273,8 +272,6 @@ class NameEdit extends Component {
|
||||
}
|
||||
|
||||
export default class ArticleViewName extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -294,7 +291,6 @@ export default class ArticleViewName extends PureComponent {
|
||||
handleSave = async () => {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
const { nameDraft, categoryIdsDraft } = this.state;
|
||||
const store = this.context;
|
||||
const name = nameDraft.trim();
|
||||
const categoryIds = categoryIdsDraft;
|
||||
const nameChanged = name !== String(articleData.name ?? '').trim();
|
||||
@@ -303,14 +299,12 @@ export default class ArticleViewName extends PureComponent {
|
||||
let article = null;
|
||||
|
||||
if (nameChanged) {
|
||||
console.log('storing name update', name, articleData.name);
|
||||
const nameRes = await store.article.update(articleData.sku, { name });
|
||||
const nameRes = await artikelAutomat.actions.update(articleData.sku, { name });
|
||||
article = nameRes.article;
|
||||
}
|
||||
|
||||
if (categoriesChanged) {
|
||||
console.log('storing categories update', categoryIds, articleData.categoryIds);
|
||||
const catRes = await store.article.setCategories(articleData.sku, { categoryIds });
|
||||
const catRes = await artikelAutomat.actions.setCategories(articleData.sku, categoryIds);
|
||||
article = catRes.article;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import Typography from '@mui/material/Typography';
|
||||
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
||||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import unitAutomat from '../../../automats/unitAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
import {
|
||||
buildPosPricesPutBody,
|
||||
@@ -169,9 +171,8 @@ function priceDraftValid(draft) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function companyFromStore(store) {
|
||||
if (!store) return null;
|
||||
return store.company ?? null;
|
||||
function companyFromStore() {
|
||||
return companyAutomat.state.current ?? null;
|
||||
}
|
||||
|
||||
class PriceAmountField extends Component {
|
||||
@@ -361,13 +362,10 @@ class PriceEdit extends Component {
|
||||
}
|
||||
|
||||
export default class ArticleViewPrice extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const store = this.context;
|
||||
const company = companyFromStore(store);
|
||||
const units = store?.units;
|
||||
const company = companyFromStore();
|
||||
const units = unitAutomat.state.units;
|
||||
this.state = {
|
||||
tiersDialogOpen: false,
|
||||
unitEditorOpen: false,
|
||||
@@ -375,37 +373,28 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
company,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this._ready = company != null && units != null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
if (this._ready) return;
|
||||
void this.load();
|
||||
this.unsubUnits = unitAutomat.subscribe(this, (state) => ({
|
||||
units: state.units ?? [],
|
||||
}));
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
company: state.current ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
}
|
||||
|
||||
async load() {
|
||||
const store = this.context;
|
||||
try {
|
||||
const [unitList, company] = await Promise.all([
|
||||
store.unitPromise,
|
||||
store.companyPromise,
|
||||
]);
|
||||
if (!this._cancelled) {
|
||||
this.setState({ units: unitList, company: company ?? null });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!this._cancelled) notifyError(err);
|
||||
}
|
||||
this.unsubUnits?.();
|
||||
this.unsubUnits = null;
|
||||
this.unsubCompany?.();
|
||||
this.unsubCompany = null;
|
||||
}
|
||||
|
||||
handleSave = async (draft) => {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
const store = this.context;
|
||||
const { company } = this.state;
|
||||
const saved = priceDraftValue(articleData, company);
|
||||
let nextArticle = articleData;
|
||||
@@ -426,12 +415,12 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
if (isNoPackUnit(unitSi, unitamount)) {
|
||||
throw new Error('Einheit pc × 1 ist keine Packungseinheit');
|
||||
}
|
||||
const res = await store.article.update(articleData.sku, { unitamount, unitSi });
|
||||
const res = await artikelAutomat.actions.update(articleData.sku, { unitamount, unitSi });
|
||||
requireObject(res, 'PATCH /api/article/:sku');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
} else {
|
||||
const res = await store.article.update(articleData.sku, {
|
||||
const res = await artikelAutomat.actions.update(articleData.sku, {
|
||||
unitamount: 1,
|
||||
unitSi: 'pc',
|
||||
});
|
||||
@@ -456,7 +445,7 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
taxRate,
|
||||
tiers: draft.tiers ?? [],
|
||||
});
|
||||
const res = await store.article.setPrices(articleData.sku, { prices });
|
||||
const res = await artikelAutomat.actions.setPrices(articleData.sku, prices);
|
||||
requireObject(res, 'PUT /api/article/:sku/prices');
|
||||
requireObject(res.article, 'article');
|
||||
nextArticle = res.article;
|
||||
@@ -480,13 +469,14 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
}
|
||||
|
||||
refreshUnits = async () => {
|
||||
this.setState({ units: await this.context.unitPromise });
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({ units });
|
||||
};
|
||||
|
||||
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}
|
||||
|
||||
@@ -8,7 +8,7 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../../TextInput';
|
||||
import { findUnitBySi, unitLabel } from '../../../utils/units';
|
||||
|
||||
const priceUnitCornerSx = {
|
||||
@@ -73,15 +73,13 @@ export class PriceUnitRow extends Component {
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
<TextInput
|
||||
label="Menge"
|
||||
value={unitamount}
|
||||
onChange={(event) => onChangeAmount(event.target.value)}
|
||||
size="small"
|
||||
error={amountError}
|
||||
sx={{ width: 88, flexShrink: 0 }}
|
||||
slotProps={{
|
||||
inputLabel: { shrink: true },
|
||||
htmlInput: { inputMode: 'decimal' },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3,30 +3,61 @@ import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { article as articleApi } from '../../../api';
|
||||
|
||||
export default class ArticleAttributesTab extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
getArticle() {
|
||||
return this.props.article || getArticle(this.props.sku);
|
||||
}
|
||||
|
||||
isParentVariantAttribute(attr) {
|
||||
const article = this.getArticle();
|
||||
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?.parentAttributeName &&
|
||||
String(attr.name).trim().toLowerCase() === String(article.parentAttributeName).trim().toLowerCase()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialAttributes = selectAttributes() || [];
|
||||
const initialProperties = selectProperties() || [];
|
||||
|
||||
const firstAttr = initialAttributes[0];
|
||||
const article = props.article || getArticle(props.sku);
|
||||
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 (!isParent) return true;
|
||||
if (parentAttrId != null && Number(a.id) === Number(parentAttrId)) return false;
|
||||
if (
|
||||
parentAttrName &&
|
||||
String(a.name).trim().toLowerCase() === String(parentAttrName).trim().toLowerCase()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const firstAttr = selectableAttrs[0];
|
||||
const initialKey = firstAttr
|
||||
? `attr-${firstAttr.id}`
|
||||
: initialProperties[0]
|
||||
@@ -56,11 +87,24 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
let nextKey = prev.selectedMerkmalKey;
|
||||
let nextItemId = prev.selectedItemId;
|
||||
|
||||
if (!nextKey) {
|
||||
const first = state.attributes[0];
|
||||
const selectableAttrs = state.attributes;
|
||||
|
||||
const isCurrentKeyValid =
|
||||
nextKey &&
|
||||
(selectableAttrs.some((a) => `attr-${a.id}` === nextKey) ||
|
||||
prev.globalProperties.some((p) => `prop-${p.id}` === nextKey));
|
||||
|
||||
if (!isCurrentKeyValid) {
|
||||
const first = selectableAttrs[0];
|
||||
if (first) {
|
||||
nextKey = `attr-${first.id}`;
|
||||
nextItemId = first.values?.[0]?.id ?? '';
|
||||
} else if (prev.globalProperties[0]) {
|
||||
nextKey = `prop-${prev.globalProperties[0].id}`;
|
||||
nextItemId = '';
|
||||
} else {
|
||||
nextKey = '';
|
||||
nextItemId = '';
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -74,8 +118,9 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
this.unsubProp = propertyAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => {
|
||||
const selectableAttrs = prev.globalAttributes;
|
||||
let nextKey = prev.selectedMerkmalKey;
|
||||
if (!nextKey && !prev.globalAttributes.length && state.properties[0]) {
|
||||
if (!nextKey && !selectableAttrs.length && state.properties[0]) {
|
||||
nextKey = `prop-${state.properties[0].id}`;
|
||||
}
|
||||
return {
|
||||
@@ -85,6 +130,11 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
});
|
||||
});
|
||||
|
||||
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
||||
if (this._cancelled) return;
|
||||
this.forceUpdate();
|
||||
});
|
||||
|
||||
if (attributeAutomat.state.status === 'idle') {
|
||||
void attributeAutomat.actions.load();
|
||||
}
|
||||
@@ -105,22 +155,22 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
this._cancelled = true;
|
||||
this.unsubAttr?.();
|
||||
this.unsubProp?.();
|
||||
this.unsubArtikel?.();
|
||||
}
|
||||
|
||||
async loadArticleData(sku) {
|
||||
if (!sku) return;
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const api = this.context?.article || articleApi;
|
||||
const [attrRes, propRes] = await Promise.all([
|
||||
api.getAttributeValues(sku),
|
||||
api.getProperties(sku),
|
||||
const res = await Promise.all([
|
||||
articleApi.getAttributeValues(sku),
|
||||
articleApi.getProperties(sku),
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
|
||||
this.setState({
|
||||
assignedAttributes: Array.isArray(attrRes?.attributevalues) ? attrRes.attributevalues : [],
|
||||
assignedProperties: Array.isArray(propRes?.properties) ? propRes.properties : [],
|
||||
assignedAttributes: Array.isArray(res[0]?.attributevalues) ? res[0].attributevalues : [],
|
||||
assignedProperties: Array.isArray(res[1]?.properties) ? res[1].properties : [],
|
||||
loading: false,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -167,34 +217,47 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
saving,
|
||||
} = this.state;
|
||||
|
||||
if (saving || !sku || !selectedMerkmalKey) return;
|
||||
const api = this.context?.article || articleApi;
|
||||
const selectableAttributes = globalAttributes;
|
||||
const merkmalOptions = [
|
||||
...selectableAttributes.map((a) => ({ key: `attr-${a.id}`, id: a.id, type: 'attr' })),
|
||||
...globalProperties.map((p) => ({ key: `prop-${p.id}`, id: p.id, type: 'prop' })),
|
||||
];
|
||||
const selectedOption =
|
||||
merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? merkmalOptions[0] ?? null;
|
||||
const effectiveKey = selectedOption?.key || selectedMerkmalKey;
|
||||
|
||||
if (selectedMerkmalKey.startsWith('attr-')) {
|
||||
const attrId = Number(selectedMerkmalKey.replace('attr-', ''));
|
||||
const targetAttr = globalAttributes.find((a) => a.id === attrId);
|
||||
if (!targetAttr || !selectedItemId) return;
|
||||
if (saving || !sku || !effectiveKey) return;
|
||||
|
||||
if (effectiveKey.startsWith('attr-')) {
|
||||
const attrId = Number(effectiveKey.replace('attr-', ''));
|
||||
const targetAttr = selectableAttributes.find((a) => a.id === attrId);
|
||||
if (!targetAttr || this.isParentVariantAttribute(targetAttr)) return;
|
||||
|
||||
const effectiveItemId = targetAttr.values?.some((v) => v.id === selectedItemId)
|
||||
? selectedItemId
|
||||
: (targetAttr.values?.[0]?.id ?? '');
|
||||
if (!effectiveItemId) return;
|
||||
|
||||
const isExclusive = Boolean(targetAttr.exclusive);
|
||||
const targetValId = Number(selectedItemId);
|
||||
const targetValId = Number(effectiveItemId);
|
||||
const isAlreadyAssigned = assignedAttributes.some((a) => a.attributeValueId === targetValId);
|
||||
const isMerkmalAlreadyAssigned = assignedAttributes.some((a) => a.attributeId === targetAttr.id);
|
||||
|
||||
if (isExclusive && isMerkmalAlreadyAssigned) {
|
||||
enqueueSnackbar('Eindeutiges Merkmal ist bereits zugeordnet.', { variant: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAlreadyAssigned) {
|
||||
enqueueSnackbar('Bereits zugeordnet.', { variant: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
let nextValues = [];
|
||||
if (isExclusive) {
|
||||
const others = assignedAttributes.filter((a) => a.attributeId !== targetAttr.id);
|
||||
nextValues = [...others.map((a) => a.attributeValueId), targetValId];
|
||||
} else {
|
||||
nextValues = [...assignedAttributes.map((a) => a.attributeValueId), targetValId];
|
||||
}
|
||||
const nextValues = [...assignedAttributes.map((a) => a.attributeValueId), targetValId];
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
const res = await api.setAttributeValues(sku, nextValues);
|
||||
const res = await articleApi.setAttributeValues(sku, nextValues);
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
||||
@@ -213,6 +276,13 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
if (!targetProp || !val) return;
|
||||
|
||||
const isExclusive = Boolean(targetProp.exclusive);
|
||||
const isMerkmalAlreadyAssigned = assignedProperties.some((p) => p.propertyId === propId);
|
||||
|
||||
if (isExclusive && isMerkmalAlreadyAssigned) {
|
||||
enqueueSnackbar('Eindeutiges Merkmal ist bereits zugeordnet.', { variant: 'info' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isExclusive) {
|
||||
const isAlreadyAssigned = assignedProperties.some(
|
||||
(p) => p.propertyId === propId && p.value.trim().toLowerCase() === val.toLowerCase()
|
||||
@@ -222,23 +292,14 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let nextProps = [];
|
||||
if (isExclusive) {
|
||||
const existing = assignedProperties.filter((p) => p.propertyId !== propId);
|
||||
nextProps = [
|
||||
...existing.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
|
||||
{ propertyId: propId, value: val },
|
||||
];
|
||||
} else {
|
||||
nextProps = [
|
||||
...assignedProperties.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
|
||||
{ propertyId: propId, value: val },
|
||||
];
|
||||
}
|
||||
const nextProps = [
|
||||
...assignedProperties.map((p) => ({ propertyId: p.propertyId, value: p.value, sort: p.sort })),
|
||||
{ propertyId: propId, value: val },
|
||||
];
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
const res = await api.setProperties(sku, nextProps);
|
||||
const res = await articleApi.setProperties(sku, nextProps);
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
||||
@@ -265,8 +326,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
const api = this.context?.article || articleApi;
|
||||
const res = await api.setAttributeValues(sku, nextValues);
|
||||
const res = await articleApi.setAttributeValues(sku, nextValues);
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
||||
@@ -291,8 +351,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
const api = this.context?.article || articleApi;
|
||||
const res = await api.setProperties(sku, nextProperties);
|
||||
const res = await articleApi.setProperties(sku, nextProperties);
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
||||
@@ -328,8 +387,9 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
}
|
||||
|
||||
// Unified list of selectable Merkmale
|
||||
const selectableAttributes = globalAttributes;
|
||||
const merkmalOptions = [
|
||||
...globalAttributes.map((a) => ({
|
||||
...selectableAttributes.map((a) => ({
|
||||
key: `attr-${a.id}`,
|
||||
id: a.id,
|
||||
type: 'attr',
|
||||
@@ -347,9 +407,17 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
})),
|
||||
];
|
||||
|
||||
const selectedOption = merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? null;
|
||||
const selectedOption =
|
||||
merkmalOptions.find((m) => m.key === selectedMerkmalKey) ?? merkmalOptions[0] ?? null;
|
||||
const currentMerkmalKey = selectedOption?.key ?? '';
|
||||
const isAttr = selectedOption?.type === 'attr';
|
||||
const isVariantBlocked = isAttr && this.isParentVariantAttribute(selectedOption);
|
||||
const availableItems = selectedOption?.values || [];
|
||||
const currentItemId = isAttr
|
||||
? (availableItems.some((v) => v.id === selectedItemId)
|
||||
? selectedItemId
|
||||
: (availableItems[0]?.id ?? ''))
|
||||
: '';
|
||||
|
||||
// Unified list of assigned groups sorted by definition order
|
||||
const assignedGroups = [];
|
||||
@@ -398,55 +466,83 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
|
||||
assignedGroups.sort((a, b) => a.sort - b.sort || a.name.localeCompare(b.name));
|
||||
|
||||
const isExclusive = Boolean(selectedOption?.exclusive);
|
||||
const isMerkmalAlreadyAssigned = isAttr
|
||||
? assignedAttributes.some((a) => a.attributeId === selectedOption?.id)
|
||||
: assignedProperties.some((p) => p.propertyId === selectedOption?.id);
|
||||
|
||||
const isValueAlreadyAssigned = isAttr
|
||||
? assignedAttributes.some((a) => a.attributeValueId === Number(currentItemId))
|
||||
: (Boolean(customValueText.trim()) && assignedProperties.some(
|
||||
(p) => p.propertyId === selectedOption?.id && p.value.trim().toLowerCase() === customValueText.trim().toLowerCase()
|
||||
));
|
||||
|
||||
const isUniqueAlreadyAdded = isExclusive && isMerkmalAlreadyAssigned;
|
||||
|
||||
const isAddDisabled =
|
||||
saving ||
|
||||
isVariantBlocked ||
|
||||
!selectedMerkmalKey ||
|
||||
(isAttr ? !currentItemId || availableItems.length === 0 : !customValueText.trim()) ||
|
||||
isUniqueAlreadyAdded ||
|
||||
isValueAlreadyAssigned;
|
||||
|
||||
let addTooltipTitle = 'Hinzufügen';
|
||||
if (isUniqueAlreadyAdded) {
|
||||
addTooltipTitle = 'Eindeutiges Merkmal ist bereits zugeordnet';
|
||||
} else if (isValueAlreadyAssigned) {
|
||||
addTooltipTitle = 'Bereits zugeordnet';
|
||||
} else if (isVariantBlocked) {
|
||||
addTooltipTitle = 'Varianten-Merkmal kann nicht zugeordnet werden';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Unified Add Bar: [Merkmal ▼] [Wert ▼ / Input] [+] */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<InputLabel id="merkmal-select-label">Merkmal</InputLabel>
|
||||
<Select
|
||||
labelId="merkmal-select-label"
|
||||
value={selectedMerkmalKey}
|
||||
label="Merkmal"
|
||||
disabled={saving || merkmalOptions.length === 0}
|
||||
onChange={this.handleMerkmalChange}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
{merkmalOptions.map((m) => (
|
||||
<MenuItem key={m.key} value={m.key}>
|
||||
{m.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextInput
|
||||
select
|
||||
label="Merkmal"
|
||||
value={currentMerkmalKey}
|
||||
disabled={saving || merkmalOptions.length === 0}
|
||||
onChange={this.handleMerkmalChange}
|
||||
sx={{ minWidth: 170 }}
|
||||
>
|
||||
{merkmalOptions.map((m) => (
|
||||
<MenuItem key={m.key} value={m.key}>
|
||||
{m.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextInput>
|
||||
|
||||
{isAttr ? (
|
||||
<FormControl size="small" sx={{ minWidth: 170 }}>
|
||||
<InputLabel id="item-select-label">Wert</InputLabel>
|
||||
<Select
|
||||
labelId="item-select-label"
|
||||
value={selectedItemId}
|
||||
label="Wert"
|
||||
disabled={saving || availableItems.length === 0}
|
||||
onChange={this.handleItemChange}
|
||||
sx={{ bgcolor: 'background.paper' }}
|
||||
>
|
||||
{availableItems.length === 0 ? (
|
||||
<MenuItem value="" disabled>
|
||||
(Keine Werte)
|
||||
{isVariantBlocked ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 170, flex: 1 }}>
|
||||
Ein Vaterartikel kann keine Werte für sein Varianten-Merkmal haben.
|
||||
</Typography>
|
||||
) : isAttr ? (
|
||||
<TextInput
|
||||
select
|
||||
label="Wert"
|
||||
value={currentItemId}
|
||||
disabled={saving || availableItems.length === 0}
|
||||
onChange={this.handleItemChange}
|
||||
sx={{ minWidth: 170 }}
|
||||
>
|
||||
{availableItems.length === 0 ? (
|
||||
<MenuItem value="" disabled>
|
||||
(Keine Werte)
|
||||
</MenuItem>
|
||||
) : (
|
||||
availableItems.map((val) => (
|
||||
<MenuItem key={val.id} value={val.id}>
|
||||
{val.value}
|
||||
</MenuItem>
|
||||
) : (
|
||||
availableItems.map((val) => (
|
||||
<MenuItem key={val.id} value={val.id}>
|
||||
{val.value}
|
||||
</MenuItem>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
))
|
||||
)}
|
||||
</TextInput>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
<TextInput
|
||||
label="Wert"
|
||||
placeholder="Wert..."
|
||||
value={customValueText}
|
||||
disabled={saving || !selectedMerkmalKey}
|
||||
@@ -457,19 +553,15 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
void this.handleAddItem();
|
||||
}
|
||||
}}
|
||||
sx={{ minWidth: 170, bgcolor: 'background.paper' }}
|
||||
sx={{ minWidth: 170 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title="Hinzufügen">
|
||||
<Tooltip title={addTooltipTitle}>
|
||||
<span>
|
||||
<IconButton
|
||||
color="primary"
|
||||
disabled={
|
||||
saving ||
|
||||
!selectedMerkmalKey ||
|
||||
(isAttr ? !selectedItemId || availableItems.length === 0 : !customValueText.trim())
|
||||
}
|
||||
disabled={isAddDisabled}
|
||||
onClick={() => void this.handleAddItem()}
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
@@ -489,14 +581,20 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
{assignedGroups.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{assignedGroups.map((group) => (
|
||||
<Box key={group.key} sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box key={group.key} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, minWidth: 100, color: 'text.secondary' }}
|
||||
sx={{ fontWeight: 600, minWidth: 100, color: 'text.secondary', pt: 0.25 }}
|
||||
>
|
||||
{group.name}:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
<Box
|
||||
sx={
|
||||
group.key.startsWith('prop-')
|
||||
? { display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 0.75, minWidth: 0 }
|
||||
: { display: 'flex', flexWrap: 'wrap', gap: 0.75 }
|
||||
}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
<Chip
|
||||
key={item.id}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Component } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { articleHistoryAutomat } from '../../../automats/articleDetailAutomat';
|
||||
import sseAutomat from '../../../automats/sseAutomat';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import ResultsPagination from '../ResultsPagination';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -13,46 +15,68 @@ function formatDate(iso) {
|
||||
return new Date(iso).toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
export default class ArticleHistoryTab extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
export default class ArticleHistoryTab extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
history: null,
|
||||
total: null,
|
||||
skip: 0,
|
||||
};
|
||||
this.unwatchHistory = null;
|
||||
this.setSkipAndOffset = null;
|
||||
this.state = { all: [], skip: 0 };
|
||||
this.handlePageChange = this.handlePageChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { unwatch, setSkipAndOffset } = this.context.article.historyWatch(
|
||||
this.props.sku,
|
||||
(history, total) => this.setState({ history, total }),
|
||||
);
|
||||
this.unwatchHistory = unwatch;
|
||||
this.setSkipAndOffset = setSkipAndOffset;
|
||||
setSkipAndOffset(0, PAGE_SIZE);
|
||||
this.watch();
|
||||
void articleHistoryAutomat.load(String(this.props.sku)).catch((err) => {
|
||||
if (this.unsub) notifyError(err);
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.sku !== this.props.sku) {
|
||||
this.watch();
|
||||
void articleHistoryAutomat.load(String(this.props.sku)).catch((err) => {
|
||||
if (this.unsub) notifyError(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unwatchHistory?.();
|
||||
this.unwatchHistory = null;
|
||||
this.setSkipAndOffset = null;
|
||||
this.unsub?.();
|
||||
this.unsub = null;
|
||||
this.unwatchSignal?.();
|
||||
this.unwatchSignal = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the keyed history cache and to the article SSE signal.
|
||||
* The backend re-emits `article-<sku>` whenever the article changes, and
|
||||
* `sseAutomat` reloads the history key for us.
|
||||
*/
|
||||
watch() {
|
||||
this.unsub?.();
|
||||
const handle = articleHistoryAutomat.key(String(this.props.sku));
|
||||
this.unsub = handle.subscribe(this, (item) => {
|
||||
const all = Array.isArray(item.data) ? item.data : [];
|
||||
this.setState({ all });
|
||||
});
|
||||
|
||||
this.unwatchSignal?.();
|
||||
this.unwatchSignal = sseAutomat.actions.watchArticle(String(this.props.sku), () => {
|
||||
void articleHistoryAutomat.reload(String(this.props.sku)).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Client-side pagination over the full history list. */
|
||||
pageOf(all, skip) {
|
||||
return all.slice(skip, skip + PAGE_SIZE);
|
||||
}
|
||||
|
||||
handlePageChange(_event, page) {
|
||||
const skip = (page - 1) * PAGE_SIZE;
|
||||
this.setSkipAndOffset?.(skip, PAGE_SIZE);
|
||||
this.setState({ skip });
|
||||
this.setState({ skip: (page - 1) * PAGE_SIZE });
|
||||
}
|
||||
|
||||
render() {
|
||||
const { history, total, skip } = this.state;
|
||||
if (history == null || total == null) return null;
|
||||
const { all, skip } = this.state;
|
||||
const total = all.length;
|
||||
const history = this.pageOf(all, skip);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const currentPage = Math.floor(skip / PAGE_SIZE) + 1;
|
||||
|
||||
@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../../TextInput';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Paper from '@mui/material/Paper';
|
||||
@@ -210,22 +210,21 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3, alignItems: 'flex-start' }}>
|
||||
<TextField
|
||||
<TextInput
|
||||
select
|
||||
label="Drucker"
|
||||
value={selectedPrinterId}
|
||||
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
|
||||
sx={{ minWidth: 200 }}
|
||||
size="small"
|
||||
>
|
||||
{printers.map((p) => (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{p.desc ? `${p.desc} (${p.address})` : p.address}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
</TextInput>
|
||||
|
||||
<TextField
|
||||
<TextInput
|
||||
type="number"
|
||||
label="Anzahl"
|
||||
value={quantity}
|
||||
@@ -233,9 +232,8 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
this.setState({ quantity: isNaN(val) ? 1 : Math.max(1, val) });
|
||||
}}
|
||||
inputProps={{ min: 1, max: 9999 }}
|
||||
slotProps={{ htmlInput: { min: 1, max: 9999 } }}
|
||||
sx={{ width: 100 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -2,13 +2,11 @@ import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { article as articleApi } from '../../../api';
|
||||
import { artikelAutomat } from '../../../automats';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
|
||||
export default class ArticleSettingsTab extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { busy: false };
|
||||
@@ -24,7 +22,7 @@ export default class ArticleSettingsTab extends Component {
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.context.article.delete(articleData.sku);
|
||||
await articleApi.delete(articleData.sku);
|
||||
artikelAutomat.actions.clearArticle(articleData.sku);
|
||||
onArticleDeleted?.(articleData);
|
||||
} catch (err) {
|
||||
|
||||
@@ -20,15 +20,16 @@ import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { article as articleApi } from '../../../api';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { formatPrice } from '../../../utils/price';
|
||||
import supplierAutomat from '../../../automats/supplierAutomat';
|
||||
import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat';
|
||||
import sseAutomat from '../../../automats/sseAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
import ArticleSupplierDialog from '../ArticleSupplierDialog';
|
||||
|
||||
export default class ArticleSupplierTab extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -40,10 +41,12 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
editItem: null,
|
||||
busyId: null,
|
||||
};
|
||||
this.unwatchSuppliers = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
company: state.current ?? null,
|
||||
}));
|
||||
this.initData();
|
||||
}
|
||||
|
||||
@@ -54,46 +57,53 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unwatchSuppliers?.();
|
||||
this.unwatchSuppliers = null;
|
||||
this.unsubSuppliers?.();
|
||||
this.unsubSuppliers = null;
|
||||
this.unwatchSignal?.();
|
||||
this.unwatchSignal = null;
|
||||
this.unsubCompany?.();
|
||||
this.unsubCompany = null;
|
||||
}
|
||||
|
||||
async initData() {
|
||||
const sku = this.props.article?.sku;
|
||||
if (!sku) return;
|
||||
|
||||
this.unwatchSuppliers?.();
|
||||
this.setState({ loading: true });
|
||||
|
||||
// 1. Subscribe to SSE-backed supplier updates
|
||||
const { unwatch, refresh } = this.context.article.supplierWatch(
|
||||
sku,
|
||||
(assignedSuppliers) => {
|
||||
this.setState({
|
||||
assignedSuppliers: assignedSuppliers ?? [],
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
this.unwatchSuppliers = unwatch;
|
||||
this.refreshSuppliers = refresh;
|
||||
|
||||
// 2. Fetch available company suppliers and company metadata
|
||||
try {
|
||||
const [companySuppliers, company] = await Promise.all([
|
||||
supplierAutomat.actions.load(),
|
||||
this.context.company ?? this.context.companyPromise,
|
||||
]);
|
||||
// 1. Subscribe to the keyed supplier cache and the article SSE signal
|
||||
this.unsubSuppliers?.();
|
||||
const handle = articleSupplierAutomat.key(String(sku));
|
||||
this.unsubSuppliers = handle.subscribe(this, (item) => {
|
||||
this.setState({
|
||||
companySuppliers: companySuppliers ?? [],
|
||||
company: company ?? null,
|
||||
assignedSuppliers: Array.isArray(item.data) ? item.data : [],
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
void articleSupplierAutomat.load(String(sku)).catch((err) => {
|
||||
if (this.unsubSuppliers) notifyError(err);
|
||||
});
|
||||
|
||||
this.unwatchSignal?.();
|
||||
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
|
||||
void articleSupplierAutomat.reload(String(sku)).catch(() => {});
|
||||
});
|
||||
|
||||
// 2. Fetch available company suppliers
|
||||
try {
|
||||
const companySuppliers = await supplierAutomat.actions.load();
|
||||
this.setState({ companySuppliers: companySuppliers ?? [] });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetches the assigned suppliers for the current article. */
|
||||
refreshSuppliers = async () => {
|
||||
const sku = this.props.article?.sku;
|
||||
if (!sku) return;
|
||||
await articleSupplierAutomat.reload(String(sku));
|
||||
};
|
||||
|
||||
handleOpenAdd = () => {
|
||||
this.setState({ dialogOpen: true, editItem: null });
|
||||
};
|
||||
@@ -112,7 +122,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
|
||||
try {
|
||||
if (payload.id) {
|
||||
await this.context.article.updateSupplier(sku, payload.id, {
|
||||
await articleApi.updateSupplier(sku, payload.id, {
|
||||
sku: payload.sku,
|
||||
pu: payload.pu,
|
||||
stock: payload.stock,
|
||||
@@ -121,7 +131,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
});
|
||||
enqueueSnackbar('Lieferantendaten aktualisiert', { variant: 'success' });
|
||||
} else {
|
||||
await this.context.article.addSupplier(sku, {
|
||||
await articleApi.addSupplier(sku, {
|
||||
entityId: payload.entityId,
|
||||
sku: payload.sku,
|
||||
pu: payload.pu,
|
||||
@@ -132,7 +142,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
enqueueSnackbar('Lieferant erfolgreich zugewiesen', { variant: 'success' });
|
||||
}
|
||||
this.handleCloseDialog();
|
||||
await this.refreshSuppliers?.();
|
||||
await this.refreshSuppliers();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
throw err;
|
||||
@@ -150,9 +160,9 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
|
||||
this.setState({ busyId: item.id });
|
||||
try {
|
||||
await this.context.article.deleteSupplier(sku, item.id);
|
||||
await articleApi.deleteSupplier(sku, item.id);
|
||||
enqueueSnackbar('Lieferant entfernt', { variant: 'success' });
|
||||
await this.refreshSuppliers?.();
|
||||
await this.refreshSuppliers();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
@@ -176,11 +186,11 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
this.setState({ assignedSuppliers: nextList, busyId: moved.id });
|
||||
|
||||
try {
|
||||
await this.context.article.orderSuppliers(sku, { ids });
|
||||
await this.refreshSuppliers?.();
|
||||
await articleApi.orderSuppliers(sku, ids);
|
||||
await this.refreshSuppliers();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
await this.refreshSuppliers?.();
|
||||
await this.refreshSuppliers();
|
||||
} finally {
|
||||
this.setState({ busyId: null });
|
||||
}
|
||||
@@ -197,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={{
|
||||
|
||||
@@ -11,7 +11,7 @@ import IconButton from '@mui/material/IconButton';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import TextInput from '../TextInput';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
@@ -276,9 +276,9 @@ export default class BrandImageSearchDialog extends PureComponent {
|
||||
}}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
<TextInput
|
||||
fullWidth
|
||||
label="Suchbegriff"
|
||||
value={query}
|
||||
disabled={isSearching || Boolean(processingId)}
|
||||
onChange={(e) => this.setState({ query: e.target.value })}
|
||||
|
||||
@@ -43,7 +43,7 @@ function getMatchingAndAncestorIds(categories, query, rootCategoryId, excludeIds
|
||||
if (!categories || !query.trim()) return matches;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
for (const [idStr, node] of Object.entries(categories)) {
|
||||
for (const node of Object.values(categories)) {
|
||||
if (excludeIds.some((id) => String(id) === String(node.id))) continue;
|
||||
const label = categoryDisplayName(node.name, node.id, rootCategoryId).toLowerCase();
|
||||
if (label.includes(q)) {
|
||||
@@ -580,7 +580,6 @@ export default class CategoryTree extends PureComponent {
|
||||
addCategoryName,
|
||||
isSubmitting,
|
||||
renameDialogOpen,
|
||||
renameNode,
|
||||
renameValue,
|
||||
deleteDialogOpen,
|
||||
deleteNode,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
ROOT_CATEGORY_NAME,
|
||||
categorySlug,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
} from '../../utils/categoryUtils';
|
||||
|
||||
export { ROOT_CATEGORY_NAME, categorySlug };
|
||||
|
||||
@@ -9,7 +9,6 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import SendRoundedIcon from '@mui/icons-material/SendRounded';
|
||||
@@ -231,7 +230,6 @@ export default class ChatDrawer extends PureComponent {
|
||||
const {
|
||||
conversations,
|
||||
selectedConversationId,
|
||||
sseStatus,
|
||||
loadingConversations,
|
||||
} = this.state;
|
||||
|
||||
@@ -249,21 +247,8 @@ export default class ChatDrawer extends PureComponent {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 2,
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 22, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
KI-Assistent
|
||||
</Typography>
|
||||
@@ -275,80 +260,82 @@ export default class ChatDrawer extends PureComponent {
|
||||
</Box>
|
||||
|
||||
{/* Conversation Selector Bar */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
|
||||
<Select
|
||||
labelId="chat-conversation-label"
|
||||
value={selectedConversationId || ''}
|
||||
label="Konversation"
|
||||
onChange={this.handleSelectConversation}
|
||||
disabled={loadingConversations || conversations.length === 0}
|
||||
sx={{ borderRadius: 2 }}
|
||||
renderValue={(selectedId) => {
|
||||
const conv = conversations.find((c) => c.id === selectedId);
|
||||
if (!conv) return '';
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{conv.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{conv.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
{conversations.length > 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="chat-conversation-label">Konversation</InputLabel>
|
||||
<Select
|
||||
labelId="chat-conversation-label"
|
||||
value={selectedConversationId || ''}
|
||||
label="Konversation"
|
||||
onChange={this.handleSelectConversation}
|
||||
disabled={loadingConversations || conversations.length === 0}
|
||||
sx={{ borderRadius: 2 }}
|
||||
renderValue={(selectedId) => {
|
||||
const conv = conversations.find((c) => c.id === selectedId);
|
||||
if (!conv) return '';
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{conv.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{conv.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{conversations.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{c.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexGrow: 1 }}>
|
||||
{c.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
|
||||
aria-label="Konversation löschen"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{conversations.map((c) => (
|
||||
<MenuItem key={c.id} value={c.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%', overflow: 'hidden' }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
#{c.id}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ flexGrow: 1 }}>
|
||||
{c.model || 'Standard'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={(e) => this.handleDeleteConversation(e, selectedConversationId)}
|
||||
aria-label="Konversation löschen"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={this.handleNewChat}
|
||||
aria-label="Neuer Chat"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AddRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={!selectedConversationId}
|
||||
onClick={this.handleNewChat}
|
||||
aria-label="Neuer Chat"
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
p: 0.9,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<AddRoundedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -362,7 +349,6 @@ export default class ChatDrawer extends PureComponent {
|
||||
messages,
|
||||
streamingText,
|
||||
loadingConversations,
|
||||
loadingMessages,
|
||||
model,
|
||||
} = this.state;
|
||||
|
||||
@@ -391,20 +377,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'primary.light',
|
||||
color: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 32 }} />
|
||||
</Box>
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 44, color: 'primary.main', opacity: 0.85 }} />
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Willkommen im Chat
|
||||
</Typography>
|
||||
|
||||
@@ -177,14 +177,17 @@ function MessageRow({ msg, showTime = true }) {
|
||||
bodyLength = textContent.length;
|
||||
}
|
||||
|
||||
let displayText = '';
|
||||
if (resObj && ('answer' in resObj || 'content' in resObj) && ('tool_call_id' in resObj) && !('status' in resObj)) {
|
||||
displayText = resObj.answer || resObj.content || '';
|
||||
} else {
|
||||
displayText = statusText
|
||||
const isToolAnswer =
|
||||
Boolean(resObj) &&
|
||||
('answer' in resObj || 'content' in resObj) &&
|
||||
'tool_call_id' in resObj &&
|
||||
!('status' in resObj);
|
||||
|
||||
const displayText = isToolAnswer
|
||||
? resObj.answer || resObj.content || ''
|
||||
: statusText
|
||||
? `${statusText}${bodyLength != null ? ` · ${bodyLength} byte` : ''}`
|
||||
: (bodyLength != null ? `${bodyLength} byte` : String(textContent));
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 0.35, px: 2 }}>
|
||||
|
||||
@@ -20,13 +20,11 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import LinkIcon from '@mui/icons-material/Link';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { BrandLabel } from '../brand';
|
||||
|
||||
export default class ArticleSearchDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -68,13 +66,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
async loadArticles() {
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
let list = [];
|
||||
if (this.context?.articleIndexPromise) {
|
||||
list = await this.context.articleIndexPromise;
|
||||
} else if (this.context?.article?.index) {
|
||||
const res = await this.context.article.index();
|
||||
list = res?.articles || [];
|
||||
}
|
||||
const list = await articleIndexAutomat.actions.ensureLoaded();
|
||||
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden des Artikel-Index:', err);
|
||||
@@ -97,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;
|
||||
}
|
||||
@@ -117,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) => {
|
||||
|
||||
@@ -68,7 +68,7 @@ export class Attachment extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleTileClick = (e) => {
|
||||
handleTileClick = () => {
|
||||
const { attachment, onClick } = this.props;
|
||||
if (onClick && attachment) {
|
||||
onClick(attachment);
|
||||
|
||||
@@ -27,7 +27,6 @@ import CloseIcon from '@mui/icons-material/Close';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { formatDate } from './manifestConfig';
|
||||
@@ -38,8 +37,6 @@ import {
|
||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||
|
||||
export default class BookDeliveryDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -160,12 +157,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
const ledgerApi = this.context?.ledger;
|
||||
const deliveryApi = this.context?.delivery;
|
||||
const summary = await fetchDeliveryLedgerSummary(delivery.id, {
|
||||
ledgerApi,
|
||||
deliveryApi,
|
||||
});
|
||||
const summary = await fetchDeliveryLedgerSummary(delivery.id);
|
||||
this.initAmountsFromSummary(summary, prefillLineId);
|
||||
this.setState({ summary, loading: false });
|
||||
} catch (err) {
|
||||
@@ -255,14 +247,10 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
|
||||
this.setState({ isBooking: true, error: null, warehouseError: null });
|
||||
try {
|
||||
const ledgerApi = this.context?.ledger;
|
||||
const deliveryApi = this.context?.delivery;
|
||||
|
||||
await bookDeliveryLines({
|
||||
delivery: summary.delivery || delivery,
|
||||
linesToBook,
|
||||
warehouseId: selectedWarehouseId,
|
||||
ledgerApi,
|
||||
transactionText,
|
||||
});
|
||||
|
||||
@@ -273,10 +261,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
);
|
||||
|
||||
// Refresh summary
|
||||
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id, {
|
||||
ledgerApi,
|
||||
deliveryApi,
|
||||
});
|
||||
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id);
|
||||
|
||||
if (onBookSuccess) {
|
||||
onBookSuccess(updatedSummary);
|
||||
|
||||
@@ -13,7 +13,7 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { RecordList } from 'dashgui/lists';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import deliveryAutomat from '../../automats/deliveryAutomat';
|
||||
import { delivery } from '../../api';
|
||||
import { supplierAutomat } from '../../automats';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
@@ -39,8 +39,6 @@ function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
export class DeliveryList extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -77,14 +75,7 @@ export class DeliveryList extends PureComponent {
|
||||
}
|
||||
|
||||
async fetchDeliveries() {
|
||||
if (this.context?.deliveries) {
|
||||
return this.context.deliveries;
|
||||
}
|
||||
if (this.context?.deliveryPromise) {
|
||||
return await this.context.deliveryPromise;
|
||||
}
|
||||
const res = await (this.context?.delivery?.list ? this.context.delivery.list() : delivery.list());
|
||||
return res?.deliveries ?? res ?? [];
|
||||
return deliveryAutomat.actions.ensureLoaded();
|
||||
}
|
||||
|
||||
async load() {
|
||||
@@ -103,12 +94,10 @@ export class DeliveryList extends PureComponent {
|
||||
}
|
||||
|
||||
async handleRetry() {
|
||||
if (this.context?.updateDelivery) {
|
||||
try {
|
||||
await this.context.updateDelivery();
|
||||
} catch {
|
||||
// Will be caught in load()
|
||||
}
|
||||
try {
|
||||
await deliveryAutomat.actions.reload();
|
||||
} catch {
|
||||
// Will be caught in load()
|
||||
}
|
||||
void this.load();
|
||||
}
|
||||
@@ -140,15 +129,13 @@ export class DeliveryList extends PureComponent {
|
||||
|
||||
async handleBookSuccess() {
|
||||
this.handleCloseBookDialog();
|
||||
if (this.context?.updateDelivery) {
|
||||
try {
|
||||
const updated = await this.context.updateDelivery();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ docs: sortDeliveries(updated) });
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to load()
|
||||
}
|
||||
try {
|
||||
const updated = await deliveryAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.setState({ docs: sortDeliveries(updated) });
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to load()
|
||||
}
|
||||
void this.load();
|
||||
}
|
||||
@@ -159,10 +146,8 @@ export class DeliveryList extends PureComponent {
|
||||
|
||||
this.setState({ busyId: doc.id });
|
||||
try {
|
||||
await (this.context?.delivery?.delete ? this.context.delivery.delete(doc.id) : delivery.delete(doc.id));
|
||||
if (this.context?.updateDelivery) {
|
||||
await this.context.updateDelivery();
|
||||
}
|
||||
await delivery.delete(doc.id);
|
||||
await deliveryAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
await this.load();
|
||||
enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' });
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { PageTitleRight } from '../PageLayout';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { PanelTileGrid } from 'dashgui/panels';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -23,11 +20,10 @@ import {
|
||||
lineFromApi,
|
||||
loadParties,
|
||||
manifestConfig,
|
||||
apiFor,
|
||||
} from './manifestConfig';
|
||||
|
||||
class ManifestForm extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig(props.kind);
|
||||
@@ -91,7 +87,7 @@ class ManifestForm extends PureComponent {
|
||||
async loadPartiesOnly() {
|
||||
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
||||
try {
|
||||
const parties = await loadParties(this.props.kind, this.context);
|
||||
const parties = await loadParties(this.props.kind);
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
status: 'ready',
|
||||
@@ -123,8 +119,8 @@ class ManifestForm extends PureComponent {
|
||||
this.setState({ status: 'loading', error: null, partiesLoading: true });
|
||||
try {
|
||||
const [docRes, parties] = await Promise.all([
|
||||
this.context[cfg.api].get(docId),
|
||||
loadParties(kind, this.context),
|
||||
apiFor(cfg).get(docId),
|
||||
loadParties(kind),
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
|
||||
@@ -246,10 +242,10 @@ class ManifestForm extends PureComponent {
|
||||
};
|
||||
|
||||
try {
|
||||
const gen = this.context?.delivery?.parsePdfSse;
|
||||
const gen = deliveryApi.parsePdfSse;
|
||||
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
|
||||
|
||||
for await (const { event, data } of this.context.delivery.parsePdfSse(file, { signal: abortController.signal })) {
|
||||
for await (const { event, data } of gen(file, { signal: abortController.signal })) {
|
||||
if (this._cancelled || abortController.signal.aborted) return;
|
||||
switch (event) {
|
||||
case 'status':
|
||||
@@ -375,24 +371,25 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
|
||||
this.setState({ saving: true });
|
||||
const api = apiFor(cfg);
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const data = await this.context[cfg.api].create(body);
|
||||
const data = await api.create(body);
|
||||
requireObject(data, cfg.postPathLabel);
|
||||
requireObject(data[cfg.docField], cfg.docField);
|
||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||
await this.context[cfg.updateCache]();
|
||||
await cfg.listAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await this.context[cfg.api].update(docId, body);
|
||||
const data = await api.update(docId, body);
|
||||
requireObject(data, cfg.patchPathLabel);
|
||||
requireObject(data[cfg.docField], cfg.docField);
|
||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||
await this.context[cfg.updateCache]();
|
||||
await cfg.listAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
const doc = data[cfg.docField];
|
||||
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
||||
@@ -435,7 +432,7 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { kind, mode } = this.props;
|
||||
const { kind } = this.props;
|
||||
const cfg = this.cfg;
|
||||
const {
|
||||
status,
|
||||
|
||||
@@ -85,7 +85,6 @@ class ManifestHeader extends PureComponent {
|
||||
saving,
|
||||
pdfUploading,
|
||||
attachments,
|
||||
lines,
|
||||
onPdfUpload,
|
||||
onPdfRescan,
|
||||
onPdfDelete,
|
||||
|
||||
@@ -24,7 +24,6 @@ class ManifestLines extends PureComponent {
|
||||
onAddLine,
|
||||
onRemoveLine,
|
||||
onRemoveAllLines,
|
||||
onSave,
|
||||
columns = LINE_COLUMNS,
|
||||
supplierArticlesMap = {},
|
||||
supplierArticlesByLineKey = {},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { PureComponent } from 'react';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { RecordList } from 'dashgui/lists';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import withNavigate from '../withNavigate';
|
||||
@@ -14,11 +13,10 @@ import {
|
||||
numberDisplay,
|
||||
partyDisplayName,
|
||||
sortDocs,
|
||||
apiFor,
|
||||
} from './manifestConfig';
|
||||
|
||||
class ManifestList extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.cfg = manifestConfig(props.kind);
|
||||
@@ -52,8 +50,8 @@ class ManifestList extends PureComponent {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const [docs, parties] = await Promise.all([
|
||||
cfg.listPromise(this.context),
|
||||
loadParties(this.props.kind, this.context),
|
||||
cfg.listAutomat.actions.ensureLoaded(),
|
||||
loadParties(this.props.kind),
|
||||
]);
|
||||
if (this._cancelled) return;
|
||||
const sortedDocs = sortDocs(docs);
|
||||
@@ -96,8 +94,9 @@ class ManifestList extends PureComponent {
|
||||
const cfg = this.cfg;
|
||||
this.setState({ busyId: doc.id });
|
||||
try {
|
||||
await this.context[cfg.api].delete(doc.id);
|
||||
const list = await this.context[cfg.updateCache]();
|
||||
const api = apiFor(cfg.kind);
|
||||
await api.delete(doc.id);
|
||||
const list = await cfg.listAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
this.setState({
|
||||
docs: sortDocs(list),
|
||||
|
||||
@@ -14,7 +14,6 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
|
||||
/**
|
||||
@@ -24,8 +23,6 @@ import { delivery as deliveryApi } from '../../api';
|
||||
* attachment is selected; unmounting closes it.
|
||||
*/
|
||||
export class PdfViewerPanel extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -77,10 +74,7 @@ export class PdfViewerPanel extends PureComponent {
|
||||
if (effectiveDocId != null && fileId != null) {
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
const getFile =
|
||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
||||
deliveryApi.getFile;
|
||||
const blob = await getFile(effectiveDocId, fileId);
|
||||
const blob = await deliveryApi.getFile(effectiveDocId, fileId);
|
||||
this._objectUrl = URL.createObjectURL(blob);
|
||||
this.setState({ url: this._objectUrl, loading: false, error: null });
|
||||
return;
|
||||
|
||||
@@ -17,7 +17,6 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
|
||||
/**
|
||||
@@ -26,8 +25,6 @@ import { delivery as deliveryApi } from '../../api';
|
||||
* selected; unmounting closes it.
|
||||
*/
|
||||
export class TextEditorPanel extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -89,10 +86,7 @@ export class TextEditorPanel extends PureComponent {
|
||||
if (effectiveDocId != null && fileId != null) {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const getFile =
|
||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
||||
deliveryApi.getFile;
|
||||
const blob = await getFile(effectiveDocId, fileId);
|
||||
const blob = await deliveryApi.getFile(effectiveDocId, fileId);
|
||||
const text = await blob.text();
|
||||
this.setState({
|
||||
content: text,
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import {
|
||||
delivery as deliveryApi,
|
||||
quote as quoteApi,
|
||||
order as orderApi,
|
||||
} from '../../api';
|
||||
import supplierAutomat from '../../automats/supplierAutomat';
|
||||
import entityAutomat from '../../automats/entityAutomat';
|
||||
import customergroupAutomat from '../../automats/customergroupAutomat';
|
||||
import deliveryAutomat from '../../automats/deliveryAutomat';
|
||||
import quoteAutomat from '../../automats/quoteAutomat';
|
||||
import orderAutomat from '../../automats/orderAutomat';
|
||||
|
||||
/** Groups that are not sales customers (filter client-side). */
|
||||
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
||||
@@ -275,31 +285,6 @@ function fileToBase64(file) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Blob
|
||||
* @param {string} base64
|
||||
* @param {string} mimeType
|
||||
* @returns {Blob}
|
||||
*/
|
||||
function base64ToBlob(base64, mimeType) {
|
||||
const clean = base64.includes(',') ? base64.split(',')[1] : base64;
|
||||
const byteCharacters = atob(clean);
|
||||
const byteArrays = [];
|
||||
const sliceSize = 512;
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||
const slice = byteCharacters.slice(offset, offset + sliceSize);
|
||||
const byteNumbers = new Array(slice.length);
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
byteArrays.push(byteArray);
|
||||
}
|
||||
|
||||
return new Blob(byteArrays, { type: mimeType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore attachments from API response
|
||||
* @param {Array<{id: number, fileId?: number, manifestId?: number, mimetype: string, name: string, thumbavif?: any, size: number, created?: string, meta?: object}>} apiFiles
|
||||
@@ -506,12 +491,27 @@ export const DELIVERY_LINE_COLUMNS = [
|
||||
|
||||
/** @typedef {'delivery'|'quote'|'order'} ManifestKind */
|
||||
|
||||
/**
|
||||
* Resolves the api namespace for a manifest kind.
|
||||
*
|
||||
* Delivery, quote, and order share one editor/list shape but talk to three
|
||||
* different endpoints.
|
||||
*
|
||||
* @param {ManifestKind} kind Manifest kind.
|
||||
* @returns {{get: Function, create: Function, update: Function, delete: Function}}
|
||||
*/
|
||||
export function apiFor(kind) {
|
||||
if (kind === 'delivery') return deliveryApi;
|
||||
if (kind === 'quote') return quoteApi;
|
||||
if (kind === 'order') return orderApi;
|
||||
throw new Error(`Unknown manifest kind: ${kind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load party options for Autocomplete / list name lookup.
|
||||
* @param {ManifestKind} kind
|
||||
* @param {object} ctx StorageManager instance
|
||||
*/
|
||||
export async function loadParties(kind, ctx) {
|
||||
export async function loadParties(kind) {
|
||||
if (kind === 'delivery') {
|
||||
if (supplierAutomat.state.status === 'ready') {
|
||||
return sortSuppliers(supplierAutomat.state.suppliers);
|
||||
@@ -520,8 +520,8 @@ export async function loadParties(kind, ctx) {
|
||||
return sortSuppliers(list);
|
||||
}
|
||||
const [entities, groups] = await Promise.all([
|
||||
ctx.entities ?? ctx.entityPromise,
|
||||
ctx.customergroups ?? ctx.customergroupPromise,
|
||||
entityAutomat.actions.ensureLoaded(),
|
||||
customergroupAutomat.actions.ensureLoaded(),
|
||||
]);
|
||||
return filterCustomerEntities(entities, groups);
|
||||
}
|
||||
@@ -563,8 +563,7 @@ export function manifestConfig(kind) {
|
||||
headerColor: '#fed7aa',
|
||||
linesColor: '#bbf7d0',
|
||||
requirePartyBeforeLines: false,
|
||||
updateCache: 'updateDelivery',
|
||||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
||||
listAutomat: deliveryAutomat,
|
||||
partiesByIdFromList: (parties) =>
|
||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||
// Delivery uses a server-allocated read-only number plus an optional
|
||||
@@ -600,8 +599,7 @@ export function manifestConfig(kind) {
|
||||
headerColor: '#fde68a',
|
||||
linesColor: '#bbf7d0',
|
||||
requirePartyBeforeLines: false,
|
||||
updateCache: 'updateQuote',
|
||||
listPromise: (ctx) => ctx.quotes ?? ctx.quotePromise,
|
||||
listAutomat: quoteAutomat,
|
||||
partiesByIdFromList: (parties) =>
|
||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||
columns: LINE_COLUMNS,
|
||||
@@ -634,8 +632,7 @@ export function manifestConfig(kind) {
|
||||
headerColor: '#c4b5fd',
|
||||
linesColor: '#bbf7d0',
|
||||
requirePartyBeforeLines: false,
|
||||
updateCache: 'updateOrder',
|
||||
listPromise: (ctx) => ctx.orders ?? ctx.orderPromise,
|
||||
listAutomat: orderAutomat,
|
||||
partiesByIdFromList: (parties) =>
|
||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||
columns: LINE_COLUMNS,
|
||||
|
||||
@@ -15,7 +15,8 @@ import Typography from '@mui/material/Typography';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { number as numberApi } from '../../api';
|
||||
import numberAutomat from '../../automats/numberAutomat';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -31,8 +32,6 @@ function parseNext(value) {
|
||||
}
|
||||
|
||||
export default class NumberSettingsPanel extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -54,7 +53,7 @@ export default class NumberSettingsPanel extends Component {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const numbers = requireArray(
|
||||
await this.context.updateNumber(),
|
||||
await numberAutomat.actions.reload(),
|
||||
'numbers',
|
||||
);
|
||||
const drafts = Object.fromEntries(
|
||||
@@ -141,7 +140,7 @@ export default class NumberSettingsPanel extends Component {
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const data = await this.context.number.update(id, body);
|
||||
const data = await numberApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/number');
|
||||
requireObject(data.number, 'number');
|
||||
const updated = requireDefined(data.number, 'number');
|
||||
|
||||
@@ -25,7 +25,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { printer as printerApi } from '../../api';
|
||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -186,8 +186,6 @@ class PrinterFormDialog extends Component {
|
||||
}
|
||||
|
||||
export default class PrinterSettingsPanel extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -216,7 +214,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
async load() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const data = await this.context.printer.list();
|
||||
const data = await printerApi.list();
|
||||
requireObject(data, 'GET /api/printer');
|
||||
const printers = requireArray(data.printers, 'printers');
|
||||
this.setState({ status: 'ready', printers });
|
||||
@@ -243,7 +241,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
}
|
||||
|
||||
async handleAddSubmit(body) {
|
||||
const data = await this.context.printer.create(body);
|
||||
const data = await printerApi.create(body);
|
||||
requireObject(data, 'POST /api/printer');
|
||||
const printer = data.printer;
|
||||
this.setState((prev) => ({
|
||||
@@ -255,7 +253,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
|
||||
async handleEditSubmit(body) {
|
||||
const { editPrinter } = this.state;
|
||||
const data = await this.context.printer.update(editPrinter.id, body);
|
||||
const data = await printerApi.update(editPrinter.id, body);
|
||||
requireObject(data, 'PATCH /api/printer/:id');
|
||||
const updated = data.printer;
|
||||
this.setState((prev) => ({
|
||||
@@ -268,7 +266,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
async handleDelete(id) {
|
||||
this.setState({ deleteId: id, deleteBusy: true });
|
||||
try {
|
||||
await this.context.printer.delete(id);
|
||||
await printerApi.delete(id);
|
||||
this.setState((prev) => ({
|
||||
printers: prev.printers.filter((p) => p.id !== id),
|
||||
deleteId: null,
|
||||
@@ -314,7 +312,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
this.setState({ printers: reordered });
|
||||
|
||||
try {
|
||||
await this.context.printer.order({ printerIds });
|
||||
await printerApi.order({ printerIds });
|
||||
} catch (err) {
|
||||
if (this._unmounted) return;
|
||||
notifyError(err);
|
||||
|
||||
@@ -14,7 +14,7 @@ import Typography from '@mui/material/Typography';
|
||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import BackendError from '../BackendError';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { setting as settingApi } from '../../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -42,8 +42,6 @@ function keyLabel(key) {
|
||||
}
|
||||
|
||||
export default class SettingsKeysPanel extends Component {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -64,7 +62,7 @@ export default class SettingsKeysPanel extends Component {
|
||||
async load() {
|
||||
this.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const data = await this.context.setting.list();
|
||||
const data = await settingApi.list();
|
||||
requireObject(data, 'GET /api/company/setting');
|
||||
const settings = requireArray(data.settings, 'settings');
|
||||
const drafts = Object.fromEntries(
|
||||
@@ -119,7 +117,7 @@ export default class SettingsKeysPanel extends Component {
|
||||
},
|
||||
}));
|
||||
try {
|
||||
const data = await this.context.setting.upsert(key, body);
|
||||
const data = await settingApi.upsert(key, body);
|
||||
requireObject(data, 'PUT /api/company/setting/:key');
|
||||
requireObject(data.setting, 'setting');
|
||||
const updated = requireDefined(data.setting, 'setting');
|
||||
|
||||
@@ -3,10 +3,15 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { TILE_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../article/articleRoute';
|
||||
import { decodeThumbHash } from '../article/ThumbHashImage';
|
||||
import {
|
||||
getThumbHashDataUrl,
|
||||
getCachedImage,
|
||||
isImageLoaded,
|
||||
markImageLoaded,
|
||||
} from '../article/ThumbHashImage';
|
||||
import { BrandLabel } from '../brand';
|
||||
import { thumb } from '../../api';
|
||||
|
||||
@@ -100,12 +105,41 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
img = null;
|
||||
observer = null;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
|
||||
const fullHash = props.fullHash;
|
||||
if (fullHash) {
|
||||
const cached = getCachedImage(fullHash);
|
||||
if (cached) {
|
||||
this.img = cached;
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
markImageLoaded(fullHash, img);
|
||||
if (this.img === img) {
|
||||
this.paint();
|
||||
}
|
||||
};
|
||||
img.src = thumb.url(fullHash);
|
||||
this.img = img;
|
||||
if (img.complete && img.naturalWidth) {
|
||||
markImageLoaded(fullHash, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadImage();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.thumbHash !== this.props.thumbHash) {
|
||||
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
|
||||
}
|
||||
if (prevProps.fullHash !== this.props.fullHash) {
|
||||
this.img = getCachedImage(this.props.fullHash);
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
@@ -117,21 +151,33 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
|
||||
loadImage() {
|
||||
const { fullHash } = this.props;
|
||||
if (fullHash) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (this.img === img) {
|
||||
this.paint();
|
||||
}
|
||||
};
|
||||
img.src = thumb.url(fullHash);
|
||||
this.img = img;
|
||||
if (img.complete && img.naturalWidth) {
|
||||
this.paint();
|
||||
return;
|
||||
}
|
||||
if (!fullHash) return;
|
||||
const cached = getCachedImage(fullHash);
|
||||
if (cached) {
|
||||
this.img = cached;
|
||||
this.paint();
|
||||
return;
|
||||
}
|
||||
if (this.img && this.img.src === thumb.url(fullHash)) {
|
||||
if (this.img.complete && this.img.naturalWidth) {
|
||||
markImageLoaded(fullHash, this.img);
|
||||
this.paint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
markImageLoaded(fullHash, img);
|
||||
if (this.img === img) {
|
||||
this.paint();
|
||||
}
|
||||
};
|
||||
img.src = thumb.url(fullHash);
|
||||
this.img = img;
|
||||
if (img.complete && img.naturalWidth) {
|
||||
markImageLoaded(fullHash, img);
|
||||
this.paint();
|
||||
}
|
||||
this.paint();
|
||||
}
|
||||
|
||||
setCanvas = (canvas) => {
|
||||
@@ -148,17 +194,20 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
const canvas = this.canvasRef;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dstW = Math.round(rect.width);
|
||||
const dstH = Math.round(rect.height);
|
||||
const dstW = Math.round(rect.width) || canvas.clientWidth || TILE_SIZE;
|
||||
const dstH = Math.round(rect.height) || canvas.clientHeight || TILE_SIZE;
|
||||
if (!dstW || !dstH) return;
|
||||
|
||||
canvas.width = dstW;
|
||||
canvas.height = dstH;
|
||||
if (canvas.width !== dstW) canvas.width = dstW;
|
||||
if (canvas.height !== dstH) canvas.height = dstH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = this.img;
|
||||
const img = (this.img && this.img.complete && this.img.naturalWidth)
|
||||
? this.img
|
||||
: getCachedImage(this.props.fullHash);
|
||||
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
|
||||
this.img = img;
|
||||
drawContainedWithStretchedEdges(
|
||||
ctx,
|
||||
img,
|
||||
@@ -167,41 +216,50 @@ class ArticleTileBackdrop extends PureComponent {
|
||||
dstW,
|
||||
dstH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { thumbHash } = this.props;
|
||||
if (thumbHash) {
|
||||
const decoded = decodeThumbHash(thumbHash);
|
||||
if (decoded) {
|
||||
const { w, h, rgba } = decoded;
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = w;
|
||||
temp.height = h;
|
||||
temp.getContext('2d').putImageData(
|
||||
new ImageData(new Uint8ClampedArray(rgba), w, h),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { fullHash } = this.props;
|
||||
const showThumb = !isImageLoaded(fullHash) && Boolean(this.thumbUrl);
|
||||
return (
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={this.setCanvas}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{showThumb ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={this.thumbUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={this.setCanvas}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -252,8 +310,11 @@ export class ArticleTile extends Tile {
|
||||
}
|
||||
|
||||
renderExtra() {
|
||||
const childCount = Number(this.props.article?.childCount) || 0;
|
||||
if (childCount <= 0) return null;
|
||||
const { article } = this.props;
|
||||
if (!article) return null;
|
||||
const isParent = Boolean(article.isParent || article.is_parent);
|
||||
const childCount = Number(article.childCount) || 0;
|
||||
if (!isParent && childCount <= 0) return null;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -9,7 +9,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import ThumbHashImage from '../article/ThumbHashImage';
|
||||
import { getThumbHashDataUrl, isImageLoaded, markImageLoaded } from '../article/ThumbHashImage';
|
||||
import { thumb } from '../../api';
|
||||
import { tileBaseSx } from 'dashgui/tiles';
|
||||
|
||||
@@ -195,36 +195,56 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
||||
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
|
||||
*/
|
||||
class CategoryBackdrop extends PureComponent {
|
||||
state = { thumbLoaded: false };
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
|
||||
this.isCached = isImageLoaded(props.fullHash);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.thumbHash !== this.props.thumbHash) {
|
||||
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
|
||||
}
|
||||
if (prevProps.fullHash !== this.props.fullHash) {
|
||||
this.setState({ thumbLoaded: false });
|
||||
this.isCached = isImageLoaded(this.props.fullHash);
|
||||
this.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
handleThumbLoad = () => {
|
||||
this.setState({ thumbLoaded: true });
|
||||
};
|
||||
|
||||
handleThumbError = () => {
|
||||
this.setState({ thumbLoaded: false });
|
||||
handleLoad = (e) => {
|
||||
if (this.props.fullHash && e.target) {
|
||||
markImageLoaded(this.props.fullHash, e.target);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { thumbHash, fullHash } = this.props;
|
||||
const { thumbLoaded } = this.state;
|
||||
if (!thumbHash && !fullHash) return null;
|
||||
const { fullHash } = this.props;
|
||||
if (!this.thumbUrl && !fullHash) return null;
|
||||
const isCached = this.isCached || isImageLoaded(fullHash);
|
||||
return (
|
||||
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
|
||||
{thumbHash && !thumbLoaded ? <ThumbHashImage thumbHash={thumbHash} /> : null}
|
||||
{fullHash ? (
|
||||
{!isCached && this.thumbUrl ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={thumb.url(fullHash)}
|
||||
src={this.thumbUrl}
|
||||
alt=""
|
||||
onLoad={this.handleThumbLoad}
|
||||
onError={this.handleThumbError}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{fullHash ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={thumb.url(fullHash)}
|
||||
alt=""
|
||||
onLoad={this.handleLoad}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
@@ -232,7 +252,6 @@ class CategoryBackdrop extends PureComponent {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -5,7 +5,7 @@ html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
background-color: #c5ddd8;
|
||||
background-color: #85b7ad;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -30,4 +30,4 @@ html {
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||||
cursor: grabbing !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
}
|
||||
33
src/main.jsx
33
src/main.jsx
@@ -5,10 +5,11 @@ import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
import AuthBootstrap from './components/AuthBootstrap';
|
||||
import { StorageManagerProvider } from './api/storageManagerContext';
|
||||
import App from './App';
|
||||
import { getActiveTheme } from './theme';
|
||||
import './automats/supplierAutomat';
|
||||
// Side-effect imports: each module registers its automat and subscribes to
|
||||
// authAutomat, so the stores must be evaluated before the session bootstraps.
|
||||
import './automats';
|
||||
import './index.css';
|
||||
|
||||
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
|
||||
@@ -39,22 +40,20 @@ createRoot(document.getElementById('root')).render(
|
||||
autoHideDuration={2000}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<StorageManagerProvider>
|
||||
<AuthBootstrap>
|
||||
{/* Flex row so the dev inspector docks beside the app instead of
|
||||
overlaying it; the app content shrinks while the panel is open. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<App />
|
||||
</Box>
|
||||
{AutomatInspector ? (
|
||||
<Suspense fallback={null}>
|
||||
<AutomatInspector />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<AuthBootstrap>
|
||||
{/* Flex row so the dev inspector docks beside the app instead of
|
||||
overlaying it; the app content shrinks while the panel is open. */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<App />
|
||||
</Box>
|
||||
</AuthBootstrap>
|
||||
</StorageManagerProvider>
|
||||
{AutomatInspector ? (
|
||||
<Suspense fallback={null}>
|
||||
<AutomatInspector />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Box>
|
||||
</AuthBootstrap>
|
||||
</SnackbarProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import PeopleIcon from '@mui/icons-material/People';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import ViewListIcon from '@mui/icons-material/ViewList';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import GridViewIcon from '@mui/icons-material/GridView';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import CopyrightIcon from '@mui/icons-material/Copyright';
|
||||
import ListAltIcon from '@mui/icons-material/ListAlt';
|
||||
@@ -16,7 +17,6 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
|
||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import LabelIcon from '@mui/icons-material/Label';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
@@ -142,7 +142,7 @@ export const SUB_NAV = {
|
||||
segment: 'kategorien',
|
||||
label: 'Kategorieen',
|
||||
title: 'Kategorien',
|
||||
icon: CategoryIcon,
|
||||
icon: GridViewIcon,
|
||||
help: 'Hierarchischer Kategoriebaum zur Sortimentsgliederung',
|
||||
},
|
||||
{
|
||||
@@ -166,8 +166,8 @@ export const SUB_NAV = {
|
||||
{
|
||||
segment: 'merkmale',
|
||||
label: 'Merkmale',
|
||||
title: 'Merkmale & Attribute',
|
||||
icon: TuneIcon,
|
||||
title: 'Merkmale',
|
||||
icon: CategoryIcon,
|
||||
help: 'Globale Merkmale, Attribute und Werte verwalten',
|
||||
},
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user