u
This commit is contained in:
42
AGENTS.md
42
AGENTS.md
@@ -49,8 +49,8 @@ flow:
|
|||||||
component: dashfront
|
component: dashfront
|
||||||
runtime: React 19 (PureComponent & Function Components)
|
runtime: React 19 (PureComponent & Function Components)
|
||||||
state_layers:
|
state_layers:
|
||||||
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat]
|
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(), ApiClient)
|
transport: src/api (http(), named namespaces)
|
||||||
egress:
|
egress:
|
||||||
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
|
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
|
||||||
proxy: Vite dev server (/api -> http://localhost:4208)
|
proxy: Vite dev server (/api -> http://localhost:4208)
|
||||||
@@ -99,10 +99,7 @@ flow:
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── api/ # Backend communication layer
|
├── api/ # Backend communication layer
|
||||||
│ ├── index.js # Canonical API module exports (article, category, delivery, etc.)
|
│ └── index.js # Canonical API module (article, category, delivery, entity, …)
|
||||||
│ ├── 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
|
|
||||||
├── automats/ # react-automat state modules (fine-grained subscriptions)
|
├── automats/ # react-automat state modules (fine-grained subscriptions)
|
||||||
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
|
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
|
||||||
│ ├── authAutomat.js # Authentication state, session bootstrap, company selection
|
│ ├── 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/`)
|
### B. Configuration Automats (`src/automats/`)
|
||||||
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
|
- 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.
|
- 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)).
|
- 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
|
## 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))
|
### 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.
|
- **Role**: Stores client-side user layout preferences such as viewer panel coordinates and drawer positions.
|
||||||
|
|
||||||
|
### 7. `sseAutomat` ([src/automats/sseAutomat.js](file:///home/seb/src/dashfront/src/automats/sseAutomat.js))
|
||||||
|
- **Role**: Owns the **single** `EventSource` for `/api/events` and fans signals out into the domain automats. Reconnects whenever the selected company changes; disconnects on logout.
|
||||||
|
- **Built-in handlers**: `category` → `categoryAutomat.loadTree()` + `invalidateAllCategoryViews()`; `articles` → `articleIndexAutomat.reload()` + category-view invalidation; `warehouse` → `warehouseAutomat.reload()`; `attributename` / `propertyname` → `attributeAutomat.load()` / `propertyAutomat.load()`; `supplier` → `supplierAutomat.reload()`.
|
||||||
|
- **Extension API**:
|
||||||
|
- `sseAutomat.actions.on(event, handler)` — register a handler for a named backend event. Returns an unsubscribe callback. Handlers registered while disconnected attach on the next connect.
|
||||||
|
- `sseAutomat.actions.watchArticle(sku, handler)` — subscribe to `article-<sku>` for one SKU. Exactly one listener is bound per SKU while at least one caller is subscribed; the listener resolves its handler set at call time, so an unwatched SKU is a no-op.
|
||||||
|
- `invalidateArticle(sku)` — reloads the article plus its keyed history/supplier views. Background refreshes stay silent.
|
||||||
|
|
||||||
|
### 8. Company-Scoped List Automats
|
||||||
|
Built with the shared [src/automats/createListAutomat.js](file:///home/seb/src/dashfront/src/automats/createListAutomat.js) factory, which follows the brand/unit/warehouse pattern (`status: idle | loading | ready | failed`):
|
||||||
|
|
||||||
|
| Automat | Backs |
|
||||||
|
|---|---|
|
||||||
|
| `articleIndexAutomat` | Article search index (`GET /api/articleIndex`), mapped to the recordset shape; also patched optimistically on rename |
|
||||||
|
| `deliveryAutomat` | Delivery list |
|
||||||
|
| `quoteAutomat` / `orderAutomat` | Quote / order lists |
|
||||||
|
| `entityAutomat` / `customergroupAutomat` | Customer list and customer groups |
|
||||||
|
| `numberAutomat` | Document number sequences |
|
||||||
|
|
||||||
|
Each exposes `load()` (deduplicated), `reload()` (silent), `replace()`, `ensureLoaded()`, and `reset()`. They subscribe to `authAutomat`, so they load on `authed` with a `selectedCompanyId` and reset to `idle` on `anon`.
|
||||||
|
|
||||||
|
### 9. `articleSearchAutomat` ([src/automats/articleSearchAutomat.js](file:///home/seb/src/dashfront/src/automats/articleSearchAutomat.js))
|
||||||
|
- **Role**: Client-side search over the cached article index for the `/artikel` overview. `search(target)` returns `{ categoryFacets, totalCount, recordset, error }`; the page subscribes to `result` instead of receiving a callback.
|
||||||
|
|
||||||
|
### 10. `articleDetailAutomat` ([src/automats/articleDetailAutomat.js](file:///home/seb/src/dashfront/src/automats/articleDetailAutomat.js))
|
||||||
|
- **Role**: Two `KeyedAutomat` stores keyed by SKU — `articleHistoryAutomat` (`GET /api/article/{sku}/history`, paginated client-side) and `articleSupplierAutomat` (`GET /api/article/{sku}/supplier`). Both reload automatically when `sseAutomat` reports an `article-<sku>` signal.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
|
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
|
||||||
|
|||||||
@@ -1,678 +0,0 @@
|
|||||||
function enc(value) {
|
|
||||||
return encodeURIComponent(String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUrl(baseUrl, path) {
|
|
||||||
return new URL(path, baseUrl || window.location.origin).href;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPath(path, query) {
|
|
||||||
if (!query) return path;
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
for (const [key, value] of Object.entries(query)) {
|
|
||||||
if (value == null) continue;
|
|
||||||
params.set(key, String(value));
|
|
||||||
}
|
|
||||||
const qs = params.toString();
|
|
||||||
return qs ? `${path}?${qs}` : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty).
|
|
||||||
*/
|
|
||||||
export class ApiClient {
|
|
||||||
#baseUrl;
|
|
||||||
|
|
||||||
constructor(baseUrl = window.location.origin) {
|
|
||||||
this.#baseUrl = baseUrl;
|
|
||||||
|
|
||||||
this.auth = {
|
|
||||||
/** POST /api/auth/login */
|
|
||||||
login: (body) => this.#json('POST', '/api/auth/login', { body }),
|
|
||||||
/** POST /api/auth/logout */
|
|
||||||
logout: () => this.#json('POST', '/api/auth/logout'),
|
|
||||||
/** GET /api/auth/me */
|
|
||||||
me: () => this.#json('GET', '/api/auth/me'),
|
|
||||||
/** PUT /api/auth/selected-company */
|
|
||||||
selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.health = {
|
|
||||||
/** GET /api/health */
|
|
||||||
get: () => this.#json('GET', '/api/health'),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.company = {
|
|
||||||
/** GET /api/company */
|
|
||||||
list: () => this.#json('GET', '/api/company'),
|
|
||||||
/** POST /api/company */
|
|
||||||
create: (body) => this.#json('POST', '/api/company', { body }),
|
|
||||||
/** PUT /api/company/{id} */
|
|
||||||
update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/company/{id} */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.unit = {
|
|
||||||
/** GET /api/unit */
|
|
||||||
list: () => this.#json('GET', '/api/unit'),
|
|
||||||
/** PUT /api/unit */
|
|
||||||
upsert: (body) => this.#json('PUT', '/api/unit', { body }),
|
|
||||||
/** DELETE /api/unit?si=… */
|
|
||||||
delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.brand = {
|
|
||||||
/** GET /api/brand */
|
|
||||||
list: () => this.#json('GET', '/api/brand'),
|
|
||||||
/** PUT /api/brand */
|
|
||||||
upsert: (body) => this.#json('PUT', '/api/brand', { body }),
|
|
||||||
/** DELETE /api/brand/{id} */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.attributename = {
|
|
||||||
/** GET /api/attributename */
|
|
||||||
list: () => this.#json('GET', '/api/attributename'),
|
|
||||||
/** POST /api/attributename */
|
|
||||||
create: (body) => this.#json('POST', '/api/attributename', { body }),
|
|
||||||
/** PATCH /api/attributename/{id} */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/attributename/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/attributename/{id} */
|
|
||||||
delete: (id) => this.#json('DELETE', `/api/attributename/${enc(id)}`),
|
|
||||||
/** PUT /api/attributename/order */
|
|
||||||
order: (body) => this.#json('PUT', '/api/attributename/order', { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.attributevalue = {
|
|
||||||
/** GET /api/attributename/{attributeId}/value */
|
|
||||||
listByAttributeId: (attributeId) =>
|
|
||||||
this.#json('GET', `/api/attributename/${enc(attributeId)}/value`),
|
|
||||||
/** POST /api/attributename/{attributeId}/value */
|
|
||||||
create: (attributeId, body) =>
|
|
||||||
this.#json('POST', `/api/attributename/${enc(attributeId)}/value`, { body }),
|
|
||||||
/** PATCH /api/attributevalue/{id} */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/attributevalue/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/attributevalue/{id} */
|
|
||||||
delete: (id) => this.#json('DELETE', `/api/attributevalue/${enc(id)}`),
|
|
||||||
/** PUT /api/attributename/{attributeId}/value/order */
|
|
||||||
order: (attributeId, body) =>
|
|
||||||
this.#json('PUT', `/api/attributename/${enc(attributeId)}/value/order`, { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.propertyname = {
|
|
||||||
/** GET /api/propertyname */
|
|
||||||
list: () => this.#json('GET', '/api/propertyname'),
|
|
||||||
/** POST /api/propertyname */
|
|
||||||
create: (body) => this.#json('POST', '/api/propertyname', { body }),
|
|
||||||
/** PATCH /api/propertyname/{id} */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/propertyname/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/propertyname/{id} */
|
|
||||||
delete: (id) => this.#json('DELETE', `/api/propertyname/${enc(id)}`),
|
|
||||||
/** PUT /api/propertyname/order */
|
|
||||||
order: (body) => this.#json('PUT', '/api/propertyname/order', { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.supplier = {
|
|
||||||
/** GET /api/supplier → { suppliers: Supplier[] } */
|
|
||||||
list: () => this.#json('GET', '/api/supplier'),
|
|
||||||
/** POST /api/supplier → 201 { supplier } */
|
|
||||||
create: (body) => this.#json('POST', '/api/supplier', { body }),
|
|
||||||
/** PATCH /api/supplier/{id} → { supplier } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/supplier/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
|
|
||||||
/** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */
|
|
||||||
getArticles: (id, skus) => {
|
|
||||||
let query = '';
|
|
||||||
if (skus != null) {
|
|
||||||
const list = Array.isArray(skus) ? skus : [skus];
|
|
||||||
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.warehouse = {
|
|
||||||
/** GET /api/warehouse → { warehouses: Warehouse[] } */
|
|
||||||
list: () => this.#json('GET', '/api/warehouse'),
|
|
||||||
/** GET /api/warehouse/{id} → { warehouse: Warehouse } */
|
|
||||||
getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`),
|
|
||||||
/** POST /api/warehouse → 201 { warehouse: Warehouse } */
|
|
||||||
create: (body) => this.#json('POST', '/api/warehouse', { body }),
|
|
||||||
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
|
|
||||||
/** PUT /api/warehouse/order → 200 { warehouseIds: number[] } */
|
|
||||||
order: (body) => this.#json('PUT', '/api/warehouse/order', { body }),
|
|
||||||
/** DELETE /api/warehouse/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.stock = {
|
|
||||||
/** POST /api/stock/adjust → 201 { manifest, lines, ledger } */
|
|
||||||
adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.delivery = {
|
|
||||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
|
||||||
list: () => this.#json('GET', '/api/delivery'),
|
|
||||||
/** GET /api/delivery/{id} → { delivery: Delivery } */
|
|
||||||
get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
|
|
||||||
/** POST /api/delivery → 201 { delivery: Delivery } */
|
|
||||||
create: (body) => this.#json('POST', '/api/delivery', { body }),
|
|
||||||
/** PATCH /api/delivery/{id} → { delivery: Delivery } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/delivery/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
|
|
||||||
/** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */
|
|
||||||
getFile: (id, fileId, { download = false } = {}) =>
|
|
||||||
this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, {
|
|
||||||
query: download ? { download: 'true' } : undefined,
|
|
||||||
}),
|
|
||||||
/**
|
|
||||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
|
||||||
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename
|
|
||||||
* Returns an async generator that yields SSE event objects:
|
|
||||||
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
|
|
||||||
*/
|
|
||||||
parseSse: async function* (file, { signal } = {}) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file, file.name);
|
|
||||||
const res = await fetch('/api/delivery/parse', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const data = await res.json();
|
|
||||||
message = data.error || data.message || message;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buf = '';
|
|
||||||
let currentEvent = 'message';
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
try {
|
|
||||||
reader.cancel();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
onAbort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buf.split('\n');
|
|
||||||
buf = lines.pop(); // keep incomplete last line
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
currentEvent = line.slice(6).trim();
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
const raw = line.slice(5).trim();
|
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
||||||
yield { event: currentEvent, data };
|
|
||||||
currentEvent = 'message';
|
|
||||||
}
|
|
||||||
// blank line or comment: reset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (signal?.aborted || err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
parsePdfSse(file, opts) {
|
|
||||||
return this.parseSse(file, opts);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ledger = {
|
|
||||||
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
|
|
||||||
add: (body) => this.#json('POST', '/api/ledger', { body }),
|
|
||||||
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
|
|
||||||
listByManifestarticleId: (manifestarticleId, transactionType) =>
|
|
||||||
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
|
|
||||||
query: transactionType ? { transaction_type: transactionType } : undefined,
|
|
||||||
}),
|
|
||||||
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
|
|
||||||
listBySku: (sku, transactionType) =>
|
|
||||||
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
|
|
||||||
query: transactionType ? { transaction_type: transactionType } : undefined,
|
|
||||||
}),
|
|
||||||
/** GET /api/ledger → { ledger: LedgerEntry[] } */
|
|
||||||
list: (query) => this.#json('GET', '/api/ledger', { query }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.quote = {
|
|
||||||
/** GET /api/quote → { quotes } */
|
|
||||||
list: () => this.#json('GET', '/api/quote'),
|
|
||||||
/** GET /api/quote/{id} → { quote } */
|
|
||||||
get: (id) => this.#json('GET', `/api/quote/${enc(id)}`),
|
|
||||||
/** POST /api/quote → 201 { quote } */
|
|
||||||
create: (body) => this.#json('POST', '/api/quote', { body }),
|
|
||||||
/** PATCH /api/quote/{id} → { quote } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/quote/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.order = {
|
|
||||||
/** GET /api/order → { orders } */
|
|
||||||
list: () => this.#json('GET', '/api/order'),
|
|
||||||
/** GET /api/order/{id} → { order } */
|
|
||||||
get: (id) => this.#json('GET', `/api/order/${enc(id)}`),
|
|
||||||
/** POST /api/order → 201 { order } */
|
|
||||||
create: (body) => this.#json('POST', '/api/order', { body }),
|
|
||||||
/** PATCH /api/order/{id} → { order } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/order/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.customergroup = {
|
|
||||||
/** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */
|
|
||||||
list: () => this.#json('GET', '/api/customergroup'),
|
|
||||||
/** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */
|
|
||||||
create: (body) => this.#json('POST', '/api/customergroup', { body }),
|
|
||||||
/** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */
|
|
||||||
update: (id, body) =>
|
|
||||||
this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }),
|
|
||||||
/** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */
|
|
||||||
order: (body) => this.#json('PUT', '/api/customergroup/order', { body }),
|
|
||||||
/** DELETE /api/customergroup/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.entity = {
|
|
||||||
/** GET /api/entity → { entities } · optional query customergroupId */
|
|
||||||
list: (query) => this.#json('GET', '/api/entity', { query }),
|
|
||||||
/** GET /api/entity/{id} → { entity } */
|
|
||||||
get: (id) => this.#json('GET', `/api/entity/${enc(id)}`),
|
|
||||||
/** POST /api/entity → 201 { entity } */
|
|
||||||
create: (body) => this.#json('POST', '/api/entity', { body }),
|
|
||||||
/** PATCH /api/entity/{id} → { entity } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/entity/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
|
|
||||||
/** GET /api/entity/next-number → { number: string } */
|
|
||||||
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
|
|
||||||
/** GET /api/entity/{entityId}/address → { addresses } */
|
|
||||||
listAddresses: (entityId) =>
|
|
||||||
this.#json('GET', `/api/entity/${enc(entityId)}/address`),
|
|
||||||
/** POST /api/entity/{entityId}/address → 201 { address } */
|
|
||||||
createAddress: (entityId, body) =>
|
|
||||||
this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }),
|
|
||||||
/** PATCH /api/entity/{entityId}/address/{id} → { address } */
|
|
||||||
updateAddress: (entityId, id, body) =>
|
|
||||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, {
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
/** DELETE /api/entity/{entityId}/address/{id} → 204 */
|
|
||||||
deleteAddress: (entityId, id) =>
|
|
||||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`),
|
|
||||||
/** GET /api/entity/{entityId}/contact → { contacts } */
|
|
||||||
listContacts: (entityId) =>
|
|
||||||
this.#json('GET', `/api/entity/${enc(entityId)}/contact`),
|
|
||||||
/** POST /api/entity/{entityId}/contact → 201 { contact } */
|
|
||||||
createContact: (entityId, body) =>
|
|
||||||
this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }),
|
|
||||||
/** PATCH /api/entity/{entityId}/contact/{id} → { contact } */
|
|
||||||
updateContact: (entityId, id, body) =>
|
|
||||||
this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, {
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
/** DELETE /api/entity/{entityId}/contact/{id} → 204 */
|
|
||||||
deleteContact: (entityId, id) =>
|
|
||||||
this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.category = {
|
|
||||||
/** GET /api/category/tree */
|
|
||||||
tree: () => this.#json('GET', '/api/category/tree'),
|
|
||||||
/** GET /api/category/export.zip */
|
|
||||||
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
|
|
||||||
/** POST /api/category */
|
|
||||||
create: (body) => this.#json('POST', '/api/category', { body }),
|
|
||||||
/** GET /api/category/{id}/articles */
|
|
||||||
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
|
|
||||||
/** PATCH /api/category/{id} */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/category/{id} */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
|
|
||||||
/** PUT /api/category/order */
|
|
||||||
order: (body) => this.#json('PUT', '/api/category/order', { body }),
|
|
||||||
/** POST /api/category/{id}/image — raw image body */
|
|
||||||
addImage: (id, imageBytes) =>
|
|
||||||
this.#json('POST', `/api/category/${enc(id)}/image`, {
|
|
||||||
body: imageBytes,
|
|
||||||
contentType: imageBytes?.type || 'image/webp',
|
|
||||||
raw: true,
|
|
||||||
}),
|
|
||||||
/** DELETE /api/category/{id}/image */
|
|
||||||
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.article = {
|
|
||||||
/** GET /api/articleIndex */
|
|
||||||
index: () => this.#json('GET', '/api/articleIndex'),
|
|
||||||
/** POST /api/article */
|
|
||||||
create: (body) => this.#json('POST', '/api/article', { body }),
|
|
||||||
/** GET /api/article/export.zip */
|
|
||||||
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
|
|
||||||
/** GET /api/article/{sku} */
|
|
||||||
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
|
|
||||||
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
|
|
||||||
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
|
|
||||||
/**
|
|
||||||
* Load history and refetch on SSE `article-<sku>`.
|
|
||||||
* Paginates in StorageManager until the HTTP API supports skip/offset.
|
|
||||||
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
|
|
||||||
*/
|
|
||||||
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
|
|
||||||
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi, track_stock } */
|
|
||||||
update: async (sku, body) => {
|
|
||||||
const nameChange = body != null && Object.hasOwn(body, 'name');
|
|
||||||
if (nameChange) {
|
|
||||||
this.setArticleIndexName(sku, body.name);
|
|
||||||
this.setArticleName(sku, body.name);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body });
|
|
||||||
this.setArticle(res.article);
|
|
||||||
return res;
|
|
||||||
} catch (err) {
|
|
||||||
if (nameChange) {
|
|
||||||
this.clearArticleIndex();
|
|
||||||
if (this.getArticle(sku)) {
|
|
||||||
void this.loadArticle(sku).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** DELETE /api/article/{sku} */
|
|
||||||
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
|
|
||||||
/** PUT /api/article/{sku}/prices */
|
|
||||||
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
|
|
||||||
/** POST /api/article/{sku}/image — raw image body */
|
|
||||||
addImage: (sku, imageBytes) =>
|
|
||||||
this.#json('POST', `/api/article/${enc(sku)}/image`, {
|
|
||||||
body: imageBytes,
|
|
||||||
contentType: imageBytes?.type || 'image/webp',
|
|
||||||
raw: true,
|
|
||||||
}),
|
|
||||||
/** DELETE /api/article/{sku}/image/{fullHash} */
|
|
||||||
deleteImage: (sku, fullHash) =>
|
|
||||||
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
|
|
||||||
/** PUT /api/article/{sku}/images/order */
|
|
||||||
orderImages: (sku, body) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/eans */
|
|
||||||
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/brand */
|
|
||||||
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/weight */
|
|
||||||
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/categories */
|
|
||||||
setCategories: (sku, body) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/track_stock */
|
|
||||||
setTrackStock: async (sku, body) => {
|
|
||||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
|
||||||
const res = await this.#json('PUT', `/api/article/${enc(sku)}/track_stock`, { body: payload });
|
|
||||||
if (res?.article) {
|
|
||||||
this.setArticle(res.article);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
/** GET /api/article/{sku}/supplier */
|
|
||||||
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
|
|
||||||
/** POST /api/article/{sku}/supplier */
|
|
||||||
addSupplier: (sku, body) =>
|
|
||||||
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
|
|
||||||
/** PATCH /api/article/{sku}/supplier/{id} */
|
|
||||||
updateSupplier: (sku, id, body) =>
|
|
||||||
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
|
|
||||||
/** PUT /api/article/{sku}/supplier/order */
|
|
||||||
orderSuppliers: (sku, body) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
|
|
||||||
/** DELETE /api/article/{sku}/supplier/{id} */
|
|
||||||
deleteSupplier: (sku, id) =>
|
|
||||||
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
|
|
||||||
/** GET /api/article/{sku}/attributevalue */
|
|
||||||
getAttributeValues: (sku) =>
|
|
||||||
this.#json('GET', `/api/article/${enc(sku)}/attributevalue`),
|
|
||||||
/** PUT /api/article/{sku}/attributevalue */
|
|
||||||
setAttributeValues: (sku, body) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/attributevalue`, { body }),
|
|
||||||
/** GET /api/article/{sku}/property */
|
|
||||||
getProperties: (sku) =>
|
|
||||||
this.#json('GET', `/api/article/${enc(sku)}/property`),
|
|
||||||
/** PUT /api/article/{sku}/property */
|
|
||||||
setProperties: (sku, body) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/property`, { body }),
|
|
||||||
/** DELETE /api/article/{sku}/property/{propertyId} */
|
|
||||||
deleteProperty: (sku, propertyId) =>
|
|
||||||
this.#json('DELETE', `/api/article/${enc(sku)}/property/${enc(propertyId)}`),
|
|
||||||
/** POST /api/article/{sku}/father */
|
|
||||||
toggleFather: (sku, body) =>
|
|
||||||
this.#json('POST', `/api/article/${enc(sku)}/father`, { body }),
|
|
||||||
/** GET /api/article/{sku}/children */
|
|
||||||
getChildren: (sku) =>
|
|
||||||
this.#json('GET', `/api/article/${enc(sku)}/children`),
|
|
||||||
/** POST /api/article/{sku}/children */
|
|
||||||
addChild: (sku, body) =>
|
|
||||||
this.#json('POST', `/api/article/${enc(sku)}/children`, { body }),
|
|
||||||
/** DELETE /api/article/{sku}/children/{childSku} */
|
|
||||||
removeChild: (sku, childSku, { deleteArticle = false } = {}) =>
|
|
||||||
this.#json(
|
|
||||||
'DELETE',
|
|
||||||
`/api/article/${enc(sku)}/children/${enc(childSku)}${deleteArticle ? '?deleteArticle=true' : ''}`
|
|
||||||
),
|
|
||||||
/** GET /api/article/{sku}/articlelist */
|
|
||||||
getArticlelist: (sku) =>
|
|
||||||
this.#json('GET', `/api/article/${enc(sku)}/articlelist`),
|
|
||||||
/** PUT /api/article/{sku}/articlelist */
|
|
||||||
setArticlelist: (sku, items) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/articlelist`, {
|
|
||||||
body: Array.isArray(items) ? { items } : items,
|
|
||||||
}),
|
|
||||||
/** GET /api/article/{sku}/optionslist */
|
|
||||||
getOptionslist: (sku) =>
|
|
||||||
this.#json('GET', `/api/article/${enc(sku)}/optionslist`),
|
|
||||||
/** PUT /api/article/{sku}/optionslist */
|
|
||||||
setOptionslist: (sku, options) =>
|
|
||||||
this.#json('PUT', `/api/article/${enc(sku)}/optionslist`, {
|
|
||||||
body: Array.isArray(options) ? { options } : options,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.posterminal = {
|
|
||||||
/** GET /api/posterminal */
|
|
||||||
list: () => this.#json('GET', '/api/posterminal'),
|
|
||||||
/** PUT /api/posterminal */
|
|
||||||
upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
|
|
||||||
/** DELETE /api/posterminal/{id} */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
|
|
||||||
/** PUT /api/posterminal/{id}/category/{categoryId} */
|
|
||||||
setOn: (id, categoryId) =>
|
|
||||||
this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
|
||||||
/** DELETE /api/posterminal/{id}/category/{categoryId} */
|
|
||||||
setOff: (id, categoryId) =>
|
|
||||||
this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
|
|
||||||
/** PUT /api/posterminal/{id}/all */
|
|
||||||
setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
|
|
||||||
/** DELETE /api/posterminal/{id}/all */
|
|
||||||
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.number = {
|
|
||||||
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
|
|
||||||
list: () => this.#json('GET', '/api/number'),
|
|
||||||
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.printer = {
|
|
||||||
/** GET /api/printer → { printers: Printer[] } */
|
|
||||||
list: () => this.#json('GET', '/api/printer'),
|
|
||||||
/** POST /api/printer → 201 { printer } */
|
|
||||||
create: (body) => this.#json('POST', '/api/printer', { body }),
|
|
||||||
/** PATCH /api/printer/{id} → { printer } */
|
|
||||||
update: (id, body) => this.#json('PATCH', `/api/printer/${enc(id)}`, { body }),
|
|
||||||
/** DELETE /api/printer/{id} → 204 */
|
|
||||||
delete: (id) => this.#empty('DELETE', `/api/printer/${enc(id)}`),
|
|
||||||
/** PUT /api/printer/order body { printerIds } → { printerIds } */
|
|
||||||
order: (body) => this.#json('PUT', '/api/printer/order', { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.setting = {
|
|
||||||
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
|
|
||||||
list: (query) => this.#json('GET', '/api/company/setting', { query }),
|
|
||||||
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
|
|
||||||
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
|
|
||||||
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
|
|
||||||
upsert: (key, body) =>
|
|
||||||
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.image = {
|
|
||||||
/** GET /api/image/{fullHash} → AVIF blob */
|
|
||||||
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
|
|
||||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
|
||||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.thumb = {
|
|
||||||
/** GET /api/thumb/{fullHash} → AVIF blob */
|
|
||||||
get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
|
|
||||||
/** Absolute URL for <img src> (same-origin / cookie auth). */
|
|
||||||
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.modelconversation = {
|
|
||||||
/** GET /api/modelconversation → { conversations: [...] } */
|
|
||||||
list: () => this.#json('GET', '/api/modelconversation'),
|
|
||||||
/** GET /api/modelconversation/{id} → { conversation } */
|
|
||||||
get: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}`),
|
|
||||||
/** POST /api/modelconversation body { provider, model } → { conversation } */
|
|
||||||
create: (body) => this.#json('POST', '/api/modelconversation', { body }),
|
|
||||||
/** GET /api/modelconversation/{id}/message → { messages: [...] } */
|
|
||||||
listMessages: (id) => this.#json('GET', `/api/modelconversation/${enc(id)}/message`),
|
|
||||||
/** POST /api/modelconversation/{id}/message body { message } → { message } */
|
|
||||||
addMessage: (id, body) => this.#json('POST', `/api/modelconversation/${enc(id)}/message`, { body }),
|
|
||||||
/** DELETE /api/modelconversation/{id} */
|
|
||||||
delete: (id) => this.#json('DELETE', `/api/modelconversation/${enc(id)}`),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/** Absolute URL for a same-origin API path (e.g. SSE). */
|
|
||||||
urlFor(path) {
|
|
||||||
return buildUrl(this.#baseUrl, path);
|
|
||||||
}
|
|
||||||
|
|
||||||
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
|
|
||||||
const url = buildPath(path, query);
|
|
||||||
const headers = { ...extraHeaders };
|
|
||||||
let payload;
|
|
||||||
if (body !== undefined) {
|
|
||||||
if (typeof FormData !== 'undefined' && body instanceof FormData) {
|
|
||||||
payload = body;
|
|
||||||
} else if (raw) {
|
|
||||||
if (contentType) headers['Content-Type'] = contentType;
|
|
||||||
payload = body;
|
|
||||||
} else {
|
|
||||||
headers['Content-Type'] = contentType || 'application/json';
|
|
||||||
payload = JSON.stringify(body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = res.statusText;
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
message = data.error || data.message || message;
|
|
||||||
} catch {
|
|
||||||
message = (await res.text().catch(() => message)) || message;
|
|
||||||
}
|
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
err.path = url;
|
|
||||||
err.method = method;
|
|
||||||
err.data = data;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
if (res.status === 204) return null;
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async #empty(method, path, { query } = {}) {
|
|
||||||
await this.#json(method, path, { query });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async #blob(method, path, { query } = {}) {
|
|
||||||
const url = buildPath(path, query);
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = res.statusText;
|
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = await res.json();
|
|
||||||
message = data.error || data.message || message;
|
|
||||||
} catch {
|
|
||||||
message = (await res.text().catch(() => message)) || message;
|
|
||||||
}
|
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
err.path = url;
|
|
||||||
err.method = method;
|
|
||||||
err.data = data;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
return res.blob();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ApiClient;
|
|
||||||
@@ -1,819 +0,0 @@
|
|||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
|
||||||
import { notifyError } from '../utils/notify';
|
|
||||||
import { ApiClient } from './ApiClient';
|
|
||||||
import artikelAutomat from '../automats/artikelAutomat';
|
|
||||||
import companyAutomat from '../automats/companyAutomat';
|
|
||||||
import brandAutomat from '../automats/brandAutomat';
|
|
||||||
import unitAutomat from '../automats/unitAutomat';
|
|
||||||
import authAutomat from '../automats/authAutomat';
|
|
||||||
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
|
|
||||||
import posterminalAutomat from '../automats/posterminalAutomat';
|
|
||||||
import warehouseAutomat from '../automats/warehouseAutomat';
|
|
||||||
import attributeAutomat from '../automats/attributeAutomat';
|
|
||||||
import propertyAutomat from '../automats/propertyAutomat';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stale-while-revalidate cache: readers keep the current promise; update()
|
|
||||||
* swaps only after a successful reload. Failed updates leave the cache intact.
|
|
||||||
* replace() writes local data without a round-trip.
|
|
||||||
*/
|
|
||||||
function createCache(label, load) {
|
|
||||||
/** Settled value, or `undefined` if not loaded yet / last load failed. */
|
|
||||||
let current;
|
|
||||||
let promise = load()
|
|
||||||
.then((data) => {
|
|
||||||
current = data;
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(`[storageMgr] ${label} failed`, err);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function update() {
|
|
||||||
try {
|
|
||||||
const data = await load();
|
|
||||||
current = data;
|
|
||||||
promise = Promise.resolve(data);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[storageMgr] update ${label} failed`, err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replace(data) {
|
|
||||||
current = data;
|
|
||||||
promise = Promise.resolve(data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
current = undefined;
|
|
||||||
promise = load()
|
|
||||||
.then((data) => {
|
|
||||||
current = data;
|
|
||||||
return data;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(`[storageMgr] ${label} failed`, err);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
update,
|
|
||||||
replace,
|
|
||||||
clear,
|
|
||||||
get promise() {
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
/** Loaded value, or `undefined` if the initial load has not settled successfully. */
|
|
||||||
get value() {
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
|
|
||||||
*/
|
|
||||||
export class StorageManager extends ApiClient {
|
|
||||||
/** @type {EventSource | null} */
|
|
||||||
#events = null;
|
|
||||||
/** @type {ReturnType<StorageManager['#createDeferredCache']> | null} */
|
|
||||||
#articleIndexCache = null;
|
|
||||||
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
|
|
||||||
#articleWatchers = new Map();
|
|
||||||
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
|
|
||||||
#historyWatchers = new Map();
|
|
||||||
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
|
|
||||||
#supplierWatchers = new Map();
|
|
||||||
|
|
||||||
constructor(baseUrl = window.location.origin) {
|
|
||||||
super(baseUrl);
|
|
||||||
this.#initCaches();
|
|
||||||
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
|
|
||||||
this.article.supplierWatch = (sku, onSuppliers) =>
|
|
||||||
this.watchArticleSuppliers(sku, onSuppliers);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Cached article detail from the shared artikelAutomat, or null. */
|
|
||||||
getArticle(sku) {
|
|
||||||
return artikelAutomat.state.bySku[String(sku)] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
|
|
||||||
* notify any StorageManager-level watchers.
|
|
||||||
*/
|
|
||||||
setArticle(article) {
|
|
||||||
requireObject(article, 'article');
|
|
||||||
requireDefined(article.sku, 'article.sku');
|
|
||||||
const key = String(article.sku);
|
|
||||||
const stored = artikelAutomat.actions.setArticle(article);
|
|
||||||
|
|
||||||
const watchers = this.#articleWatchers.get(key);
|
|
||||||
if (watchers) {
|
|
||||||
for (const onArticle of watchers) onArticle(stored);
|
|
||||||
}
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
|
|
||||||
* @param {string} sku
|
|
||||||
*/
|
|
||||||
async loadArticle(sku) {
|
|
||||||
const article = await artikelAutomat.actions.loadArticle(sku);
|
|
||||||
const watchers = this.#articleWatchers.get(String(sku));
|
|
||||||
if (watchers) {
|
|
||||||
for (const onArticle of watchers) onArticle(article);
|
|
||||||
}
|
|
||||||
return article;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
|
|
||||||
* @param {string} sku
|
|
||||||
* @param {(article: object) => void} onArticle
|
|
||||||
* @returns {{ article: object | null, unwatch: () => void }}
|
|
||||||
*/
|
|
||||||
watchArticle(sku, onArticle) {
|
|
||||||
const key = String(sku);
|
|
||||||
const needListen =
|
|
||||||
!this.#articleWatchers.has(key) &&
|
|
||||||
!this.#historyWatchers.has(key) &&
|
|
||||||
!this.#supplierWatchers.has(key);
|
|
||||||
|
|
||||||
let watchers = this.#articleWatchers.get(key);
|
|
||||||
if (!watchers) {
|
|
||||||
watchers = new Set();
|
|
||||||
this.#articleWatchers.set(key, watchers);
|
|
||||||
}
|
|
||||||
watchers.add(onArticle);
|
|
||||||
if (needListen) {
|
|
||||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
article: this.getArticle(key) ?? null,
|
|
||||||
unwatch: () => {
|
|
||||||
const set = this.#articleWatchers.get(key);
|
|
||||||
if (!set) return;
|
|
||||||
set.delete(onArticle);
|
|
||||||
if (set.size === 0) {
|
|
||||||
this.#articleWatchers.delete(key);
|
|
||||||
if (
|
|
||||||
!this.#historyWatchers.has(key) &&
|
|
||||||
!this.#supplierWatchers.has(key)
|
|
||||||
) {
|
|
||||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
|
|
||||||
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
|
|
||||||
*/
|
|
||||||
getCategoryState() {
|
|
||||||
const catState = categoryAutomat.state;
|
|
||||||
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
|
|
||||||
return {
|
|
||||||
categories: catState.categories,
|
|
||||||
rootCategoryId: catState.rootCategoryId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
|
|
||||||
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
|
|
||||||
* @param {string} sku
|
|
||||||
* @param {(history: unknown[], total: number) => void} onHistory
|
|
||||||
* @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
|
|
||||||
*/
|
|
||||||
watchArticleHistory(sku, onHistory) {
|
|
||||||
const key = String(sku);
|
|
||||||
let active = true;
|
|
||||||
let loaded = false;
|
|
||||||
/** @type {unknown[]} */
|
|
||||||
let all = [];
|
|
||||||
let skip = 0;
|
|
||||||
/** Page size (how many rows to emit). */
|
|
||||||
let offset = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
const emit = () => {
|
|
||||||
if (!active || !loaded) return;
|
|
||||||
const total = all.length;
|
|
||||||
const end =
|
|
||||||
offset === Number.POSITIVE_INFINITY ? total : skip + offset;
|
|
||||||
onHistory(all.slice(skip, end), total);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refresh = () =>
|
|
||||||
this.article.history(key).then((res) => {
|
|
||||||
if (!active) return;
|
|
||||||
requireObject(res, 'GET /api/article/{sku}/history');
|
|
||||||
all = requireArray(res.history, 'history');
|
|
||||||
loaded = true;
|
|
||||||
emit();
|
|
||||||
});
|
|
||||||
|
|
||||||
const run = () =>
|
|
||||||
void refresh().catch((err) => {
|
|
||||||
if (!active) return;
|
|
||||||
notifyError(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
const needListen =
|
|
||||||
!this.#articleWatchers.has(key) &&
|
|
||||||
!this.#historyWatchers.has(key) &&
|
|
||||||
!this.#supplierWatchers.has(key);
|
|
||||||
|
|
||||||
let watchers = this.#historyWatchers.get(key);
|
|
||||||
if (!watchers) {
|
|
||||||
watchers = new Set();
|
|
||||||
this.#historyWatchers.set(key, watchers);
|
|
||||||
}
|
|
||||||
watchers.add(run);
|
|
||||||
if (needListen) {
|
|
||||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
|
|
||||||
return {
|
|
||||||
unwatch: () => {
|
|
||||||
active = false;
|
|
||||||
const set = this.#historyWatchers.get(key);
|
|
||||||
if (!set) return;
|
|
||||||
set.delete(run);
|
|
||||||
if (set.size === 0) {
|
|
||||||
this.#historyWatchers.delete(key);
|
|
||||||
if (
|
|
||||||
!this.#articleWatchers.has(key) &&
|
|
||||||
!this.#supplierWatchers.has(key)
|
|
||||||
) {
|
|
||||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setSkipAndOffset: (nextSkip, nextOffset) => {
|
|
||||||
skip = nextSkip;
|
|
||||||
offset = nextOffset;
|
|
||||||
emit();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load suppliers for an article and refetch on SSE `article-<sku>`.
|
|
||||||
* @param {string} sku
|
|
||||||
* @param {(suppliers: Array<object>) => void} onSuppliers
|
|
||||||
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
|
|
||||||
*/
|
|
||||||
watchArticleSuppliers(sku, onSuppliers) {
|
|
||||||
const key = String(sku);
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
const res = await this.article.getSuppliers(key);
|
|
||||||
if (!active) return;
|
|
||||||
requireObject(res, 'GET /api/article/{sku}/supplier');
|
|
||||||
const list = requireArray(res.suppliers, 'suppliers');
|
|
||||||
onSuppliers(list);
|
|
||||||
};
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
void refresh().catch((err) => {
|
|
||||||
if (!active) return;
|
|
||||||
notifyError(err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const needListen =
|
|
||||||
!this.#articleWatchers.has(key) &&
|
|
||||||
!this.#historyWatchers.has(key) &&
|
|
||||||
!this.#supplierWatchers.has(key);
|
|
||||||
|
|
||||||
let watchers = this.#supplierWatchers.get(key);
|
|
||||||
if (!watchers) {
|
|
||||||
watchers = new Set();
|
|
||||||
this.#supplierWatchers.set(key, watchers);
|
|
||||||
}
|
|
||||||
watchers.add(run);
|
|
||||||
if (needListen) {
|
|
||||||
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh,
|
|
||||||
unwatch: () => {
|
|
||||||
active = false;
|
|
||||||
const set = this.#supplierWatchers.get(key);
|
|
||||||
if (!set) return;
|
|
||||||
set.delete(run);
|
|
||||||
if (set.size === 0) {
|
|
||||||
this.#supplierWatchers.delete(key);
|
|
||||||
if (
|
|
||||||
!this.#articleWatchers.has(key) &&
|
|
||||||
!this.#historyWatchers.has(key)
|
|
||||||
) {
|
|
||||||
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
#onArticleSignal = (event) => {
|
|
||||||
const sku = event.type.slice('article-'.length);
|
|
||||||
|
|
||||||
// Refresh the shared cache; silently when nothing is watching this SKU.
|
|
||||||
if (this.#articleWatchers.has(sku)) {
|
|
||||||
void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
|
|
||||||
} else {
|
|
||||||
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
|
|
||||||
}
|
|
||||||
const historyWatchers = this.#historyWatchers.get(sku);
|
|
||||||
if (historyWatchers) {
|
|
||||||
for (const run of historyWatchers) run();
|
|
||||||
}
|
|
||||||
const supplierWatchers = this.#supplierWatchers.get(sku);
|
|
||||||
if (supplierWatchers) {
|
|
||||||
for (const run of supplierWatchers) run();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Patch local articleIndex name if already loaded; no-op otherwise. */
|
|
||||||
setArticleIndexName(sku, name) {
|
|
||||||
const articles = this.#articleIndexCache.value;
|
|
||||||
if (!articles) return;
|
|
||||||
this.#articleIndexCache.replace(
|
|
||||||
articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Patch cached article detail name if already cached; no-op otherwise. */
|
|
||||||
setArticleName(sku, name) {
|
|
||||||
const current = this.getArticle(sku);
|
|
||||||
if (!current) return;
|
|
||||||
this.setArticle({ ...current, name });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drop local articleIndex if loaded (forces reload on next read). */
|
|
||||||
clearArticleIndex() {
|
|
||||||
if (!this.#articleIndexCache.value) return;
|
|
||||||
this.#articleIndexCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
#initCaches() {
|
|
||||||
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
|
|
||||||
const deliveryCache = this.#createDeferredCache('GET /delivery', () =>
|
|
||||||
this.#loadDeliveries(),
|
|
||||||
);
|
|
||||||
const customergroupCache = this.#createDeferredCache('GET /customergroup', () =>
|
|
||||||
this.#loadCustomergroups(),
|
|
||||||
);
|
|
||||||
const entityCache = this.#createDeferredCache('GET /entity', () =>
|
|
||||||
this.#loadEntities(),
|
|
||||||
);
|
|
||||||
const numberCache = this.#createDeferredCache('GET /number', () =>
|
|
||||||
this.#loadNumbers(),
|
|
||||||
);
|
|
||||||
const quoteCache = this.#createDeferredCache('GET /quote', () =>
|
|
||||||
this.#loadQuotes(),
|
|
||||||
);
|
|
||||||
const orderCache = this.#createDeferredCache('GET /order', () =>
|
|
||||||
this.#loadOrders(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// articleIndex is auth-gated — create a deferred cache that only loads after auth
|
|
||||||
this.#articleIndexCache = this.#createDeferredCache(
|
|
||||||
'GET /articleIndex',
|
|
||||||
() => this.#loadArticleIndex(),
|
|
||||||
);
|
|
||||||
const articleIndexCache = this.#articleIndexCache;
|
|
||||||
|
|
||||||
Object.defineProperties(this, {
|
|
||||||
authMePromise: { get: () => authMeCache.promise },
|
|
||||||
updateAuthMe: { value: authMeCache.update },
|
|
||||||
/** Settled GET /auth/me body, or undefined. */
|
|
||||||
authMe: { get: () => authMeCache.value },
|
|
||||||
|
|
||||||
articleIndexPromise: { get: () => articleIndexCache.promise },
|
|
||||||
updateArticleIndex: { value: articleIndexCache.update },
|
|
||||||
|
|
||||||
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
|
|
||||||
/** Brand list from brandAutomat (may be empty until authed). */
|
|
||||||
brands: { get: () => brandAutomat.state.brands },
|
|
||||||
/** Promise that resolves when brands are first loaded. */
|
|
||||||
brandPromise: {
|
|
||||||
get: () => brandAutomat.state.status === 'succeeded'
|
|
||||||
? Promise.resolve(brandAutomat.state.brands)
|
|
||||||
: new Promise((resolve) => {
|
|
||||||
const unsub = brandAutomat.subscribe((s) => {
|
|
||||||
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
/** Reload brands from API. */
|
|
||||||
updateBrand: { value: () => brandAutomat.actions.load() },
|
|
||||||
|
|
||||||
/** Unit list from unitAutomat. */
|
|
||||||
units: { get: () => unitAutomat.state.units },
|
|
||||||
/** Promise that resolves when units are first loaded. */
|
|
||||||
unitPromise: {
|
|
||||||
get: () => unitAutomat.state.status === 'succeeded'
|
|
||||||
? Promise.resolve(unitAutomat.state.units)
|
|
||||||
: new Promise((resolve) => {
|
|
||||||
const unsub = unitAutomat.subscribe((s) => {
|
|
||||||
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
/** Reload units from API. */
|
|
||||||
updateUnit: { value: () => unitAutomat.actions.load() },
|
|
||||||
|
|
||||||
/** Posterminal list from posterminalAutomat. */
|
|
||||||
posterminals: { get: () => posterminalAutomat.state.terminals },
|
|
||||||
/** Promise that resolves when posterminals are first loaded. */
|
|
||||||
posterminalPromise: {
|
|
||||||
get: () => posterminalAutomat.state.status === 'succeeded'
|
|
||||||
? Promise.resolve(posterminalAutomat.state.terminals)
|
|
||||||
: new Promise((resolve, reject) => {
|
|
||||||
const unsub = posterminalAutomat.subscribe((s) => {
|
|
||||||
if (s.status === 'succeeded') { unsub(); resolve(s.terminals); }
|
|
||||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
/** Reload posterminals from API. */
|
|
||||||
updatePosterminal: { value: () => posterminalAutomat.actions.load() },
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current company object from companyAutomat, or null.
|
|
||||||
* Previously: companies array from GET /api/company.
|
|
||||||
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
|
|
||||||
*/
|
|
||||||
company: { get: () => companyAutomat.state.current ?? null },
|
|
||||||
/** Promise that resolves when company data is loaded. */
|
|
||||||
companyPromise: {
|
|
||||||
get: () => companyAutomat.state.status === 'succeeded'
|
|
||||||
? Promise.resolve(companyAutomat.state.current)
|
|
||||||
: new Promise((resolve, reject) => {
|
|
||||||
const unsub = companyAutomat.subscribe((s) => {
|
|
||||||
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
|
|
||||||
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
/** Reload company data from API. */
|
|
||||||
updateCompany: { value: () => companyAutomat.actions.load() },
|
|
||||||
|
|
||||||
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
|
|
||||||
// Return a single-element array so .find() still works.
|
|
||||||
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
|
|
||||||
|
|
||||||
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
|
|
||||||
/** Category tree promise resolving when categoryAutomat tree is loaded. */
|
|
||||||
categoryTreePromise: {
|
|
||||||
get: () => categoryAutomat.state.treeStatus === 'succeeded'
|
|
||||||
? Promise.resolve(categoryAutomat.state.categories)
|
|
||||||
: new Promise((resolve, reject) => {
|
|
||||||
const unsub = categoryAutomat.subscribe((s) => {
|
|
||||||
if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); }
|
|
||||||
else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); }
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
/** Reload category tree via categoryAutomat. */
|
|
||||||
updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() },
|
|
||||||
/** Current categories map from categoryAutomat, or null. */
|
|
||||||
categoryTree: { get: () => categoryAutomat.state.categories },
|
|
||||||
|
|
||||||
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
|
|
||||||
deliveryPromise: { get: () => deliveryCache.promise },
|
|
||||||
updateDelivery: { value: deliveryCache.update },
|
|
||||||
/** Settled GET /delivery list, or undefined. */
|
|
||||||
deliveries: { get: () => deliveryCache.value },
|
|
||||||
|
|
||||||
customergroupPromise: { get: () => customergroupCache.promise },
|
|
||||||
updateCustomergroup: { value: customergroupCache.update },
|
|
||||||
/** Settled GET /customergroup list, or undefined. */
|
|
||||||
customergroups: { get: () => customergroupCache.value },
|
|
||||||
|
|
||||||
entityPromise: { get: () => entityCache.promise },
|
|
||||||
updateEntity: { value: entityCache.update },
|
|
||||||
/** Settled GET /entity list, or undefined. */
|
|
||||||
entities: { get: () => entityCache.value },
|
|
||||||
|
|
||||||
numberPromise: { get: () => numberCache.promise },
|
|
||||||
updateNumber: { value: numberCache.update },
|
|
||||||
/** Settled GET /number list, or undefined. */
|
|
||||||
numbers: { get: () => numberCache.value },
|
|
||||||
|
|
||||||
quotePromise: { get: () => quoteCache.promise },
|
|
||||||
updateQuote: { value: quoteCache.update },
|
|
||||||
/** Settled GET /quote list, or undefined. */
|
|
||||||
quotes: { get: () => quoteCache.value },
|
|
||||||
|
|
||||||
orderPromise: { get: () => orderCache.promise },
|
|
||||||
updateOrder: { value: orderCache.update },
|
|
||||||
/** Settled GET /order list, or undefined. */
|
|
||||||
orders: { get: () => orderCache.value },
|
|
||||||
|
|
||||||
/** Close and reopen GET /api/events (required after selected-company change). */
|
|
||||||
reconnectEvents: { value: () => this.#reconnectEvents() },
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastCompanyId = null;
|
|
||||||
|
|
||||||
const syncWithAuth = (auth) => {
|
|
||||||
const companyId =
|
|
||||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
|
||||||
? String(auth.user.selectedCompanyId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (companyId === lastCompanyId) return;
|
|
||||||
const prevCompanyId = lastCompanyId;
|
|
||||||
lastCompanyId = companyId;
|
|
||||||
|
|
||||||
if (!companyId) {
|
|
||||||
// Unauthenticated or logged out
|
|
||||||
this.#disconnectEvents();
|
|
||||||
articleIndexCache.clear();
|
|
||||||
deliveryCache.clear();
|
|
||||||
customergroupCache.clear();
|
|
||||||
entityCache.clear();
|
|
||||||
numberCache.clear();
|
|
||||||
quoteCache.clear();
|
|
||||||
orderCache.clear();
|
|
||||||
artikelAutomat.actions.clear();
|
|
||||||
} else {
|
|
||||||
// Company established or switched
|
|
||||||
if (prevCompanyId) {
|
|
||||||
articleIndexCache.clear();
|
|
||||||
deliveryCache.clear();
|
|
||||||
customergroupCache.clear();
|
|
||||||
entityCache.clear();
|
|
||||||
numberCache.clear();
|
|
||||||
quoteCache.clear();
|
|
||||||
orderCache.clear();
|
|
||||||
artikelAutomat.actions.clear();
|
|
||||||
}
|
|
||||||
this.#reconnectEvents();
|
|
||||||
void articleIndexCache.trigger().catch(() => {});
|
|
||||||
void deliveryCache.trigger().catch(() => {});
|
|
||||||
void customergroupCache.trigger().catch(() => {});
|
|
||||||
void entityCache.trigger().catch(() => {});
|
|
||||||
void numberCache.trigger().catch(() => {});
|
|
||||||
void quoteCache.trigger().catch(() => {});
|
|
||||||
void orderCache.trigger().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
authAutomat.subscribe(syncWithAuth);
|
|
||||||
syncWithAuth(authAutomat.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a cache that does NOT load immediately but waits for an explicit trigger
|
|
||||||
* or loads on-demand once authenticated.
|
|
||||||
*/
|
|
||||||
#createDeferredCache(label, load) {
|
|
||||||
let current;
|
|
||||||
let loadPromise = null;
|
|
||||||
|
|
||||||
const trigger = () => {
|
|
||||||
if (loadPromise) return loadPromise;
|
|
||||||
loadPromise = (async () => {
|
|
||||||
try {
|
|
||||||
const data = await load();
|
|
||||||
current = data;
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[storageMgr] ${label} failed`, err);
|
|
||||||
loadPromise = null;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return loadPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const update = async () => {
|
|
||||||
try {
|
|
||||||
const data = await load();
|
|
||||||
current = data;
|
|
||||||
loadPromise = Promise.resolve(data);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[storageMgr] update ${label} failed`, err);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const replace = (data) => {
|
|
||||||
current = data;
|
|
||||||
loadPromise = Promise.resolve(data);
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clear = () => {
|
|
||||||
current = undefined;
|
|
||||||
loadPromise = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
update,
|
|
||||||
replace,
|
|
||||||
clear,
|
|
||||||
trigger,
|
|
||||||
get promise() {
|
|
||||||
if (!loadPromise) {
|
|
||||||
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
|
||||||
return trigger();
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const unsub = authAutomat.subscribe((auth) => {
|
|
||||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
|
||||||
unsub();
|
|
||||||
trigger().then(resolve, reject);
|
|
||||||
} else if (auth.status === 'anon') {
|
|
||||||
unsub();
|
|
||||||
reject(new Error(`Cannot load ${label}: unauthenticated`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return loadPromise;
|
|
||||||
},
|
|
||||||
get value() {
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/events — SSE signals for the selected company.
|
|
||||||
* Stream is bound at connect time; reconnect after company switch.
|
|
||||||
*/
|
|
||||||
#reconnectEvents() {
|
|
||||||
this.#disconnectEvents();
|
|
||||||
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
|
|
||||||
es.addEventListener('category', () => {
|
|
||||||
void categoryAutomat.actions.loadTree().catch((err) => {
|
|
||||||
console.error('[storageMgr] category tree SSE refresh failed', err);
|
|
||||||
});
|
|
||||||
invalidateAllCategoryViews();
|
|
||||||
});
|
|
||||||
es.addEventListener('articles', () => {
|
|
||||||
invalidateAllCategoryViews();
|
|
||||||
void this.updateArticleIndex().catch((err) => {
|
|
||||||
console.error('[storageMgr] articles SSE refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
es.addEventListener('warehouse', () => {
|
|
||||||
void warehouseAutomat.actions.reload().catch((err) => {
|
|
||||||
console.error('[storageMgr] warehouse SSE refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
es.addEventListener('attributename', () => {
|
|
||||||
void attributeAutomat.actions.load().catch((err) => {
|
|
||||||
console.error('[storageMgr] attributename SSE refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
es.addEventListener('propertyname', () => {
|
|
||||||
void propertyAutomat.actions.load().catch((err) => {
|
|
||||||
console.error('[storageMgr] propertyname SSE refresh failed', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
for (const sku of new Set([
|
|
||||||
...this.#articleWatchers.keys(),
|
|
||||||
...this.#historyWatchers.keys(),
|
|
||||||
...this.#supplierWatchers.keys(),
|
|
||||||
])) {
|
|
||||||
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
|
|
||||||
}
|
|
||||||
es.onerror = () => {
|
|
||||||
console.error('[storageMgr] events stream error');
|
|
||||||
};
|
|
||||||
this.#events = es;
|
|
||||||
}
|
|
||||||
|
|
||||||
#disconnectEvents() {
|
|
||||||
if (!this.#events) return;
|
|
||||||
this.#events.close();
|
|
||||||
this.#events = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
#syncEvents(user) {
|
|
||||||
if (user?.selectedCompanyId != null) {
|
|
||||||
this.#reconnectEvents();
|
|
||||||
} else {
|
|
||||||
this.#disconnectEvents();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadAuthMe() {
|
|
||||||
try {
|
|
||||||
const data = await this.auth.me();
|
|
||||||
requireObject(data, 'GET /api/auth/me');
|
|
||||||
const session = {
|
|
||||||
user: data.user,
|
|
||||||
googleClientId: data.googleClientId,
|
|
||||||
};
|
|
||||||
this.#syncEvents(session.user);
|
|
||||||
return session;
|
|
||||||
} catch (err) {
|
|
||||||
if (err.status === 401 || err.status === 403) {
|
|
||||||
this.#syncEvents(null);
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
googleClientId: err.data?.googleClientId,
|
|
||||||
forbidden: err.status === 403,
|
|
||||||
message: err.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadArticleIndex() {
|
|
||||||
const data = await this.article.index();
|
|
||||||
requireObject(data, 'GET /api/articleIndex');
|
|
||||||
const articles = requireArray(data.articles, 'articleIndex.articles');
|
|
||||||
// Map backend rows to the recordset shape used by the result list.
|
|
||||||
// `categories` is a comma-joined string (formatCategoryList splits on ',').
|
|
||||||
return articles.map((a) => ({
|
|
||||||
id: a.id,
|
|
||||||
sku: a.sku,
|
|
||||||
name: a.name,
|
|
||||||
eans: a.eans ?? [],
|
|
||||||
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
|
|
||||||
isFather: Boolean(a.isFather ?? a.is_father),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadDeliveries() {
|
|
||||||
const data = await this.delivery.list();
|
|
||||||
requireObject(data, 'GET /api/delivery');
|
|
||||||
return requireArray(data.deliveries, 'deliveries');
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadCustomergroups() {
|
|
||||||
const data = await this.customergroup.list();
|
|
||||||
requireObject(data, 'GET /api/customergroup');
|
|
||||||
return requireArray(data.customergroups, 'customergroups');
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadEntities() {
|
|
||||||
const data = await this.entity.list();
|
|
||||||
requireObject(data, 'GET /api/entity');
|
|
||||||
return requireArray(data.entities, 'entities');
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadNumbers() {
|
|
||||||
const data = await this.number.list();
|
|
||||||
requireObject(data, 'GET /api/number');
|
|
||||||
return requireArray(data.numbers, 'numbers');
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadQuotes() {
|
|
||||||
const data = await this.quote.list();
|
|
||||||
requireObject(data, 'GET /api/quote');
|
|
||||||
return requireArray(data.quotes, 'quotes');
|
|
||||||
}
|
|
||||||
|
|
||||||
async #loadOrders() {
|
|
||||||
const data = await this.order.list();
|
|
||||||
requireObject(data, 'GET /api/order');
|
|
||||||
return requireArray(data.orders, 'orders');
|
|
||||||
}
|
|
||||||
|
|
||||||
async doSearch({ target, onUpdate }) {
|
|
||||||
const articles = await this.articleIndexPromise;
|
|
||||||
const query = target.trim().toLowerCase();
|
|
||||||
const matches = articles.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(query) ||
|
|
||||||
p.sku.toLowerCase().includes(query) ||
|
|
||||||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
|
|
||||||
);
|
|
||||||
onUpdate({
|
|
||||||
categoryFacets: {},
|
|
||||||
totalCount: matches.length,
|
|
||||||
recordset: matches,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StorageManager;
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
// Single source of truth for all backend communication.
|
// Single source of truth for all backend communication.
|
||||||
|
|
||||||
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
|
|
||||||
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
/** WebView consoles stringify objects as [object Object]; log JSON text. */
|
||||||
function logApi(tag, details) {
|
function logApi(tag, details) {
|
||||||
let text;
|
let text;
|
||||||
@@ -195,6 +197,29 @@ export const articleIndex = {
|
|||||||
list() {
|
list() {
|
||||||
return http('/api/articleIndex');
|
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<Array<ArticleIndexRecord>>}
|
||||||
|
*/
|
||||||
|
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 = {
|
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
|
* PUT /api/article/:sku/brand
|
||||||
* Body: { brandId: number | null } — null clears the brand.
|
* Body: { brandId: number | null } — null clears the brand.
|
||||||
@@ -624,6 +660,46 @@ export const printer = {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return http(`/api/printer/${id}`, { method: 'DELETE' });
|
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,
|
printer,
|
||||||
propertyname,
|
propertyname,
|
||||||
quote,
|
quote,
|
||||||
|
setting,
|
||||||
supplier,
|
supplier,
|
||||||
thumb,
|
thumb,
|
||||||
unit,
|
unit,
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { createContext, createElement, useContext, useRef } from 'react';
|
|
||||||
import { StorageManager } from './StorageManager';
|
|
||||||
|
|
||||||
let defaultStorageManager = null;
|
|
||||||
|
|
||||||
export function getStorageManager() {
|
|
||||||
if (!defaultStorageManager) {
|
|
||||||
defaultStorageManager = new StorageManager();
|
|
||||||
}
|
|
||||||
return defaultStorageManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StorageManagerContext = createContext(getStorageManager());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider — mount once near the top of the tree.
|
|
||||||
*/
|
|
||||||
export function StorageManagerProvider({ children }) {
|
|
||||||
const mgrRef = useRef(null);
|
|
||||||
if (!mgrRef.current) {
|
|
||||||
mgrRef.current = getStorageManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for function components that need the store manager.
|
|
||||||
*/
|
|
||||||
export function useStorageManager() {
|
|
||||||
const ctx = useContext(StorageManagerContext) || getStorageManager();
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
61
src/automats/articleDetailAutomat.js
Normal file
61
src/automats/articleDetailAutomat.js
Normal file
@@ -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<Array<ArticleHistoryEntry>>}
|
||||||
|
*/
|
||||||
|
export const articleHistoryAutomat = new KeyedAutomat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name: 'articleHistory',
|
||||||
|
loader: async (sku) => {
|
||||||
|
const res = await articleApi.getBySkuHistory(String(sku));
|
||||||
|
requireObject(res, 'GET /api/article/:sku/history');
|
||||||
|
return requireArray(res.history, 'history');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||||
|
*
|
||||||
|
* @type {KeyedAutomat<Array<ArticleSupplierRow>>}
|
||||||
|
*/
|
||||||
|
export const articleSupplierAutomat = new KeyedAutomat(
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
name: 'articleSuppliers',
|
||||||
|
loader: async (sku) => {
|
||||||
|
const res = await articleApi.getSuppliers(String(sku));
|
||||||
|
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||||
|
return requireArray(res.suppliers, 'suppliers');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
44
src/automats/articleIndexAutomat.js
Normal file
44
src/automats/articleIndexAutomat.js
Normal file
@@ -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<string>} 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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
108
src/automats/articleSearchAutomat.js
Normal file
108
src/automats/articleSearchAutomat.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import articleIndexAutomat from './articleIndexAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ArticleSearchResult
|
||||||
|
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||||
|
* @property {number} totalCount Number of matching records.
|
||||||
|
* @property {Array<object>} recordset Matching article index records.
|
||||||
|
* @property {Error|null} error Search failure, or `null`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ArticleSearchState
|
||||||
|
* @property {ArticleSearchResult} result Latest search outcome.
|
||||||
|
* @property {string} target Active search term.
|
||||||
|
* @property {boolean} loading Whether a search is in flight.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {ArticleSearchResult} */
|
||||||
|
const emptyResult = {
|
||||||
|
categoryFacets: {},
|
||||||
|
totalCount: 0,
|
||||||
|
recordset: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {ArticleSearchState} */
|
||||||
|
const initialState = {
|
||||||
|
result: emptyResult,
|
||||||
|
target: '',
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let articleSearchAutomat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches one index record against a lowercased search term.
|
||||||
|
*
|
||||||
|
* @param {object} row Article index record.
|
||||||
|
* @param {string} query Lowercased search term.
|
||||||
|
* @returns {boolean} Whether the record matches.
|
||||||
|
*/
|
||||||
|
function matches(row, query) {
|
||||||
|
if (String(row.name ?? '').toLowerCase().includes(query)) return true;
|
||||||
|
if (String(row.sku ?? '').toLowerCase().includes(query)) return true;
|
||||||
|
return (row.eans ?? []).some((ean) => String(ean).includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Runs a client-side search over the article index.
|
||||||
|
*
|
||||||
|
* The 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<ArticleSearchResult>} Search outcome.
|
||||||
|
*/
|
||||||
|
async search(target) {
|
||||||
|
articleSearchAutomat.setState({ target, loading: true });
|
||||||
|
try {
|
||||||
|
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
||||||
|
const query = String(target ?? '').trim().toLowerCase();
|
||||||
|
const recordset = query
|
||||||
|
? articles.filter((row) => matches(row, query))
|
||||||
|
: articles;
|
||||||
|
const result = {
|
||||||
|
categoryFacets: {},
|
||||||
|
totalCount: recordset.length,
|
||||||
|
recordset,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
articleSearchAutomat.setState({ result, loading: false });
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const result = { ...emptyResult, error };
|
||||||
|
articleSearchAutomat.setState({ result, loading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset the result set. */
|
||||||
|
reset() {
|
||||||
|
articleSearchAutomat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Article search state for the `/artikel` overview page.
|
||||||
|
*
|
||||||
|
* Replaces `StorageManager.doSearch()`; components subscribe to `result`
|
||||||
|
* instead of receiving a callback.
|
||||||
|
*
|
||||||
|
* @type {Automat<ArticleSearchState>}
|
||||||
|
*/
|
||||||
|
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the latest search result.
|
||||||
|
*
|
||||||
|
* @param {ArticleSearchState} state Automat state.
|
||||||
|
* @returns {ArticleSearchResult} Search outcome.
|
||||||
|
*/
|
||||||
|
export function selectSearchResult(state) {
|
||||||
|
return state.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default articleSearchAutomat;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { article as articleApi } from '../api';
|
import { article as articleApi } from '../api';
|
||||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
import articleIndexAutomat, { setArticleIndexName } from './articleIndexAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} ArtikelState
|
* @typedef {Object} ArtikelState
|
||||||
@@ -125,6 +127,117 @@ const actions = {
|
|||||||
artikelAutomat.setState({ bySku });
|
artikelAutomat.setState({ bySku });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/article/:sku with optimistic name handling.
|
||||||
|
*
|
||||||
|
* A `name` in the body is applied to both caches immediately so the tile
|
||||||
|
* does not flicker, then rolled back from the server on failure.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock? }`).
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async update(sku, body) {
|
||||||
|
const key = String(sku);
|
||||||
|
const nameChange = body != null && Object.hasOwn(body, 'name');
|
||||||
|
if (nameChange) {
|
||||||
|
setArticleIndexName(key, body.name);
|
||||||
|
actions.setArticleName(key, body.name);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await articleApi.update(key, body);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
if (nameChange) {
|
||||||
|
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||||
|
articleIndexAutomat.actions.reset();
|
||||||
|
if (hasArticle(key)) {
|
||||||
|
void actions.loadArticle(key).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/eans → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {Array<string>} eans Replacement EAN list.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setEans(sku, eans) {
|
||||||
|
const res = await articleApi.setEansBySku(String(sku), eans);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/brand → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {{ brandId: number|null }} body Brand payload.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setBrand(sku, body) {
|
||||||
|
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/categories → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {Array<number>} categoryIds Replacement category ids.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setCategories(sku, categoryIds) {
|
||||||
|
const res = await articleApi.setCategories(String(sku), categoryIds);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/prices → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {Array<object>} prices Net price rows.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setPrices(sku, prices) {
|
||||||
|
const res = await articleApi.setPrices(String(sku), prices);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/weight → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {object} body Weight payload.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setWeight(sku, body) {
|
||||||
|
const res = await articleApi.setWeight(String(sku), body);
|
||||||
|
actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/article/:sku/track_stock → refreshes the cached article.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @param {boolean|object} body Flag or full body.
|
||||||
|
* @returns {Promise<object>} Response body containing the fresh `article`.
|
||||||
|
*/
|
||||||
|
async setTrackStock(sku, body) {
|
||||||
|
const res = await articleApi.setTrackStock(String(sku), body);
|
||||||
|
if (res?.article) actions.setArticle(res.article);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forgets every cached article.
|
* Forgets every cached article.
|
||||||
*
|
*
|
||||||
@@ -187,4 +300,12 @@ export function selectArticle(sku) {
|
|||||||
return artikelAutomat.select(createArticleSelector(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;
|
export default artikelAutomat;
|
||||||
|
|||||||
@@ -191,4 +191,16 @@ const actions = {
|
|||||||
*/
|
*/
|
||||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
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;
|
export default authAutomat;
|
||||||
|
|||||||
@@ -145,6 +145,27 @@ export function disposeAllCategoryViewAutomats() {
|
|||||||
categoryViews.clear();
|
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<string,object>, 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<CategoryState>} State with `treeStatus === 'succeeded'`.
|
||||||
|
*/
|
||||||
|
export function whenCategoryTreeReady() {
|
||||||
|
return waitForCategoryTree();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Actions ───────────────────────────────────────────────────────────────────
|
// ─── Actions ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
|||||||
148
src/automats/createListAutomat.js
Normal file
148
src/automats/createListAutomat.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ListAutomatState
|
||||||
|
* @property {Array<object>} items Loaded records (empty until the first success).
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||||
|
* @property {Error|string|null} error Last load error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a company-scoped list store following the brand/unit/warehouse
|
||||||
|
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
||||||
|
* silently, and `reset()` clears on logout.
|
||||||
|
*
|
||||||
|
* This replaces the stale-while-revalidate list caches that previously lived
|
||||||
|
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
||||||
|
* customergroup, number).
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
* @param {object} options Factory options.
|
||||||
|
* @param {string} options.name Automat registry name (also used for logging).
|
||||||
|
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
||||||
|
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
||||||
|
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
||||||
|
*/
|
||||||
|
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
||||||
|
const initialState = { items: [], status: 'idle', error: null };
|
||||||
|
|
||||||
|
let automat;
|
||||||
|
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and normalizes the record list.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<T>>} Records from the backend.
|
||||||
|
*/
|
||||||
|
const fetchRecords = async () => {
|
||||||
|
const items = await fetchItems();
|
||||||
|
return sort ? sort(items) : items;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Loads the list, deduplicating concurrent callers.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<T>>} Loaded records.
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
automat.setState({ status: 'loading', error: null });
|
||||||
|
try {
|
||||||
|
const items = await fetchRecords();
|
||||||
|
automat.setState({ items, status: 'ready', error: null });
|
||||||
|
return items;
|
||||||
|
} catch (error) {
|
||||||
|
automat.setState({ status: 'failed', error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently re-fetches the list while keeping the current state, so
|
||||||
|
* subscribers do not flash a loading state.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<T>>} Fresh records.
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
try {
|
||||||
|
const items = await fetchRecords();
|
||||||
|
automat.setState({ items, status: 'ready', error: null });
|
||||||
|
return items;
|
||||||
|
} catch (error) {
|
||||||
|
automat.setState({ error });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the cached list without a round-trip.
|
||||||
|
*
|
||||||
|
* @param {Array<T>} items Records to cache.
|
||||||
|
* @returns {Array<T>} Cached records.
|
||||||
|
*/
|
||||||
|
replace(items) {
|
||||||
|
automat.setState({ items, status: 'ready', error: null });
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-fetches the list once the selected company is known.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<T>>} Loaded records.
|
||||||
|
*/
|
||||||
|
ensureLoaded() {
|
||||||
|
if (automat.state.status === 'ready') {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
return actions.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset to idle state (called on logout). */
|
||||||
|
reset() {
|
||||||
|
inflightLoad = null;
|
||||||
|
automat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(initialState, actions, { name });
|
||||||
|
|
||||||
|
// Load on session/company establishment, reset on logout.
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
|
if (auth.status === 'authed' && companyId) {
|
||||||
|
void actions.load().catch(() => {});
|
||||||
|
} else if (auth.status === 'anon') {
|
||||||
|
actions.reset();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the record list of a list automat.
|
||||||
|
*
|
||||||
|
* @param {Automat<ListAutomatState>} automat List automat.
|
||||||
|
* @returns {Array<object>} Current records.
|
||||||
|
*/
|
||||||
|
export function selectListItems(automat) {
|
||||||
|
return automat.state.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the load status of a list automat.
|
||||||
|
*
|
||||||
|
* @param {Automat<ListAutomatState>} automat List automat.
|
||||||
|
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
||||||
|
*/
|
||||||
|
export function selectListStatus(automat) {
|
||||||
|
return automat.state.status;
|
||||||
|
}
|
||||||
30
src/automats/customergroupAutomat.js
Normal file
30
src/automats/customergroupAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const customergroupAutomat = createListAutomat({
|
||||||
|
name: 'customergroup',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await customergroupApi.list();
|
||||||
|
return data.customergroups ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default customergroupAutomat;
|
||||||
32
src/automats/deliveryAutomat.js
Normal file
32
src/automats/deliveryAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const deliveryAutomat = createListAutomat({
|
||||||
|
name: 'delivery',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await deliveryApi.list();
|
||||||
|
return data.deliveries ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default deliveryAutomat;
|
||||||
31
src/automats/entityAutomat.js
Normal file
31
src/automats/entityAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const entityAutomat = createListAutomat({
|
||||||
|
name: 'entity',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await entityApi.list();
|
||||||
|
return data.entities ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
const byName = String(a.name).localeCompare(String(b.name), 'de');
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default entityAutomat;
|
||||||
@@ -23,6 +23,25 @@ export {
|
|||||||
export { default as supplierAutomat } from './supplierAutomat';
|
export { default as supplierAutomat } from './supplierAutomat';
|
||||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
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 { default as artikelAutomat } from './artikelAutomat';
|
||||||
export {
|
export {
|
||||||
getArticle,
|
getArticle,
|
||||||
@@ -68,6 +87,8 @@ export {
|
|||||||
getOrCreateCategoryAutomat,
|
getOrCreateCategoryAutomat,
|
||||||
disposeAllCategoryViewAutomats,
|
disposeAllCategoryViewAutomats,
|
||||||
invalidateAllCategoryViews,
|
invalidateAllCategoryViews,
|
||||||
|
getCategoryState,
|
||||||
|
whenCategoryTreeReady,
|
||||||
} from './categoryAutomat';
|
} from './categoryAutomat';
|
||||||
|
|
||||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||||
|
|||||||
30
src/automats/numberAutomat.js
Normal file
30
src/automats/numberAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const numberAutomat = createListAutomat({
|
||||||
|
name: 'number',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await numberApi.list();
|
||||||
|
return data.numbers ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) =>
|
||||||
|
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default numberAutomat;
|
||||||
28
src/automats/orderAutomat.js
Normal file
28
src/automats/orderAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const orderAutomat = createListAutomat({
|
||||||
|
name: 'order',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await orderApi.list();
|
||||||
|
return data.orders ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default orderAutomat;
|
||||||
28
src/automats/quoteAutomat.js
Normal file
28
src/automats/quoteAutomat.js
Normal file
@@ -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<import('./createListAutomat').ListAutomatState>}
|
||||||
|
*/
|
||||||
|
export const quoteAutomat = createListAutomat({
|
||||||
|
name: 'quote',
|
||||||
|
fetch: async () => {
|
||||||
|
const data = await quoteApi.list();
|
||||||
|
return data.quotes ?? [];
|
||||||
|
},
|
||||||
|
sort: (items) =>
|
||||||
|
[...items].sort((a, b) => String(b.date).localeCompare(String(a.date))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default quoteAutomat;
|
||||||
269
src/automats/sseAutomat.js
Normal file
269
src/automats/sseAutomat.js
Normal file
@@ -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<string, Set<(event: MessageEvent) => void>>}
|
||||||
|
*/
|
||||||
|
const handlers = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
||||||
|
* at least one view is open on that SKU.
|
||||||
|
* @type {Map<string, Set<(sku: string) => void>>}
|
||||||
|
*/
|
||||||
|
const articleWatchers = new Map();
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (!eventSource) return;
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
sseAutomat.setState({ status: 'disconnected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches every registered handler to a freshly opened stream.
|
||||||
|
*
|
||||||
|
* @param {EventSource} es Open event stream.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function attachHandlers(es) {
|
||||||
|
for (const [event, set] of handlers) {
|
||||||
|
for (const handler of set) {
|
||||||
|
es.addEventListener(event, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [sku] of articleWatchers) {
|
||||||
|
attachArticleListener(sku);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the single `article-<sku>` listener for a watched SKU.
|
||||||
|
*
|
||||||
|
* The listener resolves the handler set at call time, so a SKU that lost all
|
||||||
|
* watchers turns into a cheap no-op instead of needing removal. Listeners are
|
||||||
|
* reclaimed whenever the stream is rebuilt on a company switch.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function attachArticleListener(sku) {
|
||||||
|
eventSource?.addEventListener(`${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
|
||||||
|
const set = articleWatchers.get(sku);
|
||||||
|
if (!set || set.size === 0) return;
|
||||||
|
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
|
||||||
|
for (const handler of set) handler(changed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||||
|
if (companyId == null) {
|
||||||
|
disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL('/api/events', window.location.origin).href;
|
||||||
|
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||||
|
|
||||||
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
|
// Category tree changed: refresh the tree and every open category view.
|
||||||
|
es.addEventListener('category', () => {
|
||||||
|
void categoryAutomat.actions.loadTree().catch((err) => {
|
||||||
|
console.error('[sse] category tree refresh failed', err);
|
||||||
|
});
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Article index changed: refresh the index and open category views.
|
||||||
|
es.addEventListener('articles', () => {
|
||||||
|
invalidateAllCategoryViews();
|
||||||
|
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||||
|
console.error('[sse] article index refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('warehouse', () => {
|
||||||
|
void warehouseAutomat.actions.reload().catch((err) => {
|
||||||
|
console.error('[sse] warehouse refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('attributename', () => {
|
||||||
|
void attributeAutomat.actions.load().catch((err) => {
|
||||||
|
console.error('[sse] attributename refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('propertyname', () => {
|
||||||
|
void propertyAutomat.actions.load().catch((err) => {
|
||||||
|
console.error('[sse] propertyname refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('supplier', () => {
|
||||||
|
void supplierAutomat.actions.reload().catch((err) => {
|
||||||
|
console.error('[sse] supplier refresh failed', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('open', () => {
|
||||||
|
sseAutomat.setState({ status: 'connected' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// EventSource reconnects on its own; only surface the state.
|
||||||
|
es.onerror = () => {
|
||||||
|
console.error('[sse] event stream error');
|
||||||
|
sseAutomat.setState({ status: 'error' });
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource = es;
|
||||||
|
attachHandlers(es);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/**
|
||||||
|
* Closes and reopens the event stream. Required after a company switch,
|
||||||
|
* because the stream is scoped to the selected company at connect time.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
reconnect() {
|
||||||
|
disconnect();
|
||||||
|
connect();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a handler for a statically named backend event.
|
||||||
|
*
|
||||||
|
* Registration order does not matter: handlers added while disconnected are
|
||||||
|
* attached on the next connect.
|
||||||
|
*
|
||||||
|
* @param {string} event Event name (e.g. `category`, `articles`).
|
||||||
|
* @param {(event: MessageEvent) => void} handler Signal handler.
|
||||||
|
* @returns {() => void} Unsubscribe callback.
|
||||||
|
*/
|
||||||
|
on(event, handler) {
|
||||||
|
let set = handlers.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
handlers.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
eventSource?.addEventListener(event, handler);
|
||||||
|
return () => {
|
||||||
|
eventSource?.removeEventListener(event, handler);
|
||||||
|
set.delete(handler);
|
||||||
|
if (set.size === 0) handlers.delete(event);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to `article-<sku>` signals for one SKU.
|
||||||
|
*
|
||||||
|
* The backend emits this event whenever that article changes. Exactly one
|
||||||
|
* listener is attached per SKU while at least one caller is subscribed.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU to watch.
|
||||||
|
* @param {(sku: string) => void} handler Called with the changed SKU.
|
||||||
|
* @returns {() => void} Unsubscribe callback.
|
||||||
|
*/
|
||||||
|
watchArticle(sku, handler) {
|
||||||
|
const key = String(sku);
|
||||||
|
let set = articleWatchers.get(key);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
articleWatchers.set(key, set);
|
||||||
|
attachArticleListener(key);
|
||||||
|
}
|
||||||
|
set.add(handler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const current = articleWatchers.get(key);
|
||||||
|
if (!current) return;
|
||||||
|
current.delete(handler);
|
||||||
|
if (current.size === 0) articleWatchers.delete(key);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global `GET /api/events` connection.
|
||||||
|
*
|
||||||
|
* Owns the single `EventSource` for the tab and dispatches signals into the
|
||||||
|
* domain automats. Reconnects whenever the selected company changes.
|
||||||
|
*
|
||||||
|
* @type {Automat<SseState>}
|
||||||
|
*/
|
||||||
|
sseAutomat = new Automat(initialState, actions, { name: 'sse' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the cached article plus any open history/supplier view.
|
||||||
|
*
|
||||||
|
* Background refreshes stay silent: an article that is not on screen has no
|
||||||
|
* visible subscriber to notify, so failures are swallowed.
|
||||||
|
*
|
||||||
|
* @param {string} sku Article SKU.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function invalidateArticle(sku) {
|
||||||
|
const key = String(sku);
|
||||||
|
void artikelAutomat.actions.reloadArticle(key).catch(() => {});
|
||||||
|
if (articleHistoryAutomat.has(key)) {
|
||||||
|
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||||
|
}
|
||||||
|
if (articleSupplierAutomat.has(key)) {
|
||||||
|
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect when the selected company changes; disconnect on logout.
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId =
|
||||||
|
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||||
|
? String(auth.user.selectedCompanyId)
|
||||||
|
: null;
|
||||||
|
if (companyId === sseAutomat.state.companyId) return;
|
||||||
|
|
||||||
|
if (companyId == null) {
|
||||||
|
disconnect();
|
||||||
|
sseAutomat.setState({ companyId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A different company means a different stream scope.
|
||||||
|
disconnect();
|
||||||
|
sseAutomat.setState({ companyId });
|
||||||
|
connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default sseAutomat;
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
import authAutomat from '../automats/authAutomat';
|
|
||||||
|
|
||||||
let sessionBootstrapped = false;
|
let sessionBootstrapped = false;
|
||||||
|
|
||||||
class AuthBootstrap extends Component {
|
class AuthBootstrap extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (sessionBootstrapped) return;
|
if (sessionBootstrapped) return;
|
||||||
sessionBootstrapped = true;
|
sessionBootstrapped = true;
|
||||||
|
|
||||||
this.context.authMePromise
|
bootstrapAuthMe()
|
||||||
.then((session) => {
|
.then((session) => {
|
||||||
if (session.forbidden) {
|
if (session.forbidden) {
|
||||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||||
|
|||||||
@@ -25,15 +25,14 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
import posterminalAutomat from '../automats/posterminalAutomat';
|
||||||
|
import { whenCategoryTreeReady } from '../automats/categoryAutomat';
|
||||||
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
|
||||||
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
|
||||||
export default class CompanySettingsDialog extends Component {
|
export default class CompanySettingsDialog extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -84,13 +83,7 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
async loadCompany() {
|
async loadCompany() {
|
||||||
try {
|
try {
|
||||||
const session = await this.context.authMePromise;
|
const companyRow = await companyAutomat.actions.load();
|
||||||
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',
|
|
||||||
);
|
|
||||||
this.syncFormFromCompany(companyRow);
|
this.syncFormFromCompany(companyRow);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -99,7 +92,7 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
async loadTerminals() {
|
async loadTerminals() {
|
||||||
try {
|
try {
|
||||||
const terminals = await this.context.posterminalPromise;
|
const terminals = await posterminalAutomat.actions.load();
|
||||||
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -112,17 +105,17 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
this.setState({ creatingTerminal: true });
|
this.setState({ creatingTerminal: true });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.posterminal.upsert({ name: trimmed });
|
await posterminalAutomat.actions.create({ name: trimmed });
|
||||||
requireObject(data, 'PUT /api/posterminal');
|
|
||||||
requireObject(data.posterminal, 'posterminal');
|
|
||||||
requireDefined(data.posterminal.id, 'posterminal.id');
|
|
||||||
|
|
||||||
if (enableAllForNew) {
|
if (enableAllForNew) {
|
||||||
await this.context.posterminal.setOnAll(data.posterminal.id);
|
const created = posterminalAutomat.state.terminals.at(-1);
|
||||||
await this.context.updateCategoryTree();
|
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 : [] });
|
this.setState({ newTerminalName: '', terminals: Array.isArray(terminals) ? terminals : [] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -133,8 +126,8 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
async handleDeleteTerminal(terminal) {
|
async handleDeleteTerminal(terminal) {
|
||||||
try {
|
try {
|
||||||
await this.context.posterminal.delete(terminal.id);
|
await posterminalAutomat.actions.delete(terminal.id);
|
||||||
const terminals = await this.context.updatePosterminal();
|
const terminals = await posterminalAutomat.actions.load();
|
||||||
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
this.setState({ terminals: Array.isArray(terminals) ? terminals : [] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -160,15 +153,12 @@ export default class CompanySettingsDialog extends Component {
|
|||||||
|
|
||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
const session = await this.context.authMePromise;
|
await companyAutomat.actions.update({
|
||||||
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
|
|
||||||
await this.context.company.update(companyId, {
|
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
iso639,
|
iso639,
|
||||||
taxrate: parsedTaxrate,
|
taxrate: parsedTaxrate,
|
||||||
reducedtaxrate: parsedReducedtaxrate,
|
reducedtaxrate: parsedReducedtaxrate,
|
||||||
});
|
});
|
||||||
await this.context.updateCompany();
|
|
||||||
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
enqueueSnackbar('Unternehmen gespeichert.', { variant: 'success' });
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ import TuneIcon from '@mui/icons-material/Tune';
|
|||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import { company, number as numberApi } from '../api';
|
import { company, number as numberApi } from '../api';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
import authAutomat from '../automats/authAutomat';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import numberAutomat from '../automats/numberAutomat';
|
||||||
import {
|
import {
|
||||||
defaultIso639,
|
defaultIso639,
|
||||||
ISO639_OPTIONS,
|
ISO639_OPTIONS,
|
||||||
@@ -105,8 +106,6 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CompanySetupDialog extends Component {
|
class CompanySetupDialog extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -149,7 +148,7 @@ class CompanySetupDialog extends Component {
|
|||||||
this.setState({ busy: true, error: null });
|
this.setState({ busy: true, error: null });
|
||||||
try {
|
try {
|
||||||
await authAutomat.actions.selectCompany(companyId);
|
await authAutomat.actions.selectCompany(companyId);
|
||||||
await this.context.updateAuthMe();
|
await bootstrapAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,9 +222,9 @@ class CompanySetupDialog extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.context.updateAuthMe();
|
await bootstrapAuthMe();
|
||||||
await this.context.updateCompany();
|
await companyAutomat.actions.load();
|
||||||
await this.context.updateNumber?.();
|
await numberAutomat.actions.reload().catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -236,7 +235,7 @@ class CompanySetupDialog extends Component {
|
|||||||
async handleLogout() {
|
async handleLogout() {
|
||||||
try {
|
try {
|
||||||
await authAutomat.actions.logout();
|
await authAutomat.actions.logout();
|
||||||
await this.context.updateAuthMe();
|
await bootstrapAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ error: backendErrorFrom(err) });
|
this.setState({ error: backendErrorFrom(err) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import GoogleIcon from '@mui/icons-material/Google';
|
import GoogleIcon from '@mui/icons-material/Google';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyErrorMessage } from '../utils/notify';
|
import { notifyErrorMessage } from '../utils/notify';
|
||||||
import authAutomat from '../automats/authAutomat';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
|
||||||
import {
|
import {
|
||||||
abortGoogleSignIn,
|
abortGoogleSignIn,
|
||||||
FEDCM_BLOCKED_MESSAGE,
|
FEDCM_BLOCKED_MESSAGE,
|
||||||
@@ -20,8 +19,6 @@ import {
|
|||||||
import quixposLogo from '../assets/quixpos-logo.png';
|
import quixposLogo from '../assets/quixpos-logo.png';
|
||||||
|
|
||||||
class LoginPage extends PureComponent {
|
class LoginPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { ...authAutomat.state, busy: false };
|
this.state = { ...authAutomat.state, busy: false };
|
||||||
@@ -50,7 +47,7 @@ class LoginPage extends PureComponent {
|
|||||||
});
|
});
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
await authAutomat.actions.loginWithGoogle(idToken);
|
await authAutomat.actions.loginWithGoogle(idToken);
|
||||||
await this.context.updateAuthMe();
|
await bootstrapAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GoogleSignInCancelledError) return;
|
if (err instanceof GoogleSignInCancelledError) return;
|
||||||
logError('[login] Google sign-in failed', {
|
logError('[login] Google sign-in failed', {
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
|||||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import { StorageManagerContext } from '../api/storageManagerContext';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
import authAutomat from '../automats/authAutomat';
|
|
||||||
import { helpAutomat, toggleHelp } from '../automats';
|
import { helpAutomat, toggleHelp } from '../automats';
|
||||||
import CompanySettingsDialog from './CompanySettingsDialog';
|
import CompanySettingsDialog from './CompanySettingsDialog';
|
||||||
import SettingsDialog from './settings/SettingsDialog';
|
import SettingsDialog from './settings/SettingsDialog';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
|
|
||||||
class UserMenu extends PureComponent {
|
class UserMenu extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -87,7 +84,7 @@ class UserMenu extends PureComponent {
|
|||||||
this.setState({ anchorEl: null });
|
this.setState({ anchorEl: null });
|
||||||
try {
|
try {
|
||||||
await authAutomat.actions.logout();
|
await authAutomat.actions.logout();
|
||||||
await this.context.updateAuthMe();
|
await bootstrapAuthMe();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import BrokenImageIcon from '@mui/icons-material/BrokenImage';
|
|||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { serper } from '../../api';
|
import { serper, article as articleApi } from '../../api';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import artikelAutomat from '../../automats/artikelAutomat';
|
import artikelAutomat from '../../automats/artikelAutomat';
|
||||||
import { articleWithUploadedPhoto } from '../../utils/articleDetail';
|
import { articleWithUploadedPhoto } from '../../utils/articleDetail';
|
||||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
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.
|
* Dialog that searches Google Images via Serper.dev SSE stream for article photos.
|
||||||
*/
|
*/
|
||||||
export default class ArticleImageSearchDialog extends PureComponent {
|
export default class ArticleImageSearchDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -236,7 +233,7 @@ export default class ArticleImageSearchDialog extends PureComponent {
|
|||||||
const file = avifBlob instanceof File
|
const file = avifBlob instanceof File
|
||||||
? avifBlob
|
? avifBlob
|
||||||
: new File([avifBlob], 'article-photo.webp', { type: avifBlob.type || 'image/webp' });
|
: 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, 'POST /api/article/:sku/image');
|
||||||
requireObject(res.image, 'image');
|
requireObject(res.image, 'image');
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { thumb } from '../../api';
|
||||||
import ThumbHashImage from './ThumbHashImage';
|
import ThumbHashImage from './ThumbHashImage';
|
||||||
|
|
||||||
class ArticlePhoto extends Component {
|
class ArticlePhoto extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { thumbLoaded: false };
|
this.state = { thumbLoaded: false };
|
||||||
@@ -80,7 +78,7 @@ class ArticlePhoto extends Component {
|
|||||||
{fullHash ? (
|
{fullHash ? (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={this.context.thumb.url(fullHash)}
|
src={thumb.url(fullHash)}
|
||||||
alt=""
|
alt=""
|
||||||
onLoad={this.handleThumbLoad}
|
onLoad={this.handleThumbLoad}
|
||||||
onError={this.handleThumbError}
|
onError={this.handleThumbError}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined
|
|||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { article as articleApi } from '../../api';
|
||||||
import artikelAutomat from '../../automats/artikelAutomat';
|
import artikelAutomat from '../../automats/artikelAutomat';
|
||||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||||
import {
|
import {
|
||||||
@@ -46,8 +46,6 @@ const overlayIconSx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class ArticlePhotosDialog extends Component {
|
class ArticlePhotosDialog extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.fileInputRef = createRef();
|
this.fileInputRef = createRef();
|
||||||
@@ -72,7 +70,7 @@ class ArticlePhotosDialog extends Component {
|
|||||||
try {
|
try {
|
||||||
const file = await prepareArticlePhoto(rawBlob);
|
const file = await prepareArticlePhoto(rawBlob);
|
||||||
const uploadStarted = performance.now();
|
const uploadStarted = performance.now();
|
||||||
const res = await this.context.article.addImage(articleData.sku, file);
|
const res = await articleApi.uploadImage(articleData.sku, file);
|
||||||
console.log('[article-photo] upload', {
|
console.log('[article-photo] upload', {
|
||||||
uploadMs: Math.round(performance.now() - uploadStarted),
|
uploadMs: Math.round(performance.now() - uploadStarted),
|
||||||
bytes: file.size,
|
bytes: file.size,
|
||||||
@@ -130,7 +128,7 @@ class ArticlePhotosDialog extends Component {
|
|||||||
const { article: articleData, onArticleUpdated } = this.props;
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
await this.context.article.deleteImage(articleData.sku, fullHash);
|
await articleApi.deleteImage(articleData.sku, fullHash);
|
||||||
const updated = articleWithoutPhoto(articleData, fullHash);
|
const updated = articleWithoutPhoto(articleData, fullHash);
|
||||||
artikelAutomat.actions.setArticle(updated);
|
artikelAutomat.actions.setArticle(updated);
|
||||||
onArticleUpdated?.(updated);
|
onArticleUpdated?.(updated);
|
||||||
@@ -155,7 +153,7 @@ class ArticlePhotosDialog extends Component {
|
|||||||
|
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const res = await this.context.article.orderImages(articleData.sku, { fullHashes });
|
const res = await articleApi.orderImages(articleData.sku, fullHashes);
|
||||||
requireObject(res, 'PUT /api/article/:sku/images/order');
|
requireObject(res, 'PUT /api/article/:sku/images/order');
|
||||||
requireArray(res.fullHashes, 'fullHashes');
|
requireArray(res.fullHashes, 'fullHashes');
|
||||||
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
|
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ import TrendingDownIcon from '@mui/icons-material/TrendingDown';
|
|||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { stock, article as articleApi } from '../../api';
|
import { stock } from '../../api';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import { formatStock } from './searchFormat';
|
import { formatStock } from './searchFormat';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import artikelAutomat from '../../automats/artikelAutomat';
|
import artikelAutomat from '../../automats/artikelAutomat';
|
||||||
@@ -43,8 +42,6 @@ const REASON_PRESETS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default class ArticleStockAdjustDialog extends PureComponent {
|
export default class ArticleStockAdjustDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const initialWhId = this.resolveInitialWarehouseId(props);
|
const initialWhId = this.resolveInitialWarehouseId(props);
|
||||||
@@ -214,9 +211,9 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
|
|
||||||
// 1. Update track_stock if changed
|
// 1. Update track_stock if changed
|
||||||
if (trackStockChanged) {
|
if (trackStockChanged) {
|
||||||
const store = this.context;
|
const trackRes = await artikelAutomat.actions.setTrackStock(article.sku, {
|
||||||
const setFn = store?.article?.setTrackStock ?? articleApi.setTrackStock;
|
track_stock: trackStock,
|
||||||
const trackRes = await setFn(article.sku, { track_stock: trackStock });
|
});
|
||||||
updatedArticle = trackRes?.article || null;
|
updatedArticle = trackRes?.article || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import ListItemButton from '@mui/material/ListItemButton';
|
|||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import TextInput from '../TextInput';
|
import TextInput from '../TextInput';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import unitAutomat from '../../automats/unitAutomat';
|
||||||
|
import { unit as unitApi } from '../../api';
|
||||||
import { requireObject } from '../../utils/apiShape';
|
import { requireObject } from '../../utils/apiShape';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import { parsePriceInput } from '../../utils/price';
|
import { parsePriceInput } from '../../utils/price';
|
||||||
@@ -63,8 +64,6 @@ function formValid(form) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleUnitEditorDialog extends Component {
|
export default class ArticleUnitEditorDialog extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -86,7 +85,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
async loadUnits() {
|
async loadUnits() {
|
||||||
this.setState({ unitsLoading: true });
|
this.setState({ unitsLoading: true });
|
||||||
try {
|
try {
|
||||||
const units = await this.context.unitPromise;
|
const units = await unitAutomat.actions.load();
|
||||||
this.setState({ units, unitsLoading: false });
|
this.setState({ units, unitsLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.setState({ unitsLoading: false });
|
this.setState({ unitsLoading: false });
|
||||||
@@ -117,7 +116,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
if (!formValid(form)) return;
|
if (!formValid(form)) return;
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.unit.upsert({
|
const data = await unitApi.upsert({
|
||||||
si: String(form.si ?? '').trim(),
|
si: String(form.si ?? '').trim(),
|
||||||
basesi: String(form.basesi ?? '').trim(),
|
basesi: String(form.basesi ?? '').trim(),
|
||||||
basesifactor: parseFactor(form.basesifactor),
|
basesifactor: parseFactor(form.basesifactor),
|
||||||
@@ -126,7 +125,7 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
});
|
});
|
||||||
requireObject(data, 'PUT /api/unit');
|
requireObject(data, 'PUT /api/unit');
|
||||||
requireObject(data.unit, 'unit');
|
requireObject(data.unit, 'unit');
|
||||||
const units = await this.context.updateUnit();
|
const units = await unitAutomat.actions.load();
|
||||||
this.setState({
|
this.setState({
|
||||||
busy: false,
|
busy: false,
|
||||||
units,
|
units,
|
||||||
@@ -145,8 +144,8 @@ export default class ArticleUnitEditorDialog extends Component {
|
|||||||
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
|
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
await this.context.unit.delete(selectedSi);
|
await unitApi.delete(selectedSi);
|
||||||
const units = await this.context.updateUnit();
|
const units = await unitAutomat.actions.load();
|
||||||
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
|
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
|
||||||
this.props.onUnitDeleted?.(selectedSi);
|
this.props.onUnitDeleted?.(selectedSi);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import { PanelTileGrid } from 'dashgui/panels';
|
import { PanelTileGrid } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { getCategoryState } from '../../automats/categoryAutomat';
|
||||||
import withNavigate from '../withNavigate';
|
import withNavigate from '../withNavigate';
|
||||||
import {
|
import {
|
||||||
buildCategoryPath,
|
buildCategoryPath,
|
||||||
@@ -21,8 +21,6 @@ import ArticleViewLists from './boxes/ArticleViewLists';
|
|||||||
const PHOTO_TILE_SX = { minHeight: 0 };
|
const PHOTO_TILE_SX = { minHeight: 0 };
|
||||||
|
|
||||||
class ArticleView extends PureComponent {
|
class ArticleView extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ class ArticleView extends PureComponent {
|
|||||||
handleArticleDeleted() {
|
handleArticleDeleted() {
|
||||||
const { navigate } = this.props;
|
const { navigate } = this.props;
|
||||||
const { article } = this.state;
|
const { article } = this.state;
|
||||||
const categoryState = this.context.getCategoryState();
|
const categoryState = getCategoryState();
|
||||||
const categories = categoryState?.categories ?? null;
|
const categories = categoryState?.categories ?? null;
|
||||||
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
||||||
const categoryId = article?.categoryIds?.[0];
|
const categoryId = article?.categoryIds?.[0];
|
||||||
@@ -83,7 +81,7 @@ class ArticleView extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { article } = this.state;
|
const { article } = this.state;
|
||||||
const categoryState = this.context.getCategoryState();
|
const categoryState = getCategoryState();
|
||||||
const categories = categoryState?.categories ?? null;
|
const categories = categoryState?.categories ?? null;
|
||||||
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
const rootCategoryId = categoryState?.rootCategoryId ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import TextInput from '../../TextInput';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import brandAutomat from '../../../automats/brandAutomat';
|
||||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||||
|
import { requireObject } from '../../../utils/apiShape';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import { BrandLabel, BrandThumb } from '../../brand';
|
import { BrandLabel, BrandThumb } from '../../brand';
|
||||||
@@ -111,37 +112,29 @@ class BrandEdit extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleViewBrand extends PureComponent {
|
export default class ArticleViewBrand extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const brands = this.context?.brands;
|
|
||||||
this.state = {
|
this.state = {
|
||||||
brands: brands ?? [],
|
brands: brandAutomat.state.brands ?? [],
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this._brandsReady = brands != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
if (this._brandsReady) return;
|
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||||
this.context.brandPromise
|
brands: state.brands ?? [],
|
||||||
.then((list) => {
|
}));
|
||||||
if (!this._cancelled) this.setState({ brands: list });
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!this._cancelled) notifyError(err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
|
this.unsub?.();
|
||||||
|
this.unsub = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSave = async (draft) => {
|
handleSave = async (draft) => {
|
||||||
const { article: articleData, onArticleUpdated } = this.props;
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
const store = this.context;
|
|
||||||
const { brands } = this.state;
|
const { brands } = this.state;
|
||||||
const name = String(draft.name ?? '').trim();
|
const name = String(draft.name ?? '').trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -156,16 +149,16 @@ export default class ArticleViewBrand extends PureComponent {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
brandId = existing.id;
|
brandId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
const createdRes = await store.brand.upsert({ name });
|
await brandAutomat.actions.upsert({ name });
|
||||||
requireObject(createdRes, 'PUT /api/brand');
|
const nextBrands = await brandAutomat.actions.load();
|
||||||
requireObject(createdRes.brand, 'brand');
|
|
||||||
requireDefined(createdRes.brand.id, 'brand.id');
|
|
||||||
brandId = createdRes.brand.id;
|
|
||||||
const nextBrands = await store.updateBrand();
|
|
||||||
this.setState({ brands: nextBrands });
|
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, 'PUT /api/article/:sku/brand');
|
||||||
requireObject(res.article, 'article');
|
requireObject(res.article, 'article');
|
||||||
onArticleUpdated?.(res.article);
|
onArticleUpdated?.(res.article);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import TextInput from '../../TextInput';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||||
import { requireObject } from '../../../utils/apiShape';
|
import { requireObject } from '../../../utils/apiShape';
|
||||||
import { articleEans } from '../../../utils/articleDetail';
|
import { articleEans } from '../../../utils/articleDetail';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
@@ -131,14 +131,12 @@ class EansEdit extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleViewEANs extends PureComponent {
|
export default class ArticleViewEANs extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
handleSave = async (draft) => {
|
handleSave = async (draft) => {
|
||||||
const { article: articleData, onArticleUpdated } = this.props;
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
const eans = (draft.eans ?? [])
|
const eans = (draft.eans ?? [])
|
||||||
.map((ean) => String(ean).trim())
|
.map((ean) => String(ean).trim())
|
||||||
.filter((ean) => ean.length > 0);
|
.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, 'PUT /api/article/:sku/eans');
|
||||||
requireObject(res.article, 'article');
|
requireObject(res.article, 'article');
|
||||||
onArticleUpdated?.(res.article);
|
onArticleUpdated?.(res.article);
|
||||||
|
|||||||
@@ -35,11 +35,9 @@ import { article as articleApi } from '../../../api';
|
|||||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import articleIndexAutomat from '../../../automats/articleIndexAutomat';
|
||||||
|
|
||||||
export default class ArticleViewLists extends PureComponent {
|
export default class ArticleViewLists extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const initialAttributes = selectAttributes() || [];
|
const initialAttributes = selectAttributes() || [];
|
||||||
@@ -167,13 +165,12 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getApi() {
|
getApi() {
|
||||||
return this.context?.article || articleApi;
|
return articleApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAllArticles() {
|
async loadAllArticles() {
|
||||||
try {
|
try {
|
||||||
const data = await this.context?.articleIndexPromise;
|
const articles = await articleIndexAutomat.actions.ensureLoaded();
|
||||||
const articles = Array.isArray(data) ? data : (data?.articles || []);
|
|
||||||
if (!this._cancelled) {
|
if (!this._cancelled) {
|
||||||
this.setState({ allArticles: articles });
|
this.setState({ allArticles: articles });
|
||||||
}
|
}
|
||||||
@@ -1316,7 +1313,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
||||||
const { busy, activeCreatorMode, selectedAttributeId, attributes } = this.state;
|
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{activeCreatorMode === 'father' ? (
|
{activeCreatorMode === 'father' ? (
|
||||||
@@ -1422,7 +1419,6 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
allArticles,
|
allArticles,
|
||||||
busy,
|
busy,
|
||||||
activeCreatorMode,
|
activeCreatorMode,
|
||||||
activeListTab,
|
|
||||||
selectedChildValueId,
|
selectedChildValueId,
|
||||||
newChildText,
|
newChildText,
|
||||||
isCustomChildValue,
|
isCustomChildValue,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Component, PureComponent } from 'react';
|
import { Component, PureComponent } from 'react';
|
||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
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 { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||||
import { Box, Chip, IconButton } from '@mui/material';
|
import { Box, Chip, IconButton } from '@mui/material';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
@@ -88,8 +89,6 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CategoryPills extends Component {
|
class CategoryPills extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { pickerOpen: false, targetPillId: null };
|
this.state = { pickerOpen: false, targetPillId: null };
|
||||||
@@ -133,7 +132,7 @@ class CategoryPills extends Component {
|
|||||||
render() {
|
render() {
|
||||||
const { categoryIds, editMode } = this.props;
|
const { categoryIds, editMode } = this.props;
|
||||||
const { pickerOpen, targetPillId } = this.state;
|
const { pickerOpen, targetPillId } = this.state;
|
||||||
const { categories, rootCategoryId } = this.context.getCategoryState() ?? {};
|
const { categories, rootCategoryId } = getCategoryState() ?? {};
|
||||||
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||||
|
|
||||||
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
||||||
@@ -273,8 +272,6 @@ class NameEdit extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleViewName extends PureComponent {
|
export default class ArticleViewName extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -294,7 +291,6 @@ export default class ArticleViewName extends PureComponent {
|
|||||||
handleSave = async () => {
|
handleSave = async () => {
|
||||||
const { article: articleData, onArticleUpdated } = this.props;
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
const { nameDraft, categoryIdsDraft } = this.state;
|
const { nameDraft, categoryIdsDraft } = this.state;
|
||||||
const store = this.context;
|
|
||||||
const name = nameDraft.trim();
|
const name = nameDraft.trim();
|
||||||
const categoryIds = categoryIdsDraft;
|
const categoryIds = categoryIdsDraft;
|
||||||
const nameChanged = name !== String(articleData.name ?? '').trim();
|
const nameChanged = name !== String(articleData.name ?? '').trim();
|
||||||
@@ -303,14 +299,12 @@ export default class ArticleViewName extends PureComponent {
|
|||||||
let article = null;
|
let article = null;
|
||||||
|
|
||||||
if (nameChanged) {
|
if (nameChanged) {
|
||||||
console.log('storing name update', name, articleData.name);
|
const nameRes = await artikelAutomat.actions.update(articleData.sku, { name });
|
||||||
const nameRes = await store.article.update(articleData.sku, { name });
|
|
||||||
article = nameRes.article;
|
article = nameRes.article;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (categoriesChanged) {
|
if (categoriesChanged) {
|
||||||
console.log('storing categories update', categoryIds, articleData.categoryIds);
|
const catRes = await artikelAutomat.actions.setCategories(articleData.sku, categoryIds);
|
||||||
const catRes = await store.article.setCategories(articleData.sku, { categoryIds });
|
|
||||||
article = catRes.article;
|
article = catRes.article;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
||||||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||||||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
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 { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||||
import {
|
import {
|
||||||
buildPosPricesPutBody,
|
buildPosPricesPutBody,
|
||||||
@@ -169,9 +171,8 @@ function priceDraftValid(draft) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function companyFromStore(store) {
|
function companyFromStore() {
|
||||||
if (!store) return null;
|
return companyAutomat.state.current ?? null;
|
||||||
return store.company ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PriceAmountField extends Component {
|
class PriceAmountField extends Component {
|
||||||
@@ -361,13 +362,10 @@ class PriceEdit extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleViewPrice extends PureComponent {
|
export default class ArticleViewPrice extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const store = this.context;
|
const company = companyFromStore();
|
||||||
const company = companyFromStore(store);
|
const units = unitAutomat.state.units;
|
||||||
const units = store?.units;
|
|
||||||
this.state = {
|
this.state = {
|
||||||
tiersDialogOpen: false,
|
tiersDialogOpen: false,
|
||||||
unitEditorOpen: false,
|
unitEditorOpen: false,
|
||||||
@@ -375,37 +373,28 @@ export default class ArticleViewPrice extends PureComponent {
|
|||||||
company,
|
company,
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
this._ready = company != null && units != null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
if (this._ready) return;
|
this.unsubUnits = unitAutomat.subscribe(this, (state) => ({
|
||||||
void this.load();
|
units: state.units ?? [],
|
||||||
|
}));
|
||||||
|
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
company: state.current ?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
}
|
this.unsubUnits?.();
|
||||||
|
this.unsubUnits = null;
|
||||||
async load() {
|
this.unsubCompany?.();
|
||||||
const store = this.context;
|
this.unsubCompany = null;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSave = async (draft) => {
|
handleSave = async (draft) => {
|
||||||
const { article: articleData, onArticleUpdated } = this.props;
|
const { article: articleData, onArticleUpdated } = this.props;
|
||||||
const store = this.context;
|
|
||||||
const { company } = this.state;
|
const { company } = this.state;
|
||||||
const saved = priceDraftValue(articleData, company);
|
const saved = priceDraftValue(articleData, company);
|
||||||
let nextArticle = articleData;
|
let nextArticle = articleData;
|
||||||
@@ -426,12 +415,12 @@ export default class ArticleViewPrice extends PureComponent {
|
|||||||
if (isNoPackUnit(unitSi, unitamount)) {
|
if (isNoPackUnit(unitSi, unitamount)) {
|
||||||
throw new Error('Einheit pc × 1 ist keine Packungseinheit');
|
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, 'PATCH /api/article/:sku');
|
||||||
requireObject(res.article, 'article');
|
requireObject(res.article, 'article');
|
||||||
nextArticle = res.article;
|
nextArticle = res.article;
|
||||||
} else {
|
} else {
|
||||||
const res = await store.article.update(articleData.sku, {
|
const res = await artikelAutomat.actions.update(articleData.sku, {
|
||||||
unitamount: 1,
|
unitamount: 1,
|
||||||
unitSi: 'pc',
|
unitSi: 'pc',
|
||||||
});
|
});
|
||||||
@@ -456,7 +445,7 @@ export default class ArticleViewPrice extends PureComponent {
|
|||||||
taxRate,
|
taxRate,
|
||||||
tiers: draft.tiers ?? [],
|
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, 'PUT /api/article/:sku/prices');
|
||||||
requireObject(res.article, 'article');
|
requireObject(res.article, 'article');
|
||||||
nextArticle = res.article;
|
nextArticle = res.article;
|
||||||
@@ -480,7 +469,8 @@ export default class ArticleViewPrice extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
refreshUnits = async () => {
|
refreshUnits = async () => {
|
||||||
this.setState({ units: await this.context.unitPromise });
|
const units = await unitAutomat.actions.load();
|
||||||
|
this.setState({ units });
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -10,15 +10,12 @@ import Tooltip from '@mui/material/Tooltip';
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
|
||||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||||
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
|
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
|
||||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||||
import { article as articleApi } from '../../../api';
|
import { article as articleApi } from '../../../api';
|
||||||
|
|
||||||
export default class ArticleAttributesTab extends PureComponent {
|
export default class ArticleAttributesTab extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
getArticle() {
|
getArticle() {
|
||||||
return this.props.article || getArticle(this.props.sku);
|
return this.props.article || getArticle(this.props.sku);
|
||||||
}
|
}
|
||||||
@@ -165,16 +162,15 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
if (!sku) return;
|
if (!sku) return;
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
try {
|
try {
|
||||||
const api = this.context?.article || articleApi;
|
const res = await Promise.all([
|
||||||
const [attrRes, propRes] = await Promise.all([
|
articleApi.getAttributeValues(sku),
|
||||||
api.getAttributeValues(sku),
|
articleApi.getProperties(sku),
|
||||||
api.getProperties(sku),
|
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
assignedAttributes: Array.isArray(attrRes?.attributevalues) ? attrRes.attributevalues : [],
|
assignedAttributes: Array.isArray(res[0]?.attributevalues) ? res[0].attributevalues : [],
|
||||||
assignedProperties: Array.isArray(propRes?.properties) ? propRes.properties : [],
|
assignedProperties: Array.isArray(res[1]?.properties) ? res[1].properties : [],
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -231,7 +227,6 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
const effectiveKey = selectedOption?.key || selectedMerkmalKey;
|
const effectiveKey = selectedOption?.key || selectedMerkmalKey;
|
||||||
|
|
||||||
if (saving || !sku || !effectiveKey) return;
|
if (saving || !sku || !effectiveKey) return;
|
||||||
const api = this.context?.article || articleApi;
|
|
||||||
|
|
||||||
if (effectiveKey.startsWith('attr-')) {
|
if (effectiveKey.startsWith('attr-')) {
|
||||||
const attrId = Number(effectiveKey.replace('attr-', ''));
|
const attrId = Number(effectiveKey.replace('attr-', ''));
|
||||||
@@ -262,7 +257,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await api.setAttributeValues(sku, nextValues);
|
const res = await articleApi.setAttributeValues(sku, nextValues);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
||||||
@@ -304,7 +299,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const res = await api.setProperties(sku, nextProps);
|
const res = await articleApi.setProperties(sku, nextProps);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
||||||
@@ -331,8 +326,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const api = this.context?.article || articleApi;
|
const res = await articleApi.setAttributeValues(sku, nextValues);
|
||||||
const res = await api.setAttributeValues(sku, nextValues);
|
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
assignedAttributes: Array.isArray(res?.attributevalues) ? res.attributevalues : [],
|
||||||
@@ -357,8 +351,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const api = this.context?.article || articleApi;
|
const res = await articleApi.setProperties(sku, nextProperties);
|
||||||
const res = await api.setProperties(sku, nextProperties);
|
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
assignedProperties: Array.isArray(res?.properties) ? res.properties : [],
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Component } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Table from '@mui/material/Table';
|
import Table from '@mui/material/Table';
|
||||||
import TableBody from '@mui/material/TableBody';
|
import TableBody from '@mui/material/TableBody';
|
||||||
import TableCell from '@mui/material/TableCell';
|
import TableCell from '@mui/material/TableCell';
|
||||||
import TableHead from '@mui/material/TableHead';
|
import TableHead from '@mui/material/TableHead';
|
||||||
import TableRow from '@mui/material/TableRow';
|
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';
|
import ResultsPagination from '../ResultsPagination';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -13,46 +15,68 @@ function formatDate(iso) {
|
|||||||
return new Date(iso).toLocaleString('de-DE');
|
return new Date(iso).toLocaleString('de-DE');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ArticleHistoryTab extends Component {
|
export default class ArticleHistoryTab extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = { all: [], skip: 0 };
|
||||||
history: null,
|
|
||||||
total: null,
|
|
||||||
skip: 0,
|
|
||||||
};
|
|
||||||
this.unwatchHistory = null;
|
|
||||||
this.setSkipAndOffset = null;
|
|
||||||
this.handlePageChange = this.handlePageChange.bind(this);
|
this.handlePageChange = this.handlePageChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { unwatch, setSkipAndOffset } = this.context.article.historyWatch(
|
this.watch();
|
||||||
this.props.sku,
|
void articleHistoryAutomat.load(String(this.props.sku)).catch((err) => {
|
||||||
(history, total) => this.setState({ history, total }),
|
if (this.unsub) notifyError(err);
|
||||||
);
|
});
|
||||||
this.unwatchHistory = unwatch;
|
}
|
||||||
this.setSkipAndOffset = setSkipAndOffset;
|
|
||||||
setSkipAndOffset(0, PAGE_SIZE);
|
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() {
|
componentWillUnmount() {
|
||||||
this.unwatchHistory?.();
|
this.unsub?.();
|
||||||
this.unwatchHistory = null;
|
this.unsub = null;
|
||||||
this.setSkipAndOffset = null;
|
this.unwatchSignal?.();
|
||||||
|
this.unwatchSignal = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes to the keyed history cache and to the article SSE signal.
|
||||||
|
* The backend re-emits `article-<sku>` whenever the article changes, and
|
||||||
|
* `sseAutomat` reloads the history key for us.
|
||||||
|
*/
|
||||||
|
watch() {
|
||||||
|
this.unsub?.();
|
||||||
|
const handle = articleHistoryAutomat.key(String(this.props.sku));
|
||||||
|
this.unsub = handle.subscribe(this, (item) => {
|
||||||
|
const all = Array.isArray(item.data) ? item.data : [];
|
||||||
|
this.setState({ all });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unwatchSignal?.();
|
||||||
|
this.unwatchSignal = sseAutomat.actions.watchArticle(String(this.props.sku), () => {
|
||||||
|
void articleHistoryAutomat.reload(String(this.props.sku)).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Client-side pagination over the full history list. */
|
||||||
|
pageOf(all, skip) {
|
||||||
|
return all.slice(skip, skip + PAGE_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePageChange(_event, page) {
|
handlePageChange(_event, page) {
|
||||||
const skip = (page - 1) * PAGE_SIZE;
|
this.setState({ skip: (page - 1) * PAGE_SIZE });
|
||||||
this.setSkipAndOffset?.(skip, PAGE_SIZE);
|
|
||||||
this.setState({ skip });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { history, total, skip } = this.state;
|
const { all, skip } = this.state;
|
||||||
if (history == null || total == null) return null;
|
const total = all.length;
|
||||||
|
const history = this.pageOf(all, skip);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
const currentPage = Math.floor(skip / PAGE_SIZE) + 1;
|
const currentPage = Math.floor(skip / PAGE_SIZE) + 1;
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { Component } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import { article as articleApi } from '../../../api';
|
||||||
import { artikelAutomat } from '../../../automats';
|
import { artikelAutomat } from '../../../automats';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
|
|
||||||
export default class ArticleSettingsTab extends Component {
|
export default class ArticleSettingsTab extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { busy: false };
|
this.state = { busy: false };
|
||||||
@@ -24,7 +22,7 @@ export default class ArticleSettingsTab extends Component {
|
|||||||
|
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
await this.context.article.delete(articleData.sku);
|
await articleApi.delete(articleData.sku);
|
||||||
artikelAutomat.actions.clearArticle(articleData.sku);
|
artikelAutomat.actions.clearArticle(articleData.sku);
|
||||||
onArticleDeleted?.(articleData);
|
onArticleDeleted?.(articleData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -20,15 +20,16 @@ import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
|||||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
import { article as articleApi } from '../../../api';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
import { formatPrice } from '../../../utils/price';
|
import { formatPrice } from '../../../utils/price';
|
||||||
import supplierAutomat from '../../../automats/supplierAutomat';
|
import supplierAutomat from '../../../automats/supplierAutomat';
|
||||||
|
import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat';
|
||||||
|
import sseAutomat from '../../../automats/sseAutomat';
|
||||||
|
import companyAutomat from '../../../automats/companyAutomat';
|
||||||
import ArticleSupplierDialog from '../ArticleSupplierDialog';
|
import ArticleSupplierDialog from '../ArticleSupplierDialog';
|
||||||
|
|
||||||
export default class ArticleSupplierTab extends PureComponent {
|
export default class ArticleSupplierTab extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -40,10 +41,12 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
editItem: null,
|
editItem: null,
|
||||||
busyId: null,
|
busyId: null,
|
||||||
};
|
};
|
||||||
this.unwatchSuppliers = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
company: state.current ?? null,
|
||||||
|
}));
|
||||||
this.initData();
|
this.initData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,46 +57,53 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unwatchSuppliers?.();
|
this.unsubSuppliers?.();
|
||||||
this.unwatchSuppliers = null;
|
this.unsubSuppliers = null;
|
||||||
|
this.unwatchSignal?.();
|
||||||
|
this.unwatchSignal = null;
|
||||||
|
this.unsubCompany?.();
|
||||||
|
this.unsubCompany = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initData() {
|
async initData() {
|
||||||
const sku = this.props.article?.sku;
|
const sku = this.props.article?.sku;
|
||||||
if (!sku) return;
|
if (!sku) return;
|
||||||
|
|
||||||
this.unwatchSuppliers?.();
|
// 1. Subscribe to the keyed supplier cache and the article SSE signal
|
||||||
this.setState({ loading: true });
|
this.unsubSuppliers?.();
|
||||||
|
const handle = articleSupplierAutomat.key(String(sku));
|
||||||
// 1. Subscribe to SSE-backed supplier updates
|
this.unsubSuppliers = handle.subscribe(this, (item) => {
|
||||||
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,
|
|
||||||
]);
|
|
||||||
this.setState({
|
this.setState({
|
||||||
companySuppliers: companySuppliers ?? [],
|
assignedSuppliers: Array.isArray(item.data) ? item.data : [],
|
||||||
company: company ?? null,
|
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) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
this.setState({ loading: false });
|
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 = () => {
|
handleOpenAdd = () => {
|
||||||
this.setState({ dialogOpen: true, editItem: null });
|
this.setState({ dialogOpen: true, editItem: null });
|
||||||
};
|
};
|
||||||
@@ -112,7 +122,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
await this.context.article.updateSupplier(sku, payload.id, {
|
await articleApi.updateSupplier(sku, payload.id, {
|
||||||
sku: payload.sku,
|
sku: payload.sku,
|
||||||
pu: payload.pu,
|
pu: payload.pu,
|
||||||
stock: payload.stock,
|
stock: payload.stock,
|
||||||
@@ -121,7 +131,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
});
|
});
|
||||||
enqueueSnackbar('Lieferantendaten aktualisiert', { variant: 'success' });
|
enqueueSnackbar('Lieferantendaten aktualisiert', { variant: 'success' });
|
||||||
} else {
|
} else {
|
||||||
await this.context.article.addSupplier(sku, {
|
await articleApi.addSupplier(sku, {
|
||||||
entityId: payload.entityId,
|
entityId: payload.entityId,
|
||||||
sku: payload.sku,
|
sku: payload.sku,
|
||||||
pu: payload.pu,
|
pu: payload.pu,
|
||||||
@@ -132,7 +142,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
enqueueSnackbar('Lieferant erfolgreich zugewiesen', { variant: 'success' });
|
enqueueSnackbar('Lieferant erfolgreich zugewiesen', { variant: 'success' });
|
||||||
}
|
}
|
||||||
this.handleCloseDialog();
|
this.handleCloseDialog();
|
||||||
await this.refreshSuppliers?.();
|
await this.refreshSuppliers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -150,9 +160,9 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyId: item.id });
|
this.setState({ busyId: item.id });
|
||||||
try {
|
try {
|
||||||
await this.context.article.deleteSupplier(sku, item.id);
|
await articleApi.deleteSupplier(sku, item.id);
|
||||||
enqueueSnackbar('Lieferant entfernt', { variant: 'success' });
|
enqueueSnackbar('Lieferant entfernt', { variant: 'success' });
|
||||||
await this.refreshSuppliers?.();
|
await this.refreshSuppliers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -176,11 +186,11 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
this.setState({ assignedSuppliers: nextList, busyId: moved.id });
|
this.setState({ assignedSuppliers: nextList, busyId: moved.id });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.context.article.orderSuppliers(sku, { ids });
|
await articleApi.orderSuppliers(sku, ids);
|
||||||
await this.refreshSuppliers?.();
|
await this.refreshSuppliers();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
await this.refreshSuppliers?.();
|
await this.refreshSuppliers();
|
||||||
} finally {
|
} finally {
|
||||||
this.setState({ busyId: null });
|
this.setState({ busyId: null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import LinkIcon from '@mui/icons-material/Link';
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import articleIndexAutomat from '../../automats/articleIndexAutomat';
|
||||||
import ArticlePhoto from '../article/ArticlePhoto';
|
import ArticlePhoto from '../article/ArticlePhoto';
|
||||||
import { BrandLabel } from '../brand';
|
import { BrandLabel } from '../brand';
|
||||||
|
|
||||||
export default class ArticleSearchDialog extends PureComponent {
|
export default class ArticleSearchDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -68,13 +66,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
async loadArticles() {
|
async loadArticles() {
|
||||||
this.setState({ loading: true, error: null });
|
this.setState({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
let list = [];
|
const list = await articleIndexAutomat.actions.ensureLoaded();
|
||||||
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 || [];
|
|
||||||
}
|
|
||||||
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
|
this.setState({ articles: Array.isArray(list) ? list : [], loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fehler beim Laden des Artikel-Index:', err);
|
console.error('Fehler beim Laden des Artikel-Index:', err);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import { formatDate } from './manifestConfig';
|
import { formatDate } from './manifestConfig';
|
||||||
@@ -38,8 +37,6 @@ import {
|
|||||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||||
|
|
||||||
export default class BookDeliveryDialog extends PureComponent {
|
export default class BookDeliveryDialog extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@@ -160,12 +157,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ loading: true, error: null });
|
this.setState({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const ledgerApi = this.context?.ledger;
|
const summary = await fetchDeliveryLedgerSummary(delivery.id);
|
||||||
const deliveryApi = this.context?.delivery;
|
|
||||||
const summary = await fetchDeliveryLedgerSummary(delivery.id, {
|
|
||||||
ledgerApi,
|
|
||||||
deliveryApi,
|
|
||||||
});
|
|
||||||
this.initAmountsFromSummary(summary, prefillLineId);
|
this.initAmountsFromSummary(summary, prefillLineId);
|
||||||
this.setState({ summary, loading: false });
|
this.setState({ summary, loading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -255,14 +247,10 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ isBooking: true, error: null, warehouseError: null });
|
this.setState({ isBooking: true, error: null, warehouseError: null });
|
||||||
try {
|
try {
|
||||||
const ledgerApi = this.context?.ledger;
|
|
||||||
const deliveryApi = this.context?.delivery;
|
|
||||||
|
|
||||||
await bookDeliveryLines({
|
await bookDeliveryLines({
|
||||||
delivery: summary.delivery || delivery,
|
delivery: summary.delivery || delivery,
|
||||||
linesToBook,
|
linesToBook,
|
||||||
warehouseId: selectedWarehouseId,
|
warehouseId: selectedWarehouseId,
|
||||||
ledgerApi,
|
|
||||||
transactionText,
|
transactionText,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,10 +261,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Refresh summary
|
// Refresh summary
|
||||||
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id, {
|
const updatedSummary = await fetchDeliveryLedgerSummary(delivery.id);
|
||||||
ledgerApi,
|
|
||||||
deliveryApi,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onBookSuccess) {
|
if (onBookSuccess) {
|
||||||
onBookSuccess(updatedSummary);
|
onBookSuccess(updatedSummary);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { RecordList } from 'dashgui/lists';
|
import { RecordList } from 'dashgui/lists';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import deliveryAutomat from '../../automats/deliveryAutomat';
|
||||||
import { delivery } from '../../api';
|
import { delivery } from '../../api';
|
||||||
import { supplierAutomat } from '../../automats';
|
import { supplierAutomat } from '../../automats';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
@@ -39,8 +39,6 @@ function formatFileSize(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class DeliveryList extends PureComponent {
|
export class DeliveryList extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -77,14 +75,7 @@ export class DeliveryList extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchDeliveries() {
|
async fetchDeliveries() {
|
||||||
if (this.context?.deliveries) {
|
return deliveryAutomat.actions.ensureLoaded();
|
||||||
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 ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
@@ -103,12 +94,10 @@ export class DeliveryList extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleRetry() {
|
async handleRetry() {
|
||||||
if (this.context?.updateDelivery) {
|
try {
|
||||||
try {
|
await deliveryAutomat.actions.reload();
|
||||||
await this.context.updateDelivery();
|
} catch {
|
||||||
} catch {
|
// Will be caught in load()
|
||||||
// Will be caught in load()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
@@ -140,15 +129,13 @@ export class DeliveryList extends PureComponent {
|
|||||||
|
|
||||||
async handleBookSuccess() {
|
async handleBookSuccess() {
|
||||||
this.handleCloseBookDialog();
|
this.handleCloseBookDialog();
|
||||||
if (this.context?.updateDelivery) {
|
try {
|
||||||
try {
|
const updated = await deliveryAutomat.actions.reload();
|
||||||
const updated = await this.context.updateDelivery();
|
if (this._cancelled) return;
|
||||||
if (this._cancelled) return;
|
this.setState({ docs: sortDeliveries(updated) });
|
||||||
this.setState({ docs: sortDeliveries(updated) });
|
return;
|
||||||
return;
|
} catch {
|
||||||
} catch {
|
// Fall back to load()
|
||||||
// Fall back to load()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
void this.load();
|
void this.load();
|
||||||
}
|
}
|
||||||
@@ -159,10 +146,8 @@ export class DeliveryList extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyId: doc.id });
|
this.setState({ busyId: doc.id });
|
||||||
try {
|
try {
|
||||||
await (this.context?.delivery?.delete ? this.context.delivery.delete(doc.id) : delivery.delete(doc.id));
|
await delivery.delete(doc.id);
|
||||||
if (this.context?.updateDelivery) {
|
await deliveryAutomat.actions.reload();
|
||||||
await this.context.updateDelivery();
|
|
||||||
}
|
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
await this.load();
|
await this.load();
|
||||||
enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' });
|
enqueueSnackbar('Lieferung gelöscht.', { variant: 'success' });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { PanelTileGrid } from 'dashgui/panels';
|
import { PanelTileGrid } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { delivery as deliveryApi } from '../../api';
|
||||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -20,11 +20,10 @@ import {
|
|||||||
lineFromApi,
|
lineFromApi,
|
||||||
loadParties,
|
loadParties,
|
||||||
manifestConfig,
|
manifestConfig,
|
||||||
|
apiFor,
|
||||||
} from './manifestConfig';
|
} from './manifestConfig';
|
||||||
|
|
||||||
class ManifestForm extends PureComponent {
|
class ManifestForm extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cfg = manifestConfig(props.kind);
|
this.cfg = manifestConfig(props.kind);
|
||||||
@@ -88,7 +87,7 @@ class ManifestForm extends PureComponent {
|
|||||||
async loadPartiesOnly() {
|
async loadPartiesOnly() {
|
||||||
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
||||||
try {
|
try {
|
||||||
const parties = await loadParties(this.props.kind, this.context);
|
const parties = await loadParties(this.props.kind);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -120,8 +119,8 @@ class ManifestForm extends PureComponent {
|
|||||||
this.setState({ status: 'loading', error: null, partiesLoading: true });
|
this.setState({ status: 'loading', error: null, partiesLoading: true });
|
||||||
try {
|
try {
|
||||||
const [docRes, parties] = await Promise.all([
|
const [docRes, parties] = await Promise.all([
|
||||||
this.context[cfg.api].get(docId),
|
apiFor(cfg).get(docId),
|
||||||
loadParties(kind, this.context),
|
loadParties(kind),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
@@ -243,10 +242,10 @@ class ManifestForm extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gen = this.context?.delivery?.parsePdfSse;
|
const gen = deliveryApi.parsePdfSse;
|
||||||
if (!gen) throw new Error('parsePdfSse nicht verfügbar.');
|
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;
|
if (this._cancelled || abortController.signal.aborted) return;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'status':
|
case 'status':
|
||||||
@@ -372,24 +371,25 @@ class ManifestForm extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
|
const api = apiFor(cfg);
|
||||||
try {
|
try {
|
||||||
if (mode === 'create') {
|
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.postPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
await this.context[cfg.updateCache]();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||||
return;
|
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.patchPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
await this.context[cfg.updateCache]();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
const doc = data[cfg.docField];
|
const doc = data[cfg.docField];
|
||||||
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { PureComponent } from 'react';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { RecordList } from 'dashgui/lists';
|
import { RecordList } from 'dashgui/lists';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import withNavigate from '../withNavigate';
|
import withNavigate from '../withNavigate';
|
||||||
@@ -14,11 +13,10 @@ import {
|
|||||||
numberDisplay,
|
numberDisplay,
|
||||||
partyDisplayName,
|
partyDisplayName,
|
||||||
sortDocs,
|
sortDocs,
|
||||||
|
apiFor,
|
||||||
} from './manifestConfig';
|
} from './manifestConfig';
|
||||||
|
|
||||||
class ManifestList extends PureComponent {
|
class ManifestList extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cfg = manifestConfig(props.kind);
|
this.cfg = manifestConfig(props.kind);
|
||||||
@@ -52,8 +50,8 @@ class ManifestList extends PureComponent {
|
|||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const [docs, parties] = await Promise.all([
|
const [docs, parties] = await Promise.all([
|
||||||
cfg.listPromise(this.context),
|
cfg.listAutomat.actions.ensureLoaded(),
|
||||||
loadParties(this.props.kind, this.context),
|
loadParties(this.props.kind),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
const sortedDocs = sortDocs(docs);
|
const sortedDocs = sortDocs(docs);
|
||||||
@@ -96,8 +94,9 @@ class ManifestList extends PureComponent {
|
|||||||
const cfg = this.cfg;
|
const cfg = this.cfg;
|
||||||
this.setState({ busyId: doc.id });
|
this.setState({ busyId: doc.id });
|
||||||
try {
|
try {
|
||||||
await this.context[cfg.api].delete(doc.id);
|
const api = apiFor(cfg.kind);
|
||||||
const list = await this.context[cfg.updateCache]();
|
await api.delete(doc.id);
|
||||||
|
const list = await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
docs: sortDocs(list),
|
docs: sortDocs(list),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
|||||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import { delivery as deliveryApi } from '../../api';
|
import { delivery as deliveryApi } from '../../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,8 +23,6 @@ import { delivery as deliveryApi } from '../../api';
|
|||||||
* attachment is selected; unmounting closes it.
|
* attachment is selected; unmounting closes it.
|
||||||
*/
|
*/
|
||||||
export class PdfViewerPanel extends PureComponent {
|
export class PdfViewerPanel extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -77,10 +74,7 @@ export class PdfViewerPanel extends PureComponent {
|
|||||||
if (effectiveDocId != null && fileId != null) {
|
if (effectiveDocId != null && fileId != null) {
|
||||||
this.setState({ loading: true, error: null });
|
this.setState({ loading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const getFile =
|
const blob = await deliveryApi.getFile(effectiveDocId, fileId);
|
||||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
|
||||||
deliveryApi.getFile;
|
|
||||||
const blob = await getFile(effectiveDocId, fileId);
|
|
||||||
this._objectUrl = URL.createObjectURL(blob);
|
this._objectUrl = URL.createObjectURL(blob);
|
||||||
this.setState({ url: this._objectUrl, loading: false, error: null });
|
this.setState({ url: this._objectUrl, loading: false, error: null });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
|||||||
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
|
||||||
import { delivery as deliveryApi } from '../../api';
|
import { delivery as deliveryApi } from '../../api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,8 +25,6 @@ import { delivery as deliveryApi } from '../../api';
|
|||||||
* selected; unmounting closes it.
|
* selected; unmounting closes it.
|
||||||
*/
|
*/
|
||||||
export class TextEditorPanel extends PureComponent {
|
export class TextEditorPanel extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -89,10 +86,7 @@ export class TextEditorPanel extends PureComponent {
|
|||||||
if (effectiveDocId != null && fileId != null) {
|
if (effectiveDocId != null && fileId != null) {
|
||||||
this.setState({ loading: true });
|
this.setState({ loading: true });
|
||||||
try {
|
try {
|
||||||
const getFile =
|
const blob = await deliveryApi.getFile(effectiveDocId, fileId);
|
||||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
|
||||||
deliveryApi.getFile;
|
|
||||||
const blob = await getFile(effectiveDocId, fileId);
|
|
||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
this.setState({
|
this.setState({
|
||||||
content: text,
|
content: text,
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import {
|
||||||
|
delivery as deliveryApi,
|
||||||
|
quote as quoteApi,
|
||||||
|
order as orderApi,
|
||||||
|
} from '../../api';
|
||||||
import supplierAutomat from '../../automats/supplierAutomat';
|
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). */
|
/** Groups that are not sales customers (filter client-side). */
|
||||||
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
||||||
@@ -481,12 +491,27 @@ export const DELIVERY_LINE_COLUMNS = [
|
|||||||
|
|
||||||
/** @typedef {'delivery'|'quote'|'order'} ManifestKind */
|
/** @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.
|
* Load party options for Autocomplete / list name lookup.
|
||||||
* @param {ManifestKind} kind
|
* @param {ManifestKind} kind
|
||||||
* @param {object} ctx StorageManager instance
|
|
||||||
*/
|
*/
|
||||||
export async function loadParties(kind, ctx) {
|
export async function loadParties(kind) {
|
||||||
if (kind === 'delivery') {
|
if (kind === 'delivery') {
|
||||||
if (supplierAutomat.state.status === 'ready') {
|
if (supplierAutomat.state.status === 'ready') {
|
||||||
return sortSuppliers(supplierAutomat.state.suppliers);
|
return sortSuppliers(supplierAutomat.state.suppliers);
|
||||||
@@ -495,8 +520,8 @@ export async function loadParties(kind, ctx) {
|
|||||||
return sortSuppliers(list);
|
return sortSuppliers(list);
|
||||||
}
|
}
|
||||||
const [entities, groups] = await Promise.all([
|
const [entities, groups] = await Promise.all([
|
||||||
ctx.entities ?? ctx.entityPromise,
|
entityAutomat.actions.ensureLoaded(),
|
||||||
ctx.customergroups ?? ctx.customergroupPromise,
|
customergroupAutomat.actions.ensureLoaded(),
|
||||||
]);
|
]);
|
||||||
return filterCustomerEntities(entities, groups);
|
return filterCustomerEntities(entities, groups);
|
||||||
}
|
}
|
||||||
@@ -538,8 +563,7 @@ export function manifestConfig(kind) {
|
|||||||
headerColor: '#fed7aa',
|
headerColor: '#fed7aa',
|
||||||
linesColor: '#bbf7d0',
|
linesColor: '#bbf7d0',
|
||||||
requirePartyBeforeLines: false,
|
requirePartyBeforeLines: false,
|
||||||
updateCache: 'updateDelivery',
|
listAutomat: deliveryAutomat,
|
||||||
listPromise: (ctx) => ctx.deliveries ?? ctx.deliveryPromise,
|
|
||||||
partiesByIdFromList: (parties) =>
|
partiesByIdFromList: (parties) =>
|
||||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||||
// Delivery uses a server-allocated read-only number plus an optional
|
// Delivery uses a server-allocated read-only number plus an optional
|
||||||
@@ -575,8 +599,7 @@ export function manifestConfig(kind) {
|
|||||||
headerColor: '#fde68a',
|
headerColor: '#fde68a',
|
||||||
linesColor: '#bbf7d0',
|
linesColor: '#bbf7d0',
|
||||||
requirePartyBeforeLines: false,
|
requirePartyBeforeLines: false,
|
||||||
updateCache: 'updateQuote',
|
listAutomat: quoteAutomat,
|
||||||
listPromise: (ctx) => ctx.quotes ?? ctx.quotePromise,
|
|
||||||
partiesByIdFromList: (parties) =>
|
partiesByIdFromList: (parties) =>
|
||||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||||
columns: LINE_COLUMNS,
|
columns: LINE_COLUMNS,
|
||||||
@@ -609,8 +632,7 @@ export function manifestConfig(kind) {
|
|||||||
headerColor: '#c4b5fd',
|
headerColor: '#c4b5fd',
|
||||||
linesColor: '#bbf7d0',
|
linesColor: '#bbf7d0',
|
||||||
requirePartyBeforeLines: false,
|
requirePartyBeforeLines: false,
|
||||||
updateCache: 'updateOrder',
|
listAutomat: orderAutomat,
|
||||||
listPromise: (ctx) => ctx.orders ?? ctx.orderPromise,
|
|
||||||
partiesByIdFromList: (parties) =>
|
partiesByIdFromList: (parties) =>
|
||||||
Object.fromEntries(parties.map((row) => [row.id, row])),
|
Object.fromEntries(parties.map((row) => [row.id, row])),
|
||||||
columns: LINE_COLUMNS,
|
columns: LINE_COLUMNS,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import Typography from '@mui/material/Typography';
|
|||||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
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 { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -31,8 +32,6 @@ function parseNext(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class NumberSettingsPanel extends Component {
|
export default class NumberSettingsPanel extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -54,7 +53,7 @@ export default class NumberSettingsPanel extends Component {
|
|||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const numbers = requireArray(
|
const numbers = requireArray(
|
||||||
await this.context.updateNumber(),
|
await numberAutomat.actions.reload(),
|
||||||
'numbers',
|
'numbers',
|
||||||
);
|
);
|
||||||
const drafts = Object.fromEntries(
|
const drafts = Object.fromEntries(
|
||||||
@@ -141,7 +140,7 @@ export default class NumberSettingsPanel extends Component {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const data = await this.context.number.update(id, body);
|
const data = await numberApi.update(id, body);
|
||||||
requireObject(data, 'PATCH /api/number');
|
requireObject(data, 'PATCH /api/number');
|
||||||
requireObject(data.number, 'number');
|
requireObject(data.number, 'number');
|
||||||
const updated = requireDefined(data.number, 'number');
|
const updated = requireDefined(data.number, 'number');
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
|||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { printer as printerApi } from '../../api';
|
||||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -186,8 +186,6 @@ class PrinterFormDialog extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class PrinterSettingsPanel extends Component {
|
export default class PrinterSettingsPanel extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -216,7 +214,7 @@ export default class PrinterSettingsPanel extends Component {
|
|||||||
async load() {
|
async load() {
|
||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.printer.list();
|
const data = await printerApi.list();
|
||||||
requireObject(data, 'GET /api/printer');
|
requireObject(data, 'GET /api/printer');
|
||||||
const printers = requireArray(data.printers, 'printers');
|
const printers = requireArray(data.printers, 'printers');
|
||||||
this.setState({ status: 'ready', printers });
|
this.setState({ status: 'ready', printers });
|
||||||
@@ -243,7 +241,7 @@ export default class PrinterSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleAddSubmit(body) {
|
async handleAddSubmit(body) {
|
||||||
const data = await this.context.printer.create(body);
|
const data = await printerApi.create(body);
|
||||||
requireObject(data, 'POST /api/printer');
|
requireObject(data, 'POST /api/printer');
|
||||||
const printer = data.printer;
|
const printer = data.printer;
|
||||||
this.setState((prev) => ({
|
this.setState((prev) => ({
|
||||||
@@ -255,7 +253,7 @@ export default class PrinterSettingsPanel extends Component {
|
|||||||
|
|
||||||
async handleEditSubmit(body) {
|
async handleEditSubmit(body) {
|
||||||
const { editPrinter } = this.state;
|
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');
|
requireObject(data, 'PATCH /api/printer/:id');
|
||||||
const updated = data.printer;
|
const updated = data.printer;
|
||||||
this.setState((prev) => ({
|
this.setState((prev) => ({
|
||||||
@@ -268,7 +266,7 @@ export default class PrinterSettingsPanel extends Component {
|
|||||||
async handleDelete(id) {
|
async handleDelete(id) {
|
||||||
this.setState({ deleteId: id, deleteBusy: true });
|
this.setState({ deleteId: id, deleteBusy: true });
|
||||||
try {
|
try {
|
||||||
await this.context.printer.delete(id);
|
await printerApi.delete(id);
|
||||||
this.setState((prev) => ({
|
this.setState((prev) => ({
|
||||||
printers: prev.printers.filter((p) => p.id !== id),
|
printers: prev.printers.filter((p) => p.id !== id),
|
||||||
deleteId: null,
|
deleteId: null,
|
||||||
@@ -314,7 +312,7 @@ export default class PrinterSettingsPanel extends Component {
|
|||||||
this.setState({ printers: reordered });
|
this.setState({ printers: reordered });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.context.printer.order({ printerIds });
|
await printerApi.order({ printerIds });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._unmounted) return;
|
if (this._unmounted) return;
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { setting as settingApi } from '../../api';
|
||||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -42,8 +42,6 @@ function keyLabel(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class SettingsKeysPanel extends Component {
|
export default class SettingsKeysPanel extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -64,7 +62,7 @@ export default class SettingsKeysPanel extends Component {
|
|||||||
async load() {
|
async load() {
|
||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.setting.list();
|
const data = await settingApi.list();
|
||||||
requireObject(data, 'GET /api/company/setting');
|
requireObject(data, 'GET /api/company/setting');
|
||||||
const settings = requireArray(data.settings, 'settings');
|
const settings = requireArray(data.settings, 'settings');
|
||||||
const drafts = Object.fromEntries(
|
const drafts = Object.fromEntries(
|
||||||
@@ -119,7 +117,7 @@ export default class SettingsKeysPanel extends Component {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
try {
|
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, 'PUT /api/company/setting/:key');
|
||||||
requireObject(data.setting, 'setting');
|
requireObject(data.setting, 'setting');
|
||||||
const updated = requireDefined(data.setting, 'setting');
|
const updated = requireDefined(data.setting, 'setting');
|
||||||
|
|||||||
33
src/main.jsx
33
src/main.jsx
@@ -5,10 +5,11 @@ import CssBaseline from '@mui/material/CssBaseline';
|
|||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import { SnackbarProvider } from 'notistack';
|
import { SnackbarProvider } from 'notistack';
|
||||||
import AuthBootstrap from './components/AuthBootstrap';
|
import AuthBootstrap from './components/AuthBootstrap';
|
||||||
import { StorageManagerProvider } from './api/storageManagerContext';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { getActiveTheme } from './theme';
|
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';
|
import './index.css';
|
||||||
|
|
||||||
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
|
// Dev-only Automat inspector. The `import.meta.env.DEV` branch is statically
|
||||||
@@ -39,22 +40,20 @@ createRoot(document.getElementById('root')).render(
|
|||||||
autoHideDuration={2000}
|
autoHideDuration={2000}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
>
|
>
|
||||||
<StorageManagerProvider>
|
<AuthBootstrap>
|
||||||
<AuthBootstrap>
|
{/* Flex row so the dev inspector docks beside the app instead of
|
||||||
{/* Flex row so the dev inspector docks beside the app instead of
|
overlaying it; the app content shrinks while the panel is open. */}
|
||||||
overlaying it; the app content shrinks while the panel is open. */}
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
|
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
<App />
|
||||||
<App />
|
|
||||||
</Box>
|
|
||||||
{AutomatInspector ? (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AutomatInspector />
|
|
||||||
</Suspense>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
</AuthBootstrap>
|
{AutomatInspector ? (
|
||||||
</StorageManagerProvider>
|
<Suspense fallback={null}>
|
||||||
|
<AutomatInspector />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</AuthBootstrap>
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</ThemeProvider>,
|
</ThemeProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import { StorageManagerContext, getStorageManager } from '../api/storageManagerContext';
|
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
||||||
import BackendError from '../components/BackendError';
|
import BackendError from '../components/BackendError';
|
||||||
import { formatPrice } from '../utils/price';
|
import { formatPrice } from '../utils/price';
|
||||||
import {
|
import {
|
||||||
@@ -83,12 +83,6 @@ function initialSearchState(searchParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ArtikelSearchPage extends Component {
|
class ArtikelSearchPage extends Component {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
get storageManager() {
|
|
||||||
return this.context || getStorageManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
state = initialSearchState(this.props.searchParams);
|
state = initialSearchState(this.props.searchParams);
|
||||||
listSlotRef = { current: null };
|
listSlotRef = { current: null };
|
||||||
rowMeasureRef = { current: null };
|
rowMeasureRef = { current: null };
|
||||||
@@ -176,20 +170,18 @@ class ArtikelSearchPage extends Component {
|
|||||||
window.addEventListener('resize', this.handleWindowResize);
|
window.addEventListener('resize', this.handleWindowResize);
|
||||||
setLastArticleSku(null);
|
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).
|
// Kick off initial search (empty input = show all products).
|
||||||
const { input } = this.state;
|
void articleSearchAutomat.actions.search(this.state.input).catch(() => {});
|
||||||
this.storageManager?.doSearch({
|
|
||||||
target: input,
|
|
||||||
filter: this.props.searchParams.get('filter') || undefined,
|
|
||||||
offset: 0,
|
|
||||||
maxCount: this.state.pageSize ?? 20,
|
|
||||||
onUpdate: (result) => this.setState(result),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
document.removeEventListener('mouseup', preserveSearchFocus, true);
|
||||||
window.removeEventListener('resize', this.handleWindowResize);
|
window.removeEventListener('resize', this.handleWindowResize);
|
||||||
|
this.unsubSearch?.();
|
||||||
|
this.unsubSearch = null;
|
||||||
this.persistQuery();
|
this.persistQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,34 +218,18 @@ class ArtikelSearchPage extends Component {
|
|||||||
handleInputChange = (event) => {
|
handleInputChange = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
this.setState({ input: value });
|
this.setState({ input: value });
|
||||||
|
void articleSearchAutomat.actions.search(value).catch(() => {});
|
||||||
this.storageManager?.doSearch({
|
|
||||||
target: value,
|
|
||||||
filter: this.props.searchParams.get('filter') || undefined,
|
|
||||||
offset: 0,
|
|
||||||
maxCount: this.state.pageSize ?? 20,
|
|
||||||
onUpdate: (result) => this.setState(result),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchResults = () => {
|
fetchResults = () => {
|
||||||
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
const { target, filter, offset } = queryFromParams(this.props.searchParams);
|
||||||
const trimmed = target.trim();
|
|
||||||
const { pageSize } = this.state;
|
const { pageSize } = this.state;
|
||||||
|
|
||||||
if (pageSize == null) return;
|
if (pageSize == null) return;
|
||||||
|
|
||||||
writeArtikelQuery({ target, filter, offset });
|
writeArtikelQuery({ target, filter, offset });
|
||||||
|
|
||||||
// Delegate fetch + debounce to the global StorageManager.
|
void articleSearchAutomat.actions.search(target.trim()).catch(() => {});
|
||||||
// The callback pushes results into local component state.
|
|
||||||
this.storageManager?.doSearch({
|
|
||||||
target: trimmed,
|
|
||||||
filter: filter || undefined,
|
|
||||||
offset,
|
|
||||||
maxCount: pageSize,
|
|
||||||
onUpdate: (result) => this.setState(result),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
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 BackendError from '../../components/BackendError';
|
||||||
import { ArticleView } from '../../components/article';
|
import { ArticleView } from '../../components/article';
|
||||||
import {
|
import {
|
||||||
@@ -22,13 +24,11 @@ function ArticleLoading() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ArtikelSkuPage extends Component {
|
class ArtikelSkuPage extends Component {
|
||||||
static contextType = StorageManagerContext;
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
this.state = {
|
this.state = {
|
||||||
article: getArticle(props.sku),
|
article: getArticle(props.sku),
|
||||||
ready: context.getCategoryState() != null,
|
ready: getCategoryState() != null,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,9 +72,9 @@ class ArtikelSkuPage extends Component {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
artikelAutomat.actions.loadArticle(sku),
|
artikelAutomat.actions.loadArticle(sku),
|
||||||
this.context.categoryTreePromise,
|
whenCategoryTreeReady(),
|
||||||
this.context.companyPromise,
|
companyAutomat.actions.load(),
|
||||||
this.context.authMePromise,
|
bootstrapAuthMe(),
|
||||||
]);
|
]);
|
||||||
if (this._mounted) this.setState({ ready: true });
|
if (this._mounted) this.setState({ ready: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { PureComponent } from 'react';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../components/BackendError';
|
import BackendError from '../components/BackendError';
|
||||||
import { RecordList } from 'dashgui/lists';
|
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 { router } from '../appRouter';
|
||||||
import { backendErrorFrom } from '../utils/backendError';
|
import { backendErrorFrom } from '../utils/backendError';
|
||||||
import { notifyError } from '../utils/notify';
|
import { notifyError } from '../utils/notify';
|
||||||
@@ -21,8 +23,6 @@ function formatDate(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KundeListePage extends PureComponent {
|
class KundeListePage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -50,8 +50,8 @@ class KundeListePage extends PureComponent {
|
|||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const [entities, groups] = await Promise.all([
|
const [entities, groups] = await Promise.all([
|
||||||
this.context.entities ?? this.context.entityPromise,
|
entityAutomat.actions.ensureLoaded(),
|
||||||
this.context.customergroups ?? this.context.customergroupPromise,
|
customergroupAutomat.actions.ensureLoaded(),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
const groupsById = Object.fromEntries(groups.map((row) => [row.id, row]));
|
const groupsById = Object.fromEntries(groups.map((row) => [row.id, row]));
|
||||||
@@ -89,8 +89,8 @@ class KundeListePage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyId: entity.id });
|
this.setState({ busyId: entity.id });
|
||||||
try {
|
try {
|
||||||
await this.context.entity.delete(entity.id);
|
await entityApi.delete(entity.id);
|
||||||
const list = await this.context.updateEntity();
|
const list = await entityAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
entities: sortEntities(list),
|
entities: sortEntities(list),
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import { enqueueSnackbar } from 'notistack';
|
|||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
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 { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -72,8 +74,6 @@ function emptyContactDraft() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KundeEditPage extends PureComponent {
|
class KundeEditPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -128,10 +128,10 @@ class KundeEditPage extends PureComponent {
|
|||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const [entityRes, addressRes, contactRes, groups] = await Promise.all([
|
const [entityRes, addressRes, contactRes, groups] = await Promise.all([
|
||||||
this.context.entity.get(entityId),
|
entityApi.get(entityId),
|
||||||
this.context.entity.listAddresses(entityId),
|
entityApi.listAddresses(entityId),
|
||||||
this.context.entity.listContacts(entityId),
|
entityApi.listContacts(entityId),
|
||||||
this.context.customergroups ?? this.context.customergroupPromise,
|
customergroupAutomat.actions.ensureLoaded(),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
@@ -186,14 +186,14 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ savingEntity: true });
|
this.setState({ savingEntity: true });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.entity.update(entityId, {
|
const data = await entityApi.update(entityId, {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
firstName: firstName.trim() === '' ? null : firstName.trim(),
|
firstName: firstName.trim() === '' ? null : firstName.trim(),
|
||||||
});
|
});
|
||||||
requireObject(data, 'PATCH /api/entity/:id');
|
requireObject(data, 'PATCH /api/entity/:id');
|
||||||
requireObject(data.entity, 'entity');
|
requireObject(data.entity, 'entity');
|
||||||
requireDefined(data.entity.id, 'entity.id');
|
requireDefined(data.entity.id, 'entity.id');
|
||||||
await this.context.updateEntity();
|
await entityAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
this.setState({
|
||||||
entity: data.entity,
|
entity: data.entity,
|
||||||
@@ -230,14 +230,14 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyAddressKey: address.id });
|
this.setState({ busyAddressKey: address.id });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.entity.updateAddress(
|
const data = await entityApi.updateAddress(
|
||||||
entityId,
|
entityId,
|
||||||
address.id,
|
address.id,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
requireObject(data, 'PATCH /api/entity/:id/address/:id');
|
requireObject(data, 'PATCH /api/entity/:id/address/:id');
|
||||||
requireObject(data.address, 'address');
|
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');
|
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -268,11 +268,11 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyAddressKey: 'new' });
|
this.setState({ busyAddressKey: 'new' });
|
||||||
try {
|
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, 'POST /api/entity/:id/address');
|
||||||
requireObject(data.address, 'address');
|
requireObject(data.address, 'address');
|
||||||
requireDefined(data.address.id, 'address.id');
|
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');
|
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -299,8 +299,8 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyAddressKey: address.id });
|
this.setState({ busyAddressKey: address.id });
|
||||||
try {
|
try {
|
||||||
await this.context.entity.deleteAddress(entityId, address.id);
|
await entityApi.deleteAddress(entityId, address.id);
|
||||||
const listRes = await this.context.entity.listAddresses(entityId);
|
const listRes = await entityApi.listAddresses(entityId);
|
||||||
requireObject(listRes, 'GET /api/entity/:id/address');
|
requireObject(listRes, 'GET /api/entity/:id/address');
|
||||||
const addresses = requireArray(listRes.addresses, 'addresses');
|
const addresses = requireArray(listRes.addresses, 'addresses');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -341,13 +341,13 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyContactKey: contact.id });
|
this.setState({ busyContactKey: contact.id });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.entity.updateContact(entityId, contact.id, {
|
const data = await entityApi.updateContact(entityId, contact.id, {
|
||||||
contactType,
|
contactType,
|
||||||
contact: value,
|
contact: value,
|
||||||
});
|
});
|
||||||
requireObject(data, 'PATCH /api/entity/:id/contact/:id');
|
requireObject(data, 'PATCH /api/entity/:id/contact/:id');
|
||||||
requireObject(data.contact, 'contact');
|
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');
|
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -382,14 +382,14 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyContactKey: 'new' });
|
this.setState({ busyContactKey: 'new' });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.entity.createContact(entityId, {
|
const data = await entityApi.createContact(entityId, {
|
||||||
contactType,
|
contactType,
|
||||||
contact: value,
|
contact: value,
|
||||||
});
|
});
|
||||||
requireObject(data, 'POST /api/entity/:id/contact');
|
requireObject(data, 'POST /api/entity/:id/contact');
|
||||||
requireObject(data.contact, 'contact');
|
requireObject(data.contact, 'contact');
|
||||||
requireDefined(data.contact.id, 'contact.id');
|
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');
|
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -419,8 +419,8 @@ class KundeEditPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyContactKey: contact.id });
|
this.setState({ busyContactKey: contact.id });
|
||||||
try {
|
try {
|
||||||
await this.context.entity.deleteContact(entityId, contact.id);
|
await entityApi.deleteContact(entityId, contact.id);
|
||||||
const listRes = await this.context.entity.listContacts(entityId);
|
const listRes = await entityApi.listContacts(entityId);
|
||||||
requireObject(listRes, 'GET /api/entity/:id/contact');
|
requireObject(listRes, 'GET /api/entity/:id/contact');
|
||||||
const contacts = requireArray(listRes.contacts, 'contacts');
|
const contacts = requireArray(listRes.contacts, 'contacts');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import { enqueueSnackbar } from 'notistack';
|
|||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
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 { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -43,8 +44,6 @@ function requireGroupListItem(row, path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class KundeGruppenPage extends PureComponent {
|
class KundeGruppenPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -85,9 +84,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
async load() {
|
async load() {
|
||||||
this.setState({ status: 'loading', error: null });
|
this.setState({ status: 'loading', error: null });
|
||||||
try {
|
try {
|
||||||
const list =
|
const list = await customergroupAutomat.actions.ensureLoaded();
|
||||||
this.context.customergroups ??
|
|
||||||
(await this.context.customergroupPromise);
|
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.applyGroups(list);
|
this.applyGroups(list);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,7 +98,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshFromServer() {
|
async refreshFromServer() {
|
||||||
const list = await this.context.updateCustomergroup();
|
const list = await customergroupAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.applyGroups(list);
|
this.applyGroups(list);
|
||||||
}
|
}
|
||||||
@@ -112,7 +109,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyKey: 'new' });
|
this.setState({ busyKey: 'new' });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.customergroup.create({ name });
|
const data = await customergroupApi.create({ name });
|
||||||
requireObject(data, 'POST /api/customergroup');
|
requireObject(data, 'POST /api/customergroup');
|
||||||
requireObject(data.customergroup, 'customergroup');
|
requireObject(data.customergroup, 'customergroup');
|
||||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||||
@@ -141,7 +138,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyKey: group.id });
|
this.setState({ busyKey: group.id });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.customergroup.update(group.id, {
|
const data = await customergroupApi.update(group.id, {
|
||||||
name: draft,
|
name: draft,
|
||||||
});
|
});
|
||||||
requireObject(data, 'PATCH /api/customergroup/:id');
|
requireObject(data, 'PATCH /api/customergroup/:id');
|
||||||
@@ -177,7 +174,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyKey: group.id });
|
this.setState({ busyKey: group.id });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.customergroup.order({ customergroupIds });
|
const data = await customergroupApi.order({ customergroupIds });
|
||||||
requireObject(data, 'PUT /api/customergroup/order');
|
requireObject(data, 'PUT /api/customergroup/order');
|
||||||
requireArray(data.customergroupIds, 'customergroupIds');
|
requireArray(data.customergroupIds, 'customergroupIds');
|
||||||
await this.refreshFromServer();
|
await this.refreshFromServer();
|
||||||
@@ -204,7 +201,7 @@ class KundeGruppenPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ busyKey: group.id });
|
this.setState({ busyKey: group.id });
|
||||||
try {
|
try {
|
||||||
await this.context.customergroup.delete(group.id);
|
await customergroupApi.delete(group.id);
|
||||||
await this.refreshFromServer();
|
await this.refreshFromServer();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({ busyKey: null });
|
this.setState({ busyKey: null });
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ import TextField from '@mui/material/TextField';
|
|||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
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 { router } from '../../appRouter';
|
||||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
class KundeNeuPage extends PureComponent {
|
class KundeNeuPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
constructor(props, context) {
|
const groups = customergroupAutomat.state.items ?? [];
|
||||||
super(props, context);
|
|
||||||
const groups = this.context.customergroups ?? [];
|
|
||||||
this.state = {
|
this.state = {
|
||||||
customergroupId: groups.length > 0 ? groups[0].id : null,
|
customergroupId: groups.length > 0 ? groups[0].id : null,
|
||||||
number: '',
|
number: '',
|
||||||
@@ -36,7 +36,7 @@ class KundeNeuPage extends PureComponent {
|
|||||||
|
|
||||||
async loadNumber() {
|
async loadNumber() {
|
||||||
try {
|
try {
|
||||||
const res = await this.context.entity.nextNumber();
|
const res = await entityApi.nextNumber();
|
||||||
requireObject(res, 'GET /api/entity/next-number');
|
requireObject(res, 'GET /api/entity/next-number');
|
||||||
requireDefined(res.number, 'number');
|
requireDefined(res.number, 'number');
|
||||||
this.setState({ number: res.number });
|
this.setState({ number: res.number });
|
||||||
@@ -73,11 +73,11 @@ class KundeNeuPage extends PureComponent {
|
|||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
try {
|
try {
|
||||||
const data = await this.context.entity.create(body);
|
const data = await entityApi.create(body);
|
||||||
requireObject(data, 'POST /api/entity');
|
requireObject(data, 'POST /api/entity');
|
||||||
requireObject(data.entity, 'entity');
|
requireObject(data.entity, 'entity');
|
||||||
requireDefined(data.entity.id, 'entity.id');
|
requireDefined(data.entity.id, 'entity.id');
|
||||||
await this.context.updateEntity();
|
await entityAutomat.actions.reload();
|
||||||
enqueueSnackbar('Kunde angelegt.', { variant: 'success' });
|
enqueueSnackbar('Kunde angelegt.', { variant: 'success' });
|
||||||
void router.navigate(`/kunde/${data.entity.id}`);
|
void router.navigate(`/kunde/${data.entity.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -88,7 +88,7 @@ class KundeNeuPage extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { customergroupId, number, name, firstName, saving } = this.state;
|
const { customergroupId, number, name, firstName, saving } = this.state;
|
||||||
const groups = this.context.customergroups ?? [];
|
const groups = customergroupAutomat.state.items ?? [];
|
||||||
|
|
||||||
const canSave =
|
const canSave =
|
||||||
customergroupId != null && name.trim() !== '' && !saving;
|
customergroupId != null && name.trim() !== '' && !saving;
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ import {
|
|||||||
BookDeliveryDialog,
|
BookDeliveryDialog,
|
||||||
} from '../../components/manifest';
|
} from '../../components/manifest';
|
||||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||||
import { loadParties } from '../../components/manifest/manifestConfig';
|
import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
|
||||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
import { article as articleApi, delivery as deliveryApi } from '../../api';
|
||||||
|
import { getCategoryState } from '../../automats/categoryAutomat';
|
||||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
@@ -47,12 +48,10 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
|
|||||||
import { extractPdfText } from '../../utils/pdfText';
|
import { extractPdfText } from '../../utils/pdfText';
|
||||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||||
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
||||||
import { delivery as deliveryApi, ledger as defaultLedgerApi } from '../../api';
|
|
||||||
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||||
|
|
||||||
class LieferungNeuPage extends PureComponent {
|
class LieferungNeuPage extends PureComponent {
|
||||||
static contextType = StorageManagerContext;
|
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cfg = manifestConfig('delivery');
|
this.cfg = manifestConfig('delivery');
|
||||||
@@ -168,7 +167,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
async loadPartiesOnly() {
|
async loadPartiesOnly() {
|
||||||
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
||||||
try {
|
try {
|
||||||
const parties = await loadParties('delivery', this.context);
|
const parties = await loadParties('delivery');
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.automat.actions.setDeliveryEntities(parties);
|
this.automat.actions.setDeliveryEntities(parties);
|
||||||
this.setState({
|
this.setState({
|
||||||
@@ -200,8 +199,8 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.setState({ status: 'loading', error: null, partiesLoading: true });
|
this.setState({ status: 'loading', error: null, partiesLoading: true });
|
||||||
try {
|
try {
|
||||||
const [docRes, parties] = await Promise.all([
|
const [docRes, parties] = await Promise.all([
|
||||||
this.context[cfg.api].get(docId),
|
apiFor(cfg.kind).get(docId),
|
||||||
loadParties('delivery', this.context),
|
loadParties('delivery'),
|
||||||
]);
|
]);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
@@ -228,12 +227,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
|
|
||||||
let ledgerSummary = null;
|
let ledgerSummary = null;
|
||||||
try {
|
try {
|
||||||
const ledgerClient = this.context?.ledger || defaultLedgerApi;
|
ledgerSummary = await fetchDeliveryLedgerSummary(docId);
|
||||||
const deliveryClient = this.context?.[cfg.api] || deliveryApi;
|
|
||||||
ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
|
|
||||||
ledgerApi: ledgerClient,
|
|
||||||
deliveryApi: deliveryClient,
|
|
||||||
});
|
|
||||||
} catch (lErr) {
|
} catch (lErr) {
|
||||||
console.error('[neu] Failed to load ledger summary:', lErr);
|
console.error('[neu] Failed to load ledger summary:', lErr);
|
||||||
}
|
}
|
||||||
@@ -272,12 +266,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const { docId } = this.props;
|
const { docId } = this.props;
|
||||||
if (docId == null) return;
|
if (docId == null) return;
|
||||||
try {
|
try {
|
||||||
const ledgerClient = this.context?.ledger || defaultLedgerApi;
|
const ledgerSummary = await fetchDeliveryLedgerSummary(docId);
|
||||||
const deliveryClient = this.context?.[this.cfg.api] || deliveryApi;
|
|
||||||
const ledgerSummary = await fetchDeliveryLedgerSummary(docId, {
|
|
||||||
ledgerApi: ledgerClient,
|
|
||||||
deliveryApi: deliveryClient,
|
|
||||||
});
|
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({ ledgerSummary });
|
this.setState({ ledgerSummary });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -381,7 +370,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Get root category ID
|
// Get root category ID
|
||||||
const categoryState = this.context.getCategoryState();
|
const categoryState = getCategoryState();
|
||||||
if (!categoryState) {
|
if (!categoryState) {
|
||||||
enqueueSnackbar('Kategorie-Daten nicht verfügbar.', { variant: 'error' });
|
enqueueSnackbar('Kategorie-Daten nicht verfügbar.', { variant: 'error' });
|
||||||
return;
|
return;
|
||||||
@@ -394,7 +383,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create article in root category
|
// Create article in root category
|
||||||
const articleRes = await this.context.article.create({
|
const articleRes = await articleApi.create({
|
||||||
parentId: rootCategoryId,
|
parentId: rootCategoryId,
|
||||||
name: categoryName,
|
name: categoryName,
|
||||||
});
|
});
|
||||||
@@ -407,7 +396,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const supplierSku = line.sku;
|
const supplierSku = line.sku;
|
||||||
|
|
||||||
// Add supplier relationship with the supplier's SKU
|
// Add supplier relationship with the supplier's SKU
|
||||||
await this.context.article.addSupplier(internalSku, {
|
await articleApi.addSupplier(internalSku, {
|
||||||
entityId: partyId,
|
entityId: partyId,
|
||||||
sku: supplierSku || undefined,
|
sku: supplierSku || undefined,
|
||||||
pu: line.pu ? Number(line.pu) : undefined,
|
pu: line.pu ? Number(line.pu) : undefined,
|
||||||
@@ -457,7 +446,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
const supplierSku = line?.sku;
|
const supplierSku = line?.sku;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.context.article.addSupplier(internalSku, {
|
await articleApi.addSupplier(internalSku, {
|
||||||
entityId: partyId,
|
entityId: partyId,
|
||||||
sku: supplierSku || undefined,
|
sku: supplierSku || undefined,
|
||||||
pu: line?.pu ? Number(line.pu) : undefined,
|
pu: line?.pu ? Number(line.pu) : undefined,
|
||||||
@@ -699,10 +688,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parser = this.context?.delivery?.parseSse;
|
for await (const { event, data } of deliveryApi.parseSse(file, { signal: abortController.signal })) {
|
||||||
if (!parser) throw new Error('parseSse nicht verfügbar.');
|
|
||||||
|
|
||||||
for await (const { event, data } of parser(file, { signal: abortController.signal })) {
|
|
||||||
if (this._cancelled || abortController.signal.aborted) return;
|
if (this._cancelled || abortController.signal.aborted) return;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case 'status':
|
case 'status':
|
||||||
@@ -914,10 +900,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const getFile =
|
const blob = await deliveryApi.getFile(docId, fileId);
|
||||||
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
|
|
||||||
deliveryApi.getFile;
|
|
||||||
const blob = await getFile(docId, fileId);
|
|
||||||
const filename = attachment.name || 'Dokument';
|
const filename = attachment.name || 'Dokument';
|
||||||
const mimetype = attachment.type || blob.type || 'application/octet-stream';
|
const mimetype = attachment.type || blob.type || 'application/octet-stream';
|
||||||
const file = new File([blob], filename, { type: mimetype });
|
const file = new File([blob], filename, { type: mimetype });
|
||||||
@@ -1164,23 +1147,24 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ saving: true });
|
this.setState({ saving: true });
|
||||||
|
const api = apiFor(cfg.kind);
|
||||||
try {
|
try {
|
||||||
if (isCreate) {
|
if (isCreate) {
|
||||||
const data = await this.context[cfg.api].create(body);
|
const data = await api.create(body);
|
||||||
requireObject(data, cfg.postPathLabel);
|
requireObject(data, cfg.postPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
await this.context[cfg.updateCache]();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||||
this.automat.actions.resetDelivery();
|
this.automat.actions.resetDelivery();
|
||||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||||
} else {
|
} 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.patchPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
await this.context[cfg.updateCache]();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
const doc = data[cfg.docField];
|
const doc = data[cfg.docField];
|
||||||
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
||||||
|
|||||||
Reference in New Issue
Block a user