From f765b87336c434d65175dc5300af7370fc4803ae Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 4 Oct 2026 21:04:40 +0200 Subject: [PATCH] u --- AGENTS.md | 42 +- src/api/ApiClient.js | 678 --------------- src/api/StorageManager.js | 819 ------------------ src/api/index.js | 77 ++ src/api/storageManagerContext.js | 33 - src/automats/articleDetailAutomat.js | 61 ++ src/automats/articleIndexAutomat.js | 44 + src/automats/articleSearchAutomat.js | 108 +++ src/automats/artikelAutomat.js | 121 +++ src/automats/authAutomat.js | 12 + src/automats/categoryAutomat.js | 21 + src/automats/createListAutomat.js | 148 ++++ src/automats/customergroupAutomat.js | 30 + src/automats/deliveryAutomat.js | 32 + src/automats/entityAutomat.js | 31 + src/automats/index.js | 21 + src/automats/numberAutomat.js | 30 + src/automats/orderAutomat.js | 28 + src/automats/quoteAutomat.js | 28 + src/automats/sseAutomat.js | 269 ++++++ src/components/AuthBootstrap.jsx | 7 +- src/components/CompanySettingsDialog.jsx | 40 +- src/components/CompanySetupDialog.jsx | 17 +- src/components/LoginPage.jsx | 7 +- src/components/UserMenu.jsx | 7 +- .../article/ArticleImageSearchDialog.jsx | 7 +- src/components/article/ArticlePhoto.jsx | 6 +- .../article/ArticlePhotosDialog.jsx | 10 +- .../article/ArticleStockAdjustDialog.jsx | 11 +- .../article/ArticleUnitEditorDialog.jsx | 15 +- src/components/article/ArticleView.jsx | 8 +- .../article/boxes/ArticleViewBrand.jsx | 39 +- .../article/boxes/ArticleViewEANs.jsx | 6 +- .../article/boxes/ArticleViewLists.jsx | 12 +- .../article/boxes/ArticleViewName.jsx | 16 +- .../article/boxes/ArticleViewPrice.jsx | 54 +- .../article/tabs/ArticleAttributesTab.jsx | 25 +- .../article/tabs/ArticleHistoryTab.jsx | 78 +- .../article/tabs/ArticleSettingsTab.jsx | 6 +- .../article/tabs/ArticleSupplierTab.jsx | 86 +- .../manifest/ArticleSearchDialog.jsx | 12 +- .../manifest/BookDeliveryDialog.jsx | 19 +- src/components/manifest/DeliveryList.jsx | 45 +- src/components/manifest/ManifestForm.jsx | 24 +- src/components/manifest/ManifestList.jsx | 13 +- src/components/manifest/PdfViewerPanel.jsx | 8 +- src/components/manifest/TextEditorPanel.jsx | 8 +- src/components/manifest/manifestConfig.js | 42 +- .../settings/NumberSettingsPanel.jsx | 9 +- .../settings/PrinterSettingsPanel.jsx | 14 +- src/components/settings/SettingsKeysPanel.jsx | 8 +- src/main.jsx | 33 +- src/routes/artikel.jsx | 42 +- src/routes/artikel/_sku.jsx | 18 +- src/routes/kunde.jsx | 14 +- src/routes/kunde/_entityId.jsx | 42 +- src/routes/kunde/gruppen.jsx | 19 +- src/routes/kunde/neu.jsx | 20 +- src/routes/lieferung/neu.jsx | 56 +- 59 files changed, 1491 insertions(+), 2045 deletions(-) delete mode 100644 src/api/ApiClient.js delete mode 100644 src/api/StorageManager.js delete mode 100644 src/api/storageManagerContext.js create mode 100644 src/automats/articleDetailAutomat.js create mode 100644 src/automats/articleIndexAutomat.js create mode 100644 src/automats/articleSearchAutomat.js create mode 100644 src/automats/createListAutomat.js create mode 100644 src/automats/customergroupAutomat.js create mode 100644 src/automats/deliveryAutomat.js create mode 100644 src/automats/entityAutomat.js create mode 100644 src/automats/numberAutomat.js create mode 100644 src/automats/orderAutomat.js create mode 100644 src/automats/quoteAutomat.js create mode 100644 src/automats/sseAutomat.js diff --git a/AGENTS.md b/AGENTS.md index 09eacf1..3046d97 100644 --- a/AGENTS.md +++ b/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) @@ -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 @@ -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-` 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-` signal. + --- ## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals) diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js deleted file mode 100644 index 31fdef2..0000000 --- a/src/api/ApiClient.js +++ /dev/null @@ -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-`. - * 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 (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 (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; diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js deleted file mode 100644 index 968ffd4..0000000 --- a/src/api/StorageManager.js +++ /dev/null @@ -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 | null} */ - #articleIndexCache = null; - /** @type {Map void>>} sku → article listeners */ - #articleWatchers = new Map(); - /** @type {Map void>>} sku → history refreshers */ - #historyWatchers = new Map(); - /** @type {Map 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-`). - * @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, 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-`. - * 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-`. - * @param {string} sku - * @param {(suppliers: Array) => void} onSuppliers - * @returns {{ unwatch: () => void, refresh: () => Promise }} - */ - 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, 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; diff --git a/src/api/index.js b/src/api/index.js index 0b76776..e8ed653 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -1,5 +1,7 @@ // Single source of truth for all backend communication. +import { requireArray, requireObject } from '../utils/apiShape'; + /** WebView consoles stringify objects as [object Object]; log JSON text. */ function logApi(tag, details) { let text; @@ -195,6 +197,29 @@ export const articleIndex = { list() { return http('/api/articleIndex'); }, + + /** + * Fetches the company article index and maps backend rows to the recordset + * shape consumed by the search result list. + * + * `categories` becomes a comma-joined string because `formatCategoryList` + * splits on ','. + * + * @returns {Promise>} + */ + async listRecords() { + const data = await http('/api/articleIndex'); + requireObject(data, 'GET /api/articleIndex'); + const articles = requireArray(data.articles, 'articleIndex.articles'); + 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), + })); + }, }; export const article = { @@ -337,6 +362,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. @@ -624,6 +660,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, + }); + }, }; /** @@ -1912,6 +1988,7 @@ export default { printer, propertyname, quote, + setting, supplier, thumb, unit, diff --git a/src/api/storageManagerContext.js b/src/api/storageManagerContext.js deleted file mode 100644 index ea98a5d..0000000 --- a/src/api/storageManagerContext.js +++ /dev/null @@ -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; -} diff --git a/src/automats/articleDetailAutomat.js b/src/automats/articleDetailAutomat.js new file mode 100644 index 0000000..fd7aa48 --- /dev/null +++ b/src/automats/articleDetailAutomat.js @@ -0,0 +1,61 @@ +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>} + */ +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>} + */ +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'); + }, + }, +); diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js new file mode 100644 index 0000000..eb33c24 --- /dev/null +++ b/src/automats/articleIndexAutomat.js @@ -0,0 +1,44 @@ +import { articleIndex as articleIndexApi } from '../api'; +import { createListAutomat } from './createListAutomat'; + +/** + * @typedef {Object} ArticleIndexRecord + * @property {string|number} id Article id. + * @property {string} sku Article SKU. + * @property {string} name Article name. + * @property {Array} eans Article EANs. + * @property {string|null} categories Comma-joined category ids. + * @property {boolean} isFather Whether the article has child variants. + */ + +/** + * Company article search index (`GET /api/articleIndex`). + * + * Backs the article search page, the manifest article picker, and the + * optimistic name patches applied by `artikelAutomat`. + * + * @type {import('react-automat').Automat} + */ +export const articleIndexAutomat = createListAutomat({ + name: 'articleIndex', + fetch: () => articleIndexApi.listRecords(), +}); + +/** + * Patches a single record's name in the cached 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, + ), + ); +} + +export default articleIndexAutomat; diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js new file mode 100644 index 0000000..ac0fa28 --- /dev/null +++ b/src/automats/articleSearchAutomat.js @@ -0,0 +1,108 @@ +import { Automat } from 'react-automat'; +import articleIndexAutomat from './articleIndexAutomat'; + +/** + * @typedef {Object} ArticleSearchResult + * @property {Record} categoryFacets Facet counts per category id. + * @property {number} totalCount Number of matching records. + * @property {Array} 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 full index is already cached by `articleIndexAutomat`, so this does not + * hit the network once the index has loaded. + * + * @param {string} target Search term; an empty string returns everything. + * @returns {Promise} 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} + */ +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; diff --git a/src/automats/artikelAutomat.js b/src/automats/artikelAutomat.js index fb726a6..9390203 100644 --- a/src/automats/artikelAutomat.js +++ b/src/automats/artikelAutomat.js @@ -1,6 +1,8 @@ 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 +127,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} 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} eans Replacement EAN list. + * @returns {Promise} 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} 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} categoryIds Replacement category ids. + * @returns {Promise} 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} prices Net price rows. + * @returns {Promise} 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} 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} 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 +300,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; diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js index 7de7933..76dec7c 100644 --- a/src/automats/authAutomat.js +++ b/src/automats/authAutomat.js @@ -191,4 +191,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; diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 8726dd7..99ba88f 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -145,6 +145,27 @@ export function disposeAllCategoryViewAutomats() { categoryViews.clear(); } +/** + * Returns the parsed category map and root id, or `null` while the tree is + * still loading. Replaces the former `StorageManager.getCategoryState()`. + * + * @returns {{ categories: Record, rootCategoryId: unknown }|null} + */ +export function getCategoryState() { + const state = categoryAutomat.state; + if (state.categories == null || state.treeStatus !== 'succeeded') return null; + return { categories: state.categories, rootCategoryId: state.rootCategoryId }; +} + +/** + * Resolves once the category tree has loaded. + * + * @returns {Promise} State with `treeStatus === 'succeeded'`. + */ +export function whenCategoryTreeReady() { + return waitForCategoryTree(); +} + // ─── Actions ─────────────────────────────────────────────────────────────────── const actions = { diff --git a/src/automats/createListAutomat.js b/src/automats/createListAutomat.js new file mode 100644 index 0000000..c040f14 --- /dev/null +++ b/src/automats/createListAutomat.js @@ -0,0 +1,148 @@ +import { Automat } from 'react-automat'; +import authAutomat from './authAutomat'; + +/** + * @typedef {Object} ListAutomatState + * @property {Array} 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>} options.fetch Loads the full record list. + * @param {(items: Array) => Array} [options.sort] Optional stable ordering. + * @returns {Automat} 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>} 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>} 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>} 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} items Records to cache. + * @returns {Array} 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>} 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} automat List automat. + * @returns {Array} Current records. + */ +export function selectListItems(automat) { + return automat.state.items; +} + +/** + * Selects the load status of a list automat. + * + * @param {Automat} automat List automat. + * @returns {'idle'|'loading'|'ready'|'failed'} Current status. + */ +export function selectListStatus(automat) { + return automat.state.status; +} diff --git a/src/automats/customergroupAutomat.js b/src/automats/customergroupAutomat.js new file mode 100644 index 0000000..9a9b457 --- /dev/null +++ b/src/automats/customergroupAutomat.js @@ -0,0 +1,30 @@ +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} + */ +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; diff --git a/src/automats/deliveryAutomat.js b/src/automats/deliveryAutomat.js new file mode 100644 index 0000000..e5d3872 --- /dev/null +++ b/src/automats/deliveryAutomat.js @@ -0,0 +1,32 @@ +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} + */ +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; diff --git a/src/automats/entityAutomat.js b/src/automats/entityAutomat.js new file mode 100644 index 0000000..5d8d0e6 --- /dev/null +++ b/src/automats/entityAutomat.js @@ -0,0 +1,31 @@ +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} + */ +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; diff --git a/src/automats/index.js b/src/automats/index.js index aad69ec..234d4ad 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -23,6 +23,25 @@ export { export { default as supplierAutomat } from './supplierAutomat'; export { default as warehouseAutomat } from './warehouseAutomat'; +/** Article search over the cached index (replaces StorageManager.doSearch). */ +export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat'; + +/** Company-scoped list stores (replaced the old StorageManager caches). */ +export { default as articleIndexAutomat, setArticleIndexName } 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, @@ -68,6 +87,8 @@ export { getOrCreateCategoryAutomat, disposeAllCategoryViewAutomats, invalidateAllCategoryViews, + getCategoryState, + whenCategoryTreeReady, } from './categoryAutomat'; export { default as articleHistoryGridAutomat } from './articleHistoryAutomat'; diff --git a/src/automats/numberAutomat.js b/src/automats/numberAutomat.js new file mode 100644 index 0000000..8c3b0ca --- /dev/null +++ b/src/automats/numberAutomat.js @@ -0,0 +1,30 @@ +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} + */ +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; diff --git a/src/automats/orderAutomat.js b/src/automats/orderAutomat.js new file mode 100644 index 0000000..d11b822 --- /dev/null +++ b/src/automats/orderAutomat.js @@ -0,0 +1,28 @@ +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} + */ +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; diff --git a/src/automats/quoteAutomat.js b/src/automats/quoteAutomat.js new file mode 100644 index 0000000..e9df407 --- /dev/null +++ b/src/automats/quoteAutomat.js @@ -0,0 +1,28 @@ +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} + */ +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; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js new file mode 100644 index 0000000..dd0a282 --- /dev/null +++ b/src/automats/sseAutomat.js @@ -0,0 +1,269 @@ +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 void>>} + */ +const handlers = new Map(); + +/** + * Handlers per watched SKU, so one `article-` listener is attached while + * at least one view is open on that SKU. + * @type {Map 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-` 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-` 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} + */ +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; diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index a031266..5e2bfc8 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -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' }); diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx index 4be9f10..e394443 100644 --- a/src/components/CompanySettingsDialog.jsx +++ b/src/components/CompanySettingsDialog.jsx @@ -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) { diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index d28932e..6c2d65e 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -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) }); } diff --git a/src/components/LoginPage.jsx b/src/components/LoginPage.jsx index 5041f26..b90b02e 100644 --- a/src/components/LoginPage.jsx +++ b/src/components/LoginPage.jsx @@ -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', { diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index 8936098..04abc23 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -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); } diff --git a/src/components/article/ArticleImageSearchDialog.jsx b/src/components/article/ArticleImageSearchDialog.jsx index e74c485..de49969 100644 --- a/src/components/article/ArticleImageSearchDialog.jsx +++ b/src/components/article/ArticleImageSearchDialog.jsx @@ -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'); diff --git a/src/components/article/ArticlePhoto.jsx b/src/components/article/ArticlePhoto.jsx index 187a813..1999cab 100644 --- a/src/components/article/ArticlePhoto.jsx +++ b/src/components/article/ArticlePhoto.jsx @@ -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 ? ( { - 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); diff --git a/src/components/article/boxes/ArticleViewEANs.jsx b/src/components/article/boxes/ArticleViewEANs.jsx index bd8b507..4d4c880 100644 --- a/src/components/article/boxes/ArticleViewEANs.jsx +++ b/src/components/article/boxes/ArticleViewEANs.jsx @@ -4,7 +4,7 @@ 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'; @@ -131,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); diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index ef996d3..7b93df5 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -35,11 +35,9 @@ import { article as articleApi } from '../../../api'; import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { notifyError } from '../../../utils/notify'; -import { StorageManagerContext } from '../../../api/storageManagerContext'; +import articleIndexAutomat from '../../../automats/articleIndexAutomat'; export default class ArticleViewLists extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); const initialAttributes = selectAttributes() || []; @@ -167,13 +165,12 @@ export default class ArticleViewLists extends PureComponent { } getApi() { - return this.context?.article || articleApi; + return articleApi; } async loadAllArticles() { try { - const data = await this.context?.articleIndexPromise; - const articles = Array.isArray(data) ? data : (data?.articles || []); + const articles = await articleIndexAutomat.actions.ensureLoaded(); if (!this._cancelled) { this.setState({ allArticles: articles }); } @@ -1316,7 +1313,7 @@ export default class ArticleViewLists extends PureComponent { } renderStandardView(selectableAttributes, assignedAttributeIds) { - const { busy, activeCreatorMode, selectedAttributeId, attributes } = this.state; + const { busy, activeCreatorMode, selectedAttributeId } = this.state; return ( {activeCreatorMode === 'father' ? ( @@ -1422,7 +1419,6 @@ export default class ArticleViewLists extends PureComponent { allArticles, busy, activeCreatorMode, - activeListTab, selectedChildValueId, newChildText, isCustomChildValue, diff --git a/src/components/article/boxes/ArticleViewName.jsx b/src/components/article/boxes/ArticleViewName.jsx index 4c436cc..6b783ce 100644 --- a/src/components/article/boxes/ArticleViewName.jsx +++ b/src/components/article/boxes/ArticleViewName.jsx @@ -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; } diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index 261ef19..f1bf40c 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -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,7 +469,8 @@ 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() { diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx index 6d40777..f764938 100644 --- a/src/components/article/tabs/ArticleAttributesTab.jsx +++ b/src/components/article/tabs/ArticleAttributesTab.jsx @@ -10,15 +10,12 @@ 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); } @@ -165,16 +162,15 @@ export default class ArticleAttributesTab extends PureComponent { 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) { @@ -231,7 +227,6 @@ export default class ArticleAttributesTab extends PureComponent { const effectiveKey = selectedOption?.key || selectedMerkmalKey; if (saving || !sku || !effectiveKey) return; - const api = this.context?.article || articleApi; if (effectiveKey.startsWith('attr-')) { const attrId = Number(effectiveKey.replace('attr-', '')); @@ -262,7 +257,7 @@ export default class ArticleAttributesTab extends PureComponent { 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 : [], @@ -304,7 +299,7 @@ export default class ArticleAttributesTab extends PureComponent { 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 : [], @@ -331,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 : [], @@ -357,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 : [], diff --git a/src/components/article/tabs/ArticleHistoryTab.jsx b/src/components/article/tabs/ArticleHistoryTab.jsx index ab2c586..f715ede 100644 --- a/src/components/article/tabs/ArticleHistoryTab.jsx +++ b/src/components/article/tabs/ArticleHistoryTab.jsx @@ -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-` 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; diff --git a/src/components/article/tabs/ArticleSettingsTab.jsx b/src/components/article/tabs/ArticleSettingsTab.jsx index 300ab30..d2b9744 100644 --- a/src/components/article/tabs/ArticleSettingsTab.jsx +++ b/src/components/article/tabs/ArticleSettingsTab.jsx @@ -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) { diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index e9ad60f..cd55974 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -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 }); } diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx index f3b65a2..e79f037 100644 --- a/src/components/manifest/ArticleSearchDialog.jsx +++ b/src/components/manifest/ArticleSearchDialog.jsx @@ -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); diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx index 04b01fb..93f1623 100644 --- a/src/components/manifest/BookDeliveryDialog.jsx +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -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); diff --git a/src/components/manifest/DeliveryList.jsx b/src/components/manifest/DeliveryList.jsx index 0c2cef3..2bb7943 100644 --- a/src/components/manifest/DeliveryList.jsx +++ b/src/components/manifest/DeliveryList.jsx @@ -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' }); diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index 7842469..3f43537 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -4,7 +4,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../BackendError'; import { PanelTileGrid } from 'dashgui/panels'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { delivery as deliveryApi } from '../../api'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -20,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); @@ -88,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', @@ -120,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; @@ -243,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': @@ -372,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`); diff --git a/src/components/manifest/ManifestList.jsx b/src/components/manifest/ManifestList.jsx index 8eb10bd..eb0e39a 100644 --- a/src/components/manifest/ManifestList.jsx +++ b/src/components/manifest/ManifestList.jsx @@ -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), diff --git a/src/components/manifest/PdfViewerPanel.jsx b/src/components/manifest/PdfViewerPanel.jsx index 7bdc172..2b32397 100644 --- a/src/components/manifest/PdfViewerPanel.jsx +++ b/src/components/manifest/PdfViewerPanel.jsx @@ -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; diff --git a/src/components/manifest/TextEditorPanel.jsx b/src/components/manifest/TextEditorPanel.jsx index 9a64972..22cdb73 100644 --- a/src/components/manifest/TextEditorPanel.jsx +++ b/src/components/manifest/TextEditorPanel.jsx @@ -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, diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index 2d89fbc..c1494d7 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -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']); @@ -481,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); @@ -495,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); } @@ -538,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 @@ -575,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, @@ -609,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, diff --git a/src/components/settings/NumberSettingsPanel.jsx b/src/components/settings/NumberSettingsPanel.jsx index d33ed83..82b30d7 100644 --- a/src/components/settings/NumberSettingsPanel.jsx +++ b/src/components/settings/NumberSettingsPanel.jsx @@ -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'); diff --git a/src/components/settings/PrinterSettingsPanel.jsx b/src/components/settings/PrinterSettingsPanel.jsx index 019a1fc..2e453e7 100644 --- a/src/components/settings/PrinterSettingsPanel.jsx +++ b/src/components/settings/PrinterSettingsPanel.jsx @@ -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); diff --git a/src/components/settings/SettingsKeysPanel.jsx b/src/components/settings/SettingsKeysPanel.jsx index 2d31e37..2f83d12 100644 --- a/src/components/settings/SettingsKeysPanel.jsx +++ b/src/components/settings/SettingsKeysPanel.jsx @@ -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'); diff --git a/src/main.jsx b/src/main.jsx index cdac534..185315c 100644 --- a/src/main.jsx +++ b/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' }} > - - - {/* Flex row so the dev inspector docks beside the app instead of - overlaying it; the app content shrinks while the panel is open. */} - - - - - {AutomatInspector ? ( - - - - ) : null} + + {/* Flex row so the dev inspector docks beside the app instead of + overlaying it; the app content shrinks while the panel is open. */} + + + - - + {AutomatInspector ? ( + + + + ) : null} + + , ); diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index 92fcbb2..f55931a 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -10,7 +10,7 @@ import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; -import { StorageManagerContext, getStorageManager } from '../api/storageManagerContext'; +import articleSearchAutomat from '../automats/articleSearchAutomat'; import BackendError from '../components/BackendError'; import { formatPrice } from '../utils/price'; import { @@ -83,12 +83,6 @@ function initialSearchState(searchParams) { } class ArtikelSearchPage extends Component { - static contextType = StorageManagerContext; - - get storageManager() { - return this.context || getStorageManager(); - } - state = initialSearchState(this.props.searchParams); listSlotRef = { current: null }; rowMeasureRef = { current: null }; @@ -176,20 +170,18 @@ class ArtikelSearchPage extends Component { window.addEventListener('resize', this.handleWindowResize); setLastArticleSku(null); + // Push search results into local component state. + this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); + // Kick off initial search (empty input = show all products). - const { input } = this.state; - this.storageManager?.doSearch({ - target: input, - filter: this.props.searchParams.get('filter') || undefined, - offset: 0, - maxCount: this.state.pageSize ?? 20, - onUpdate: (result) => this.setState(result), - }); + void articleSearchAutomat.actions.search(this.state.input).catch(() => {}); } componentWillUnmount() { document.removeEventListener('mouseup', preserveSearchFocus, true); window.removeEventListener('resize', this.handleWindowResize); + this.unsubSearch?.(); + this.unsubSearch = null; this.persistQuery(); } @@ -226,34 +218,18 @@ class ArtikelSearchPage extends Component { handleInputChange = (event) => { const value = event.target.value; this.setState({ input: value }); - - this.storageManager?.doSearch({ - target: value, - filter: this.props.searchParams.get('filter') || undefined, - offset: 0, - maxCount: this.state.pageSize ?? 20, - onUpdate: (result) => this.setState(result), - }); + void articleSearchAutomat.actions.search(value).catch(() => {}); }; fetchResults = () => { const { target, filter, offset } = queryFromParams(this.props.searchParams); - const trimmed = target.trim(); const { pageSize } = this.state; if (pageSize == null) return; writeArtikelQuery({ target, filter, offset }); - // Delegate fetch + debounce to the global StorageManager. - // The callback pushes results into local component state. - this.storageManager?.doSearch({ - target: trimmed, - filter: filter || undefined, - offset, - maxCount: pageSize, - onUpdate: (result) => this.setState(result), - }); + void articleSearchAutomat.actions.search(target.trim()).catch(() => {}); }; render() { diff --git a/src/routes/artikel/_sku.jsx b/src/routes/artikel/_sku.jsx index 36c2d8e..a3656cd 100644 --- a/src/routes/artikel/_sku.jsx +++ b/src/routes/artikel/_sku.jsx @@ -1,7 +1,9 @@ import { Component } from 'react'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { getCategoryState, whenCategoryTreeReady } from '../../automats/categoryAutomat'; +import companyAutomat from '../../automats/companyAutomat'; +import { bootstrapAuthMe } from '../../automats/authAutomat'; import BackendError from '../../components/BackendError'; import { ArticleView } from '../../components/article'; import { @@ -22,13 +24,11 @@ function ArticleLoading() { } class ArtikelSkuPage extends Component { - static contextType = StorageManagerContext; - - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { article: getArticle(props.sku), - ready: context.getCategoryState() != null, + ready: getCategoryState() != null, error: null, }; } @@ -72,9 +72,9 @@ class ArtikelSkuPage extends Component { try { await Promise.all([ artikelAutomat.actions.loadArticle(sku), - this.context.categoryTreePromise, - this.context.companyPromise, - this.context.authMePromise, + whenCategoryTreeReady(), + companyAutomat.actions.load(), + bootstrapAuthMe(), ]); if (this._mounted) this.setState({ ready: true }); } catch (error) { diff --git a/src/routes/kunde.jsx b/src/routes/kunde.jsx index b5b4cfb..7031667 100644 --- a/src/routes/kunde.jsx +++ b/src/routes/kunde.jsx @@ -2,7 +2,9 @@ import { PureComponent } from 'react'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../components/BackendError'; import { RecordList } from 'dashgui/lists'; -import { StorageManagerContext } from '../api/storageManagerContext'; +import { entity as entityApi } from '../api'; +import entityAutomat from '../automats/entityAutomat'; +import customergroupAutomat from '../automats/customergroupAutomat'; import { router } from '../appRouter'; import { backendErrorFrom } from '../utils/backendError'; import { notifyError } from '../utils/notify'; @@ -21,8 +23,6 @@ function formatDate(value) { } class KundeListePage extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); this.state = { @@ -50,8 +50,8 @@ class KundeListePage extends PureComponent { this.setState({ status: 'loading', error: null }); try { const [entities, groups] = await Promise.all([ - this.context.entities ?? this.context.entityPromise, - this.context.customergroups ?? this.context.customergroupPromise, + entityAutomat.actions.ensureLoaded(), + customergroupAutomat.actions.ensureLoaded(), ]); if (this._cancelled) return; const groupsById = Object.fromEntries(groups.map((row) => [row.id, row])); @@ -89,8 +89,8 @@ class KundeListePage extends PureComponent { this.setState({ busyId: entity.id }); try { - await this.context.entity.delete(entity.id); - const list = await this.context.updateEntity(); + await entityApi.delete(entity.id); + const list = await entityAutomat.actions.reload(); if (this._cancelled) return; this.setState({ entities: sortEntities(list), diff --git a/src/routes/kunde/_entityId.jsx b/src/routes/kunde/_entityId.jsx index d31e6ce..5d1a153 100644 --- a/src/routes/kunde/_entityId.jsx +++ b/src/routes/kunde/_entityId.jsx @@ -11,7 +11,9 @@ import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { entity as entityApi } from '../../api'; +import entityAutomat from '../../automats/entityAutomat'; +import customergroupAutomat from '../../automats/customergroupAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -72,8 +74,6 @@ function emptyContactDraft() { } class KundeEditPage extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); this.state = { @@ -128,10 +128,10 @@ class KundeEditPage extends PureComponent { this.setState({ status: 'loading', error: null }); try { const [entityRes, addressRes, contactRes, groups] = await Promise.all([ - this.context.entity.get(entityId), - this.context.entity.listAddresses(entityId), - this.context.entity.listContacts(entityId), - this.context.customergroups ?? this.context.customergroupPromise, + entityApi.get(entityId), + entityApi.listAddresses(entityId), + entityApi.listContacts(entityId), + customergroupAutomat.actions.ensureLoaded(), ]); if (this._cancelled) return; @@ -186,14 +186,14 @@ class KundeEditPage extends PureComponent { this.setState({ savingEntity: true }); try { - const data = await this.context.entity.update(entityId, { + const data = await entityApi.update(entityId, { name: trimmedName, firstName: firstName.trim() === '' ? null : firstName.trim(), }); requireObject(data, 'PATCH /api/entity/:id'); requireObject(data.entity, 'entity'); requireDefined(data.entity.id, 'entity.id'); - await this.context.updateEntity(); + await entityAutomat.actions.reload(); if (this._cancelled) return; this.setState({ entity: data.entity, @@ -230,14 +230,14 @@ class KundeEditPage extends PureComponent { this.setState({ busyAddressKey: address.id }); try { - const data = await this.context.entity.updateAddress( + const data = await entityApi.updateAddress( entityId, address.id, body, ); requireObject(data, 'PATCH /api/entity/:id/address/:id'); requireObject(data.address, 'address'); - const listRes = await this.context.entity.listAddresses(entityId); + const listRes = await entityApi.listAddresses(entityId); requireObject(listRes, 'GET /api/entity/:id/address'); const addresses = requireArray(listRes.addresses, 'addresses'); if (this._cancelled) return; @@ -268,11 +268,11 @@ class KundeEditPage extends PureComponent { this.setState({ busyAddressKey: 'new' }); try { - const data = await this.context.entity.createAddress(entityId, body); + const data = await entityApi.createAddress(entityId, body); requireObject(data, 'POST /api/entity/:id/address'); requireObject(data.address, 'address'); requireDefined(data.address.id, 'address.id'); - const listRes = await this.context.entity.listAddresses(entityId); + const listRes = await entityApi.listAddresses(entityId); requireObject(listRes, 'GET /api/entity/:id/address'); const addresses = requireArray(listRes.addresses, 'addresses'); if (this._cancelled) return; @@ -299,8 +299,8 @@ class KundeEditPage extends PureComponent { this.setState({ busyAddressKey: address.id }); try { - await this.context.entity.deleteAddress(entityId, address.id); - const listRes = await this.context.entity.listAddresses(entityId); + await entityApi.deleteAddress(entityId, address.id); + const listRes = await entityApi.listAddresses(entityId); requireObject(listRes, 'GET /api/entity/:id/address'); const addresses = requireArray(listRes.addresses, 'addresses'); if (this._cancelled) return; @@ -341,13 +341,13 @@ class KundeEditPage extends PureComponent { this.setState({ busyContactKey: contact.id }); try { - const data = await this.context.entity.updateContact(entityId, contact.id, { + const data = await entityApi.updateContact(entityId, contact.id, { contactType, contact: value, }); requireObject(data, 'PATCH /api/entity/:id/contact/:id'); requireObject(data.contact, 'contact'); - const listRes = await this.context.entity.listContacts(entityId); + const listRes = await entityApi.listContacts(entityId); requireObject(listRes, 'GET /api/entity/:id/contact'); const contacts = requireArray(listRes.contacts, 'contacts'); if (this._cancelled) return; @@ -382,14 +382,14 @@ class KundeEditPage extends PureComponent { this.setState({ busyContactKey: 'new' }); try { - const data = await this.context.entity.createContact(entityId, { + const data = await entityApi.createContact(entityId, { contactType, contact: value, }); requireObject(data, 'POST /api/entity/:id/contact'); requireObject(data.contact, 'contact'); requireDefined(data.contact.id, 'contact.id'); - const listRes = await this.context.entity.listContacts(entityId); + const listRes = await entityApi.listContacts(entityId); requireObject(listRes, 'GET /api/entity/:id/contact'); const contacts = requireArray(listRes.contacts, 'contacts'); if (this._cancelled) return; @@ -419,8 +419,8 @@ class KundeEditPage extends PureComponent { this.setState({ busyContactKey: contact.id }); try { - await this.context.entity.deleteContact(entityId, contact.id); - const listRes = await this.context.entity.listContacts(entityId); + await entityApi.deleteContact(entityId, contact.id); + const listRes = await entityApi.listContacts(entityId); requireObject(listRes, 'GET /api/entity/:id/contact'); const contacts = requireArray(listRes.contacts, 'contacts'); if (this._cancelled) return; diff --git a/src/routes/kunde/gruppen.jsx b/src/routes/kunde/gruppen.jsx index d83f855..4569e92 100644 --- a/src/routes/kunde/gruppen.jsx +++ b/src/routes/kunde/gruppen.jsx @@ -15,7 +15,8 @@ import { enqueueSnackbar } from 'notistack'; import BackendError from '../../components/BackendError'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { customergroup as customergroupApi } from '../../api'; +import customergroupAutomat from '../../automats/customergroupAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -43,8 +44,6 @@ function requireGroupListItem(row, path) { } class KundeGruppenPage extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); this.state = { @@ -85,9 +84,7 @@ class KundeGruppenPage extends PureComponent { async load() { this.setState({ status: 'loading', error: null }); try { - const list = - this.context.customergroups ?? - (await this.context.customergroupPromise); + const list = await customergroupAutomat.actions.ensureLoaded(); if (this._cancelled) return; this.applyGroups(list); } catch (err) { @@ -101,7 +98,7 @@ class KundeGruppenPage extends PureComponent { } async refreshFromServer() { - const list = await this.context.updateCustomergroup(); + const list = await customergroupAutomat.actions.reload(); if (this._cancelled) return; this.applyGroups(list); } @@ -112,7 +109,7 @@ class KundeGruppenPage extends PureComponent { this.setState({ busyKey: 'new' }); try { - const data = await this.context.customergroup.create({ name }); + const data = await customergroupApi.create({ name }); requireObject(data, 'POST /api/customergroup'); requireObject(data.customergroup, 'customergroup'); requireDefined(data.customergroup.id, 'customergroup.id'); @@ -141,7 +138,7 @@ class KundeGruppenPage extends PureComponent { this.setState({ busyKey: group.id }); try { - const data = await this.context.customergroup.update(group.id, { + const data = await customergroupApi.update(group.id, { name: draft, }); requireObject(data, 'PATCH /api/customergroup/:id'); @@ -177,7 +174,7 @@ class KundeGruppenPage extends PureComponent { this.setState({ busyKey: group.id }); try { - const data = await this.context.customergroup.order({ customergroupIds }); + const data = await customergroupApi.order({ customergroupIds }); requireObject(data, 'PUT /api/customergroup/order'); requireArray(data.customergroupIds, 'customergroupIds'); await this.refreshFromServer(); @@ -204,7 +201,7 @@ class KundeGruppenPage extends PureComponent { this.setState({ busyKey: group.id }); try { - await this.context.customergroup.delete(group.id); + await customergroupApi.delete(group.id); await this.refreshFromServer(); if (this._cancelled) return; this.setState({ busyKey: null }); diff --git a/src/routes/kunde/neu.jsx b/src/routes/kunde/neu.jsx index 46e4b6a..da23cdf 100644 --- a/src/routes/kunde/neu.jsx +++ b/src/routes/kunde/neu.jsx @@ -8,17 +8,17 @@ import TextField from '@mui/material/TextField'; import { enqueueSnackbar } from 'notistack'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { entity as entityApi } from '../../api'; +import entityAutomat from '../../automats/entityAutomat'; +import customergroupAutomat from '../../automats/customergroupAutomat'; import { router } from '../../appRouter'; import { requireDefined, requireObject } from '../../utils/apiShape'; import { notifyError } from '../../utils/notify'; class KundeNeuPage extends PureComponent { - static contextType = StorageManagerContext; - - constructor(props, context) { - super(props, context); - const groups = this.context.customergroups ?? []; + constructor(props) { + super(props); + const groups = customergroupAutomat.state.items ?? []; this.state = { customergroupId: groups.length > 0 ? groups[0].id : null, number: '', @@ -36,7 +36,7 @@ class KundeNeuPage extends PureComponent { async loadNumber() { try { - const res = await this.context.entity.nextNumber(); + const res = await entityApi.nextNumber(); requireObject(res, 'GET /api/entity/next-number'); requireDefined(res.number, 'number'); this.setState({ number: res.number }); @@ -73,11 +73,11 @@ class KundeNeuPage extends PureComponent { this.setState({ saving: true }); try { - const data = await this.context.entity.create(body); + const data = await entityApi.create(body); requireObject(data, 'POST /api/entity'); requireObject(data.entity, 'entity'); requireDefined(data.entity.id, 'entity.id'); - await this.context.updateEntity(); + await entityAutomat.actions.reload(); enqueueSnackbar('Kunde angelegt.', { variant: 'success' }); void router.navigate(`/kunde/${data.entity.id}`); } catch (err) { @@ -88,7 +88,7 @@ class KundeNeuPage extends PureComponent { render() { const { customergroupId, number, name, firstName, saving } = this.state; - const groups = this.context.customergroups ?? []; + const groups = customergroupAutomat.state.items ?? []; const canSave = customergroupId != null && name.trim() !== '' && !saving; diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index b6cc5ac..173c30f 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -37,8 +37,9 @@ import { BookDeliveryDialog, } from '../../components/manifest'; import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog'; -import { loadParties } from '../../components/manifest/manifestConfig'; -import { StorageManagerContext } from '../../api/storageManagerContext'; +import { loadParties, apiFor } from '../../components/manifest/manifestConfig'; +import { article as articleApi, delivery as deliveryApi } from '../../api'; +import { getCategoryState } from '../../automats/categoryAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -47,12 +48,10 @@ import { generateTextThumbnail } from '../../utils/textThumbnail'; import { extractPdfText } from '../../utils/pdfText'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats'; -import { delivery as deliveryApi, ledger as defaultLedgerApi } from '../../api'; + import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger'; class LieferungNeuPage extends PureComponent { - static contextType = StorageManagerContext; - constructor(props) { super(props); this.cfg = manifestConfig('delivery'); @@ -168,7 +167,7 @@ class LieferungNeuPage extends PureComponent { async loadPartiesOnly() { this.setState({ status: 'loadingParties', error: null, partiesLoading: true }); try { - const parties = await loadParties('delivery', this.context); + const parties = await loadParties('delivery'); if (this._cancelled) return; this.automat.actions.setDeliveryEntities(parties); this.setState({ @@ -200,8 +199,8 @@ class LieferungNeuPage extends PureComponent { this.setState({ status: 'loading', error: null, partiesLoading: true }); try { const [docRes, parties] = await Promise.all([ - this.context[cfg.api].get(docId), - loadParties('delivery', this.context), + apiFor(cfg.kind).get(docId), + loadParties('delivery'), ]); if (this._cancelled) return; @@ -228,12 +227,7 @@ class LieferungNeuPage extends PureComponent { let ledgerSummary = null; try { - const ledgerClient = this.context?.ledger || defaultLedgerApi; - const deliveryClient = this.context?.[cfg.api] || deliveryApi; - ledgerSummary = await fetchDeliveryLedgerSummary(docId, { - ledgerApi: ledgerClient, - deliveryApi: deliveryClient, - }); + ledgerSummary = await fetchDeliveryLedgerSummary(docId); } catch (lErr) { console.error('[neu] Failed to load ledger summary:', lErr); } @@ -272,12 +266,7 @@ class LieferungNeuPage extends PureComponent { const { docId } = this.props; if (docId == null) return; try { - const ledgerClient = this.context?.ledger || defaultLedgerApi; - const deliveryClient = this.context?.[this.cfg.api] || deliveryApi; - const ledgerSummary = await fetchDeliveryLedgerSummary(docId, { - ledgerApi: ledgerClient, - deliveryApi: deliveryClient, - }); + const ledgerSummary = await fetchDeliveryLedgerSummary(docId); if (this._cancelled) return; this.setState({ ledgerSummary }); } catch (err) { @@ -381,7 +370,7 @@ class LieferungNeuPage extends PureComponent { try { // Get root category ID - const categoryState = this.context.getCategoryState(); + const categoryState = getCategoryState(); if (!categoryState) { enqueueSnackbar('Kategorie-Daten nicht verfügbar.', { variant: 'error' }); return; @@ -394,7 +383,7 @@ class LieferungNeuPage extends PureComponent { } // Create article in root category - const articleRes = await this.context.article.create({ + const articleRes = await articleApi.create({ parentId: rootCategoryId, name: categoryName, }); @@ -407,7 +396,7 @@ class LieferungNeuPage extends PureComponent { const supplierSku = line.sku; // Add supplier relationship with the supplier's SKU - await this.context.article.addSupplier(internalSku, { + await articleApi.addSupplier(internalSku, { entityId: partyId, sku: supplierSku || undefined, pu: line.pu ? Number(line.pu) : undefined, @@ -457,7 +446,7 @@ class LieferungNeuPage extends PureComponent { const supplierSku = line?.sku; try { - await this.context.article.addSupplier(internalSku, { + await articleApi.addSupplier(internalSku, { entityId: partyId, sku: supplierSku || undefined, pu: line?.pu ? Number(line.pu) : undefined, @@ -699,10 +688,7 @@ class LieferungNeuPage extends PureComponent { }; try { - const parser = this.context?.delivery?.parseSse; - if (!parser) throw new Error('parseSse nicht verfügbar.'); - - for await (const { event, data } of parser(file, { signal: abortController.signal })) { + for await (const { event, data } of deliveryApi.parseSse(file, { signal: abortController.signal })) { if (this._cancelled || abortController.signal.aborted) return; switch (event) { case 'status': @@ -914,10 +900,7 @@ class LieferungNeuPage extends PureComponent { } try { - const getFile = - this.context?.delivery?.getFile?.bind(this.context.delivery) || - deliveryApi.getFile; - const blob = await getFile(docId, fileId); + const blob = await deliveryApi.getFile(docId, fileId); const filename = attachment.name || 'Dokument'; const mimetype = attachment.type || blob.type || 'application/octet-stream'; const file = new File([blob], filename, { type: mimetype }); @@ -1164,23 +1147,24 @@ class LieferungNeuPage extends PureComponent { } this.setState({ saving: true }); + const api = apiFor(cfg.kind); try { if (isCreate) { - 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.automat.actions.resetDelivery(); this.props.navigate(cfg.editPath(data[cfg.docField].id)); } else { - 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`);