Compare commits
94 Commits
935e01cb30
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283ce3dc5f | ||
|
|
863422df8e | ||
|
|
9841002398 | ||
|
|
bc11a2c3ab | ||
|
|
ec9f2d9816 | ||
|
|
bc284c6d42 | ||
|
|
0181e08657 | ||
|
|
6071e59d30 | ||
|
|
e7103ac340 | ||
|
|
9c6d5d79e4 | ||
|
|
7c33c097d4 | ||
|
|
76e911ebaf | ||
|
|
d2139fee45 | ||
|
|
8a416ce384 | ||
|
|
7fe9f90f2c | ||
|
|
456389be1b | ||
|
|
e788c960d5 | ||
|
|
b7046d3bc6 | ||
|
|
fcaf3d5821 | ||
|
|
ca28f56f3f | ||
|
|
545eb98191 | ||
|
|
ee80646c1c | ||
|
|
df015da8fc | ||
|
|
2d1a7364f2 | ||
|
|
f6eb31b70d | ||
|
|
6719cf9d51 | ||
|
|
cdb031921b | ||
|
|
9cb82745e3 | ||
|
|
56d376c9bd | ||
|
|
72f168f188 | ||
|
|
eed979bf11 | ||
|
|
28d6fece46 | ||
|
|
1b60965cea | ||
|
|
d3e16fd2b7 | ||
|
|
3bb861907e | ||
|
|
6ca7597c59 | ||
|
|
05f1311892 | ||
|
|
dba9ae5cf0 | ||
|
|
8b680b5a1a | ||
|
|
f555a1c9dd | ||
|
|
867d550814 | ||
|
|
afb33cde38 | ||
|
|
5fcc5670d9 | ||
|
|
6851e7c16f | ||
|
|
6e9d706bd6 | ||
|
|
d21def186e | ||
|
|
6d299c1f43 | ||
|
|
87ef79bea3 | ||
|
|
2e69f49e4d | ||
|
|
003b198b86 | ||
|
|
b13d994058 | ||
|
|
550586f46e | ||
|
|
3c51de06c2 | ||
|
|
e45f35e97b | ||
|
|
0453680c59 | ||
|
|
23e4a0951c | ||
|
|
96d0ae6742 | ||
|
|
fab291f68e | ||
|
|
481ea16a6a | ||
|
|
75691e6afc | ||
|
|
f52ab1be0c | ||
|
|
13e1821355 | ||
|
|
eeb4b92a79 | ||
|
|
3801ac4e52 | ||
|
|
31fdc30c5a | ||
|
|
bf39f4d017 | ||
|
|
2f946442c8 | ||
|
|
35144c7187 | ||
|
|
5dd45916b8 | ||
|
|
b9bd0380cb | ||
|
|
57347bdef9 | ||
|
|
778840ec5e | ||
|
|
82f00ad593 | ||
|
|
985f4a3da2 | ||
|
|
a32ea19b5e | ||
|
|
a2103b9d0b | ||
|
|
ae245af6d7 | ||
|
|
fbf107843f | ||
|
|
b09100b13e | ||
|
|
b9abe8ec55 | ||
|
|
3d571a988a | ||
|
|
18fa62514a | ||
|
|
07206674c1 | ||
|
|
70c7d5eff2 | ||
|
|
4f367ee943 | ||
|
|
c77297af72 | ||
|
|
9bc6d55e85 | ||
|
|
65c908f44e | ||
|
|
42241ea541 | ||
|
|
b727e00928 | ||
|
|
067207c58e | ||
|
|
90ee11fefb | ||
|
|
c86c6a84f5 | ||
|
|
e953bf4c8c |
@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
|
||||
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 264 API endpoints across all modules.
|
||||
Overview of all 314 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -140,47 +140,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
||||
|
||||
### Reusable types
|
||||
|
||||
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T19|null, shippings?: T19[], payment?: T30|null, payments?: T30[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T3[], files?: T10[] }`
|
||||
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T27, brand: T26|null, categoryIds: int[], photos: T24[], eans: str[], prices: T35[], suppliers: T8[] }`
|
||||
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T19[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
|
||||
- `T4` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
|
||||
- `T5` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T20[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T39 }`
|
||||
- `T6` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T7|null }`
|
||||
- `T7` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T12, totalSizeBytes: int, durationMs: int }`
|
||||
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }`
|
||||
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T21[] }, thumb?: str|null }`
|
||||
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T33|null, payments?: T33[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
|
||||
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
|
||||
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
|
||||
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
|
||||
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
|
||||
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
|
||||
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
|
||||
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
|
||||
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T11` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||
- `T12` = `{ vfs: T40, storage: T40, database: T33 }`
|
||||
- `T13` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T14` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
|
||||
- `T15` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T16` = `{ id: int, name: str, sort: int, exclusive: bool, values: T28[], articleCount?: int }`
|
||||
- `T17` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
|
||||
- `T18` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
||||
- `T19` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T22` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T23` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T24` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T25` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T26` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T27` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T28` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T29` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T30` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T31` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
|
||||
- `T32` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T33` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||
- `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T38` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T39` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T40` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||
- `T41` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T11` = `{ vfs: T41, storage: T41, database: T35 }`
|
||||
- `T12` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int, enabled?: bool }`
|
||||
- `T13` = `{ id: int, printeragentId: int, type: str, enabled: bool, sort: int, model?: str|null, serial?: str|null, open?: bool, pause?: bool, error?: bool, online?: bool, labelLength?: int|null }`
|
||||
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- `T15` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
|
||||
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
|
||||
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
|
||||
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
|
||||
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
|
||||
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
|
||||
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
|
||||
- `T23` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T24` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
|
||||
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
|
||||
- `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||
- `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T41` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
|
||||
### Routes
|
||||
|
||||
@@ -206,11 +207,44 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
|
||||
### Section: /agent
|
||||
|
||||
#### Printer agent SSE stream — `GET /agent/events`
|
||||
- Params: `query: token?: str, agentversion?: str`
|
||||
- Returns: `200 SSE`
|
||||
#### Pair printer agent via query parameters — `GET /agent/pair`
|
||||
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
|
||||
- Returns: `200`
|
||||
#### Pair printer agent — `POST /agent/pair`
|
||||
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
|
||||
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
|
||||
#### Post agent state — `POST /agent/state`
|
||||
- Params: `body: { state?: any }`
|
||||
- Returns: `200 { success: bool, agent: T12 }`
|
||||
|
||||
### Section: /agentprinter
|
||||
|
||||
#### List agent printers — `GET /agentprinter`
|
||||
- Params: `query: printeragentId?: int`
|
||||
- Returns: `200 { agentprinters: T13[] }`
|
||||
#### Reorder agent printers — `PUT /agentprinter/order`
|
||||
- Params: `body: { agentprinterIds: int[], printeragentId?: int }`
|
||||
- Returns: `200 { agentprinterIds: int[] }`
|
||||
#### Delete agent printer — `DELETE /agentprinter/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get agent printer — `GET /agentprinter/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { agentprinter: T13 }`
|
||||
#### Update agent printer — `PATCH /agentprinter/{id}`
|
||||
- Params: `id: int, body: { enabled?: bool, sort?: int }`
|
||||
- Returns: `200 { agentprinter: T13 }`
|
||||
|
||||
### Section: /article
|
||||
|
||||
#### Create article in category — `POST /article`
|
||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||
- Returns: `201 { article: T32 }`
|
||||
- Returns: `201 { sku: str }`
|
||||
#### Export articles as ZIP — `GET /article/export.zip`
|
||||
- Params: `query: categoryId?: int`
|
||||
- Returns: `200 zip`
|
||||
@@ -222,10 +256,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get article by SKU — `GET /article/{sku}`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T28|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
|
||||
#### Update article by SKU — `PATCH /article/{sku}`
|
||||
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { items: { id: int, articleId: int, sku: str, name: str, count: int, sort: int }[] }`
|
||||
@@ -240,28 +274,28 @@ _none_
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, articleId?: int, exclusive?: bool }[] }`
|
||||
#### Set or clear article brand by SKU (null to clear) — `PUT /article/{sku}/brand`
|
||||
- Params: `sku: str, body: { brandId: int|null }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
|
||||
- Params: `sku: str, body: { categoryIds: int[] }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### List child articles of a parent article — `GET /article/{sku}/children`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
||||
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||
- Returns: `201 { article: T32 }`
|
||||
- Returns: `201 { sku: str }`
|
||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
||||
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||
- Returns: `200 { article: T32, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||
- Returns: `200 { success: bool, deleted: bool }`
|
||||
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
|
||||
- Params: `sku: str, body: { eans: str[] }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### Get article history by SKU — `GET /article/{sku}/history`
|
||||
- Params: `sku: str, query: limit?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }`
|
||||
- Params: `sku: str, query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||
- Returns: `200 { history: { id: int, date: str, text: str }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Add article image — `POST /article/{sku}/image`
|
||||
- Params: `sku: str, image/avif`
|
||||
- Returns: `201 { image: { picId: int, sort: int, full: { w: int, h: int, size: int }, thumb: { w: int, h: int }, hash: str, thumbHash: str } }`
|
||||
@@ -279,12 +313,12 @@ _none_
|
||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||
- Returns: `200 { article: T32 }`
|
||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
|
||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
|
||||
- Returns: `204 void`
|
||||
#### Print article label — `POST /article/{sku}/print`
|
||||
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
|
||||
- Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
|
||||
- Returns: `200 { success: bool }`
|
||||
#### List article properties — `GET /article/{sku}/property`
|
||||
- Params: `sku: str`
|
||||
@@ -312,28 +346,28 @@ _none_
|
||||
- Returns: `200 { supplier: T8 }`
|
||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||
- Params: `sku: str, body: { track_stock: bool }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
#### Set article weight by SKU — `PUT /article/{sku}/weight`
|
||||
- Params: `sku: str, body: { weight: num }`
|
||||
- Returns: `200 { article: T2 }`
|
||||
- Returns: `204 void`
|
||||
|
||||
### Section: /attributename
|
||||
|
||||
#### List attribute names — `GET /attributename`
|
||||
_none_
|
||||
- Returns: `200 { attributenames: T16[] }`
|
||||
- Returns: `200 { attributenames: T17[] }`
|
||||
#### Create attribute name — `POST /attributename`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { attributename: T16 }`
|
||||
- Returns: `201 { attributename: T17 }`
|
||||
#### Reorder attribute names — `PUT /attributename/order`
|
||||
- Params: `body: { attributenameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int`
|
||||
- Returns: `200 { values: T28[] }`
|
||||
- Returns: `200 { values: T30[] }`
|
||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||
- Returns: `201 { attributevalue: T28 }`
|
||||
- Returns: `201 { attributevalue: T30 }`
|
||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -342,7 +376,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute name — `PATCH /attributename/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { attributename: T16 }`
|
||||
- Returns: `200 { attributename: T17 }`
|
||||
|
||||
### Section: /attributevalue
|
||||
|
||||
@@ -351,34 +385,46 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||
- Returns: `200 { attributevalue: T28 }`
|
||||
- Returns: `200 { attributevalue: T30 }`
|
||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||
|
||||
### Section: /auth
|
||||
|
||||
#### Login with Google ID token — `POST /auth/login`
|
||||
- Params: `body: { idToken: str }`
|
||||
- Returns: `200 { message: str, user: T34 }`
|
||||
- Returns: `200 { message: str, user: T36 }`
|
||||
#### Logout — `POST /auth/logout`
|
||||
_none_
|
||||
- Returns: `200 { message: str }`
|
||||
#### Current user — `GET /auth/me`
|
||||
_none_
|
||||
- Returns: `200 { user: T34, googleClientId: str }`
|
||||
- Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
|
||||
#### Set selected company — `PUT /auth/selected-company`
|
||||
- Params: `body: { companyId: int }`
|
||||
- Returns: `200 { user: T34 }`
|
||||
- Returns: `200 { user: T36 }`
|
||||
|
||||
### Section: /autoinvite
|
||||
|
||||
#### List auto-invitations — `GET /autoinvite`
|
||||
_none_
|
||||
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
|
||||
#### Create auto-invitation — `POST /autoinvite`
|
||||
- Params: `body: { email: str, role?: str }`
|
||||
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
|
||||
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
|
||||
### Section: /backup
|
||||
|
||||
#### List backups and service status — `GET /backup`
|
||||
_none_
|
||||
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T6|null, latestDaily?: T6|null, latestMonthly?: T6|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T6[], daily: T6[], monthly: T6[] } }`
|
||||
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T5|null, latestDaily?: T5|null, latestMonthly?: T5|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T5[], daily: T5[], monthly: T5[] } }`
|
||||
#### List VFS files in a backup — `GET /backup/{id}/vfs`
|
||||
- Params: `id: str, query: companyId?: int`
|
||||
- Returns: `200 { backupId: str, companyId: int, files: { name: str, size: int, mime: str, mtime: str }[] }`
|
||||
@@ -393,7 +439,7 @@ _none_
|
||||
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
||||
#### Create brand — `POST /brand`
|
||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
|
||||
#### Delete brand — `DELETE /brand/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -402,19 +448,19 @@ _none_
|
||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||
|
||||
### Section: /carrier
|
||||
|
||||
#### List carriers — `GET /carrier`
|
||||
_none_
|
||||
- Returns: `200 { carriers: T31[] }`
|
||||
- Returns: `200 { carriers: T34[] }`
|
||||
#### Create carrier — `POST /carrier`
|
||||
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
||||
- Returns: `201 { carrier: T31 }`
|
||||
- Returns: `201 { carrier: T34 }`
|
||||
#### Reorder carriers — `PUT /carrier/order`
|
||||
- Params: `body: { carrierIds: int[] }`
|
||||
- Returns: `200 { carrierIds: int[] }`
|
||||
@@ -423,10 +469,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get carrier by ID — `GET /carrier/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { carrier: T31 }`
|
||||
- Returns: `200 { carrier: T34 }`
|
||||
#### Update carrier — `PATCH /carrier/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
||||
- Returns: `200 { carrier: T31 }`
|
||||
- Returns: `200 { carrier: T34 }`
|
||||
|
||||
### Section: /category
|
||||
|
||||
@@ -449,8 +495,8 @@ _none_
|
||||
- Params: `id: int, body: { name?: str, parentId?: int }`
|
||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||
#### Articles in category — `GET /category/{id}/articles`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T26|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
|
||||
- Params: `id: int, query: includeChildren?: bool`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T28|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
|
||||
#### List category attribute values — `GET /category/{id}/attributevalue`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
||||
@@ -463,6 +509,9 @@ _none_
|
||||
#### Add category image — `POST /category/{id}/image`
|
||||
- Params: `id: int, image/avif`
|
||||
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
|
||||
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
|
||||
- Params: `id: int, body?: { size?: int }`
|
||||
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
|
||||
#### List category properties — `GET /category/{id}/property`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
|
||||
@@ -477,10 +526,10 @@ _none_
|
||||
|
||||
#### List companies — `GET /company`
|
||||
_none_
|
||||
- Returns: `200 { companies: T18[] }`
|
||||
- Returns: `200 { companies: T19[] }`
|
||||
#### Create company — `POST /company`
|
||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `201 { company: T18, user: T34 }`
|
||||
- Returns: `201 { company: T19, user: T36 }`
|
||||
#### Export company catalog as zip archive (SSE) — `GET /company/export`
|
||||
- Params: `query: stream?: str`
|
||||
- Returns: `200 SSE`
|
||||
@@ -510,7 +559,22 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update company — `PUT /company/{id}`
|
||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `200 { company: T18 }`
|
||||
- Returns: `200 { company: T19 }`
|
||||
|
||||
### Section: /companyuser
|
||||
|
||||
#### List company users — `GET /companyuser`
|
||||
_none_
|
||||
- Returns: `200 { companyusers: T31[] }`
|
||||
#### Add an existing user to the company — `POST /companyuser`
|
||||
- Params: `body: { email: str, role?: str }`
|
||||
- Returns: `201 { companyuser: T31 }`
|
||||
#### Remove a user from the company — `DELETE /companyuser/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Change a company user's role — `PATCH /companyuser/{id}`
|
||||
- Params: `id: int, body: { role: str }`
|
||||
- Returns: `200 { companyuser: T31 }`
|
||||
|
||||
### Section: /customergroup
|
||||
|
||||
@@ -534,19 +598,19 @@ _none_
|
||||
|
||||
#### List deliveries — `GET /delivery`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T39 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Create delivery — `POST /delivery`
|
||||
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: T23[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `201 { delivery: T5 }`
|
||||
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `201 { delivery: T4 }`
|
||||
#### Delete delivery — `DELETE /delivery/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get delivery — `GET /delivery/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { delivery: T5 }`
|
||||
- Returns: `200 { delivery: T4 }`
|
||||
#### Update delivery — `PATCH /delivery/{id}`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T23[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `200 { delivery: T5 }`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `200 { delivery: T4 }`
|
||||
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -561,46 +625,46 @@ _none_
|
||||
|
||||
#### List entities — `GET /entity`
|
||||
- Params: `query: customergroupId?: int, role?: str`
|
||||
- Returns: `200 { entities: T25[] }`
|
||||
- Returns: `200 { entities: T27[] }`
|
||||
#### Create entity — `POST /entity`
|
||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||
- Returns: `201 { entity: T25 }`
|
||||
- Returns: `201 { entity: T27 }`
|
||||
#### Peek next customer number — `GET /entity/next-number`
|
||||
_none_
|
||||
- Returns: `200 { number: str }`
|
||||
#### List entity addresses — `GET /entity/{entityId}/address`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { addresses: T13[] }`
|
||||
- Returns: `200 { addresses: T14[] }`
|
||||
#### Create entity address — `POST /entity/{entityId}/address`
|
||||
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `201 { address: T13 }`
|
||||
- Returns: `201 { address: T14 }`
|
||||
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
||||
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||
- Returns: `200 { address: T13 }`
|
||||
- Returns: `200 { address: T14 }`
|
||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { contacts: T37[] }`
|
||||
- Returns: `200 { contacts: T38[] }`
|
||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||
- Returns: `201 { contact: T37 }`
|
||||
- Returns: `201 { contact: T38 }`
|
||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||
- Returns: `200 { contact: T37 }`
|
||||
- Returns: `200 { contact: T38 }`
|
||||
#### Delete entity — `DELETE /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get entity — `GET /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { entity: T25 }`
|
||||
- Returns: `200 { entity: T27 }`
|
||||
#### Update entity — `PATCH /entity/{id}`
|
||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||
- Returns: `200 { entity: T25 }`
|
||||
- Returns: `200 { entity: T27 }`
|
||||
|
||||
### Section: /events
|
||||
|
||||
@@ -609,6 +673,8 @@ _none_
|
||||
- Returns: `200 SSE`
|
||||
|
||||
- Events:
|
||||
- `user`: `{ selectedCompanyId }`
|
||||
- `company`: `{ companyId }`
|
||||
- `category`: `{}`
|
||||
- `articles`: `{}`
|
||||
- `article-<sku>`: `{}`
|
||||
@@ -616,9 +682,15 @@ _none_
|
||||
- `ledger`: `{}`
|
||||
- `attributename`: `{}`
|
||||
- `propertyname`: `{}`
|
||||
- `companyuser`: `{}`
|
||||
- `autoinvite`: `{}`
|
||||
- `labeltemplate`: `{}`
|
||||
- `printer`: `{}`
|
||||
- `printeragent`: `{}`
|
||||
- `agentprinter`: `{ printers } or {}`
|
||||
- `printerjob`: `{ job } or { jobId, state }`
|
||||
- `warehouse`: `{}`
|
||||
- `supplier`: `{}`
|
||||
- `vfs`: `{}`
|
||||
- `modelconversation`: `{}`
|
||||
- `modelconversation-<id>`: `{}`
|
||||
@@ -645,8 +717,11 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||
#### Create label template — `POST /labeltemplate`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T21[] } }`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] } }`
|
||||
- Returns: `201 { labeltemplate: T9 }`
|
||||
#### Test print label template — `POST /labeltemplate/testprint`
|
||||
- Params: `body: { agentprinterId: int, templateId?: int, objects?: any[], width?: int, height?: int, dpi?: int, sku?: str, quantity?: int }`
|
||||
- Returns: `200 { success: bool }`
|
||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -654,26 +729,26 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { labeltemplate: T9 }`
|
||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T21[] } }`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
|
||||
- Returns: `200 { labeltemplate: T9 }`
|
||||
#### Update label template — `PUT /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T21[] } }`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
|
||||
- Returns: `200 { labeltemplate: T9 }`
|
||||
|
||||
### Section: /ledger
|
||||
|
||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- Returns: `200 { ledger: T15[] }`
|
||||
- Returns: `200 { ledger: T16[] }`
|
||||
#### Query company-wide ledger history — `GET /ledger/history`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 { ledger: T15[] }`
|
||||
- Returns: `200 { ledger: T16[] }`
|
||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||
- Params: `sku: str, query: transaction_type?: str`
|
||||
- Returns: `200 { ledger: T15[] }`
|
||||
- Returns: `200 { ledger: T16[] }`
|
||||
|
||||
### Section: /mcp
|
||||
|
||||
@@ -703,16 +778,16 @@ _none_
|
||||
|
||||
#### List conversations — `GET /modelconversation`
|
||||
_none_
|
||||
- Returns: `200 { conversations: T17[] }`
|
||||
- Returns: `200 { conversations: T18[] }`
|
||||
#### Create conversation — `POST /modelconversation`
|
||||
- Params: `body: { provider: str, model: str }`
|
||||
- Returns: `201 { conversation: T17 }`
|
||||
- Returns: `201 { conversation: T18 }`
|
||||
#### Delete conversation — `DELETE /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get conversation — `GET /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { conversation: T17 }`
|
||||
- Returns: `200 { conversation: T18 }`
|
||||
#### List conversation messages — `GET /modelconversation/{id}/message`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
||||
@@ -753,7 +828,7 @@ _none_
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
||||
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Create order — `POST /order`
|
||||
- Params: `body: { number: str, date: str, entityId: int, lines: T23[], externalNumber?: str|null }`
|
||||
- Params: `body: { number: str, date: str, entityId: int, lines: T21[], externalNumber?: str|null }`
|
||||
- Returns: `201 { order: T1 }`
|
||||
#### Delete order — `DELETE /order/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -762,7 +837,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { order: T1 }`
|
||||
#### Update order — `PATCH /order/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T23[] }`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||
- Returns: `200 { order: T1 }`
|
||||
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
@@ -772,25 +847,25 @@ _none_
|
||||
- Returns: `200 str`
|
||||
#### List payments of an order — `GET /order/{id}/payment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { payments: T30[] }`
|
||||
- Returns: `200 { payments: T33[] }`
|
||||
#### Add payment to an order — `POST /order/{id}/payment`
|
||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||
- Returns: `201 { payment: T30 }`
|
||||
- Returns: `201 { payment: T33 }`
|
||||
#### Generate and attach order PDF — `POST /order/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipments: T3[] }`
|
||||
- Returns: `200 { shipments: T2[] }`
|
||||
#### Create shipment for an order — `POST /order/{id}/shipment`
|
||||
- Params: `id: int, body?: { date?: str, externalNumber?: str, lines?: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[] }`
|
||||
- Returns: `201 { shipment: T3 }`
|
||||
- Params: `id: int, body: { lines: { sku?: str, orderLineId?: int, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, warehouseId?: int, externalNumber?: str }`
|
||||
- Returns: `201 { shipment: T2 }`
|
||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T19[] }`
|
||||
- Returns: `200 { shippings: T23[] }`
|
||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
||||
- Returns: `201 { shipping: T19 }`
|
||||
- Returns: `201 { shipping: T23 }`
|
||||
|
||||
### Section: /package
|
||||
|
||||
@@ -808,26 +883,26 @@ _none_
|
||||
|
||||
#### List POS terminals — `GET /posterminal`
|
||||
_none_
|
||||
- Returns: `200 { posterminals: T11[] }`
|
||||
- Returns: `200 { posterminals: T7[] }`
|
||||
#### Create POS terminal — `PUT /posterminal`
|
||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
||||
- Returns: `200 { posterminal: T11 }`
|
||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool }`
|
||||
- Returns: `200 { posterminal: T7 }`
|
||||
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update POS terminal — `PATCH /posterminal/{id}`
|
||||
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
|
||||
- Returns: `200 { posterminal: T11 }`
|
||||
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, pairingpin?: str|null }`
|
||||
- Returns: `200 { posterminal: T7 }`
|
||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { id: int, categoryIds: int[] }`
|
||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { id: int, categoryIds: int[] }`
|
||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||
#### Disable category and subcategories on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 { id: int, categoryIds: int[] }`
|
||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||
#### Enable category and subcategories on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 { id: int, categoryIds: int[] }`
|
||||
|
||||
@@ -835,34 +910,76 @@ _none_
|
||||
|
||||
#### List printers — `GET /printer`
|
||||
_none_
|
||||
- Returns: `200 { printers: T22[] }`
|
||||
- Returns: `200 { printers: T26[] }`
|
||||
#### Create printer — `POST /printer`
|
||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `201 { printer: T22 }`
|
||||
- Returns: `201 { printer: T26 }`
|
||||
#### Reorder printers — `PUT /printer/order`
|
||||
- Params: `body: { printerIds: int[] }`
|
||||
- Returns: `200 { printerIds: int[] }`
|
||||
#### Get live printer states — `GET /printer/state`
|
||||
- Params: `query: serial?: str, agentId?: int`
|
||||
- Returns: `200 { printers: {}[] }`
|
||||
#### Delete printer — `DELETE /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get printer by ID — `GET /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { printer: T22 }`
|
||||
- Returns: `200 { printer: T26 }`
|
||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 { printer: T22 }`
|
||||
- Returns: `200 { printer: T26 }`
|
||||
#### Update printer — `PUT /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 { printer: T22 }`
|
||||
- Returns: `200 { printer: T26 }`
|
||||
#### Get live state for a printer — `GET /printer/{id}/state`
|
||||
- Params: `id: str`
|
||||
- Returns: `200 { printer: {} }`
|
||||
|
||||
### Section: /printeragent
|
||||
|
||||
#### List printer agents — `GET /printeragent`
|
||||
_none_
|
||||
- Returns: `200 { printeragents: T12[] }`
|
||||
#### Create printer agent — `PUT /printeragent`
|
||||
- Params: `body: { name: str, sort?: int, enabled?: bool }`
|
||||
- Returns: `200 { printeragent: T12 }`
|
||||
#### Delete printer agent — `DELETE /printeragent/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update printer agent — `PATCH /printeragent/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, enabled?: bool }`
|
||||
- Returns: `200 { printeragent: T12 }`
|
||||
|
||||
### Section: /printerjob
|
||||
|
||||
#### List printer jobs — `GET /printerjob`
|
||||
- Params: `query: agentprinterId?: int, state?: str`
|
||||
- Returns: `200 { printerjobs: T22[] }`
|
||||
#### Create print job — `POST /printerjob`
|
||||
- Params: `body: { agentprinterId: int, data: str, type?: str }`
|
||||
- Returns: `201 { printerjob: T22 }`
|
||||
#### Cancel all pending and processing printer jobs — `POST /printerjob/cancel-all`
|
||||
- Params: `body?: { agentprinterId?: int }`
|
||||
- Returns: `200 { success: bool, cancelledCount: int, jobs: T22[] }`
|
||||
#### Cancel and delete printer job — `DELETE /printerjob/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { success: bool, deletedJob: T22 }`
|
||||
#### Get printer job — `GET /printerjob/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { printerjob: T22 }`
|
||||
#### Cancel printer job — `POST /printerjob/{id}/cancel`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { success: bool, printerjob: T22 }`
|
||||
|
||||
### Section: /propertyname
|
||||
|
||||
#### List property names — `GET /propertyname`
|
||||
_none_
|
||||
- Returns: `200 { propertynames: T29[] }`
|
||||
- Returns: `200 { propertynames: T32[] }`
|
||||
#### Create property name — `POST /propertyname`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { propertyname: T29 }`
|
||||
- Returns: `201 { propertyname: T32 }`
|
||||
#### Reorder property names — `PUT /propertyname/order`
|
||||
- Params: `body: { propertynameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -871,7 +988,7 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update property name — `PATCH /propertyname/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { propertyname: T29 }`
|
||||
- Returns: `200 { propertyname: T32 }`
|
||||
|
||||
### Section: /public
|
||||
|
||||
@@ -883,7 +1000,7 @@ _none_
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
||||
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
|
||||
- Params: `companyId: int, sku: str`
|
||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T27|null, brand: T26|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: T24[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
|
||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T29|null, brand: T28|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
|
||||
#### List public categories — `GET /public/{companyId}/categories`
|
||||
- Params: `companyId: int`
|
||||
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
||||
@@ -892,19 +1009,19 @@ _none_
|
||||
|
||||
#### List quotes — `GET /quote`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||
- Returns: `200 { quotes: T4[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Create quote — `POST /quote`
|
||||
- Params: `body: { number: str, date: str, lines: T23[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Returns: `201 { quote: T4 }`
|
||||
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Returns: `201 { quote: T3 }`
|
||||
#### Delete quote — `DELETE /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get quote — `GET /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { quote: T4 }`
|
||||
- Returns: `200 { quote: T3 }`
|
||||
#### Update quote — `PATCH /quote/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T23[] }`
|
||||
- Returns: `200 { quote: T4 }`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||
- Returns: `200 { quote: T3 }`
|
||||
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -912,12 +1029,60 @@ _none_
|
||||
- Params: `id: int, fileId: int, query: download?: bool`
|
||||
- Returns: `200 str`
|
||||
#### Create order from quote — `POST /quote/{id}/order`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T23[] }`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||
- Returns: `201 { order: T1 }`
|
||||
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||
|
||||
### Section: /retoure
|
||||
|
||||
#### List retoures — `GET /retoure`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, entityId?: int, seekMonths?: int`
|
||||
- Returns: `200`
|
||||
#### Create retoure — `POST /retoure`
|
||||
- Params: `body: { date: str, number?: str, entityId?: int, externalNumber?: str, lines?: any[] }`
|
||||
- Returns: `201`
|
||||
#### Rebuild retoure summaries — `POST /retoure/rebuild-summaries`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Delete retoure — `DELETE /retoure/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get retoure by ID — `GET /retoure/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200`
|
||||
#### Update retoure — `PUT /retoure/{id}`
|
||||
- Params: `id: int, body: {}`
|
||||
- Returns: `200`
|
||||
#### Create shipment for retoure — `POST /retoure/{id}/shipment`
|
||||
- Params: `id: int`
|
||||
- Returns: `201`
|
||||
|
||||
### Section: /return
|
||||
|
||||
#### List returns — `GET /return`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int`
|
||||
- Returns: `200`
|
||||
#### Create return — `POST /return`
|
||||
- Params: `body: {}`
|
||||
- Returns: `201`
|
||||
#### Rebuild return summaries — `POST /return/rebuild-summaries`
|
||||
_none_
|
||||
- Returns: `200`
|
||||
#### Delete return — `DELETE /return/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get return by ID — `GET /return/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200`
|
||||
#### Update return — `PUT /return/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200`
|
||||
#### Book return to ledger — `POST /return/{id}/book`
|
||||
- Params: `id: int`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /serper
|
||||
|
||||
#### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image`
|
||||
@@ -943,10 +1108,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get shipment by ID — `GET /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipment: T3 }`
|
||||
- Returns: `200 { shipment: T2 }`
|
||||
#### Update shipment state or metadata — `PATCH /shipment/{id}`
|
||||
- Params: `id: int, body: { state?: str }`
|
||||
- Returns: `200 { shipment: T3 }`
|
||||
- Returns: `200 { shipment: T2 }`
|
||||
#### Delete shipment file attachment — `DELETE /shipment/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -958,10 +1123,10 @@ _none_
|
||||
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T19[] }`
|
||||
- Returns: `200 { shippings: T23[] }`
|
||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
||||
- Returns: `201 { shipping: T19 }`
|
||||
- Returns: `201 { shipping: T23 }`
|
||||
|
||||
### Section: /shipping
|
||||
|
||||
@@ -970,7 +1135,7 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update shipping — `PATCH /shipping/{id}`
|
||||
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
|
||||
- Returns: `200 { shipping: T19 }`
|
||||
- Returns: `200 { shipping: T23 }`
|
||||
#### List packages of a shipping — `GET /shipping/{id}/package`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
||||
@@ -991,7 +1156,7 @@ _none_
|
||||
- Returns: `200`
|
||||
#### Adjust stock manually — `POST /stock/adjust`
|
||||
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T15[] }`
|
||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
|
||||
#### Ship shipment manifest item(s) — `POST /stock/ship`
|
||||
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
||||
- Returns: `200`
|
||||
@@ -1000,40 +1165,40 @@ _none_
|
||||
|
||||
#### List subagent schedules — `GET /subagentschedule`
|
||||
_none_
|
||||
- Returns: `200 { schedules: T14[] }`
|
||||
- Returns: `200 { schedules: T15[] }`
|
||||
#### Create subagent schedule — `POST /subagentschedule`
|
||||
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
|
||||
- Returns: `201 { schedule: T14 }`
|
||||
- Returns: `201 { schedule: T15 }`
|
||||
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { schedule: T14 }`
|
||||
- Returns: `200 { schedule: T15 }`
|
||||
#### Update subagent schedule — `PUT /subagentschedule/{id}`
|
||||
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
|
||||
- Returns: `200 { schedule: T14 }`
|
||||
- Returns: `200 { schedule: T15 }`
|
||||
|
||||
### Section: /supplier
|
||||
|
||||
#### List suppliers — `GET /supplier`
|
||||
_none_
|
||||
- Returns: `200 { suppliers: T38[] }`
|
||||
- Returns: `200 { suppliers: T39[] }`
|
||||
#### Create supplier — `POST /supplier`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { supplier: T38 }`
|
||||
- Returns: `201 { supplier: T39 }`
|
||||
#### Delete supplier — `DELETE /supplier/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update supplier — `PATCH /supplier/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { supplier: T38 }`
|
||||
- Returns: `200 { supplier: T39 }`
|
||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||
- Params: `id: int, query: skus?: str`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||
- Params: `id: int, body?: { skus?: str[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||
|
||||
### Section: /thumb
|
||||
|
||||
@@ -1048,24 +1213,39 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### List units — `GET /unit`
|
||||
_none_
|
||||
- Returns: `200 { units: T27[] }`
|
||||
- Returns: `200 { units: T29[] }`
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: T27 }`
|
||||
- Returns: `200 { unit: T29 }`
|
||||
|
||||
### Section: /v1
|
||||
|
||||
#### JTL-POS category synchronization — `GET /v1/category`
|
||||
- Params: `query: lastChangedCategory?: str, limit?: int`
|
||||
- Returns: `200 {}[]`
|
||||
#### POS category image delivery — `GET /v1/cimage`
|
||||
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
|
||||
- Returns: `200 jpeg`
|
||||
#### POS discovery and pairing — `GET /v1/client`
|
||||
- Params: `query: authCode: str, name?: str`
|
||||
- Params: `query: authCode: str, name?: str, clientversion?: str`
|
||||
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
|
||||
#### JTL-POS customer catalog synchronization — `GET /v1/customer`
|
||||
- Params: `query: lastChangedCustomer?: str, limit?: int`
|
||||
- Returns: `200 { id: str, customerNumber: str, firstname: str, lastname: str, title: str, company: str, address: str, addressSupplement: str, city: str, postalCode: str, state: str, country: str, phone: str, email: str, customerGroupId: str, salutation: str, birthday: str, discount: str, taxIdNumber: str, lastChanged: str, debtorNumber: str }[]`
|
||||
#### JTL-POS customer group synchronization — `GET /v1/customergroup`
|
||||
- Params: `query: lastChangedCustomerGroup?: str, limit?: int`
|
||||
- Returns: `200 { customerGroupId: str, name: str, standard: str, discountPercent: str, lastChanged: str }[]`
|
||||
#### JTL-POS deleted entity synchronization — `GET /v1/deletedentity`
|
||||
- Params: `query: lastChangedDeletedEntity?: str, limit?: int`
|
||||
- Returns: `200 { entityId: str, entityType: int, lastChanged: int }[]`
|
||||
#### POS sync initialization counts — `GET /v1/init`
|
||||
- Params: `query: lastChangedProduct?: int, lastChangedCategory?: int, lastChangedCustomer?: int, lastChangedCustomerGroup?: int, lastChangedCompositeProduct?: int, lastChangedDeletedEntity?: int`
|
||||
- Returns: `200 { version: str, product_count: str, category_count: str, customer_count: str, customerGroup_count: str, compositeProduct_count: str, configurationGroup_count: str, configurationItem_count: str, deletedEntity_count: str, max_orderId_count: str }`
|
||||
#### JTL-POS order upload — `POST /v1/order`
|
||||
- Params: `query: mandantId?: str, body: { orders?: {}[] }`
|
||||
- Returns: `200 { status: str, externalOrderId: str, message: str }[]`
|
||||
#### POS product image delivery — `GET /v1/pimage`
|
||||
- Params: `query: path: str, w?: int, h?: int, format?: str`
|
||||
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
|
||||
- Returns: `200 jpeg`
|
||||
#### JTL-POS product catalog synchronization — `GET /v1/product`
|
||||
- Params: `query: lastChangedProduct?: str, limit?: int`
|
||||
@@ -1127,3 +1307,9 @@ _none_
|
||||
#### Update warehouse — `PUT /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 { warehouse: { id: int, name: str, sort: int } }`
|
||||
|
||||
### Section: /zpljob
|
||||
|
||||
#### Create ZPL print job — `POST /zpljob`
|
||||
- Params: `body: { agentprinterId: int, data: str, copiescount?: int }`
|
||||
- Returns: `201 { printerjob: T22 }`
|
||||
|
||||
File diff suppressed because one or more lines are too long
3444
automat-graph.svg
3444
automat-graph.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 199 KiB |
@@ -2,6 +2,67 @@ import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
const banMuiTooltipRule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ban MUI Tooltip component',
|
||||
},
|
||||
messages: {
|
||||
badDesign: '<Tooltip> is bad design',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const muiTooltipIdentifiers = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === '@mui/material/Tooltip') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
for (const specifier of node.specifiers) {
|
||||
if (specifier.local?.name) {
|
||||
muiTooltipIdentifiers.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
} else if (node.source.value === '@mui/material') {
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported?.name === 'Tooltip'
|
||||
) {
|
||||
context.report({
|
||||
node: specifier,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
if (specifier.local?.name) {
|
||||
muiTooltipIdentifiers.add(specifier.local.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
JSXOpeningElement(node) {
|
||||
let tagName = '';
|
||||
if (node.name.type === 'JSXIdentifier') {
|
||||
tagName = node.name.name;
|
||||
} else if (node.name.type === 'JSXMemberExpression') {
|
||||
tagName = node.name.property.name;
|
||||
}
|
||||
|
||||
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'badDesign',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
@@ -32,10 +93,16 @@ export default [
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
react: {
|
||||
rules: {
|
||||
'no-mui-tooltip': banMuiTooltipRule,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react/no-mui-tooltip': 'error',
|
||||
'no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
|
||||
@@ -58,45 +58,30 @@ const CLUSTERS = [
|
||||
label: 'Session & Real-Time Hub',
|
||||
automats: ['authAutomat', 'sseAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'company_basedata',
|
||||
label: 'Company & Consolidated Base Data',
|
||||
automats: ['companyAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'catalog_products',
|
||||
label: 'Catalog & Products',
|
||||
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat', 'brandAutomat', 'unitAutomat'],
|
||||
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'search_indexing',
|
||||
label: 'Search & Indexing',
|
||||
automats: ['articleIndexAutomat', 'articleSearchAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'properties_attributes',
|
||||
label: 'Properties & Attributes',
|
||||
automats: ['propertyAutomat', 'attributeAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'purchasing_logistics',
|
||||
label: 'Suppliers & Deliveries',
|
||||
automats: ['supplierAutomat', 'deliveryAutomat', 'deliveryEditorAutomat'],
|
||||
label: 'Deliveries & Inbound',
|
||||
automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'sales_crm',
|
||||
label: 'Sales & CRM',
|
||||
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat', 'customergroupAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'inventory_storage',
|
||||
label: 'Inventory & Warehousing',
|
||||
automats: ['warehouseAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'finance_config',
|
||||
label: 'Company & Finance',
|
||||
automats: ['companyAutomat', 'accountAutomat', 'numberAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'hardware_pos',
|
||||
label: 'POS & Hardware',
|
||||
automats: ['posterminalAutomat'],
|
||||
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
|
||||
},
|
||||
{
|
||||
id: 'ai_workspace',
|
||||
@@ -110,8 +95,8 @@ const CLUSTERS = [
|
||||
},
|
||||
{
|
||||
id: 'system_ui',
|
||||
label: 'UI, Settings & System',
|
||||
automats: ['navigationAutomat', 'userSettingsAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
|
||||
label: 'UI, Navigation & System',
|
||||
automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -136,16 +121,18 @@ const AUTOMAT_SPECS = [
|
||||
{
|
||||
id: 'authAutomat',
|
||||
title: 'authAutomat',
|
||||
subtitle: 'Session & Multi-Tenant Gateway',
|
||||
subtitle: 'Always-On Session & Multi-Tenant Gateway',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company' },
|
||||
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'' },
|
||||
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
||||
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
||||
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
||||
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
|
||||
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
||||
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
||||
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
||||
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
||||
{ type: 'SELECT', name: 'selectSelectedCompanyId()', desc: 'Tenant selector' },
|
||||
{ type: 'HOOK', name: 'auth/sessionEstablished', desc: 'Session ready lifecycle', port: 'p_auth_status' },
|
||||
{ type: 'HOOK', name: 'auth/companyChanged', desc: 'Company change signal', port: 'p_auth_user' },
|
||||
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
||||
{ type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
|
||||
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
||||
],
|
||||
},
|
||||
@@ -166,7 +153,32 @@ const AUTOMAT_SPECS = [
|
||||
],
|
||||
},
|
||||
|
||||
// 2. Catalog & Products
|
||||
// 2. Company & Consolidated Base Data
|
||||
{
|
||||
id: 'companyAutomat',
|
||||
title: 'companyAutomat',
|
||||
subtitle: 'Consolidated Company & Base Data Hub',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' },
|
||||
{ type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' },
|
||||
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' },
|
||||
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' },
|
||||
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' },
|
||||
{ type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' },
|
||||
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' },
|
||||
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' },
|
||||
{ type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' },
|
||||
{ type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' },
|
||||
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' },
|
||||
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' },
|
||||
{ type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' },
|
||||
{ type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' },
|
||||
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
|
||||
{ type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' },
|
||||
],
|
||||
},
|
||||
|
||||
// 3. Catalog & Products
|
||||
{
|
||||
id: 'categoryAutomat',
|
||||
title: 'categoryAutomat',
|
||||
@@ -225,35 +237,8 @@ const AUTOMAT_SPECS = [
|
||||
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'brandAutomat',
|
||||
title: 'brandAutomat',
|
||||
subtitle: 'Catalog Brands & Makers',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Decoupled brand catalog' },
|
||||
{ type: 'STATE', name: 'filteredBrands: Brand[]', desc: 'Filtered search slice' },
|
||||
{ type: 'ACTION', name: 'load(silent)', desc: 'Deduplicated list fetch' },
|
||||
{ type: 'ACTION', name: 'create() / rename()', desc: 'Brand mutations' },
|
||||
{ type: 'ACTION', name: 'addImage() / deleteImage()', desc: 'Brand logo AVIF image' },
|
||||
{ type: 'API', name: 'GET /api/brand', desc: 'Paginated brand records' },
|
||||
{ type: 'SSE', name: 'brand', desc: 'SSE signal with sender tab' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'unitAutomat',
|
||||
title: 'unitAutomat',
|
||||
subtitle: 'Units of Measure (UoM)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units' },
|
||||
{ type: 'STATE', name: 'status: \'idle\'|\'ready\'', desc: 'Load lifecycle state' },
|
||||
{ type: 'ACTION', name: 'load()', desc: 'Fetch localized units' },
|
||||
{ type: 'ACTION', name: 'upsert(body)', desc: 'Create or update unit' },
|
||||
{ type: 'ACTION', name: 'delete(id)', desc: 'Remove unused unit' },
|
||||
{ type: 'API', name: 'GET /api/unit', desc: 'Company unit directory' },
|
||||
],
|
||||
},
|
||||
|
||||
// 3. Search & Discovery
|
||||
// 4. Search & Discovery
|
||||
{
|
||||
id: 'articleIndexAutomat',
|
||||
title: 'articleIndexAutomat',
|
||||
@@ -281,52 +266,11 @@ const AUTOMAT_SPECS = [
|
||||
],
|
||||
},
|
||||
|
||||
// 4. Properties & Attributes
|
||||
{
|
||||
id: 'propertyAutomat',
|
||||
title: 'propertyAutomat',
|
||||
subtitle: 'Custom Metadata Properties',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Sorted company properties' },
|
||||
{ type: 'ACTION', name: 'load()', desc: 'Fetch property definitions' },
|
||||
{ type: 'ACTION', name: 'createProperty()', desc: 'Add new property definition' },
|
||||
{ type: 'ACTION', name: 'updateProperty()', desc: 'Edit title & sort' },
|
||||
{ type: 'API', name: 'GET /api/propertyname', desc: 'Company properties' },
|
||||
{ type: 'SSE', name: 'propertyname', desc: 'Real-time property signal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'attributeAutomat',
|
||||
title: 'attributeAutomat',
|
||||
subtitle: 'Product Attributes & Discrete Values',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes with sorted values' },
|
||||
{ type: 'ACTION', name: 'load()', desc: 'Fetch attribute definitions' },
|
||||
{ type: 'ACTION', name: 'createAttribute()', desc: 'Create attribute group' },
|
||||
{ type: 'ACTION', name: 'addValue() / deleteValue()', desc: 'Discrete value options' },
|
||||
{ type: 'API', name: 'GET /api/attributename', desc: 'Attributes & values' },
|
||||
{ type: 'SSE', name: 'attributename', desc: 'Real-time attribute signal' },
|
||||
],
|
||||
},
|
||||
|
||||
// 5. Suppliers & Deliveries
|
||||
{
|
||||
id: 'supplierAutomat',
|
||||
title: 'supplierAutomat',
|
||||
subtitle: 'Supplier Directory & Catalogs',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Active vendor list' },
|
||||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Load lifecycle state' },
|
||||
{ type: 'ACTION', name: 'load()', desc: 'Deduplicated fetch' },
|
||||
{ type: 'ACTION', name: 'reload()', desc: 'Silent background sync' },
|
||||
{ type: 'API', name: 'GET /api/supplier', desc: 'Vendor records' },
|
||||
{ type: 'SSE', name: 'supplier', desc: 'Real-time supplier signal' },
|
||||
],
|
||||
},
|
||||
// 5. Deliveries & Inbound
|
||||
{
|
||||
id: 'deliveryAutomat',
|
||||
title: 'deliveryAutomat',
|
||||
subtitle: 'createListAutomat (Deliveries)',
|
||||
subtitle: 'Chunk-Paginated Deliveries',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
|
||||
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
|
||||
@@ -355,7 +299,7 @@ const AUTOMAT_SPECS = [
|
||||
{
|
||||
id: 'quoteAutomat',
|
||||
title: 'quoteAutomat',
|
||||
subtitle: 'createListAutomat (Quotes)',
|
||||
subtitle: 'Chunk-Paginated Quotes',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' },
|
||||
@@ -381,7 +325,7 @@ const AUTOMAT_SPECS = [
|
||||
{
|
||||
id: 'entityAutomat',
|
||||
title: 'entityAutomat',
|
||||
subtitle: 'createListAutomat (Entities)',
|
||||
subtitle: 'Customer & Partner Directory',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
|
||||
@@ -389,83 +333,8 @@ const AUTOMAT_SPECS = [
|
||||
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'customergroupAutomat',
|
||||
title: 'customergroupAutomat',
|
||||
subtitle: 'createListAutomat (Groups)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Customergroup[]', desc: 'Pricing discount tiers' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Discount group load' },
|
||||
{ type: 'API', name: 'GET /api/customergroup', desc: 'Pricing tiers' },
|
||||
],
|
||||
},
|
||||
|
||||
// 7. Inventory & Storage
|
||||
{
|
||||
id: 'warehouseAutomat',
|
||||
title: 'warehouseAutomat',
|
||||
subtitle: 'Warehouse Locations & Stock',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage areas & bins' },
|
||||
{ type: 'STATE', name: 'filteredWarehouses', desc: 'Filter search slice' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated sync' },
|
||||
{ type: 'ACTION', name: 'setFilter(query)', desc: 'Local filter query' },
|
||||
{ type: 'API', name: 'GET /api/warehouse', desc: 'Warehouse listings' },
|
||||
{ type: 'SSE', name: 'warehouse', desc: 'Real-time stock movement' },
|
||||
],
|
||||
},
|
||||
|
||||
// 8. Company & Finance
|
||||
{
|
||||
id: 'companyAutomat',
|
||||
title: 'companyAutomat',
|
||||
subtitle: 'Active Company Configuration',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates & ISO language', port: 'p_comp_curr' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Fetch tenant settings' },
|
||||
{ type: 'SELECT', name: 'selectCompanyTaxrate()', desc: 'Standard tax rate' },
|
||||
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
|
||||
{ type: 'SELECT', name: 'selectCompanyName()', desc: 'Company legal name' },
|
||||
{ type: 'API', name: 'GET /api/company/:id', desc: 'Legal & tax profile' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'accountAutomat',
|
||||
title: 'accountAutomat',
|
||||
subtitle: 'createListAutomat (Accounts)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: Account[]', desc: 'Bank & payment ledgers' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Payment accounts load' },
|
||||
{ type: 'API', name: 'GET /api/account', desc: 'Chart of accounts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'numberAutomat',
|
||||
title: 'numberAutomat',
|
||||
subtitle: 'createListAutomat (Sequences)',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'items: NumberSeq[]', desc: 'Invoice/receipt counters' },
|
||||
{ type: 'ACTION', name: 'load() / reload()', desc: 'Number series sync' },
|
||||
{ type: 'API', name: 'GET /api/number', desc: 'Document counters' },
|
||||
],
|
||||
},
|
||||
|
||||
// 9. POS & Hardware
|
||||
{
|
||||
id: 'posterminalAutomat',
|
||||
title: 'posterminalAutomat',
|
||||
subtitle: 'POS Terminals & Gating',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'Hardware terminals', port: 'p_pos_term' },
|
||||
{ type: 'ACTION', name: 'load()', desc: 'Fetch POS registers' },
|
||||
{ type: 'ACTION', name: 'create() / delete()', desc: 'Terminal registrations' },
|
||||
{ type: 'ACTION', name: 'toggleCategory()', desc: 'Gated category filters' },
|
||||
{ type: 'SELECT', name: 'selectPosterminals()', desc: 'Active registers' },
|
||||
{ type: 'API', name: 'GET /api/posterminal', desc: 'Terminal device configs' },
|
||||
],
|
||||
},
|
||||
|
||||
// 10. AI Assistant & Workspace
|
||||
// 7. AI Assistant & Workspace
|
||||
{
|
||||
id: 'chatAutomat',
|
||||
title: 'chatAutomat',
|
||||
@@ -497,7 +366,7 @@ const AUTOMAT_SPECS = [
|
||||
],
|
||||
},
|
||||
|
||||
// 11. Audit & Virtual Grids
|
||||
// 8. Audit & Virtual Grids
|
||||
{
|
||||
id: 'articleHistoryGridAutomat',
|
||||
title: 'articleHistoryGridAutomat',
|
||||
@@ -532,7 +401,7 @@ const AUTOMAT_SPECS = [
|
||||
],
|
||||
},
|
||||
|
||||
// 12. UI, Settings & System
|
||||
// 9. UI, Navigation & System
|
||||
{
|
||||
id: 'navigationAutomat',
|
||||
title: 'navigationAutomat',
|
||||
@@ -547,18 +416,6 @@ const AUTOMAT_SPECS = [
|
||||
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'userSettingsAutomat',
|
||||
title: 'userSettingsAutomat',
|
||||
subtitle: 'IndexedDB User Preferences',
|
||||
entries: [
|
||||
{ type: 'STATE', name: 'viewerPosition: \'top\'|\'right\'', desc: 'Editor docking location' },
|
||||
{ type: 'STATE', name: 'chatDrawerWidth: 460', desc: 'AI drawer pixel width', port: 'p_usr_drawer' },
|
||||
{ type: 'ACTION', name: 'setViewerPosition()', desc: 'Persist docking' },
|
||||
{ type: 'ACTION', name: 'setChatDrawerWidth()', desc: 'Persist drawer width' },
|
||||
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'helpAutomat',
|
||||
title: 'helpAutomat',
|
||||
@@ -595,23 +452,13 @@ const AUTOMAT_SPECS = [
|
||||
*/
|
||||
const EDGE_DEFS = [
|
||||
// Session & Auth lifecycle
|
||||
{ from: 'authAutomat', to: 'sseAutomat', label: 'bind session & cid', port: 'p_auth_user' },
|
||||
{ from: 'authAutomat', to: 'companyAutomat', label: 'load company profile', port: 'p_auth_switchCompany' },
|
||||
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'brandAutomat', label: 'sync brands on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'unitAutomat', label: 'sync units on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'attributeAutomat', label: 'sync attributes on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'propertyAutomat', label: 'sync properties on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'supplierAutomat', label: 'sync suppliers on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'warehouseAutomat', label: 'sync warehouses on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'posterminalAutomat', label: 'sync terminals on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' },
|
||||
{ from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' },
|
||||
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' },
|
||||
{ from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'customergroupAutomat', label: 'sync customer groups', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'numberAutomat', label: 'sync number series', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'accountAutomat', label: 'sync payment accounts', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' },
|
||||
{ from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' },
|
||||
@@ -624,16 +471,12 @@ const EDGE_DEFS = [
|
||||
{ from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'warehouseAutomat', label: '\'warehouse\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'supplierAutomat', label: '\'supplier\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'brandAutomat', label: '\'brand\' sse trigger', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'attributeAutomat', label: '\'attributename\' sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'propertyAutomat', label: '\'propertyname\' sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' },
|
||||
{ from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' },
|
||||
|
||||
// Catalog, Indexing & Search pipeline
|
||||
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId', port: 'p_comp_root' },
|
||||
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' },
|
||||
{ from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' },
|
||||
{ from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' },
|
||||
{ from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' },
|
||||
@@ -643,18 +486,13 @@ const EDGE_DEFS = [
|
||||
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
|
||||
|
||||
// Purchasing & Deliveries
|
||||
{ from: 'deliveryEditorAutomat', to: 'supplierAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
|
||||
{ from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
|
||||
{ from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' },
|
||||
{ from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' },
|
||||
|
||||
// Sales & CRM
|
||||
{ from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' },
|
||||
{ from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' },
|
||||
{ from: 'companyAutomat', to: 'customergroupAutomat', label: 'poscustomergroup', port: 'p_comp_curr' },
|
||||
|
||||
// POS & Hardware
|
||||
{ from: 'posterminalAutomat', to: 'categoryAutomat', label: 'filter enabled roots', port: 'p_pos_term' },
|
||||
{ from: 'companyAutomat', to: 'posterminalAutomat', label: 'company context', port: 'p_comp_curr' },
|
||||
|
||||
// AI Assistant & Workspace
|
||||
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
|
||||
@@ -665,7 +503,7 @@ const EDGE_DEFS = [
|
||||
{ from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' },
|
||||
{ from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' },
|
||||
{ from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' },
|
||||
{ from: 'userSettingsAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_usr_drawer' },
|
||||
{ from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
|
||||
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
|
||||
|
||||
// Self-loops
|
||||
|
||||
1652
package-lock.json
generated
1652
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
||||
"cert": "./scripts/generate-cert.sh",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||
"rig:collect": "node test/collect.mjs",
|
||||
@@ -33,8 +34,6 @@
|
||||
"@mui/material": "^9.5.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"@uiw/react-codemirror": "^4.25.12",
|
||||
"ag-grid-community": "^36.2.0",
|
||||
"ag-grid-react": "^36.2.0",
|
||||
"dashgui": "file:../dashgui",
|
||||
"flexsearch": "^0.8.212",
|
||||
"jsbarcode": "^3.12.3",
|
||||
@@ -42,12 +41,11 @@
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
"react": "^19.3.1",
|
||||
"react-automat": "file:../automat",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
||||
"react-window": "^2.3.4",
|
||||
"skelehash-wasm": "^0.1.0",
|
||||
"skelehash-wasm": "^0.1.1",
|
||||
"sortablejs": "^1.15.7",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
@@ -64,6 +62,7 @@
|
||||
},
|
||||
"allowScripts": {
|
||||
"canvas@3.2.3": true,
|
||||
"react-canvas-label-editor@20.0.0": true,
|
||||
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
||||
}
|
||||
}
|
||||
|
||||
72
src/App.jsx
72
src/App.jsx
@@ -1,11 +1,16 @@
|
||||
import { PureComponent, Suspense } from 'react';
|
||||
import { PureComponent, lazy, Suspense } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import authAutomat from './automats/authAutomat';
|
||||
import LoginPage from './components/LoginPage';
|
||||
import CompanySetupDialog from './components/CompanySetupDialog';
|
||||
import { router } from './appRouter';
|
||||
import AppRouter from './appRouter';
|
||||
|
||||
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
|
||||
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||
const SseMonitor = import.meta.env.DEV
|
||||
? lazy(() => import('./devtools/SseMonitor'))
|
||||
: null;
|
||||
|
||||
function PageFallback() {
|
||||
return (
|
||||
@@ -30,7 +35,13 @@ class App extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
|
||||
this.unsubscribe = authAutomat.subscribe(this, [
|
||||
'status',
|
||||
'user',
|
||||
'hasAuth',
|
||||
'hasCompany',
|
||||
'retryNotification',
|
||||
]);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -38,25 +49,44 @@ class App extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { status, user } = this.state;
|
||||
const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
|
||||
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
|
||||
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 'var(--df-vh, 100vh)',
|
||||
bgcolor: 'background.default',
|
||||
gap: 2.5,
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
{retryNotification ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="outlined"
|
||||
sx={{
|
||||
maxWidth: 480,
|
||||
alignItems: 'center',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 1,
|
||||
}}
|
||||
>
|
||||
{retryNotification}
|
||||
</Alert>
|
||||
) : null}
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'authed') {
|
||||
if (!isAuthed) {
|
||||
if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
|
||||
const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
|
||||
@@ -64,16 +94,28 @@ class App extends PureComponent {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
|
||||
|
||||
if (user?.selectedCompanyId == null && !isOAuthAuthorize) {
|
||||
return <CompanySetupDialog />;
|
||||
}
|
||||
const isOAuthAuthorize =
|
||||
typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<RouterProvider router={router} />
|
||||
</Suspense>
|
||||
<>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
{!isCompanySelected ? (
|
||||
isOAuthAuthorize ? (
|
||||
<OAuthAuthorizePage />
|
||||
) : (
|
||||
<CompanySetupDialog />
|
||||
)
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</Suspense>
|
||||
{SseMonitor ? (
|
||||
<Suspense fallback={null}>
|
||||
<SseMonitor />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
69
src/api/agentprinter.js
Normal file
69
src/api/agentprinter.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const agentprinter = {
|
||||
/**
|
||||
* GET /api/agentprinter
|
||||
* Query: ?printeragentId=<id>
|
||||
* → { agentprinters: Agentprinter[] }
|
||||
*/
|
||||
list(printeragentId) {
|
||||
const url =
|
||||
printeragentId != null
|
||||
? `/api/agentprinter?printeragentId=${encodeURIComponent(printeragentId)}`
|
||||
: '/api/agentprinter';
|
||||
return http(url);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/agentprinter/:id
|
||||
* → { agentprinter: Agentprinter }
|
||||
*/
|
||||
get(id) {
|
||||
return http(`/api/agentprinter/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/agentprinter/:id
|
||||
* Body: { enabled?: boolean, sort?: number }
|
||||
* → { agentprinter: Agentprinter }
|
||||
*/
|
||||
update(id, patch = {}) {
|
||||
const body = {};
|
||||
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||
body.sort = Number(patch.sort);
|
||||
}
|
||||
return http(`/api/agentprinter/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/agentprinter/order
|
||||
* Body: { printeragentId?: number, agentprinterIds: number[] }
|
||||
* → { agentprinterIds: number[] }
|
||||
*/
|
||||
order({ printeragentId, agentprinterIds }) {
|
||||
const body = {
|
||||
agentprinterIds: Array.isArray(agentprinterIds) ? agentprinterIds.map(Number) : [],
|
||||
};
|
||||
if (printeragentId != null) {
|
||||
body.printeragentId = Number(printeragentId);
|
||||
}
|
||||
return http('/api/agentprinter/order', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/agentprinter/:id → 204
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/agentprinter/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,16 +50,24 @@ export const article = {
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/history
|
||||
* GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* @param {string} sku
|
||||
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
|
||||
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
|
||||
*/
|
||||
getBySkuHistory(sku, { limit } = {}) {
|
||||
getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
/** POST /api/article → 201 { article } */
|
||||
/** POST /api/article → 201 { sku } */
|
||||
create({ categoryId, name, track_stock }) {
|
||||
return http('/api/article', {
|
||||
method: 'POST',
|
||||
@@ -75,7 +83,7 @@ export const article = {
|
||||
* PATCH /api/article/:sku
|
||||
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
|
||||
* unitSi must exist for selected company → 404 Unit not found
|
||||
* → 200 { article } (same detail shape as GET: includes desc, seo)
|
||||
* → 204 (no body; refetch via getBySku)
|
||||
*/
|
||||
update(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`, {
|
||||
@@ -87,7 +95,7 @@ export const article = {
|
||||
/**
|
||||
* PUT /api/article/:sku/track_stock
|
||||
* Body: { track_stock: boolean }
|
||||
* → 200 { article }
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setTrackStock(sku, body) {
|
||||
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
|
||||
@@ -147,7 +155,7 @@ export const article = {
|
||||
* PUT /api/article/:sku/prices
|
||||
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
|
||||
* Replace-all POS customer group prices. Empty prices clears all POS prices.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setPrices(sku, prices) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
|
||||
@@ -158,7 +166,7 @@ export const article = {
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/weight
|
||||
* Body: { weight } → 200 { article }
|
||||
* Body: { weight } → 204 (no body)
|
||||
*/
|
||||
setWeight(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
|
||||
@@ -171,7 +179,7 @@ export const article = {
|
||||
* PUT /api/article/:sku/brand
|
||||
* Body: { brandId: number | null } — null clears the brand.
|
||||
* brandId must belong to the article's company → 404 Article or brand not found.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setBrandIdBySku(sku, brandId) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
|
||||
@@ -184,7 +192,7 @@ export const article = {
|
||||
* PUT /api/article/:sku/eans
|
||||
* Body: { eans: string[] } — replace-all. Empty array clears all.
|
||||
* EANs must be non-empty unique strings → 400 Invalid eans.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setEansBySku(sku, eans) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
|
||||
@@ -197,7 +205,7 @@ export const article = {
|
||||
* PUT /api/article/:sku/categories
|
||||
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
|
||||
* Categories must belong to the article's company; ids must be unique.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
* → 204 (no body)
|
||||
*/
|
||||
setCategories(sku, categoryIds) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
|
||||
@@ -342,7 +350,7 @@ export const article = {
|
||||
|
||||
/**
|
||||
* POST /api/article/:sku/parent
|
||||
* Body: { isParent: boolean, attributeNameId?: number }
|
||||
* Body: { isParent: boolean, attributeNameId?: number } → 204 (no body)
|
||||
*/
|
||||
toggleParent(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
|
||||
@@ -355,6 +363,7 @@ export const article = {
|
||||
/**
|
||||
* POST /api/article/:sku/children
|
||||
* Body: { attributeValueId?: number, value?: string, name?: string }
|
||||
* → 201 { sku }
|
||||
*/
|
||||
addChild(sku, body) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
|
||||
|
||||
@@ -8,12 +8,18 @@ export const auth = {
|
||||
return {
|
||||
user: data.user ?? null,
|
||||
googleClientId: data.googleClientId ?? null,
|
||||
schemaVersion: data.schemaVersion ?? null,
|
||||
vhost: Boolean(data.vhost),
|
||||
vhostSettings: data.vhostSettings ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.status === 401 || err.status === 403) {
|
||||
return {
|
||||
user: null,
|
||||
googleClientId: err.data?.googleClientId ?? null,
|
||||
schemaVersion: err.data?.schemaVersion ?? null,
|
||||
vhost: Boolean(err.data?.vhost),
|
||||
vhostSettings: err.data?.vhostSettings ?? null,
|
||||
forbidden: err.status === 403,
|
||||
message: err.message,
|
||||
};
|
||||
|
||||
24
src/api/autoinvite.js
Normal file
24
src/api/autoinvite.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Pending invites for emails that are not registered yet (admin only).
|
||||
*/
|
||||
export const autoinvite = {
|
||||
// #region GET /api/autoinvite — list pending invites
|
||||
list() {
|
||||
return http('/api/autoinvite');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/autoinvite — create an invite
|
||||
create(body) {
|
||||
return http('/api/autoinvite', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/autoinvite/:id — delete an invite
|
||||
delete(id) {
|
||||
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
@@ -125,6 +125,21 @@ export const category = {
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/category/:id/image/generate
|
||||
* Body (optional): { size?: 2 | 3 | 4 }
|
||||
* → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } }
|
||||
*/
|
||||
// #region POST /api/category/:id/image/generate — generateImage
|
||||
generateImage(categoryId, { size } = {}) {
|
||||
return http(`/api/category/${categoryId}/image/generate`, {
|
||||
method: 'POST',
|
||||
body: size != null ? { size } : undefined,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/category/export.zip → application/zip (binary download)
|
||||
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,
|
||||
|
||||
30
src/api/companyuser.js
Normal file
30
src/api/companyuser.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Company members (admin only).
|
||||
*/
|
||||
export const companyuser = {
|
||||
// #region GET /api/companyuser — list members
|
||||
list() {
|
||||
return http('/api/companyuser');
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/companyuser — add an existing user by email
|
||||
create(body) {
|
||||
return http('/api/companyuser', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PATCH /api/companyuser/:id — change role
|
||||
setRole(id, role) {
|
||||
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region DELETE /api/companyuser/:id — remove a member
|
||||
delete(id) {
|
||||
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
@@ -15,8 +15,12 @@ export { number } from './number.js';
|
||||
export { order } from './order.js';
|
||||
export { posterminal } from './posterminal.js';
|
||||
export { printer } from './printer.js';
|
||||
export { printeragent } from './printeragent.js';
|
||||
export { agentprinter } from './agentprinter.js';
|
||||
export { printerjob } from './printerjob.js';
|
||||
export { propertyname } from './propertyname.js';
|
||||
export { quote } from './quote.js';
|
||||
export { retoure } from './retoure.js';
|
||||
export { setting } from './setting.js';
|
||||
export { serper } from './serper.js';
|
||||
export { supplier } from './supplier.js';
|
||||
@@ -30,6 +34,8 @@ export { shipment } from './shipment.js';
|
||||
export { shipping } from './shipping.js';
|
||||
export { payment } from './payment.js';
|
||||
export { account } from './account.js';
|
||||
export { companyuser } from './companyuser.js';
|
||||
export { autoinvite } from './autoinvite.js';
|
||||
export { mcp } from './mcp.js';
|
||||
export { backup } from './backup.js';
|
||||
export { carrier } from './carrier.js';
|
||||
|
||||
@@ -5,10 +5,11 @@ import { http } from './http.js';
|
||||
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
||||
*/
|
||||
export const labeltemplate = {
|
||||
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
|
||||
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
|
||||
// #region GET /api/labeltemplate — list
|
||||
list() {
|
||||
return http('/api/labeltemplate');
|
||||
list(params) {
|
||||
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
|
||||
return http(`/api/labeltemplate${search}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
@@ -48,5 +49,23 @@ export const labeltemplate = {
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
|
||||
* @param {number|object} idOrBody
|
||||
* @param {object} [maybeBody]
|
||||
* → 200 { success: true, printerjob?: PrinterJob }
|
||||
*/
|
||||
// #region POST /api/labeltemplate/testprint — testprint
|
||||
testprint(idOrBody, maybeBody) {
|
||||
if (typeof idOrBody === 'object' && idOrBody !== null) {
|
||||
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
|
||||
}
|
||||
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
|
||||
method: 'POST',
|
||||
body: maybeBody || {},
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// thumb is returned inline as a base64 string in list/get responses
|
||||
};
|
||||
|
||||
@@ -93,7 +93,7 @@ export const order = {
|
||||
* POST /api/order/:id/shipment
|
||||
* Creates shipment from order.
|
||||
* @param {number|string} id Order ID
|
||||
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku: string, amount?: number }> }
|
||||
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }> }
|
||||
* → 201 { shipment: Shipment }
|
||||
*/
|
||||
// #region POST /api/order/:id/shipment — createShipment
|
||||
|
||||
@@ -11,8 +11,17 @@ export const payment = {
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/order/:id/payment — create
|
||||
create(orderId, body) {
|
||||
return http(`/api/order/${orderId}/payment`, { method: 'POST', body });
|
||||
create(orderIdOrBody, maybeBody) {
|
||||
if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
|
||||
return http('/api/payment', { method: 'POST', body: orderIdOrBody });
|
||||
}
|
||||
return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region POST /api/payment — register
|
||||
register(body) {
|
||||
return http('/api/payment', { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { http } from './http.js';
|
||||
|
||||
export const posterminal = {
|
||||
/**
|
||||
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
|
||||
* List all terminals for the selected company.
|
||||
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
|
||||
* List all terminals for the selected company, sorted by sort ASC, id ASC.
|
||||
*/
|
||||
// #region GET /api/posterminal — list
|
||||
list() {
|
||||
@@ -13,24 +13,29 @@ export const posterminal = {
|
||||
|
||||
/**
|
||||
* PUT /api/posterminal
|
||||
* Body: { name: string }
|
||||
* Body: { name: string, sort?: number, enabled?: boolean, rootCategoryId?: number|null, syncRootArticles?: boolean }
|
||||
* Creates a new terminal (auto-generates 6-digit pin).
|
||||
* → 200 { posterminal: { id, name, token, pairingpin } }
|
||||
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||
*/
|
||||
// #region PUT /api/posterminal — create
|
||||
create({ name }) {
|
||||
create({ name, sort, enabled, rootCategoryId, syncRootArticles } = {}) {
|
||||
const body = { name };
|
||||
if (sort !== undefined) body.sort = Number(sort);
|
||||
if (enabled !== undefined) body.enabled = Boolean(enabled);
|
||||
if (rootCategoryId !== undefined) body.rootCategoryId = rootCategoryId != null ? Number(rootCategoryId) : null;
|
||||
if (syncRootArticles !== undefined) body.syncRootArticles = Boolean(syncRootArticles);
|
||||
return http('/api/posterminal', {
|
||||
method: 'PUT',
|
||||
body: { name },
|
||||
body,
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PATCH /api/posterminal/:id
|
||||
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
|
||||
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
|
||||
* Update posterminal configuration.
|
||||
* → 200 { posterminal }
|
||||
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||
*/
|
||||
// #region PATCH /api/posterminal/:id — update
|
||||
update(terminalId, patch) {
|
||||
|
||||
@@ -51,4 +51,29 @@ export const printer = {
|
||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/printer/state
|
||||
* Query: ?serial=, ?agentId=
|
||||
* → { printers: LivePrinter[] }
|
||||
*/
|
||||
// #region GET /api/printer/state — live state
|
||||
state({ serial, agentId } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (serial) params.set('serial', serial);
|
||||
if (agentId != null) params.set('agentId', agentId);
|
||||
const query = params.toString();
|
||||
return http(`/api/printer/state${query ? `?${query}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/printer/:id/state
|
||||
* → { printer: LivePrinter }
|
||||
*/
|
||||
// #region GET /api/printer/:id/state — single printer state
|
||||
printerState(id) {
|
||||
return http(`/api/printer/${id}/state`);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
58
src/api/printeragent.js
Normal file
58
src/api/printeragent.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const printeragent = {
|
||||
/**
|
||||
* GET /api/printeragent → { printeragents: Printeragent[] }
|
||||
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
|
||||
*/
|
||||
list() {
|
||||
return http('/api/printeragent');
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/printeragent
|
||||
* Body: { name: string, sort?: number }
|
||||
* Creates a new printer agent (auto-generates 6-digit pairingpin).
|
||||
* → 200 { printeragent: Printeragent }
|
||||
*/
|
||||
create({ name, sort } = {}) {
|
||||
const body = { name };
|
||||
if (sort !== undefined && sort !== null && sort !== '') {
|
||||
body.sort = Number(sort);
|
||||
}
|
||||
return http('/api/printeragent', {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PATCH /api/printeragent/:id
|
||||
* Body: { name?: string, sort?: number, enabled?: boolean }
|
||||
* Updates name, sort, enabled, or any combination.
|
||||
* → 200 { printeragent: Printeragent }
|
||||
*/
|
||||
update(id, patch = {}) {
|
||||
const body = {};
|
||||
if (patch.name !== undefined) body.name = patch.name;
|
||||
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||
body.sort = Number(patch.sort);
|
||||
}
|
||||
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||
return http(`/api/printeragent/${id}`, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/printeragent/:id → 204 No Content
|
||||
* Permanently deletes agent.
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/printeragent/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
142
src/api/printerjob.js
Normal file
142
src/api/printerjob.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
export const printerjob = {
|
||||
/**
|
||||
* POST /api/zpljob
|
||||
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
createZplJob({ agentprinterId, data, copiescount = 1 }) {
|
||||
return http('/api/zpljob', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
agentprinterId: Number(agentprinterId),
|
||||
data: String(data),
|
||||
copiescount: Number(copiescount) || 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Sends a minimal ZPL test label to a specific agent printer.
|
||||
* @param {number} agentprinterId
|
||||
* @param {{ model?: string }} [options]
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
|
||||
const timeStr = new Date().toLocaleTimeString('de-DE');
|
||||
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
|
||||
const zpl = [
|
||||
'^XA',
|
||||
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
|
||||
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
|
||||
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
|
||||
'^XZ',
|
||||
].join('\n');
|
||||
return this.createZplJob({
|
||||
agentprinterId,
|
||||
data: zpl,
|
||||
copiescount: 1,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/printerjob
|
||||
* @param {{ agentprinterId?: number, state?: string }} [query]
|
||||
* → { printerjobs: PrinterJob[] }
|
||||
*/
|
||||
list(query = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
|
||||
if (query.state != null) params.set('state', String(query.state));
|
||||
const qs = params.toString();
|
||||
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/printerjob/:id
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
get(id) {
|
||||
return http(`/api/printerjob/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob
|
||||
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
|
||||
* → { printerjob: PrinterJob }
|
||||
*/
|
||||
create({ agentprinterId, type = 'zpl', data }) {
|
||||
return http('/api/printerjob', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
agentprinterId: Number(agentprinterId),
|
||||
type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob/:id/cancel
|
||||
* → { success: true, printerjob: PrinterJob }
|
||||
*/
|
||||
cancel(id) {
|
||||
return http(`/api/printerjob/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* POST /api/printerjob/cancel-all
|
||||
* Sends ~JA to clear printer buffer and cancels all queued jobs.
|
||||
* @param {{ agentprinterId?: number }} [payload]
|
||||
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
|
||||
*/
|
||||
async cancelAll({ agentprinterId } = {}) {
|
||||
if (agentprinterId != null) {
|
||||
try {
|
||||
await this.createZplJob({
|
||||
agentprinterId: Number(agentprinterId),
|
||||
data: '~JA',
|
||||
copiescount: 1,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to send ~JA to printer:', e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await http('/api/printerjob/cancel-all', {
|
||||
method: 'POST',
|
||||
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.status === 404) {
|
||||
const { printerjobs = [] } = await this.list({ agentprinterId });
|
||||
const cancellable = printerjobs.filter(
|
||||
(j) => j.state === 'pending' || j.state === 'processing',
|
||||
);
|
||||
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
|
||||
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
|
||||
return {
|
||||
success: true,
|
||||
cancelledCount: results.length,
|
||||
jobs: results.map((r) => r.printerjob),
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE /api/printerjob/:id
|
||||
* → { success: true, deletedJob: PrinterJob }
|
||||
*/
|
||||
delete(id) {
|
||||
return http(`/api/printerjob/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
187
src/api/retoure.js
Normal file
187
src/api/retoure.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { http } from './http.js';
|
||||
|
||||
/**
|
||||
* Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
|
||||
* Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
|
||||
* positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
|
||||
*/
|
||||
export function normalizeRetoureSummary(row) {
|
||||
if (!row) return null;
|
||||
const id = row.manifest_id ?? row.id;
|
||||
const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
|
||||
const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
|
||||
const entityId = row.entity_id ?? row.entityId ?? null;
|
||||
const customerName = row.customer_name ?? row.customerName ?? '';
|
||||
const positions = Number(row.positions) || 0;
|
||||
const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
|
||||
const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
|
||||
const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
|
||||
const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
|
||||
const isPaid = Boolean(row.is_paid ?? row.isPaid);
|
||||
const isShipped = Boolean(row.is_shipped ?? row.isShipped);
|
||||
const createdAt = row.created_at ?? row.createdAt ?? null;
|
||||
|
||||
return {
|
||||
id,
|
||||
manifest_id: id,
|
||||
number: num,
|
||||
retoure_number: num,
|
||||
date: dt,
|
||||
retoure_date: dt,
|
||||
entityId,
|
||||
entity_id: entityId,
|
||||
customerName,
|
||||
customer_name: customerName,
|
||||
positions,
|
||||
grossTotal,
|
||||
gross_total: grossTotal,
|
||||
paidTotal,
|
||||
paid_total: paidTotal,
|
||||
orderedQuantity,
|
||||
ordered_quantity: orderedQuantity,
|
||||
shippedQuantity,
|
||||
shipped_quantity: shippedQuantity,
|
||||
isPaid,
|
||||
is_paid: isPaid,
|
||||
isShipped,
|
||||
is_shipped: isShipped,
|
||||
createdAt,
|
||||
created_at: createdAt,
|
||||
lines: row.lines ?? [],
|
||||
files: row.files ?? [],
|
||||
shipments: row.shipments ?? [],
|
||||
payments: row.payments ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer Returns / Retouren (manifest type = retoure).
|
||||
*/
|
||||
export const retoure = {
|
||||
/**
|
||||
* GET /api/retoure
|
||||
* Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
|
||||
* → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
|
||||
*/
|
||||
// #region GET /api/retoure — list
|
||||
async list({
|
||||
q,
|
||||
search,
|
||||
from,
|
||||
to,
|
||||
isPaid,
|
||||
isShipped,
|
||||
onlyUnpaid,
|
||||
onlyUndelivered,
|
||||
page,
|
||||
pageSize,
|
||||
offset,
|
||||
limit,
|
||||
entityId,
|
||||
seekMonths,
|
||||
} = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const query = q || search;
|
||||
if (query && String(query).trim()) {
|
||||
params.set('q', String(query).trim());
|
||||
params.set('search', String(query).trim());
|
||||
}
|
||||
if (from) params.set('from', String(from));
|
||||
if (to) params.set('to', String(to));
|
||||
if (isPaid !== undefined && isPaid !== null) {
|
||||
params.set('isPaid', String(isPaid));
|
||||
} else if (onlyUnpaid === true || onlyUnpaid === 'true') {
|
||||
params.set('isPaid', 'false');
|
||||
}
|
||||
if (isShipped !== undefined && isShipped !== null) {
|
||||
params.set('isShipped', String(isShipped));
|
||||
} else if (onlyUndelivered === true || onlyUndelivered === 'true') {
|
||||
params.set('isShipped', 'false');
|
||||
}
|
||||
if (page != null) params.set('page', String(page));
|
||||
if (pageSize != null) params.set('pageSize', String(pageSize));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
if (entityId != null) params.set('entityId', String(entityId));
|
||||
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
|
||||
const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
|
||||
const items = rawList.map(normalizeRetoureSummary);
|
||||
const total = Number(res?.total ?? res?.count ?? items.length);
|
||||
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||
|
||||
return {
|
||||
retoures: items,
|
||||
summaries: items,
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
raw: res,
|
||||
};
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** GET /api/retoure/:id → { retoure: Retoure } */
|
||||
// #region GET /api/retoure/:id — get
|
||||
async get(id) {
|
||||
const res = await http(`/api/retoure/${id}`);
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/retoure
|
||||
* Body: { number?, date, entityId?, externalNumber?, lines }
|
||||
* → 201 { retoure: Retoure }
|
||||
*/
|
||||
// #region POST /api/retoure — create
|
||||
async create(body) {
|
||||
const res = await http('/api/retoure', { method: 'POST', body });
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* PUT /api/retoure/:id
|
||||
* Body: { number?, date?, entityId?, externalNumber?, lines? }
|
||||
* → 200 { retoure: Retoure }
|
||||
*/
|
||||
// #region PUT /api/retoure/:id — update
|
||||
async update(id, body) {
|
||||
const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
|
||||
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
|
||||
return { retoure: res };
|
||||
}
|
||||
return res;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/** DELETE /api/retoure/:id → 204 */
|
||||
// #region DELETE /api/retoure/:id — delete
|
||||
delete(id) {
|
||||
return http(`/api/retoure/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/retoure/:id/shipment
|
||||
* Creates return shipment.
|
||||
* Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
|
||||
* → 201 { shipment: Shipment }
|
||||
*/
|
||||
// #region POST /api/retoure/:id/shipment — createShipment
|
||||
createShipment(id, body = {}) {
|
||||
return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
@@ -12,4 +12,10 @@ export const thumb = {
|
||||
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/smallthumb/:fullHash — smallUrl
|
||||
smallUrl(fullHash) {
|
||||
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, createBrowserRouter } from 'react-router-dom';
|
||||
import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||
import AppShell from './components/AppShell';
|
||||
import SectionLayout from './components/SectionLayout';
|
||||
|
||||
import ArtikelUebersichtPage from './routes/artikel';
|
||||
import ArtikelKategorienPage from './routes/artikel/kategorien';
|
||||
import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorien';
|
||||
import ArtikelLagerPage from './routes/artikel/lager';
|
||||
import ArtikelMarkenPage from './routes/artikel/marken';
|
||||
import ArtikelVorlagenPage from './routes/artikel/vorlagen';
|
||||
import ArtikelMerkmalePage from './routes/artikel/merkmale';
|
||||
import ArtikelSkuPage from './routes/artikel/_sku';
|
||||
import LieferungListePage from './routes/lieferung';
|
||||
import LieferungNeuPage from './routes/lieferung/neu';
|
||||
import LieferungLieferantenPage from './routes/lieferung/lieferanten';
|
||||
import LieferungEditPage from './routes/lieferung/_deliveryId';
|
||||
import AuftragAngebotPage from './routes/auftrag';
|
||||
import AuftragAngebotNeuPage from './routes/auftrag/neuesangebot';
|
||||
import AuftragAngebotEditPage from './routes/auftrag/_quoteId';
|
||||
import AuftragBestellungPage from './routes/auftrag/bestellung';
|
||||
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
|
||||
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId';
|
||||
import AuftragAusliefernPage from './routes/auftrag/ausliefern';
|
||||
import AuftragZahlungenPage from './routes/auftrag/zahlungen';
|
||||
import AuftragVersandartenPage from './routes/auftrag/versandarten';
|
||||
const ArtikelUebersichtPage = lazy(() => import('./routes/artikel'));
|
||||
const ArtikelKategorienPage = lazy(() => import('./routes/artikel/kategorien'));
|
||||
const RedirectArtikelKategorien = lazy(() => import('./routes/artikel/RedirectArtikelKategorien'));
|
||||
const ArtikelLagerPage = lazy(() => import('./routes/artikel/lager'));
|
||||
const ArtikelMarkenPage = lazy(() => import('./routes/artikel/marken'));
|
||||
const ArtikelVorlagenPage = lazy(() => import('./routes/artikel/vorlagen'));
|
||||
const ArtikelMerkmalePage = lazy(() => import('./routes/artikel/merkmale'));
|
||||
const ArtikelSkuPage = lazy(() => import('./routes/artikel/_sku'));
|
||||
const LieferungListePage = lazy(() => import('./routes/lieferung'));
|
||||
const LieferungNeuPage = lazy(() => import('./routes/lieferung/neu'));
|
||||
const LieferungLieferantenPage = lazy(() => import('./routes/lieferung/lieferanten'));
|
||||
const LieferungEditPage = lazy(() => import('./routes/lieferung/_deliveryId'));
|
||||
const AuftragAngebotPage = lazy(() => import('./routes/auftrag'));
|
||||
const AuftragAngebotNeuPage = lazy(() => import('./routes/auftrag/neuesangebot'));
|
||||
const AuftragAngebotEditPage = lazy(() => import('./routes/auftrag/_quoteId'));
|
||||
const AuftragBestellungPage = lazy(() => import('./routes/auftrag/bestellung'));
|
||||
const AuftragBestellungNeuPage = lazy(() => import('./routes/auftrag/bestellung/neu'));
|
||||
const AuftragBestellungEditPage = lazy(() => import('./routes/auftrag/bestellung/_orderId'));
|
||||
const AuftragAusliefernPage = lazy(() => import('./routes/auftrag/ausliefern'));
|
||||
const AuftragRetourePage = lazy(() => import('./routes/auftrag/retoure'));
|
||||
const AuftragRetoureNeuPage = lazy(() => import('./routes/auftrag/retoure/neu'));
|
||||
const AuftragRetoureEditPage = lazy(() => import('./routes/auftrag/retoure/_retoureId'));
|
||||
const AuftragZahlungenPage = lazy(() => import('./routes/auftrag/zahlungen'));
|
||||
const AuftragVersandartenPage = lazy(() => import('./routes/auftrag/versandarten'));
|
||||
|
||||
import KundeListePage from './routes/kunde';
|
||||
import KundeNeuPage from './routes/kunde/neu';
|
||||
import KundeGruppenPage from './routes/kunde/gruppen';
|
||||
import KundeEditPage from './routes/kunde/_entityId';
|
||||
import StatisticsPage from './routes/infos/StatisticsPage';
|
||||
import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage';
|
||||
const KundeListePage = lazy(() => import('./routes/kunde'));
|
||||
const KundeNeuPage = lazy(() => import('./routes/kunde/neu'));
|
||||
const KundeGruppenPage = lazy(() => import('./routes/kunde/gruppen'));
|
||||
const KundeEditPage = lazy(() => import('./routes/kunde/_entityId'));
|
||||
const StatisticsPage = lazy(() => import('./routes/infos/StatisticsPage'));
|
||||
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
|
||||
|
||||
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
|
||||
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
|
||||
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
|
||||
const CompanyAutomatDebugPage = import.meta.env.DEV
|
||||
? lazy(() => import('./routes/debug/CompanyAutomatPage'))
|
||||
: null;
|
||||
|
||||
function LoginRedirect() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -109,11 +115,13 @@ const routes = [
|
||||
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
||||
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
||||
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
|
||||
{ path: 'retoure', element: <AuftragRetourePage /> },
|
||||
{ path: 'retoure/neu', element: <AuftragRetoureNeuPage /> },
|
||||
{ path: 'retoure/:retoureId', element: <AuftragRetoureEditPage /> },
|
||||
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
|
||||
{ path: 'versandarten', element: <AuftragVersandartenPage /> },
|
||||
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
|
||||
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -126,6 +134,19 @@ const routes = [
|
||||
{ path: ':entityId', element: <KundeEditPage /> },
|
||||
],
|
||||
},
|
||||
...(import.meta.env.DEV
|
||||
? [
|
||||
{
|
||||
path: 'debug',
|
||||
element: <SectionLayout sectionPath="/debug" />,
|
||||
children: [
|
||||
{ index: true, element: <CompanyAutomatDebugPage /> },
|
||||
{ path: 'company', element: <CompanyAutomatDebugPage /> },
|
||||
{ path: 'companyautomat', element: <CompanyAutomatDebugPage /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ path: '*', element: <Navigate to="/artikel" replace /> },
|
||||
],
|
||||
},
|
||||
@@ -138,3 +159,7 @@ export const router =
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.data.router = router;
|
||||
}
|
||||
|
||||
export default function AppRouter() {
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
15
src/assets/printers/index.js
Normal file
15
src/assets/printers/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import zd41x from './zd41x.png';
|
||||
|
||||
// Library of printer images. Entries are matched in order against the
|
||||
// printer model (as reported by the agent), case-insensitively.
|
||||
const PRINTER_IMAGES = [
|
||||
{ pattern: /^ZD41/i, image: zd41x }, // ZD410, ZD411, ZD410-200dpi, ...
|
||||
];
|
||||
|
||||
export function getPrinterImage(model) {
|
||||
if (!model) return null;
|
||||
const entry = PRINTER_IMAGES.find(({ pattern }) => pattern.test(String(model).trim()));
|
||||
return entry?.image ?? null;
|
||||
}
|
||||
|
||||
export default PRINTER_IMAGES;
|
||||
BIN
src/assets/printers/zd41x.png
Normal file
BIN
src/assets/printers/zd41x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @file accountAutomat.js
|
||||
* @description Manages cached payment accounts (`GET /api/account`) for the selected company.
|
||||
*/
|
||||
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
import { account as accountApi } from '../api';
|
||||
|
||||
export const accountAutomat = createListAutomat({
|
||||
name: 'account',
|
||||
fetch: async () => {
|
||||
const res = await accountApi.list();
|
||||
return Array.isArray(res?.accounts) ? res.accounts : [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')
|
||||
),
|
||||
});
|
||||
|
||||
export default accountAutomat;
|
||||
@@ -11,7 +11,7 @@
|
||||
* history or supplier details of a specific article.
|
||||
*/
|
||||
|
||||
import { KeyedAutomat } from 'react-automat';
|
||||
import { Automat, KeyedAutomat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
|
||||
@@ -34,26 +34,73 @@ import { requireArray, requireObject } from '../utils/apiShape';
|
||||
* @property {number|null} taxRate Tax rate in percent.
|
||||
*/
|
||||
|
||||
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* 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>>}
|
||||
* Registry of SKU-specific chunk-list automats for article history.
|
||||
* @type {Map<string, import('react-automat').Automat>}
|
||||
*/
|
||||
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');
|
||||
},
|
||||
const skuHistoryAutomats = new Map();
|
||||
|
||||
/**
|
||||
* Gets or creates the chunk-list automat for a specific article's history.
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
|
||||
*/
|
||||
export function getArticleHistoryAutomat(sku) {
|
||||
const rawKey = String(sku);
|
||||
const key = rawKey.trim().toLowerCase();
|
||||
let inst = skuHistoryAutomats.get(key);
|
||||
if (!inst) {
|
||||
inst = createChunkListAutomat({
|
||||
name: `articleHistory:${rawKey}`,
|
||||
chunkSize: 50,
|
||||
maxLruChunks: 50,
|
||||
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
|
||||
});
|
||||
skuHistoryAutomats.set(key, inst);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
|
||||
*
|
||||
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
|
||||
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
|
||||
*/
|
||||
export const articleHistoryAutomat = {
|
||||
get: getArticleHistoryAutomat,
|
||||
key: getArticleHistoryAutomat,
|
||||
has(sku) {
|
||||
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
|
||||
},
|
||||
);
|
||||
async load(sku) {
|
||||
const inst = getArticleHistoryAutomat(sku);
|
||||
return inst.getItems();
|
||||
},
|
||||
async reload(sku) {
|
||||
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
|
||||
if (inst) {
|
||||
await inst.actions.reload();
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
for (const inst of skuHistoryAutomats.values()) {
|
||||
inst.actions.reset();
|
||||
}
|
||||
skuHistoryAutomats.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// Reset on logout or company change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') {
|
||||
articleHistoryAutomat.reset();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
|
||||
@@ -68,7 +115,12 @@ export const articleSupplierAutomat = new KeyedAutomat(
|
||||
loader: async (sku) => {
|
||||
const res = await articleApi.getSuppliers(String(sku));
|
||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||
return requireArray(res.suppliers, 'suppliers');
|
||||
const list = requireArray(res.suppliers, 'suppliers');
|
||||
const comp = Automat.get('company');
|
||||
if (comp?.actions?.syncArticleSuppliers) {
|
||||
comp.actions.syncArticleSuppliers(sku, list);
|
||||
}
|
||||
return list;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -29,12 +29,17 @@
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
import {
|
||||
categoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
loadAllCategoryViews,
|
||||
invalidateAllCategoryViews,
|
||||
updateArticleInCategoryViews,
|
||||
removeArticleBySku,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
/**
|
||||
@@ -87,10 +92,6 @@ function scheduleRebuild() {
|
||||
/**
|
||||
* Normalizes a category-view article row into an index record.
|
||||
*
|
||||
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
|
||||
* field stays empty until that endpoint exposes EANs. EAN matching in
|
||||
* `articleSearchAutomat` is therefore not functional yet.
|
||||
*
|
||||
* @param {object} article Raw category-view article row.
|
||||
* @param {number|string|null} categoryId Category the article was loaded from.
|
||||
* @returns {ArticleIndexRecord} Index record.
|
||||
@@ -98,7 +99,7 @@ function scheduleRebuild() {
|
||||
function toIndexRecord(article, categoryId) {
|
||||
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
|
||||
const childCount = Number(article.childCount) || 0;
|
||||
const price = article.price ?? article.net ?? article.vknetto ?? null;
|
||||
const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null);
|
||||
const warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
|
||||
const trackStock = article.track_stock ?? article.trackStock;
|
||||
const stock = trackStock === false
|
||||
@@ -111,12 +112,25 @@ function toIndexRecord(article, categoryId) {
|
||||
const available = article.available != null && !Number.isNaN(Number(article.available))
|
||||
? Number(article.available)
|
||||
: stock;
|
||||
const categories = categoryId != null
|
||||
? String(categoryId)
|
||||
: (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null));
|
||||
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? null;
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? null;
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
|
||||
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
|
||||
|
||||
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||
|
||||
return {
|
||||
...article,
|
||||
id: article.id,
|
||||
sku: article.sku,
|
||||
name: article.name,
|
||||
descLength,
|
||||
seo: article.seo ?? null,
|
||||
price,
|
||||
net: article.net ?? price,
|
||||
stock,
|
||||
@@ -126,9 +140,15 @@ function toIndexRecord(article, categoryId) {
|
||||
? article.articlewarehousesummary
|
||||
: [],
|
||||
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
|
||||
categories: categoryId != null ? String(categoryId) : null,
|
||||
categories,
|
||||
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
||||
childCount,
|
||||
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
|
||||
parentSku: article.parentSku ?? article.parent_sku ?? null,
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,6 +202,24 @@ export function buildArticleIndex(views) {
|
||||
existing.articlewarehousesummary = rec.articlewarehousesummary;
|
||||
}
|
||||
}
|
||||
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
|
||||
existing.descLength = incomingDescLen;
|
||||
}
|
||||
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
|
||||
existing.seo = article.seo;
|
||||
}
|
||||
if (Array.isArray(article.eans) && article.eans.length > 0) {
|
||||
const mergedEans = new Set(existing.eans ?? []);
|
||||
for (const e of article.eans) if (e) mergedEans.add(e);
|
||||
existing.eans = Array.from(mergedEans);
|
||||
}
|
||||
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
|
||||
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
|
||||
}
|
||||
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
|
||||
existing.parentSku = article.parentSku ?? article.parent_sku;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +296,32 @@ const actions = {
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refetches and updates a single article in the index and category views by SKU.
|
||||
*
|
||||
* @param {string|number} sku Article SKU.
|
||||
* @returns {Promise<object|null>} Fresh article or null.
|
||||
*/
|
||||
async updateArticleBySku(sku) {
|
||||
const key = String(sku);
|
||||
try {
|
||||
const res = await articleApi.getBySku(key);
|
||||
const article = res?.article;
|
||||
if (article) {
|
||||
updateArticleIndexRecord(article);
|
||||
return article;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err?.status === 404 || err?.statusCode === 404) {
|
||||
removeArticleBySku(key);
|
||||
const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key);
|
||||
articleIndexAutomat.setState({ items: nextItems });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Reset to idle state (logout / company switch). */
|
||||
reset() {
|
||||
inflightEnsure = null;
|
||||
@@ -286,11 +350,90 @@ categoryAutomat.subscribe((state) => {
|
||||
scheduleRebuild();
|
||||
}
|
||||
});
|
||||
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
|
||||
scheduleRebuild();
|
||||
}
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'anon') actions.reset();
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.onArticle) {
|
||||
sseAutomat.actions.onArticle(async (sku) => {
|
||||
if (articleIndexAutomat.state.status === 'ready') {
|
||||
await actions.updateArticleBySku(sku);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void actions.reload().catch(() => {});
|
||||
});
|
||||
|
||||
sseAutomat.actions.on('warehouse', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void actions.reload().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a single article record in the derived index and loaded category views.
|
||||
*
|
||||
* @param {object} article Article detail or row.
|
||||
* @returns {Array<ArticleIndexRecord>|undefined} Updated items array.
|
||||
*/
|
||||
export function updateArticleIndexRecord(article) {
|
||||
if (!article || article.sku == null) return;
|
||||
const key = String(article.sku);
|
||||
updateArticleInCategoryViews(article);
|
||||
|
||||
const items = articleIndexAutomat.state.items;
|
||||
if (!items || !items.length) return;
|
||||
|
||||
const existingIndex = items.findIndex((r) => String(r.sku) === key);
|
||||
const categoryIds = Array.isArray(article.categoryIds)
|
||||
? article.categoryIds.map(String)
|
||||
: [];
|
||||
const primaryCatId = categoryIds[0] ?? null;
|
||||
|
||||
const record = toIndexRecord(article, primaryCatId);
|
||||
if (categoryIds.length > 0) {
|
||||
record.categories = categoryIds.join(',');
|
||||
}
|
||||
|
||||
let nextItems;
|
||||
if (existingIndex >= 0) {
|
||||
const existing = items[existingIndex];
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const hasPhotosProp = 'photos' in article;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||
|
||||
nextItems = [...items];
|
||||
nextItems[existingIndex] = {
|
||||
...existing,
|
||||
...record,
|
||||
categories: record.categories ?? existing.categories,
|
||||
brand: article.brand ?? existing.brand,
|
||||
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
} else {
|
||||
nextItems = [...items, record];
|
||||
}
|
||||
|
||||
articleIndexAutomat.setState({ items: nextItems });
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches a single record's name in the derived index (optimistic update).
|
||||
*
|
||||
|
||||
@@ -14,8 +14,7 @@ import { Automat } from 'react-automat';
|
||||
import FlexSearch from 'flexsearch';
|
||||
import articleIndexAutomat from './articleIndexAutomat';
|
||||
import categoryAutomat from './categoryAutomat';
|
||||
import brandAutomat from './brandAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
import { parseFilter } from '../components/article/searchFormat';
|
||||
|
||||
// #region Types & Initial State
|
||||
@@ -68,6 +67,8 @@ const initialState = {
|
||||
filter: '',
|
||||
brand: '',
|
||||
supplier: '',
|
||||
empty: '',
|
||||
hierarchy: '',
|
||||
offset: 0,
|
||||
pageSize: null,
|
||||
loading: false,
|
||||
@@ -344,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
|
||||
extraArticles = [],
|
||||
allowParent = false,
|
||||
categories = [],
|
||||
hierarchy = '',
|
||||
empty = '',
|
||||
offset = 0,
|
||||
pageSize = 10,
|
||||
limit,
|
||||
@@ -368,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
|
||||
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
||||
|
||||
let pool = rawMatches;
|
||||
if (!allowParent) {
|
||||
if (!allowParent && !hierarchy) {
|
||||
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
||||
}
|
||||
if (excludedSet.size > 0) {
|
||||
@@ -379,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
|
||||
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
|
||||
}
|
||||
|
||||
if (hierarchy === 'father') {
|
||||
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
|
||||
} else if (hierarchy === 'child') {
|
||||
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
|
||||
}
|
||||
|
||||
const emptyFilters = parseFilter(empty);
|
||||
if (emptyFilters.length > 0) {
|
||||
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||
pool = pool.filter((row) => {
|
||||
for (const f of emptyFilters) {
|
||||
if (f === 'ean') {
|
||||
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||
(row.ean && String(row.ean).trim());
|
||||
if (hasEan) return false;
|
||||
} else if (f === 'photo') {
|
||||
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||
const hasPhoto = Boolean(
|
||||
row.fullHash ||
|
||||
row.thumbHash ||
|
||||
row.smallThumbBinary ||
|
||||
firstPhoto?.fullHash ||
|
||||
firstPhoto?.thumbHash ||
|
||||
firstPhoto?.smallThumbBinary
|
||||
);
|
||||
if (hasPhoto) return false;
|
||||
} else if (f === 'price') {
|
||||
const p = row.price ?? row.net;
|
||||
const hasPrice = p != null && Number(p) > 0;
|
||||
if (hasPrice) return false;
|
||||
} else if (f === 'brand') {
|
||||
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||
if (hasBrand) return false;
|
||||
} else if (f === 'supplier') {
|
||||
const hasSupplier = Boolean(
|
||||
(row.suppliers && row.suppliers.length > 0) ||
|
||||
row.supplierId != null ||
|
||||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||
);
|
||||
if (hasSupplier) return false;
|
||||
} else if (f === 'seo') {
|
||||
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||
if (hasSeo) return false;
|
||||
} else if (f === 'desc') {
|
||||
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||
if (hasDesc) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
let matchesList = pool;
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
@@ -585,7 +640,7 @@ function buildCategoryFacets(matchedArticles) {
|
||||
*/
|
||||
function buildBrandFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const allBrands = brandAutomat.state?.brands ?? [];
|
||||
const allBrands = companyAutomat.state?.brands ?? [];
|
||||
const brandById = new Map();
|
||||
const brandByName = new Map();
|
||||
for (const b of allBrands) {
|
||||
@@ -633,8 +688,8 @@ function buildBrandFacets(matchedArticles) {
|
||||
*/
|
||||
function buildSupplierFacets(matchedArticles) {
|
||||
const facets = {};
|
||||
const suppliers = supplierAutomat.state?.suppliers ?? [];
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
const suppliers = companyAutomat.state?.suppliers ?? [];
|
||||
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||
|
||||
const supplierArticleSets = new Map();
|
||||
for (const s of suppliers) {
|
||||
@@ -670,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
|
||||
|
||||
return facets;
|
||||
}
|
||||
function getSkusWithSupplier() {
|
||||
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||
const set = new Set();
|
||||
for (const list of Object.values(articlesBySupplier)) {
|
||||
if (Array.isArray(list)) {
|
||||
for (const item of list) {
|
||||
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
|
||||
if (item.sku) set.add(String(item.sku).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #endregion
|
||||
@@ -690,6 +759,8 @@ const actions = {
|
||||
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
|
||||
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
|
||||
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
|
||||
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
|
||||
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||
|
||||
@@ -738,7 +809,7 @@ const actions = {
|
||||
// Apply supplier filter if set
|
||||
const selectedSuppliers = parseFilter(supplier);
|
||||
if (selectedSuppliers.length > 0) {
|
||||
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {};
|
||||
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||
const allowedSkus = new Set();
|
||||
for (const sId of selectedSuppliers) {
|
||||
const list = articlesBySupplier[String(sId)] ?? [];
|
||||
@@ -753,6 +824,64 @@ const actions = {
|
||||
});
|
||||
}
|
||||
|
||||
// Apply hierarchy filter:
|
||||
// - "father": "father or normal article" -> brand editable, exclude child variants
|
||||
// - "child": "child or normal article" -> price editable, exclude parents
|
||||
if (hierarchy === 'father') {
|
||||
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
|
||||
} else if (hierarchy === 'child') {
|
||||
filteredArticles = filteredArticles.filter(
|
||||
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
|
||||
);
|
||||
}
|
||||
|
||||
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
|
||||
const emptyFilters = parseFilter(empty);
|
||||
if (emptyFilters.length > 0) {
|
||||
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||
filteredArticles = filteredArticles.filter((row) => {
|
||||
for (const f of emptyFilters) {
|
||||
if (f === 'ean') {
|
||||
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||
(row.ean && String(row.ean).trim());
|
||||
if (hasEan) return false;
|
||||
} else if (f === 'photo') {
|
||||
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||
const hasPhoto = Boolean(
|
||||
row.fullHash ||
|
||||
row.thumbHash ||
|
||||
row.smallThumbBinary ||
|
||||
firstPhoto?.fullHash ||
|
||||
firstPhoto?.thumbHash ||
|
||||
firstPhoto?.smallThumbBinary
|
||||
);
|
||||
if (hasPhoto) return false;
|
||||
} else if (f === 'price') {
|
||||
const p = row.price ?? row.net;
|
||||
const hasPrice = p != null && Number(p) > 0;
|
||||
if (hasPrice) return false;
|
||||
} else if (f === 'brand') {
|
||||
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||
if (hasBrand) return false;
|
||||
} else if (f === 'supplier') {
|
||||
const hasSupplier = Boolean(
|
||||
(row.suppliers && row.suppliers.length > 0) ||
|
||||
row.supplierId != null ||
|
||||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||
);
|
||||
if (hasSupplier) return false;
|
||||
} else if (f === 'seo') {
|
||||
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||
if (hasSeo) return false;
|
||||
} else if (f === 'desc') {
|
||||
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||
if (hasDesc) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
||||
|
||||
const totalCount = sortedArticles.length;
|
||||
@@ -782,6 +911,8 @@ const actions = {
|
||||
filter,
|
||||
brand,
|
||||
supplier,
|
||||
empty,
|
||||
hierarchy,
|
||||
offset,
|
||||
pageSize: safePageSize,
|
||||
loading: articleIndexAutomat.state.status === 'loading',
|
||||
@@ -898,15 +1029,10 @@ categoryAutomat.subscribe((catState) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update facets when brands are loaded or updated.
|
||||
brandAutomat.subscribe(() => {
|
||||
// Update facets when brands, suppliers, or supplier articles are loaded or updated.
|
||||
companyAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
|
||||
// Update facets when suppliers or supplier articles are loaded or updated.
|
||||
supplierAutomat.subscribe(() => {
|
||||
actions.runSearch();
|
||||
});
|
||||
}, ['brands', 'suppliers', 'articlesBySupplier']);
|
||||
// #endregion
|
||||
|
||||
// #region Selectors & Exports
|
||||
|
||||
@@ -20,7 +20,12 @@ import { Automat } from 'react-automat';
|
||||
import { article as articleApi } from '../api';
|
||||
import { requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
import articleIndexAutomat, { setArticleIndexName, setArticleIndexStock } from './articleIndexAutomat';
|
||||
import articleIndexAutomat, {
|
||||
setArticleIndexName,
|
||||
setArticleIndexStock,
|
||||
updateArticleIndexRecord,
|
||||
} from './articleIndexAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArtikelState
|
||||
@@ -113,12 +118,7 @@ const actions = {
|
||||
artikelAutomat.setState({
|
||||
bySku: { ...artikelAutomat.state.bySku, [key]: article },
|
||||
});
|
||||
if (article.warehouses || article.stock != null) {
|
||||
setArticleIndexStock(article.sku, {
|
||||
stock: article.stock,
|
||||
warehouses: article.warehouses,
|
||||
});
|
||||
}
|
||||
updateArticleIndexRecord(article);
|
||||
return article;
|
||||
},
|
||||
|
||||
@@ -169,9 +169,8 @@ const actions = {
|
||||
actions.setArticleName(key, body.name);
|
||||
}
|
||||
try {
|
||||
const res = await articleApi.update(key, body);
|
||||
actions.setArticle(res.article);
|
||||
return res;
|
||||
await articleApi.update(key, body);
|
||||
return { article: await actions.reloadArticle(key) };
|
||||
} catch (err) {
|
||||
if (nameChange) {
|
||||
// The optimistic patch was wrong: drop the index entry and refetch.
|
||||
@@ -192,9 +191,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setEansBySku(String(sku), eans);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -205,9 +203,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setBrandIdBySku(String(sku), body?.brandId);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -218,9 +215,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setCategories(String(sku), categoryIds);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -231,9 +227,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setPrices(String(sku), prices);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -244,9 +239,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setWeight(String(sku), body);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -257,9 +251,8 @@ const actions = {
|
||||
* @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;
|
||||
await articleApi.setTrackStock(String(sku), body);
|
||||
return { article: await actions.reloadArticle(sku) };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -332,4 +325,14 @@ authAutomat.subscribe((auth) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.onArticle) {
|
||||
sseAutomat.actions.onArticle((sku) => {
|
||||
const key = String(sku);
|
||||
if (key in artikelAutomat.state.bySku) {
|
||||
void actions.reloadArticle(key).catch(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default artikelAutomat;
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* @file attributeAutomat.js
|
||||
* @description Manages catalog product attributes and their discrete values
|
||||
* (e.g. Color, Size) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event.
|
||||
* - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`,
|
||||
* `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeValue
|
||||
* @property {number} id
|
||||
* @property {number} attributeId
|
||||
* @property {string} value
|
||||
* @property {number} sort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeName
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {number} sort
|
||||
* @property {boolean} exclusive
|
||||
* @property {AttributeValue[]} values
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AttributeState
|
||||
* @property {AttributeName[]} attributes
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {AttributeState} */
|
||||
const initialState = {
|
||||
attributes: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function sortValues(values) {
|
||||
if (!Array.isArray(values)) return [];
|
||||
return [...values].sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAttributes(attributes) {
|
||||
if (!Array.isArray(attributes)) return [];
|
||||
return [...attributes]
|
||||
.map((attr) => ({
|
||||
...attr,
|
||||
exclusive: Boolean(attr.exclusive),
|
||||
values: sortValues(attr.values),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
let attributeAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all attribute names and their values for the selected company. */
|
||||
async load() {
|
||||
attributeAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await attributenameApi.list();
|
||||
const attributes = normalizeAttributes(res.attributenames);
|
||||
attributeAutomat.setState({ attributes, status: 'succeeded' });
|
||||
return attributes;
|
||||
} catch (err) {
|
||||
attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create an attribute name.
|
||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async createAttribute(body) {
|
||||
const res = await attributenameApi.create(body);
|
||||
await actions.load();
|
||||
return res.attributename;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an attribute name.
|
||||
* @param {number} id
|
||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async updateAttribute(id, body) {
|
||||
const res = await attributenameApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.attributename;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an attribute name.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteAttribute(id) {
|
||||
await attributenameApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple attribute names.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteAttributes(ids) {
|
||||
for (const id of ids) {
|
||||
await attributenameApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder attribute names.
|
||||
* @param {number[]} attributenameIds
|
||||
*/
|
||||
async reorderAttributes(attributenameIds) {
|
||||
await attributenameApi.order(attributenameIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a value for an attribute.
|
||||
* @param {number} attributeId
|
||||
* @param {{ value: string, sort?: number }} body
|
||||
*/
|
||||
async createValue(attributeId, body) {
|
||||
const res = await attributevalueApi.create(attributeId, body);
|
||||
await actions.load();
|
||||
return res.attributevalue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an attribute value.
|
||||
* @param {number} id
|
||||
* @param {{ value?: string, sort?: number }} body
|
||||
*/
|
||||
async updateValue(id, body) {
|
||||
const res = await attributevalueApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.attributevalue;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an attribute value.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteValue(id) {
|
||||
await attributevalueApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple attribute values.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteValues(ids) {
|
||||
for (const id of ids) {
|
||||
await attributevalueApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder values of an attribute.
|
||||
* @param {number} attributeId
|
||||
* @param {number[]} attributevalueIds
|
||||
*/
|
||||
async reorderValues(attributeId, attributevalueIds) {
|
||||
await attributevalueApi.order(attributeId, attributevalueIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
attributeAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global attribute state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<AttributeState>}
|
||||
*/
|
||||
attributeAutomat = new Automat(initialState, actions, { name: 'attribute' });
|
||||
|
||||
// Selectors
|
||||
export function selectAttributes() {
|
||||
return attributeAutomat.state.attributes;
|
||||
}
|
||||
|
||||
export function selectAttributeStatus() {
|
||||
return attributeAutomat.state.status;
|
||||
}
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void attributeAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
attributeAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default attributeAutomat;
|
||||
@@ -1,30 +1,24 @@
|
||||
/**
|
||||
* @file authAutomat.js
|
||||
* @description Central authentication and multi-tenant session manager.
|
||||
* Tracks current user, Google OAuth client ID, and active company selection.
|
||||
* Tracks current user, Google OAuth client ID, active company selection, and
|
||||
* the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
|
||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`.
|
||||
* - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`.
|
||||
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`.
|
||||
* - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
|
||||
* `createCompany(payload)`, or `logout()`.
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* Subscribed to by almost all company-scoped automats, orchestrating their loading
|
||||
* when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`):
|
||||
* - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`
|
||||
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat`
|
||||
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
|
||||
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
|
||||
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
|
||||
* the category views once the session is established
|
||||
* - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`,
|
||||
* `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
|
||||
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
|
||||
* - `artikelAutomat`: Clears cached articles on logout
|
||||
* - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`.
|
||||
* - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet).
|
||||
* - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat`
|
||||
* are mounted and company APIs are triggered.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { auth, company } from '../api';
|
||||
import { auth, company, number as numberApi } from '../api';
|
||||
|
||||
/**
|
||||
* @typedef {Object} AuthUser
|
||||
@@ -36,7 +30,13 @@ import { auth, company } from '../api';
|
||||
* @typedef {Object} AuthState
|
||||
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
||||
* @property {string|null} googleClientId Google Identity Services client ID.
|
||||
* @property {number|null} schemaVersion Database schema version returned by the backend.
|
||||
* @property {boolean} vhost Whether vhost mode is enabled.
|
||||
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
|
||||
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
||||
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
||||
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
||||
* @property {string|null} retryNotification Message indicating a pending retry (e.g. 502 Bad Gateway).
|
||||
*/
|
||||
|
||||
/** Emitted after an authenticated session has been established. */
|
||||
@@ -50,11 +50,30 @@ export const AUTH_LOGGED_OUT = 'auth/loggedOut';
|
||||
const initialState = {
|
||||
user: null,
|
||||
googleClientId: null,
|
||||
schemaVersion: null,
|
||||
vhost: false,
|
||||
vhostSettings: null,
|
||||
status: 'loading',
|
||||
hasAuth: false,
|
||||
hasCompany: false,
|
||||
retryNotification: null,
|
||||
};
|
||||
|
||||
let emit = () => { };
|
||||
|
||||
// #region helpers
|
||||
/**
|
||||
* Detects whether an error represents an HTTP 502 Bad Gateway response.
|
||||
*
|
||||
* @param {any} err
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function is502BadGateway(err) {
|
||||
if (!err) return false;
|
||||
if (Number(err.status) === 502) return true;
|
||||
const msg = String(err.message || '');
|
||||
return /502|bad\s*gateway/i.test(msg);
|
||||
}
|
||||
/**
|
||||
* Registers the application-level dispatcher used for authentication events.
|
||||
*
|
||||
@@ -65,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
|
||||
emit = dispatch;
|
||||
}
|
||||
|
||||
function normalizePicture(picture) {
|
||||
export function normalizePicture(picture) {
|
||||
if (!picture || typeof picture !== 'string') return null;
|
||||
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
|
||||
return picture;
|
||||
@@ -93,43 +112,132 @@ function mergeUser(current, incoming) {
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`).
|
||||
*
|
||||
* @param {AuthUser|null} user
|
||||
* @param {'loading'|'anon'|'authed'} status
|
||||
* @param {object} [extra]
|
||||
* @returns {Partial<AuthState>}
|
||||
*/
|
||||
function buildAuthState(user, status, extra = {}) {
|
||||
const hasAuth = status === 'authed' && Boolean(user);
|
||||
const hasCompany = hasAuth && user?.selectedCompanyId != null;
|
||||
return {
|
||||
user: hasAuth ? user : null,
|
||||
status,
|
||||
hasAuth,
|
||||
hasCompany,
|
||||
vhost: authAutomat?.state?.vhost ?? false,
|
||||
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
|
||||
retryNotification: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function syncSse(state) {
|
||||
const sseAutomat = Automat.get('sse');
|
||||
if (sseAutomat?.actions?.syncWithAuth) {
|
||||
sseAutomat.actions.syncWithAuth(state);
|
||||
}
|
||||
}
|
||||
|
||||
function applySession(nextSession) {
|
||||
const googleClientId =
|
||||
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||
const schemaVersion =
|
||||
nextSession.schemaVersion !== undefined
|
||||
? nextSession.schemaVersion
|
||||
: (authAutomat.state.schemaVersion ?? null);
|
||||
const vhost =
|
||||
nextSession.vhost !== undefined
|
||||
? Boolean(nextSession.vhost)
|
||||
: (authAutomat.state.vhost ?? false);
|
||||
const vhostSettings =
|
||||
nextSession.vhostSettings !== undefined
|
||||
? nextSession.vhostSettings
|
||||
: (authAutomat.state.vhostSettings ?? null);
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (authAutomat.state.status !== 'authed') {
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
} else {
|
||||
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
|
||||
}
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return nextSession;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
let authAutomat;
|
||||
let inFlightBootstrap = null;
|
||||
|
||||
const actions = {
|
||||
// #region bootstrapSession
|
||||
// MARK: calls auth.me from ../api
|
||||
/**
|
||||
* Restores an existing session or requests it from the authentication API.
|
||||
* Retries automatically every 5 seconds if a 502 Bad Gateway is received.
|
||||
*
|
||||
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
|
||||
* Optional preloaded session.
|
||||
* @returns {Promise<Object>} Resolved session response.
|
||||
*/
|
||||
async bootstrapSession(session) {
|
||||
try {
|
||||
const nextSession = session ?? await auth.me();
|
||||
const googleClientId =
|
||||
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||
|
||||
if (!nextSession.user) {
|
||||
if (authAutomat.state.status !== 'authed') {
|
||||
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
|
||||
} else {
|
||||
authAutomat.setState({ googleClientId });
|
||||
}
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||
authAutomat.setState({ user, googleClientId, status: 'authed' });
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return nextSession;
|
||||
} catch (error) {
|
||||
if (authAutomat.state.status === 'loading') {
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
}
|
||||
throw error;
|
||||
if (session) {
|
||||
return applySession(session);
|
||||
}
|
||||
},
|
||||
|
||||
if (inFlightBootstrap) {
|
||||
return inFlightBootstrap;
|
||||
}
|
||||
|
||||
inFlightBootstrap = (async () => {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
const nextSession = await auth.me();
|
||||
return applySession(nextSession);
|
||||
} catch (error) {
|
||||
if (is502BadGateway(error)) {
|
||||
attempt++;
|
||||
authAutomat.setState({
|
||||
retryNotification: `Server nicht erreichbar (502 Bad Gateway) – erneuter Versuch in 5s…${attempt > 1 ? ` (Versuch ${attempt})` : ''}`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (authAutomat.state.status === 'loading') {
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(null, 'anon', { retryNotification: null })
|
||||
);
|
||||
syncSse(nextState);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
inFlightBootstrap = null;
|
||||
});
|
||||
|
||||
return inFlightBootstrap;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region loginWithGoogle
|
||||
// MARK: calls auth.loginWithGoogle from ../api
|
||||
/**
|
||||
* Exchanges a Google credential for an authenticated application session.
|
||||
*
|
||||
@@ -143,19 +251,25 @@ const actions = {
|
||||
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
|
||||
}
|
||||
const user = mergeUser(authAutomat.state.user, session.user);
|
||||
authAutomat.setState({
|
||||
user,
|
||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||
status: 'authed',
|
||||
});
|
||||
const nextState = authAutomat.setState(
|
||||
buildAuthState(user, 'authed', {
|
||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
|
||||
})
|
||||
);
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||
return session;
|
||||
} catch (error) {
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region selectCompany
|
||||
// MARK: calls company.select from ../api
|
||||
/**
|
||||
* Selects the company used by the current session.
|
||||
*
|
||||
@@ -168,11 +282,15 @@ const actions = {
|
||||
authAutomat.state.user,
|
||||
data.user ?? { selectedCompanyId: companyId },
|
||||
);
|
||||
authAutomat.setState({ user, status: 'authed' });
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region createCompany
|
||||
// MARK: calls company.create and company.select from ../api
|
||||
/**
|
||||
* Creates a company and selects it for the current user.
|
||||
*
|
||||
@@ -180,7 +298,8 @@ const actions = {
|
||||
* @returns {Promise<AuthUser>} Updated user.
|
||||
*/
|
||||
async createCompany(payload) {
|
||||
const data = await company.create(payload);
|
||||
const { numbers, ...companyPayload } = payload || {};
|
||||
const data = await company.create(companyPayload);
|
||||
let incoming = data.user;
|
||||
if (!incoming && data.company?.id) {
|
||||
const selected = await company.select(data.company.id);
|
||||
@@ -190,12 +309,34 @@ const actions = {
|
||||
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
|
||||
}
|
||||
|
||||
if (numbers) {
|
||||
try {
|
||||
const listRes = await numberApi.list();
|
||||
const serverNumbers = listRes?.numbers ?? [];
|
||||
for (const serverRow of serverNumbers) {
|
||||
const userConfig = numbers[serverRow.type];
|
||||
if (userConfig) {
|
||||
const prefix = String(userConfig.prefix ?? '');
|
||||
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||
}
|
||||
}
|
||||
} catch (numErr) {
|
||||
console.warn('Initial number sequences could not be saved:', numErr);
|
||||
}
|
||||
}
|
||||
|
||||
const user = mergeUser(authAutomat.state.user, incoming);
|
||||
authAutomat.setState({ user, status: 'authed' });
|
||||
const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_COMPANY_CHANGED });
|
||||
return user;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region logout
|
||||
// MARK: calls auth.logout from ../api
|
||||
/**
|
||||
* Ends the server session, clears the current user, and returns to `/`.
|
||||
*
|
||||
@@ -204,9 +345,11 @@ const actions = {
|
||||
async logout() {
|
||||
await auth.logout();
|
||||
window.history.replaceState(null, '', '/');
|
||||
authAutomat.setState({ user: null, status: 'anon' });
|
||||
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
|
||||
syncSse(nextState);
|
||||
emit({ type: AUTH_LOGGED_OUT });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -216,16 +359,27 @@ const actions = {
|
||||
*/
|
||||
authAutomat = new Automat(initialState, actions, { name: 'auth' });
|
||||
|
||||
// #region selectors
|
||||
export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
|
||||
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
|
||||
export const selectSelectedCompanyId = () =>
|
||||
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
||||
export const selectRetryNotification = () => authAutomat.state.retryNotification;
|
||||
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
|
||||
export const selectVhost = () => Boolean(authAutomat.state.vhost);
|
||||
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null, schemaVersion?: number|null}>} Session.
|
||||
*/
|
||||
export function bootstrapAuthMe() {
|
||||
return auth.me();
|
||||
return authAutomat.actions.bootstrapSession();
|
||||
}
|
||||
// #endregion
|
||||
|
||||
export default authAutomat;
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
/**
|
||||
* @file brandAutomat.js
|
||||
* @description Manages catalog brands and manufacturers for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
|
||||
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
|
||||
* `actions.load(true)`, which silently re-fetches the list. Subscribed views
|
||||
* (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then
|
||||
* re-render from the updated state. Events sent by this tab (matching `sender`)
|
||||
* are ignored.
|
||||
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { brand as brandApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import sseAutomat, { getSseClientId } from './sseAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} BrandState
|
||||
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
||||
* @property {Array<object>} filteredBrands Filtered brands according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {BrandState} */
|
||||
const initialState = {
|
||||
brands: [],
|
||||
filteredBrands: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const sortBrands = (brands) =>
|
||||
[...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||
|
||||
const applyFilter = (brands, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands;
|
||||
};
|
||||
|
||||
let brandAutomat;
|
||||
let inflightLoad = null;
|
||||
const FETCH_PAGE_SIZE = 100;
|
||||
// #region fetch
|
||||
async function fetchAllBrands() {
|
||||
let offset = 0, total = Infinity;
|
||||
const collected = [];
|
||||
while (offset === 0 || collected.length < total) {
|
||||
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
||||
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
||||
const page = Array.isArray(res.brands) ? res.brands : [];
|
||||
collected.push(...page);
|
||||
offset += page.length;
|
||||
if (page.length === 0) break;
|
||||
}
|
||||
return { brands: sortBrands(collected) };
|
||||
}
|
||||
|
||||
const updateStateWithBrands = (brands, extra = {}) => {
|
||||
const filteredBrands = applyFilter(brands, brandAutomat.state.filter);
|
||||
brandAutomat.setState({ brands, filteredBrands, ...extra });
|
||||
return brands;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/** Sets the active brand search filter and updates filteredBrands. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) });
|
||||
},
|
||||
|
||||
/**
|
||||
* Load all brands for the selected company (pages through the API).
|
||||
* @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'.
|
||||
*/
|
||||
async load(silent = false) {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
if (!silent) {
|
||||
brandAutomat.setState({ status: 'loading', error: null });
|
||||
}
|
||||
return (inflightLoad = fetchAllBrands()
|
||||
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
|
||||
.catch((err) => {
|
||||
const error = err?.message ?? String(err);
|
||||
brandAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||
throw err;
|
||||
})
|
||||
.finally(() => { inflightLoad = null; }));
|
||||
},
|
||||
// #region add
|
||||
async create(body) {
|
||||
const res = await brandApi.create(body);
|
||||
const row = res.brand;
|
||||
updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row]));
|
||||
return row;
|
||||
},
|
||||
// #region rename
|
||||
async rename(id, body) {
|
||||
const res = await brandApi.rename(id, body);
|
||||
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
||||
updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b))));
|
||||
return res;
|
||||
},
|
||||
// #region del
|
||||
async remove(id) {
|
||||
await brandApi.delete(id);
|
||||
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
|
||||
},
|
||||
async removeBrands(ids) {
|
||||
const idSet = new Set(ids);
|
||||
for (const id of ids) {
|
||||
await brandApi.delete(id);
|
||||
}
|
||||
updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id)));
|
||||
await actions.load(true);
|
||||
},
|
||||
// #region addPic
|
||||
async addImage(id, file) {
|
||||
const res = await brandApi.addImage(id, file);
|
||||
await brandAutomat.actions.load(true);
|
||||
return res.brand;
|
||||
},
|
||||
// #region delPic
|
||||
async deleteImage(id) {
|
||||
const res = await brandApi.deleteImage(id);
|
||||
const updated = res.brand ?? { pics: [] };
|
||||
updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b)));
|
||||
return res.brand;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Global brand state container.
|
||||
* @type {Automat<BrandState>}
|
||||
*/
|
||||
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
|
||||
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
|
||||
else if (auth.status === 'anon') brandAutomat.setState(initialState);
|
||||
});
|
||||
|
||||
// #region onSse
|
||||
sseAutomat.actions.on('brand', (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event?.data ?? '{}');
|
||||
if (payload?.sender && payload.sender === getSseClientId()) return;
|
||||
} catch { }
|
||||
void brandAutomat.actions.load(true).catch(() => { });
|
||||
});
|
||||
|
||||
export default brandAutomat;
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* @file carrierAutomat.js
|
||||
* @description Manages cached shipping carriers / Versandarten (`GET /api/carrier`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { carrier as carrierApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Carrier
|
||||
* @property {number} id Carrier identifier.
|
||||
* @property {string} name Carrier name.
|
||||
* @property {number} sort Sort order.
|
||||
* @property {boolean} hasPackages Whether carrier uses package tracking.
|
||||
* @property {boolean} isReverse Whether carrier is used for returns.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CarrierState
|
||||
* @property {Array<Carrier>} carriers Company-scoped carriers.
|
||||
* @property {Array<Carrier>} filteredCarriers Filtered carriers according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {CarrierState} */
|
||||
const initialState = {
|
||||
carriers: [],
|
||||
filteredCarriers: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let carrierAutomat;
|
||||
|
||||
const applyFilter = (carriers, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers;
|
||||
};
|
||||
|
||||
const updateStateWithCarriers = (carriers, extra = {}) => {
|
||||
const list = Array.isArray(carriers) ? carriers : [];
|
||||
const filteredCarriers = applyFilter(list, carrierAutomat.state.filter);
|
||||
carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped carrier list.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Carrier list.
|
||||
*/
|
||||
async function fetchCarriers() {
|
||||
const data = await carrierApi.list();
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && Array.isArray(data.carriers)) return data.carriers;
|
||||
return [];
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the carrier list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
function loadCarriers() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
carrierAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active carrier search filter and updates filteredCarriers. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
carrierAutomat.setState({
|
||||
filter,
|
||||
filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the carrier list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Loaded carriers.
|
||||
*/
|
||||
load() {
|
||||
return loadCarriers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the carrier list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Carrier>>} Fresh carriers.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const carriers = await fetchCarriers();
|
||||
updateStateWithCarriers(carriers, { status: 'ready', error: null });
|
||||
return carriers;
|
||||
} catch (error) {
|
||||
carrierAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload.
|
||||
* @returns {Promise<Carrier>} Created carrier.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await carrierApi.create(body);
|
||||
const created = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return created;
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload.
|
||||
* @returns {Promise<Carrier>} Updated carrier.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await carrierApi.update(id, body);
|
||||
const updated = data?.carrier || data;
|
||||
await carrierAutomat.actions.reload();
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a carrier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Carrier identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await carrierApi.delete(id);
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Persists a new sort order for all carriers.
|
||||
*
|
||||
* @param {Array<string|number>} carrierIds IDs in the desired order.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reorder(carrierIds) {
|
||||
const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c]));
|
||||
const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean);
|
||||
if (reordered.length === carrierAutomat.state.carriers.length) {
|
||||
updateStateWithCarriers(reordered);
|
||||
}
|
||||
await carrierApi.order({ carrierIds });
|
||||
await carrierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Carrier>} carriers Carriers to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setCarriers(carriers) {
|
||||
updateStateWithCarriers(carriers, {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
carrierAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped carrier cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<CarrierState>}
|
||||
*/
|
||||
carrierAutomat = new Automat(initialState, actions, { name: 'carrier' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the carrier list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
carrierAutomat.actions.clear();
|
||||
} else {
|
||||
void carrierAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default carrierAutomat;
|
||||
@@ -30,6 +30,7 @@ import { Automat } from 'react-automat';
|
||||
import { category as categoryApi, article as articleApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
import sseAutomat from './sseAutomat';
|
||||
import {
|
||||
attachCategoryTree,
|
||||
categoryViews,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
disposeAllCategoryViewAutomats,
|
||||
refreshCategoryViewChildren,
|
||||
appendArticlesToCategoryViews,
|
||||
updateArticleInCategoryViews,
|
||||
removeArticleBySku,
|
||||
waitForCategoryTree,
|
||||
} from './categoryViewAutomat';
|
||||
@@ -213,8 +215,8 @@ const actions = {
|
||||
* @param {{ parentId: number|string, name: string }} param0
|
||||
*/
|
||||
async createArticle({ parentId, name }) {
|
||||
const res = await articleApi.create({ categoryId: parentId, name });
|
||||
const created = res.article;
|
||||
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||
const { article: created } = await articleApi.getBySku(sku);
|
||||
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
|
||||
return created;
|
||||
},
|
||||
@@ -228,8 +230,9 @@ const actions = {
|
||||
if (list.length === 0) throw new Error('Keine Namen übergeben.');
|
||||
const created = [];
|
||||
for (const name of list) {
|
||||
const res = await articleApi.create({ categoryId: parentId, name });
|
||||
if (res.article) created.push(res.article);
|
||||
const { sku } = await articleApi.create({ categoryId: parentId, name });
|
||||
const { article: created1 } = await articleApi.getBySku(sku);
|
||||
if (created1) created.push(created1);
|
||||
}
|
||||
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
|
||||
return created;
|
||||
@@ -400,6 +403,28 @@ const actions = {
|
||||
return res.category;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a mosaic image for a category from its articles and subcategories.
|
||||
* @param {{ categoryId: number|string, size?: number }} param0
|
||||
*/
|
||||
async generateCategoryImage({ categoryId, size } = {}) {
|
||||
const res = await categoryApi.generateImage(categoryId, { size });
|
||||
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
|
||||
const { categories } = categoryAutomat.state;
|
||||
if (!categories) return res.category;
|
||||
const key = String(id);
|
||||
const nextCategories = {
|
||||
...categories,
|
||||
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
|
||||
};
|
||||
categoryAutomat.setState({
|
||||
categories: nextCategories,
|
||||
});
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
return res.category;
|
||||
},
|
||||
|
||||
|
||||
// ─── Local state patches ───────────────────────────────────────────────────
|
||||
|
||||
/** Remove an article from all live category view lists (used after article deletion). */
|
||||
@@ -429,6 +454,11 @@ const actions = {
|
||||
refreshCategoryViewChildren(nextCategories);
|
||||
},
|
||||
|
||||
/** Invalidate all loaded category views. */
|
||||
invalidateAllCategoryViews() {
|
||||
invalidateAllCategoryViews();
|
||||
},
|
||||
|
||||
/** Reset state to idle (logout / company switch). */
|
||||
reset() {
|
||||
disposeAllCategoryViewAutomats();
|
||||
@@ -456,9 +486,9 @@ categoryAutomat.views = categoryViews;
|
||||
|
||||
// Load tree whenever the company data changes (or first becomes available)
|
||||
let _lastCompanyId = null;
|
||||
companyAutomat.subscribe((company) => {
|
||||
const id = company.current?.id ?? null;
|
||||
const rootCategoryId = company.current?.rootCategoryId ?? null;
|
||||
function syncCategoryWithCompany(company) {
|
||||
const id = company?.current?.id ?? null;
|
||||
const rootCategoryId = company?.current?.rootCategoryId ?? null;
|
||||
if (id && rootCategoryId && id !== _lastCompanyId) {
|
||||
const isCompanySwitch = _lastCompanyId !== null;
|
||||
_lastCompanyId = id;
|
||||
@@ -471,7 +501,10 @@ companyAutomat.subscribe((company) => {
|
||||
_lastCompanyId = null;
|
||||
categoryAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
companyAutomat.subscribe(syncCategoryWithCompany);
|
||||
syncCategoryWithCompany(companyAutomat.state);
|
||||
|
||||
// Reset tree on logout
|
||||
authAutomat.subscribe((auth) => {
|
||||
@@ -481,6 +514,14 @@ authAutomat.subscribe((auth) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Upstream Trigger: sseAutomat
|
||||
if (sseAutomat?.actions?.on) {
|
||||
sseAutomat.actions.on('category', () => {
|
||||
void categoryAutomat.actions.loadTree().catch(() => {});
|
||||
invalidateAllCategoryViews();
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export pure helpers for consumers that previously imported from categorySlice
|
||||
export {
|
||||
ROOT_CATEGORY_NAME,
|
||||
@@ -501,6 +542,7 @@ export {
|
||||
disposeAllCategoryViewAutomats,
|
||||
loadAllCategoryViews,
|
||||
collectLoadedCategoryViewData,
|
||||
updateArticleInCategoryViews,
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
export default categoryAutomat;
|
||||
|
||||
@@ -323,6 +323,75 @@ export function appendArticlesToCategoryViews({ categoryId, articles }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an article in all loaded category views.
|
||||
*
|
||||
* @param {object} article Article detail or row with at least `sku`.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function updateArticleInCategoryViews(article) {
|
||||
if (!article || article.sku == null) return;
|
||||
const sku = String(article.sku);
|
||||
const targetCategoryIds = Array.isArray(article.categoryIds)
|
||||
? new Set(article.categoryIds.map(String))
|
||||
: null;
|
||||
|
||||
for (const [slug, item] of categoryViews._items.entries()) {
|
||||
if (!item?.data) continue;
|
||||
const viewCatId = String(item.data.categoryId);
|
||||
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
|
||||
// Article moved out of this category
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
|
||||
});
|
||||
} else {
|
||||
// Update in-place
|
||||
const existing = item.data.articles[existingIndex];
|
||||
const updatedArticles = [...item.data.articles];
|
||||
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
|
||||
const hasPhotosProp = 'photos' in article;
|
||||
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
|
||||
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
|
||||
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
|
||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
|
||||
|
||||
updatedArticles[existingIndex] = {
|
||||
...existing,
|
||||
...article,
|
||||
name: article.name ?? existing.name,
|
||||
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
|
||||
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
|
||||
stock: article.stock ?? existing.stock,
|
||||
available: article.available ?? existing.available,
|
||||
warehouses: article.warehouses ?? existing.warehouses,
|
||||
brand: article.brand ?? existing.brand,
|
||||
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
|
||||
childCount: article.childCount ?? existing.childCount,
|
||||
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
|
||||
fullHash,
|
||||
thumbHash,
|
||||
photos,
|
||||
smallThumbBinary,
|
||||
};
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: updatedArticles,
|
||||
});
|
||||
}
|
||||
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
|
||||
// Article added to this category
|
||||
categoryViews.key(slug).set({
|
||||
...item.data,
|
||||
articles: [...(item.data.articles || []), article],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a deleted article from every loaded view that contains it.
|
||||
*
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* @file createListAutomat.js
|
||||
* @description Factory function for company-scoped list automats with
|
||||
* deduplicated `load()`, silent `reload()`, and automatic session synchronization.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes each created list automat to auth state — calls
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
*
|
||||
* Used by:
|
||||
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
|
||||
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
|
||||
*
|
||||
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
|
||||
* category views rather than fetched from a whole-company endpoint.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ListAutomatState
|
||||
* @property {Array<object>} items Loaded records (empty until the first success).
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a company-scoped list store following the brand/unit/warehouse
|
||||
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
|
||||
* silently, and `reset()` clears on logout.
|
||||
*
|
||||
* This replaces the stale-while-revalidate list caches that previously lived
|
||||
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
|
||||
* customergroup, number).
|
||||
*
|
||||
* @template T
|
||||
* @param {object} options Factory options.
|
||||
* @param {string} options.name Automat registry name (also used for logging).
|
||||
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
|
||||
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
|
||||
* @returns {Automat<ListAutomatState>} Configured list automat instance.
|
||||
*/
|
||||
export function createListAutomat({ name, fetch: fetchItems, sort }) {
|
||||
const initialState = { items: [], status: 'idle', error: null };
|
||||
|
||||
let automat;
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Fetches and normalizes the record list.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Records from the backend.
|
||||
*/
|
||||
const fetchRecords = async () => {
|
||||
const items = await fetchItems();
|
||||
return sort ? sort(items) : items;
|
||||
};
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the list, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
automat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the list while keeping the current state, so
|
||||
* subscribers do not flash a loading state.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Fresh records.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const items = await fetchRecords();
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
} catch (error) {
|
||||
automat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<T>} items Records to cache.
|
||||
* @returns {Array<T>} Cached records.
|
||||
*/
|
||||
replace(items) {
|
||||
automat.setState({ items, status: 'ready', error: null });
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-fetches the list once the selected company is known.
|
||||
*
|
||||
* @returns {Promise<Array<T>>} Loaded records.
|
||||
*/
|
||||
ensureLoaded() {
|
||||
if (automat.state.status === 'ready') {
|
||||
return Promise.resolve(automat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout). */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
automat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name });
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
return automat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the record list of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {Array<object>} Current records.
|
||||
*/
|
||||
export function selectListItems(automat) {
|
||||
return automat.state.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the load status of a list automat.
|
||||
*
|
||||
* @param {Automat<ListAutomatState>} automat List automat.
|
||||
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
|
||||
*/
|
||||
export function selectListStatus(automat) {
|
||||
return automat.state.status;
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* @file customergroupAutomat.js
|
||||
* @description Manages cached customer groups (`GET /api/customergroup`) for
|
||||
* the active company. Follows the brand/warehouse automat pattern with
|
||||
* filtering, deduplicated loads, silent reloads, and CRUD actions.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`,
|
||||
* and `actions.reset()` on `'anon'`.
|
||||
* - UI / Routes: Mutating actions (`create`, `update`, `remove`, `reorder`, `filter`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { customergroup as customergroupApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomerGroupState
|
||||
* @property {Array<CustomerGroup>} items Loaded records.
|
||||
* @property {Array<CustomerGroup>} filteredItems Records matching active search filter.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|string|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {CustomerGroupState} */
|
||||
const initialState = {
|
||||
items: [],
|
||||
filteredItems: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const applyFilter = (groups, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query
|
||||
? groups.filter((g) => String(g.name ?? '').toLowerCase().includes(query))
|
||||
: groups;
|
||||
};
|
||||
|
||||
const sortGroups = (items) =>
|
||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
|
||||
|
||||
let customergroupAutomat;
|
||||
|
||||
const updateStateWithGroups = (groups, extra = {}) => {
|
||||
const list = sortGroups(Array.isArray(groups) ? groups : []);
|
||||
const filteredItems = applyFilter(list, customergroupAutomat.state.filter);
|
||||
customergroupAutomat.setState({ items: list, filteredItems, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
async function fetchGroups() {
|
||||
const data = await customergroupApi.list();
|
||||
requireObject(data, 'GET /api/customergroup');
|
||||
return requireArray(data.customergroups, 'customergroups');
|
||||
}
|
||||
|
||||
let inflightLoad = null;
|
||||
|
||||
function loadGroups() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
customergroupAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const groups = await fetchGroups();
|
||||
updateStateWithGroups(groups, { status: 'ready', error: null });
|
||||
return groups;
|
||||
} catch (error) {
|
||||
customergroupAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active search filter query and updates filteredItems. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
customergroupAutomat.setState({
|
||||
filter,
|
||||
filteredItems: applyFilter(customergroupAutomat.state.items, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/** Loads the list, deduplicating concurrent callers. */
|
||||
load() {
|
||||
return loadGroups();
|
||||
},
|
||||
|
||||
/** Silently refreshes the customer groups without flashing loading state. */
|
||||
async reload() {
|
||||
try {
|
||||
const groups = await fetchGroups();
|
||||
updateStateWithGroups(groups, { status: 'ready', error: null });
|
||||
return groups;
|
||||
} catch (error) {
|
||||
customergroupAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/** Replaces the cached list without an API call. */
|
||||
replace(items) {
|
||||
return updateStateWithGroups(items, { status: 'ready', error: null });
|
||||
},
|
||||
|
||||
/** Returns cached items if ready, otherwise loads. */
|
||||
ensureLoaded() {
|
||||
if (customergroupAutomat.state.status === 'ready') {
|
||||
return Promise.resolve(customergroupAutomat.state.items);
|
||||
}
|
||||
return actions.load();
|
||||
},
|
||||
|
||||
/** Resets state to initial idle values. */
|
||||
reset() {
|
||||
inflightLoad = null;
|
||||
customergroupAutomat.setState(initialState);
|
||||
},
|
||||
|
||||
/** Creates a customer group and refreshes the cache. */
|
||||
async create(body) {
|
||||
const data = await customergroupApi.create(body);
|
||||
requireObject(data, 'POST /api/customergroup');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
await customergroupAutomat.actions.reload();
|
||||
return data.customergroup;
|
||||
},
|
||||
|
||||
/** Updates a customer group name and refreshes the cache. */
|
||||
async update(id, body) {
|
||||
const data = await customergroupApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/customergroup/:id');
|
||||
requireObject(data.customergroup, 'customergroup');
|
||||
requireDefined(data.customergroup.id, 'customergroup.id');
|
||||
await customergroupAutomat.actions.reload();
|
||||
return data.customergroup;
|
||||
},
|
||||
|
||||
rename(id, body) {
|
||||
return actions.update(id, body);
|
||||
},
|
||||
|
||||
/** Deletes a customer group and refreshes the cache. */
|
||||
async remove(id) {
|
||||
await customergroupApi.delete(id);
|
||||
await customergroupAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/** Reorders customer groups and refreshes the cache. */
|
||||
async reorder(customergroupIds) {
|
||||
const data = await customergroupApi.order({ customergroupIds });
|
||||
requireObject(data, 'PUT /api/customergroup/order');
|
||||
await customergroupAutomat.actions.reload();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 {Automat<CustomerGroupState>}
|
||||
*/
|
||||
customergroupAutomat = new Automat(initialState, actions, {
|
||||
name: 'customergroup',
|
||||
});
|
||||
|
||||
export { customergroupAutomat };
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
export default customergroupAutomat;
|
||||
@@ -14,7 +14,7 @@ import { Automat } from 'react-automat';
|
||||
import { entity as entityApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
import customergroupAutomat from './customergroupAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Entity
|
||||
@@ -48,7 +48,7 @@ const initialState = {
|
||||
const applyFilter = (entities, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
if (!query) return entities;
|
||||
const groups = customergroupAutomat?.state?.items ?? [];
|
||||
const groups = companyAutomat?.state?.customergroups ?? [];
|
||||
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
|
||||
|
||||
return entities.filter((e) => {
|
||||
@@ -200,13 +200,15 @@ entityAutomat = new Automat(initialState, actions, {
|
||||
export { entityAutomat };
|
||||
|
||||
// Load on session/company establishment, reset on logout.
|
||||
authAutomat.subscribe((auth) => {
|
||||
const syncEntityWithAuth = (auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void actions.load().catch(() => {});
|
||||
} else if (auth.status === 'anon') {
|
||||
actions.reset();
|
||||
}
|
||||
});
|
||||
};
|
||||
authAutomat.subscribe(syncEntityWithAuth);
|
||||
syncEntityWithAuth(authAutomat.state);
|
||||
|
||||
export default entityAutomat;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file index.js
|
||||
* @description Central barrel exporting all domain, configuration, and list
|
||||
* @description Central barrel exporting domain, configuration, and list
|
||||
* automats, along with their action creators and reactive selectors.
|
||||
*/
|
||||
|
||||
@@ -22,9 +22,6 @@ export {
|
||||
clearNavigation,
|
||||
} from './navigationAutomat';
|
||||
|
||||
export { default as userSettingsAutomat } from './userSettingsAutomat';
|
||||
export { setViewerPosition } from './userSettingsAutomat';
|
||||
|
||||
export { default as helpAutomat } from './helpAutomat';
|
||||
export {
|
||||
toggleHelp,
|
||||
@@ -44,10 +41,6 @@ export {
|
||||
applyPwaUpdate,
|
||||
} from './pwaAutomat';
|
||||
|
||||
export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
export { default as carrierAutomat } from './carrierAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export {
|
||||
default as articleSearchAutomat,
|
||||
@@ -63,6 +56,7 @@ export {
|
||||
default as articleIndexAutomat,
|
||||
setArticleIndexName,
|
||||
setArticleIndexStock,
|
||||
updateArticleIndexRecord,
|
||||
buildArticleIndex,
|
||||
selectArticleIndexItems,
|
||||
} from './articleIndexAutomat';
|
||||
@@ -98,9 +92,20 @@ export {
|
||||
subscribeChunkStats,
|
||||
} from './orderAutomat';
|
||||
export { default as entityAutomat } from './entityAutomat';
|
||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { default as accountAutomat } from './accountAutomat';
|
||||
export {
|
||||
default as retoureAutomat,
|
||||
selectRetoures,
|
||||
selectRetoureTotal,
|
||||
selectRetoureUnfilteredTotal,
|
||||
isRetourePaid,
|
||||
isRetoureShipped,
|
||||
isRetoureDelivered,
|
||||
getRetoureDate,
|
||||
getItems as getRetoureItems,
|
||||
requireChunk as requireRetoureChunk,
|
||||
getChunkStats as getRetoureChunkStats,
|
||||
subscribeChunkStats as subscribeRetoureChunkStats,
|
||||
} from './retoureAutomat';
|
||||
export { default as backupAutomat } from './backupAutomat';
|
||||
export {
|
||||
default as shipmentAutomat,
|
||||
@@ -113,11 +118,9 @@ export {
|
||||
getChunkStats as getShipmentChunkStats,
|
||||
subscribeChunkStats as subscribeShipmentChunkStats,
|
||||
} from './shipmentAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
|
||||
/** Per-SKU article detail views keyed by SKU. */
|
||||
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||
|
||||
/** Single shared GET /api/events connection. */
|
||||
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
|
||||
@@ -132,6 +135,7 @@ export {
|
||||
|
||||
export { default as companyAutomat } from './companyAutomat';
|
||||
export {
|
||||
selectCompanies,
|
||||
selectCurrentCompany,
|
||||
selectCompanyId,
|
||||
selectCompanyStatus,
|
||||
@@ -142,19 +146,45 @@ export {
|
||||
selectCompanyReducedTaxrate,
|
||||
selectCompanyTaxfreerate,
|
||||
selectCompanyRootCategoryId,
|
||||
} from './companyAutomat';
|
||||
|
||||
export { default as brandAutomat } from './brandAutomat';
|
||||
export { default as unitAutomat } from './unitAutomat';
|
||||
export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat';
|
||||
export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat';
|
||||
|
||||
export { default as posterminalAutomat } from './posterminalAutomat';
|
||||
export {
|
||||
selectNumbers,
|
||||
selectNumbersStatus,
|
||||
selectPosterminals,
|
||||
selectPosterminalStatus,
|
||||
selectHasPosterminals,
|
||||
} from './posterminalAutomat';
|
||||
selectPrinterAgents,
|
||||
selectPrinterAgentStatus,
|
||||
selectHasPrinterAgents,
|
||||
selectAgentPrinters,
|
||||
selectAgentPrinterStatus,
|
||||
selectLivePrinters,
|
||||
selectSuppliers,
|
||||
selectSuppliersStatus,
|
||||
selectUnits,
|
||||
selectUnitsStatus,
|
||||
setViewerPosition,
|
||||
setChatDrawerWidth,
|
||||
selectChatDrawerWidth,
|
||||
selectWarehouses,
|
||||
selectWarehousesStatus,
|
||||
selectProperties,
|
||||
selectPropertyStatus,
|
||||
selectCustomergroups,
|
||||
selectCustomergroupsStatus,
|
||||
selectCarriers,
|
||||
selectCarriersStatus,
|
||||
selectBrands,
|
||||
selectBrandsStatus,
|
||||
selectAttributes,
|
||||
selectAttributeStatus,
|
||||
selectAccounts,
|
||||
selectAccountsStatus,
|
||||
selectCompanyRole,
|
||||
selectIsCompanyAdmin,
|
||||
selectMembers,
|
||||
selectMembersStatus,
|
||||
selectInvites,
|
||||
selectInvitesStatus,
|
||||
} from './companyAutomat';
|
||||
|
||||
export { default as categoryAutomat } from './categoryAutomat';
|
||||
export {
|
||||
@@ -218,84 +248,3 @@ export {
|
||||
closeExecution,
|
||||
normalizeRoute,
|
||||
} from './pinnedAppletAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Overview of Automats in Dashfront
|
||||
* ============================================================================
|
||||
*
|
||||
* Automats are lightweight, reactive, out-of-tree state containers powered by
|
||||
* `react-automat`. They manage all client-side state without React context,
|
||||
* hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent`
|
||||
* via constructor reads and fine-grained subscriptions.
|
||||
*
|
||||
* 1. Multi-Tenant Session & Security
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`,
|
||||
* `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`).
|
||||
* Coordinates session bootstrap, company switching, and acts as the root
|
||||
* lifecycle trigger that loads or resets all company-scoped automats.
|
||||
*
|
||||
* 2. Real-Time SSE Hub
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `sseAutomat`: Manages the single persistent Server-Sent Events connection
|
||||
* (`GET /api/events`). Automatically reconnects when the active company changes
|
||||
* and terminates on logout. Dispatches incoming server mutation signals into
|
||||
* targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`,
|
||||
* `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`,
|
||||
* `propertyAutomat`).
|
||||
*
|
||||
* 3. Domain & Entity Caches
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`)
|
||||
* with inflight request deduplication, optimistic renames, and reactive selectors.
|
||||
* - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`)
|
||||
* providing per-SKU supplier catalog bindings and change history.
|
||||
* - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts
|
||||
* and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR).
|
||||
*
|
||||
* 4. Company Configuration & Catalogs
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID).
|
||||
* - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create,
|
||||
* rename, move, reorder, delete), and breadcrumb resolution.
|
||||
* - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child
|
||||
* categories per slug path, auto-preloaded when category tree finishes loading.
|
||||
* - `brandAutomat`: Catalog manufacturer and brand records.
|
||||
* - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.).
|
||||
* - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters.
|
||||
* - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color).
|
||||
* - `propertyAutomat`: Custom product property and metadata names.
|
||||
* - `supplierAutomat`: Directory of vendor entities and supplier article mappings.
|
||||
* - `warehouseAutomat`: Warehouse locations and stock storage areas.
|
||||
*
|
||||
* 5. Company-Scoped List Automats (`createListAutomat`)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
|
||||
* and automatic synchronization with `authAutomat`:
|
||||
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
|
||||
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
|
||||
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
|
||||
* - `entityAutomat`: Customers and business partners (`GET /api/entity`).
|
||||
* - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`).
|
||||
* - `numberAutomat`: Document numbering sequences (`GET /api/number`).
|
||||
*
|
||||
* 6. Search & Infinite-Scroll Grid Automats
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
|
||||
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
|
||||
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
|
||||
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
|
||||
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
|
||||
*
|
||||
* 7. UI State & Utilities
|
||||
* ----------------------------------------------------------------------------
|
||||
* - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens.
|
||||
* - `navigationAutomat`: Persists last visited delivery document path and article SKU.
|
||||
* - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB).
|
||||
* - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB).
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@@ -224,14 +224,21 @@ export const orderDraftAutomat = createManifestDraftAutomat({
|
||||
name: 'order-draft',
|
||||
});
|
||||
|
||||
/** Shared singleton automat for retoure draft. */
|
||||
export const retoureDraftAutomat = createManifestDraftAutomat({
|
||||
kind: 'retoure',
|
||||
name: 'retoure-draft',
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the draft automat for a given manifest kind.
|
||||
* @param {'quote'|'order'} kind
|
||||
* @param {'quote'|'order'|'retoure'} kind
|
||||
* @returns {Automat|null}
|
||||
*/
|
||||
export function getManifestDraftAutomat(kind) {
|
||||
if (kind === 'quote') return quoteDraftAutomat;
|
||||
if (kind === 'order') return orderDraftAutomat;
|
||||
if (kind === 'retoure') return retoureDraftAutomat;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -343,6 +350,9 @@ export function getManifestDirtyDetails(current, baseline) {
|
||||
if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) {
|
||||
lineFieldDiffs.unit = true;
|
||||
}
|
||||
if (Boolean(cl.shippable ?? true) !== Boolean(bl.shippable ?? true)) {
|
||||
lineFieldDiffs.shippable = true;
|
||||
}
|
||||
|
||||
if (Object.keys(lineFieldDiffs).length > 0) {
|
||||
dirtyLineKeys.push(key);
|
||||
@@ -420,6 +430,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
const actions = {
|
||||
loadDoc(id, doc) {
|
||||
const date = dateInputValue(doc.date);
|
||||
const createdAt = doc.createdAt ?? doc.created_at ?? null;
|
||||
const partyId = doc.partyId ?? null;
|
||||
const number = doc.number ?? '';
|
||||
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
|
||||
@@ -428,6 +439,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
|
||||
const baseline = {
|
||||
date,
|
||||
createdAt,
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
@@ -440,6 +452,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
mode: 'edit',
|
||||
id,
|
||||
date,
|
||||
createdAt,
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
@@ -448,10 +461,10 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
shipments: doc.shipments || [],
|
||||
payments: doc.payments || [],
|
||||
shippings: doc.shippings || [],
|
||||
orderTotal: doc.orderTotal ?? null,
|
||||
paidTotal: doc.paidTotal ?? null,
|
||||
paid: Boolean(doc.paid),
|
||||
shipped: Boolean(doc.shipped),
|
||||
orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null,
|
||||
paidTotal: doc.paidTotal ?? doc.paid_total ?? null,
|
||||
paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
|
||||
shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
|
||||
delivered: Boolean(doc.delivered),
|
||||
baseline,
|
||||
isDirty: false,
|
||||
@@ -505,6 +518,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
|
||||
commitSaved(savedDoc) {
|
||||
const date = dateInputValue(savedDoc.date);
|
||||
const createdAt = savedDoc.createdAt ?? savedDoc.created_at ?? automat.state.createdAt ?? null;
|
||||
const partyId = savedDoc.partyId ?? null;
|
||||
const number = savedDoc.number ?? automat.state.number;
|
||||
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
|
||||
@@ -513,6 +527,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
|
||||
const baseline = {
|
||||
date,
|
||||
createdAt,
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
@@ -522,6 +537,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
|
||||
automat.setState({
|
||||
date,
|
||||
createdAt,
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
@@ -542,6 +558,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
mode: 'edit',
|
||||
id: null,
|
||||
date: '',
|
||||
createdAt: null,
|
||||
partyId: null,
|
||||
number: '',
|
||||
externalNumber: '',
|
||||
@@ -563,6 +580,7 @@ export function createManifestEditAutomat({ kind, name }) {
|
||||
mode: 'edit',
|
||||
id: null,
|
||||
date: '',
|
||||
createdAt: null,
|
||||
partyId: null,
|
||||
number: '',
|
||||
externalNumber: '',
|
||||
@@ -594,13 +612,20 @@ export const orderEditAutomat = createManifestEditAutomat({
|
||||
name: 'order-edit',
|
||||
});
|
||||
|
||||
/** Shared singleton automat for the single last edited retoure. */
|
||||
export const retoureEditAutomat = createManifestEditAutomat({
|
||||
kind: 'retoure',
|
||||
name: 'retoure-edit',
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the edit automat for a given manifest kind.
|
||||
* @param {'quote'|'order'} kind
|
||||
* @param {'quote'|'order'|'retoure'} kind
|
||||
* @returns {Automat|null}
|
||||
*/
|
||||
export function getManifestEditAutomat(kind) {
|
||||
if (kind === 'quote') return quoteEditAutomat;
|
||||
if (kind === 'order') return orderEditAutomat;
|
||||
if (kind === 'retoure') return retoureEditAutomat;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* @file numberAutomat.js
|
||||
* @description Manages cached document numbering sequences (`GET /api/number`)
|
||||
* (e.g. delivery, entity) for the active company. Built via `createListAutomat`.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
|
||||
* a `selectedCompanyId`, and `reset()` on `'anon'`.
|
||||
* - UI / Routes: Manual calls to `load()` or `reload()`.
|
||||
*/
|
||||
|
||||
import { number as numberApi } from '../api';
|
||||
import { createListAutomat } from './createListAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NumberSequence
|
||||
* @property {string|number} id Sequence id.
|
||||
* @property {string} type Document type key (e.g. `delivery`, `entity`).
|
||||
* @property {string} prefix Prefix rendered before the counter.
|
||||
* @property {number} padding Zero-padding width.
|
||||
* @property {number} next Next counter value.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Company document number sequences (`GET /api/number`).
|
||||
*
|
||||
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
|
||||
*/
|
||||
export const numberAutomat = createListAutomat({
|
||||
name: 'number',
|
||||
fetch: async () => {
|
||||
const data = await numberApi.list();
|
||||
return data.numbers ?? [];
|
||||
},
|
||||
sort: (items) =>
|
||||
[...items].sort((a, b) =>
|
||||
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
|
||||
),
|
||||
});
|
||||
|
||||
export default numberAutomat;
|
||||
@@ -453,7 +453,7 @@ export function isOrderPaid(order) {
|
||||
|
||||
export function isOrderDelivered(order) {
|
||||
if (!order) return false;
|
||||
return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
|
||||
return Boolean(order.isShipped ?? order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
|
||||
}
|
||||
|
||||
export function getOrderDate(order) {
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* @file posterminalAutomat.js
|
||||
* @description Manages registered Point-of-Sale (POS) hardware terminals and
|
||||
* their enabled category mappings for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading.
|
||||
* - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { posterminal as posterminalApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import companyAutomat from './companyAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PosterminalState
|
||||
* @property {Array<object>} terminals
|
||||
* @property {number|string|null} companyId
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {PosterminalState} */
|
||||
const initialState = {
|
||||
terminals: [],
|
||||
companyId: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let posterminalAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all posterminals for the selected company. */
|
||||
async load() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
|
||||
if (!companyId) return [];
|
||||
posterminalAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await posterminalApi.list();
|
||||
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
|
||||
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
|
||||
return terminals;
|
||||
} catch (err) {
|
||||
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new posterminal.
|
||||
* @param {{ name: string }} param0
|
||||
*/
|
||||
async create({ name }) {
|
||||
const res = await posterminalApi.create({ name });
|
||||
const terminal = res.posterminal;
|
||||
posterminalAutomat.setState({
|
||||
terminals: [...posterminalAutomat.state.terminals, terminal],
|
||||
});
|
||||
return terminal;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a posterminal.
|
||||
* @param {number} terminalId
|
||||
* @param {object} patch
|
||||
*/
|
||||
async update(terminalId, patch) {
|
||||
const res = await posterminalApi.update(terminalId, patch);
|
||||
const updated = res.posterminal;
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.map((t) =>
|
||||
t.id === terminalId ? { ...t, ...updated } : t
|
||||
),
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a posterminal.
|
||||
* @param {number} terminalId
|
||||
*/
|
||||
async delete(terminalId) {
|
||||
await posterminalApi.delete(terminalId);
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable all categories on a terminal.
|
||||
* @param {{ terminalId: number }} param0
|
||||
*/
|
||||
async enableAll({ terminalId }) {
|
||||
const res = await posterminalApi.enableAllCategories(terminalId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable all categories on a terminal.
|
||||
* @param {{ terminalId: number }} param0
|
||||
*/
|
||||
async disableAll({ terminalId }) {
|
||||
const res = await posterminalApi.disableAllCategories(terminalId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable a specific category on a terminal.
|
||||
* @param {{ terminalId: number, categoryId: number }} param0
|
||||
*/
|
||||
async enableCategory({ terminalId, categoryId }) {
|
||||
const res = await posterminalApi.enableCategory(terminalId, categoryId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable a specific category on a terminal.
|
||||
* @param {{ terminalId: number, categoryId: number }} param0
|
||||
*/
|
||||
async disableCategory({ terminalId, categoryId }) {
|
||||
const res = await posterminalApi.disableCategory(terminalId, categoryId);
|
||||
patchTerminalCategories(terminalId, res.categoryIds);
|
||||
return res;
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
posterminalAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
function patchTerminalCategories(terminalId, categoryIds) {
|
||||
posterminalAutomat.setState({
|
||||
terminals: posterminalAutomat.state.terminals.map((t) =>
|
||||
t.id === terminalId ? { ...t, categoryIds } : t,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Global posterminal state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<PosterminalState>}
|
||||
*/
|
||||
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void posterminalAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
posterminalAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Selectors
|
||||
export const selectPosterminals = () => posterminalAutomat.state.terminals;
|
||||
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
|
||||
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
|
||||
|
||||
export default posterminalAutomat;
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* @file propertyAutomat.js
|
||||
* @description Manages catalog product property names (custom metadata definitions)
|
||||
* for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event.
|
||||
* - UI / Settings: Mutating actions (`createProperty`, `updateProperty`,
|
||||
* `deleteProperty`, `reorderProperties`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { propertyname as propertynameApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} PropertyName
|
||||
* @property {number} id
|
||||
* @property {string} name
|
||||
* @property {number} sort
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PropertyState
|
||||
* @property {PropertyName[]} properties
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {PropertyState} */
|
||||
const initialState = {
|
||||
properties: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function normalizeProperties(properties) {
|
||||
if (!Array.isArray(properties)) return [];
|
||||
return [...properties]
|
||||
.map((p) => ({
|
||||
...p,
|
||||
exclusive: Boolean(p.exclusive),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
|
||||
if (sDiff !== 0) return sDiff;
|
||||
return (a.id ?? 0) - (b.id ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
let propertyAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all property names for the selected company. */
|
||||
async load() {
|
||||
propertyAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await propertynameApi.list();
|
||||
const properties = normalizeProperties(res.propertynames);
|
||||
propertyAutomat.setState({ properties, status: 'succeeded' });
|
||||
return properties;
|
||||
} catch (err) {
|
||||
propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a property name.
|
||||
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async createProperty(body) {
|
||||
const res = await propertynameApi.create(body);
|
||||
await actions.load();
|
||||
return res.propertyname;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a property name.
|
||||
* @param {number} id
|
||||
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
|
||||
*/
|
||||
async updateProperty(id, body) {
|
||||
const res = await propertynameApi.update(id, body);
|
||||
await actions.load();
|
||||
return res.propertyname;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a property name.
|
||||
* @param {number} id
|
||||
*/
|
||||
async deleteProperty(id) {
|
||||
await propertynameApi.delete(id);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete multiple property names.
|
||||
* @param {number[]} ids
|
||||
*/
|
||||
async deleteProperties(ids) {
|
||||
for (const id of ids) {
|
||||
await propertynameApi.delete(id);
|
||||
}
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder property names.
|
||||
* @param {number[]} propertynameIds
|
||||
*/
|
||||
async reorderProperties(propertynameIds) {
|
||||
await propertynameApi.order(propertynameIds);
|
||||
await actions.load();
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
propertyAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global property state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<PropertyState>}
|
||||
*/
|
||||
propertyAutomat = new Automat(initialState, actions, { name: 'property' });
|
||||
|
||||
// Selectors
|
||||
export function selectProperties() {
|
||||
return propertyAutomat.state.properties;
|
||||
}
|
||||
|
||||
export function selectPropertyStatus() {
|
||||
return propertyAutomat.state.status;
|
||||
}
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void propertyAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
propertyAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default propertyAutomat;
|
||||
1005
src/automats/retoureAutomat.js
Normal file
1005
src/automats/retoureAutomat.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,27 @@
|
||||
/**
|
||||
* @file sseAutomat.js
|
||||
* @description Central Server-Sent Events hub managing the single persistent
|
||||
* `GET /api/events` connection and fanning mutation signals into domain automats.
|
||||
* `GET /api/events` connection and fanning mutation signals into active automats.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — reconnects stream when
|
||||
* `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout).
|
||||
* - `authAutomat`: Connects stream when `hasAuth` is true (and reconnects when
|
||||
* `selectedCompanyId` changes); disconnects when `hasAuth` is false (logout).
|
||||
*
|
||||
* Downstream Triggers:
|
||||
* Dispatches real-time SSE mutation events into specific automats:
|
||||
* - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-<sku>` signals.
|
||||
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`):
|
||||
* Reloads SKU-keyed views on `article-<sku>` signals.
|
||||
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()`
|
||||
* on `'category'` SSE events.
|
||||
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events;
|
||||
* the index is derived from the reloaded category views, so no extra request.
|
||||
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events.
|
||||
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
|
||||
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
|
||||
* - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events.
|
||||
* Dispatches real-time SSE mutation events into active automats without eagerly
|
||||
* importing or instantiating unmounted stores:
|
||||
* - `companyAutomat`: Refreshes warehouses, carriers, attributes, properties,
|
||||
* suppliers, and brands when `companyAutomat` is active.
|
||||
* - `categoryAutomat`: Refreshes category tree and open category views on `'category'`.
|
||||
* - `articleIndexAutomat`: Refreshes derived article index on `'articles'` / `'warehouse'`.
|
||||
* - `artikelAutomat` & `articleDetailAutomat`: Refreshes watched SKU on `article-<sku>`.
|
||||
* - `orderAutomat`, `quoteAutomat`, `shipmentAutomat`, `deliveryAutomat`, `retoureAutomat`:
|
||||
* Reloads when those automats are active.
|
||||
*/
|
||||
|
||||
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';
|
||||
import deliveryAutomat from './deliveryAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
import shipmentAutomat from './shipmentAutomat';
|
||||
import carrierAutomat from './carrierAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* @typedef {Object} SseState
|
||||
@@ -45,10 +29,10 @@ import carrierAutomat from './carrierAutomat';
|
||||
* @property {string|null} companyId Company the stream is bound to.
|
||||
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
|
||||
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
||||
* @property {{id: number, type: string, data: string, lastEventId: string, time: number}|null} lastEvent Most recent event.
|
||||
*/
|
||||
|
||||
const EVENT_HISTORY_LIMIT = 200;
|
||||
|
||||
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||
|
||||
function createClientId() {
|
||||
@@ -62,8 +46,6 @@ function createClientId() {
|
||||
|
||||
/**
|
||||
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
||||
* Created when the automat module is evaluated so every `EventSource` in this
|
||||
* tab shares the same `cid`.
|
||||
*
|
||||
* @returns {string} Client id.
|
||||
*/
|
||||
@@ -80,7 +62,6 @@ export function getSseClientId() {
|
||||
}
|
||||
}
|
||||
|
||||
// Created once per tab at automat creation time and stored to sessionStorage.
|
||||
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
||||
|
||||
/** @type {SseState} */
|
||||
@@ -89,6 +70,7 @@ const initialState = {
|
||||
companyId: null,
|
||||
clientId: sseClientId,
|
||||
events: [],
|
||||
lastEvent: null,
|
||||
};
|
||||
|
||||
/** Event name prefix for per-article signals. */
|
||||
@@ -97,18 +79,20 @@ const ARTICLE_EVENT_PREFIX = 'article-';
|
||||
let sseAutomat;
|
||||
/** @type {EventSource | null} */
|
||||
let eventSource = null;
|
||||
/** @type {AbortController | null} */
|
||||
let streamAbortController = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
/**
|
||||
* Handlers for statically named events (`category`, `articles`, …).
|
||||
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>}
|
||||
* Handlers for event subscriptions (`category`, `articles`, `article-*`, …).
|
||||
* @type {Map<string, Set<{handler: Function, detach: (() => void)|null}>>}
|
||||
*/
|
||||
const handlers = new Map();
|
||||
let nextEventId = 0;
|
||||
const seenEvents = new WeakSet();
|
||||
|
||||
/**
|
||||
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
|
||||
* at least one view is open on that SKU.
|
||||
* Handlers per watched SKU.
|
||||
* @type {Map<string, Set<(sku: string) => void>>}
|
||||
*/
|
||||
const articleWatchers = new Map();
|
||||
@@ -120,25 +104,41 @@ function recordEvent(event) {
|
||||
const entry = {
|
||||
id: ++nextEventId,
|
||||
type: event.type,
|
||||
data: typeof event.data === 'string' ? event.data : '',
|
||||
data: typeof event.data === 'string' ? event.data : (event.data ? JSON.stringify(event.data) : ''),
|
||||
lastEventId: event.lastEventId ?? '',
|
||||
time: Date.now(),
|
||||
};
|
||||
sseAutomat.setState((state) => ({
|
||||
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
|
||||
lastEvent: entry,
|
||||
}));
|
||||
}
|
||||
|
||||
function addTrackedListener(es, type, handler) {
|
||||
const listener = (event) => {
|
||||
recordEvent(event);
|
||||
handler(event);
|
||||
};
|
||||
es.addEventListener(type, listener);
|
||||
return () => es.removeEventListener(type, listener);
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearReconnectTimer();
|
||||
const auth = authAutomat.state;
|
||||
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
|
||||
if (!hasAuth) return;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
clearReconnectTimer();
|
||||
if (streamAbortController) {
|
||||
streamAbortController.abort();
|
||||
streamAbortController = null;
|
||||
}
|
||||
if (eventSource) {
|
||||
console.log('[sse] disconnect');
|
||||
try {
|
||||
@@ -146,21 +146,155 @@ function disconnect() {
|
||||
} catch {}
|
||||
eventSource = null;
|
||||
}
|
||||
if (sseAutomat.state.status !== 'disconnected') {
|
||||
if (sseAutomat && sseAutomat.state.status !== 'disconnected') {
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches every registered handler to a freshly opened stream.
|
||||
* Dispatches an incoming event through registered handlers and article watchers.
|
||||
*
|
||||
* @param {EventSource} es Open event stream.
|
||||
* @returns {void}
|
||||
* @param {{ type: string, data: any, lastEventId?: string }} event
|
||||
*/
|
||||
function dispatchSseEvent(event) {
|
||||
recordEvent(event);
|
||||
|
||||
const eventType = String(event.type);
|
||||
|
||||
// 1. Built-in system handling (e.g. user / company switch sync)
|
||||
if (eventType === 'user') {
|
||||
try {
|
||||
const payload = typeof event.data === 'string' ? JSON.parse(event.data || '{}') : (event.data || {});
|
||||
const nextCompanyId = payload.selectedCompanyId ?? null;
|
||||
|
||||
if (authAutomat.state.user?.selectedCompanyId !== nextCompanyId) {
|
||||
authAutomat.setState((prev) => ({
|
||||
...prev,
|
||||
user: prev.user ? { ...prev.user, selectedCompanyId: nextCompanyId } : prev.user,
|
||||
hasCompany: Boolean((prev.hasAuth ?? (prev.status === 'authed' && prev.user)) && nextCompanyId != null),
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[sse] failed to handle user event', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dispatch to exact handlers registered via on(eventType, handler)
|
||||
const exactSet = handlers.get(eventType);
|
||||
if (exactSet && exactSet.size > 0) {
|
||||
for (const entry of exactSet) {
|
||||
try {
|
||||
entry.handler(event);
|
||||
} catch (err) {
|
||||
console.error(`[sse] error in handler for ${eventType}:`, err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback direct dispatch if store didn't subscribe yet
|
||||
if (eventType === 'articles') {
|
||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'category') {
|
||||
void Automat.get('category')?.actions?.loadTree?.().catch(() => {});
|
||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||
} else if (eventType === 'company') {
|
||||
void Automat.get('company')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'warehouse') {
|
||||
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
|
||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'supplier') {
|
||||
void Automat.get('company')?.actions?.reloadSuppliers?.().catch(() => {});
|
||||
} else if (eventType === 'brand') {
|
||||
void Automat.get('company')?.actions?.loadBrands?.(true).catch(() => {});
|
||||
} else if (eventType === 'unit') {
|
||||
void Automat.get('company')?.actions?.loadUnits?.().catch(() => {});
|
||||
} else if (eventType === 'carrier') {
|
||||
void Automat.get('company')?.actions?.reloadCarriers?.().catch(() => {});
|
||||
} else if (eventType === 'posterminal') {
|
||||
void Automat.get('company')?.actions?.loadTerminals?.().catch(() => {});
|
||||
} else if (eventType === 'customergroup') {
|
||||
void Automat.get('company')?.actions?.reloadCustomergroups?.().catch(() => {});
|
||||
} else if (eventType === 'propertyname') {
|
||||
void Automat.get('company')?.actions?.loadProperties?.().catch(() => {});
|
||||
} else if (eventType === 'attributename') {
|
||||
void Automat.get('company')?.actions?.loadAttributes?.().catch(() => {});
|
||||
} else if (eventType === 'account') {
|
||||
void Automat.get('company')?.actions?.reloadAccounts?.().catch(() => {});
|
||||
} else if (eventType === 'order') {
|
||||
void Automat.get('order')?.actions?.reload?.().catch(() => {});
|
||||
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'delivery') {
|
||||
void Automat.get('delivery')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'retoure') {
|
||||
void Automat.get('retoure')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'quote') {
|
||||
void Automat.get('quote')?.actions?.reload?.().catch(() => {});
|
||||
} else if (eventType === 'shipment') {
|
||||
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Dispatch to article-specific handlers if it starts with article-
|
||||
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
|
||||
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
|
||||
const lowerSku = sku.trim().toLowerCase();
|
||||
|
||||
// Watchers registered via watchArticle(sku, handler)
|
||||
const watchers = articleWatchers.get(lowerSku) || articleWatchers.get(sku);
|
||||
if (watchers) {
|
||||
for (const handler of watchers) {
|
||||
try {
|
||||
handler(sku);
|
||||
} catch (err) {
|
||||
console.error(`[sse] error in article watcher for ${sku}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Article wildcard handlers (registered via on('article-*', ...) or onArticle(...))
|
||||
const articleWildcard = handlers.get('article-*');
|
||||
if (articleWildcard) {
|
||||
for (const entry of articleWildcard) {
|
||||
try {
|
||||
entry.handler(sku, event);
|
||||
} catch (err) {
|
||||
console.error('[sse] error in article-* handler:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single article detail & history/supplier caches invalidation
|
||||
invalidateArticle(sku);
|
||||
}
|
||||
|
||||
// 4. Wildcard handlers registered via on('*', handler)
|
||||
const allWildcard = handlers.get('*');
|
||||
if (allWildcard) {
|
||||
for (const entry of allWildcard) {
|
||||
try {
|
||||
entry.handler(event);
|
||||
} catch (err) {
|
||||
console.error('[sse] error in * handler:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addTrackedListener(es, type, handler) {
|
||||
const listener = (event) => {
|
||||
dispatchSseEvent(event);
|
||||
handler(event);
|
||||
};
|
||||
es.addEventListener(type, listener);
|
||||
return () => es.removeEventListener(type, listener);
|
||||
}
|
||||
|
||||
function attachHandlers(es) {
|
||||
for (const [event, set] of handlers) {
|
||||
if (event === '*' || event === 'article-*') continue;
|
||||
for (const entry of set) {
|
||||
entry.detach = addTrackedListener(es, event, entry.handler);
|
||||
entry.detach = addTrackedListener(es, event, (ev) => dispatchSseEvent(ev));
|
||||
}
|
||||
}
|
||||
for (const [sku] of articleWatchers) {
|
||||
@@ -168,144 +302,178 @@ function attachHandlers(es) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (!eventSource) return;
|
||||
addTrackedListener(eventSource, `${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);
|
||||
const set = articleWatchers.get(sku);
|
||||
if (set) {
|
||||
for (const handler of set) handler(changed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
return;
|
||||
async function startFetchStream(url, companyId) {
|
||||
if (streamAbortController) {
|
||||
streamAbortController.abort();
|
||||
streamAbortController = null;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
streamAbortController = controller;
|
||||
const signal = controller.signal;
|
||||
|
||||
const url = new URL('/api/events', window.location.origin);
|
||||
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||
console.log(`[sse] connecting`);
|
||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||
try {
|
||||
const res = await fetch(url.href, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
sseAutomat.setState({ status: 'disconnected' });
|
||||
return;
|
||||
}
|
||||
throw new Error(`SSE HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
sseAutomat.setState({ status: 'connected', companyId });
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let eventType = 'message';
|
||||
let eventData = '';
|
||||
let lastEventId = '';
|
||||
|
||||
while (!signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split(/\r\n|\r|\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line === '') {
|
||||
if (eventData || eventType !== 'message') {
|
||||
dispatchSseEvent({
|
||||
type: eventType,
|
||||
data: eventData,
|
||||
lastEventId,
|
||||
});
|
||||
}
|
||||
eventType = 'message';
|
||||
eventData = '';
|
||||
} else if (line.startsWith(':')) {
|
||||
continue;
|
||||
} else if (line.startsWith('event:')) {
|
||||
eventType = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
const chunk = line.slice(5).trim();
|
||||
eventData = eventData ? `${eventData}\n${chunk}` : chunk;
|
||||
} else if (line.startsWith('id:')) {
|
||||
lastEventId = line.slice(3).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
console.error('[sse] stream error', err);
|
||||
recordEvent({ type: 'error', data: err?.message || '', lastEventId: '' });
|
||||
sseAutomat.setState({ status: 'error' });
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startEventSource(url, companyId) {
|
||||
const es = new EventSource(url.href, { withCredentials: true });
|
||||
|
||||
// Category tree changed: refresh the tree and every open category view.
|
||||
addTrackedListener(es, '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.
|
||||
addTrackedListener(es, 'articles', () => {
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'warehouse', () => {
|
||||
void warehouseAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] warehouse refresh failed', err);
|
||||
});
|
||||
invalidateAllCategoryViews();
|
||||
void articleIndexAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] article index refresh on warehouse event failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'carrier', () => {
|
||||
void carrierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] carrier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'attributename', () => {
|
||||
void attributeAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] attributename refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'propertyname', () => {
|
||||
void propertyAutomat.actions.load().catch((err) => {
|
||||
console.error('[sse] propertyname refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'supplier', () => {
|
||||
void supplierAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] supplier refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'order', () => {
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'quote', () => {
|
||||
void quoteAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] quote refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'shipment', () => {
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'delivery', () => {
|
||||
void deliveryAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] delivery refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
eventSource = es;
|
||||
|
||||
addTrackedListener(es, 'open', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
sseAutomat.setState({ status: 'connected', companyId });
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'message', () => {});
|
||||
addTrackedListener(es, 'user', (event) => {
|
||||
dispatchSseEvent(event);
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'message', (event) => {
|
||||
dispatchSseEvent(event);
|
||||
});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = (e) => {
|
||||
console.error('[sse] event stream error', e);
|
||||
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
|
||||
sseAutomat.setState({ status: 'error' });
|
||||
};
|
||||
|
||||
eventSource = es;
|
||||
attachHandlers(es);
|
||||
}
|
||||
|
||||
// #region connect
|
||||
// MARK: opens GET /api/events stream when hasAuth is true
|
||||
function connect() {
|
||||
clearReconnectTimer();
|
||||
const auth = authAutomat.state;
|
||||
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
|
||||
if (!hasAuth) {
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
const companyId =
|
||||
auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null;
|
||||
|
||||
const url = new URL('/api/events', window.location.origin);
|
||||
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||
console.log('[sse] connecting');
|
||||
sseAutomat.setState({ status: 'connecting', companyId });
|
||||
|
||||
if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
|
||||
void startFetchStream(url, companyId);
|
||||
} else {
|
||||
startEventSource(url, companyId);
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
let wasAuthed = false;
|
||||
|
||||
function handleAuthSync(auth) {
|
||||
const isAuthed = Boolean(auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user));
|
||||
const companyId =
|
||||
isAuthed && auth?.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
const relogin = isAuthed && !wasAuthed;
|
||||
wasAuthed = isAuthed;
|
||||
|
||||
if (!isAuthed) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
}
|
||||
|
||||
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.
|
||||
* Closes and reopens the event stream.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
@@ -314,18 +482,25 @@ const actions = {
|
||||
connect();
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronizes the SSE connection with `authAutomat` state.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState
|
||||
*/
|
||||
syncWithAuth(authState) {
|
||||
handleAuthSync(authState ?? authAutomat.state);
|
||||
},
|
||||
|
||||
clearEvents() {
|
||||
sseAutomat.setState({ events: [] });
|
||||
sseAutomat.setState({ events: [], lastEvent: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Registers a handler for a statically named backend event.
|
||||
* Registers a handler for a named backend event or pattern.
|
||||
* Supports exact event names (e.g. `category`, `articles`), `article-*` wildcard, or `*`.
|
||||
*
|
||||
* 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.
|
||||
* @param {string} event Event name or pattern.
|
||||
* @param {Function} handler Signal handler.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
on(event, handler) {
|
||||
@@ -338,7 +513,9 @@ const actions = {
|
||||
if (!entry) {
|
||||
entry = { handler, detach: null };
|
||||
set.add(entry);
|
||||
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler);
|
||||
if (eventSource && event !== '*' && event !== 'article-*') {
|
||||
entry.detach = addTrackedListener(eventSource, event, handler);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
entry.detach?.();
|
||||
@@ -348,40 +525,61 @@ const actions = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one SKU.
|
||||
* Subscribes to any article mutation event (`article-<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 {(sku: string, event: object) => void} handler Called with changed SKU.
|
||||
* @returns {() => void} Unsubscribe callback.
|
||||
*/
|
||||
onArticle(handler) {
|
||||
return actions.on('article-*', (sku, event) => handler(sku, event));
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribes to `article-<sku>` signals for one specific SKU.
|
||||
*
|
||||
* @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);
|
||||
const rawKey = String(sku).trim();
|
||||
const lowerKey = rawKey.toLowerCase();
|
||||
let set = articleWatchers.get(lowerKey);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
articleWatchers.set(key, set);
|
||||
attachArticleListener(key);
|
||||
articleWatchers.set(lowerKey, set);
|
||||
attachArticleListener(rawKey);
|
||||
}
|
||||
set.add(handler);
|
||||
|
||||
return () => {
|
||||
const current = articleWatchers.get(key);
|
||||
const current = articleWatchers.get(lowerKey);
|
||||
if (!current) return;
|
||||
current.delete(handler);
|
||||
if (current.size === 0) articleWatchers.delete(key);
|
||||
if (current.size === 0) articleWatchers.delete(lowerKey);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Programmatically dispatches an event through sseAutomat.
|
||||
*
|
||||
* @param {string} type Event type (e.g. 'article-ART-000001', 'articles').
|
||||
* @param {any} [data] Event data payload.
|
||||
* @param {string} [lastEventId] Optional event ID.
|
||||
* @returns {void}
|
||||
*/
|
||||
dispatch(type, data = {}, lastEventId = '') {
|
||||
dispatchSseEvent({
|
||||
type,
|
||||
data: typeof data === 'string' ? data : JSON.stringify(data),
|
||||
lastEventId,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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' });
|
||||
@@ -389,48 +587,23 @@ 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(() => {});
|
||||
const art = Automat.get('artikel');
|
||||
if (art?.actions?.reloadArticle) {
|
||||
void art.actions.reloadArticle(key).catch(() => {});
|
||||
}
|
||||
if (articleHistoryAutomat.has(key)) {
|
||||
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
if (articleSupplierAutomat.has(key)) {
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
// Reload assigned suppliers and sync with companyAutomat
|
||||
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||
}
|
||||
|
||||
// Reconnect when the selected company changes; disconnect on logout.
|
||||
// On logout the stream must close; on relogin (anon -> authed with a company)
|
||||
// the stream is re-established even if the company id matches the previous one.
|
||||
let wasAuthed = false;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId =
|
||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||
? String(auth.user.selectedCompanyId)
|
||||
: null;
|
||||
const isAuthed = companyId != null;
|
||||
const relogin = isAuthed && !wasAuthed;
|
||||
wasAuthed = isAuthed;
|
||||
|
||||
if (!relogin && companyId === sseAutomat.state.companyId) return;
|
||||
|
||||
if (companyId == null) {
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// A different company (or a fresh login) means a different stream scope.
|
||||
disconnect();
|
||||
sseAutomat.setState({ companyId });
|
||||
connect();
|
||||
});
|
||||
authAutomat.subscribe(handleAuthSync);
|
||||
handleAuthSync(authAutomat.state);
|
||||
|
||||
export default sseAutomat;
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
/**
|
||||
* @file supplierAutomat.js
|
||||
* @description Manages cached supplier list (`GET /api/supplier`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { supplier as supplierApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Supplier
|
||||
* @property {string|number} id Supplier identifier.
|
||||
* @property {string} name Supplier name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SupplierState
|
||||
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {SupplierState} */
|
||||
const initialState = {
|
||||
suppliers: [],
|
||||
articlesBySupplier: {},
|
||||
articlesLoading: false,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let supplierAutomat;
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped supplier list.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Supplier list.
|
||||
*/
|
||||
async function fetchSuppliers() {
|
||||
const data = await supplierApi.list();
|
||||
requireObject(data, 'GET /api/supplier');
|
||||
return requireArray(data.suppliers, 'suppliers');
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent consumers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the supplier list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
||||
*/
|
||||
function loadSuppliers() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
supplierAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Loads the supplier list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
|
||||
*/
|
||||
load() {
|
||||
return loadSuppliers();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the supplier list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const suppliers = await fetchSuppliers();
|
||||
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
|
||||
void actions.loadAllSupplierArticles().catch(() => {});
|
||||
return suppliers;
|
||||
} catch (error) {
|
||||
supplierAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for a single supplier, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @param {string|number} supplierId
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
async loadArticlesForSupplier(supplierId) {
|
||||
const sId = String(supplierId);
|
||||
if (supplierAutomat.state.articlesBySupplier?.[sId]) {
|
||||
return supplierAutomat.state.articlesBySupplier[sId];
|
||||
}
|
||||
const res = await supplierApi.getArticles(supplierId);
|
||||
const articles = Array.isArray(res?.articles) ? res.articles : [];
|
||||
supplierAutomat.setState({
|
||||
articlesBySupplier: {
|
||||
...(supplierAutomat.state.articlesBySupplier || {}),
|
||||
[sId]: articles,
|
||||
},
|
||||
});
|
||||
return articles;
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier.
|
||||
*
|
||||
* @returns {Promise<Record<string, Array<object>>>}
|
||||
*/
|
||||
async loadAllSupplierArticles() {
|
||||
const suppliers = supplierAutomat.state.suppliers;
|
||||
if (!suppliers.length) return {};
|
||||
supplierAutomat.setState({ articlesLoading: true });
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
suppliers.map(async (s) => {
|
||||
const res = await supplierApi.getArticles(s.id);
|
||||
return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] };
|
||||
}),
|
||||
);
|
||||
const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) };
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
nextMap[r.value.id] = r.value.articles;
|
||||
}
|
||||
}
|
||||
supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false });
|
||||
return nextMap;
|
||||
} catch {
|
||||
supplierAutomat.setState({ articlesLoading: false });
|
||||
return supplierAutomat.state.articlesBySupplier || {};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string}} body Creation payload.
|
||||
* @returns {Promise<Supplier>} Created supplier.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await supplierApi.create(body);
|
||||
requireObject(data, 'POST /api/supplier');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
await supplierAutomat.actions.reload();
|
||||
return data.supplier;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Supplier identifier.
|
||||
* @param {{name: string}} body Update payload.
|
||||
* @returns {Promise<Supplier>} Updated supplier.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await supplierApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/supplier/:id');
|
||||
requireObject(data.supplier, 'supplier');
|
||||
requireDefined(data.supplier.id, 'supplier.id');
|
||||
await supplierAutomat.actions.reload();
|
||||
return data.supplier;
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a supplier and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Supplier identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await supplierApi.delete(id);
|
||||
await supplierAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Supplier>} suppliers Suppliers to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setSuppliers(suppliers) {
|
||||
supplierAutomat.setState({
|
||||
suppliers: Array.isArray(suppliers) ? suppliers : [],
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
supplierAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped supplier cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<SupplierState>}
|
||||
*/
|
||||
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the supplier list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
supplierAutomat.actions.clear();
|
||||
} else {
|
||||
void supplierAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default supplierAutomat;
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* @file unitAutomat.js
|
||||
* @description Manages allowable units of measure (Stück, kg, Liter, etc.)
|
||||
* for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||
* - UI / Settings: Mutating actions (`upsert`, `delete`).
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { unit as unitApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} UnitState
|
||||
* @property {Array<object>} units
|
||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||
* @property {string|null} error
|
||||
*/
|
||||
|
||||
/** @type {UnitState} */
|
||||
const initialState = {
|
||||
units: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
function sortUnits(units) {
|
||||
return [...units].sort((a, b) => {
|
||||
const left = a.symbol ?? a.si ?? '';
|
||||
const right = b.symbol ?? b.si ?? '';
|
||||
return String(left).localeCompare(String(right), 'de');
|
||||
});
|
||||
}
|
||||
|
||||
let unitAutomat;
|
||||
|
||||
const actions = {
|
||||
/** Load all units for the selected company. */
|
||||
async load() {
|
||||
unitAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const res = await unitApi.list();
|
||||
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
|
||||
unitAutomat.setState({ units, status: 'succeeded' });
|
||||
return units;
|
||||
} catch (err) {
|
||||
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upsert (create or update) a unit.
|
||||
* @param {object} body
|
||||
*/
|
||||
async upsert(body) {
|
||||
const res = await unitApi.upsert(body);
|
||||
const row = res.unit;
|
||||
const units = sortUnits([
|
||||
...unitAutomat.state.units.filter((u) => u.si !== row.si),
|
||||
row,
|
||||
]);
|
||||
unitAutomat.setState({ units });
|
||||
return units;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a unit by SI symbol.
|
||||
* @param {string} si
|
||||
*/
|
||||
async delete(si) {
|
||||
await unitApi.delete(si);
|
||||
unitAutomat.setState({
|
||||
units: unitAutomat.state.units.filter((u) => u.si !== si),
|
||||
});
|
||||
},
|
||||
|
||||
/** Reset to idle state (called on logout / company switch). */
|
||||
reset() {
|
||||
unitAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global unit state container.
|
||||
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
|
||||
*
|
||||
* @type {Automat<UnitState>}
|
||||
*/
|
||||
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
|
||||
|
||||
// Subscribe to auth changes
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void unitAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
unitAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
export default unitAutomat;
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @file userSettingsAutomat.js
|
||||
* @description Manages client-side user UI layout preferences (attachment viewer
|
||||
* position, chat drawer width). Persisted in IndexedDB.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - UI / User layout preferences: Calls to `setViewerPosition(position)` and
|
||||
* `setChatDrawerWidth(width)`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
|
||||
/**
|
||||
* @typedef {'top'|'right'|'below'} ViewerPosition
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UserSettingsState
|
||||
* @property {ViewerPosition} viewerPosition Preferred docking position of the
|
||||
* attachment viewer (text editor / PDF preview) relative to the article list.
|
||||
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
|
||||
*/
|
||||
|
||||
/** @type {UserSettingsState} */
|
||||
const initialState = {
|
||||
viewerPosition: 'top',
|
||||
chatDrawerWidth: 460,
|
||||
};
|
||||
|
||||
/**
|
||||
* Persisted per-user UI preferences. Unlike the other automats this one is
|
||||
* backed by IndexedDB (`persist: true`) so settings survive reloads and
|
||||
* sessions.
|
||||
*
|
||||
* @type {Automat<UserSettingsState>}
|
||||
*/
|
||||
const userSettingsAutomat = new Automat(
|
||||
initialState,
|
||||
{
|
||||
/**
|
||||
* Set the docking position of the attachment viewer.
|
||||
*
|
||||
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
|
||||
* @returns {void}
|
||||
*/
|
||||
setViewerPosition(position) {
|
||||
if (position !== 'top' && position !== 'right' && position !== 'below') {
|
||||
return;
|
||||
}
|
||||
userSettingsAutomat.setState({ viewerPosition: position });
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the width of the chat drawer in pixels.
|
||||
*
|
||||
* @param {number} width
|
||||
* @returns {void}
|
||||
*/
|
||||
setChatDrawerWidth(width) {
|
||||
const num = Number(width);
|
||||
if (Number.isNaN(num) || num < 280) {
|
||||
return;
|
||||
}
|
||||
userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userSettings',
|
||||
persist: true,
|
||||
}
|
||||
);
|
||||
|
||||
export default userSettingsAutomat;
|
||||
export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions;
|
||||
export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460;
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* @file warehouseAutomat.js
|
||||
* @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
|
||||
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event.
|
||||
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { warehouse as warehouseApi } from '../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Warehouse
|
||||
* @property {string|number} id Warehouse identifier.
|
||||
* @property {string} name Warehouse name.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} WarehouseState
|
||||
* @property {Array<Warehouse>} warehouses Company-scoped warehouses.
|
||||
* @property {Array<Warehouse>} filteredWarehouses Filtered warehouses according to active filter query.
|
||||
* @property {string} filter Active search/filter string.
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||
* @property {Error|null} error Last load error.
|
||||
*/
|
||||
|
||||
/** @type {WarehouseState} */
|
||||
const initialState = {
|
||||
warehouses: [],
|
||||
filteredWarehouses: [],
|
||||
filter: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let warehouseAutomat;
|
||||
|
||||
const applyFilter = (warehouses, filter) => {
|
||||
const query = String(filter ?? '').trim().toLowerCase();
|
||||
return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses;
|
||||
};
|
||||
|
||||
const updateStateWithWarehouses = (warehouses, extra = {}) => {
|
||||
const list = Array.isArray(warehouses) ? warehouses : [];
|
||||
const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter);
|
||||
warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra });
|
||||
return list;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the company-scoped warehouse list.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Warehouse list.
|
||||
*/
|
||||
async function fetchWarehouses() {
|
||||
const data = await warehouseApi.list();
|
||||
requireObject(data, 'GET /api/warehouse');
|
||||
return requireArray(data.warehouses, 'warehouses');
|
||||
}
|
||||
|
||||
/** In-flight `load()` promise so concurrent callers share one request. */
|
||||
let inflightLoad = null;
|
||||
|
||||
/**
|
||||
* Loads the warehouse list once, deduplicating concurrent callers.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||
*/
|
||||
function loadWarehouses() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
inflightLoad = (async () => {
|
||||
warehouseAutomat.setState({ status: 'loading', error: null });
|
||||
try {
|
||||
const warehouses = await fetchWarehouses();
|
||||
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
|
||||
return warehouses;
|
||||
} catch (error) {
|
||||
warehouseAutomat.setState({ status: 'failed', error });
|
||||
throw error;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
return inflightLoad;
|
||||
}
|
||||
|
||||
const actions = {
|
||||
/** Sets the active warehouse search filter and updates filteredWarehouses. */
|
||||
filter(query) {
|
||||
const filter = String(query ?? '');
|
||||
warehouseAutomat.setState({
|
||||
filter,
|
||||
filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter),
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads the warehouse list, exposing a loading/failed lifecycle.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
|
||||
*/
|
||||
load() {
|
||||
return loadWarehouses();
|
||||
},
|
||||
|
||||
/**
|
||||
* Silently re-fetches the warehouse list while keeping the current state.
|
||||
*
|
||||
* @returns {Promise<Array<Warehouse>>} Fresh warehouses.
|
||||
*/
|
||||
async reload() {
|
||||
try {
|
||||
const warehouses = await fetchWarehouses();
|
||||
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
|
||||
return warehouses;
|
||||
} catch (error) {
|
||||
warehouseAutomat.setState({ error });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {{name: string}} body Creation payload.
|
||||
* @returns {Promise<Warehouse>} Created warehouse.
|
||||
*/
|
||||
async create(body) {
|
||||
const data = await warehouseApi.create(body);
|
||||
requireObject(data, 'POST /api/warehouse');
|
||||
requireObject(data.warehouse, 'warehouse');
|
||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||
await warehouseAutomat.actions.reload();
|
||||
return data.warehouse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Renames a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Warehouse identifier.
|
||||
* @param {{name: string}} body Update payload.
|
||||
* @returns {Promise<Warehouse>} Updated warehouse.
|
||||
*/
|
||||
async update(id, body) {
|
||||
const data = await warehouseApi.update(id, body);
|
||||
requireObject(data, 'PATCH /api/warehouse/:id');
|
||||
requireObject(data.warehouse, 'warehouse');
|
||||
requireDefined(data.warehouse.id, 'warehouse.id');
|
||||
await warehouseAutomat.actions.reload();
|
||||
return data.warehouse;
|
||||
},
|
||||
|
||||
rename(id, body) {
|
||||
return actions.update(id, body);
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes a warehouse and refreshes the cached list.
|
||||
*
|
||||
* @param {string|number} id Warehouse identifier.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async remove(id) {
|
||||
await warehouseApi.delete(id);
|
||||
await warehouseAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Persists a new sort order for all warehouses.
|
||||
*
|
||||
* @param {Array<string|number>} warehouseIds IDs in the desired order.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reorder(warehouseIds) {
|
||||
const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w]));
|
||||
const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean);
|
||||
if (reordered.length === warehouseAutomat.state.warehouses.length) {
|
||||
updateStateWithWarehouses(reordered);
|
||||
}
|
||||
await warehouseApi.order({ warehouseIds });
|
||||
await warehouseAutomat.actions.reload();
|
||||
},
|
||||
|
||||
/**
|
||||
* Replaces the cached list without a round-trip.
|
||||
*
|
||||
* @param {Array<Warehouse>} warehouses Warehouses to cache.
|
||||
* @returns {void}
|
||||
*/
|
||||
setWarehouses(warehouses) {
|
||||
updateStateWithWarehouses(warehouses, {
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the cached list and returns to the initial state.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
clear() {
|
||||
warehouseAutomat.setState({ ...initialState });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped warehouse cache. Loads automatically once an authenticated
|
||||
* session with a selected company exists and is cleared when auth is gone.
|
||||
*
|
||||
* @type {Automat<WarehouseState>}
|
||||
*/
|
||||
warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' });
|
||||
|
||||
/**
|
||||
* Derives the cache key from the auth state: the selected company while
|
||||
* authenticated, otherwise `null`.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {string|null} Cache key.
|
||||
*/
|
||||
function authKey(authState) {
|
||||
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||
const companyId = authState.user.selectedCompanyId;
|
||||
return companyId == null ? null : String(companyId);
|
||||
}
|
||||
|
||||
let lastAuthKey;
|
||||
|
||||
/**
|
||||
* Loads the warehouse list when auth/company becomes available and forgets it
|
||||
* when the session ends.
|
||||
*
|
||||
* @param {import('./authAutomat').AuthState} authState Auth automat state.
|
||||
* @returns {void}
|
||||
*/
|
||||
function syncWithAuth(authState) {
|
||||
const key = authKey(authState);
|
||||
if (key === lastAuthKey) return;
|
||||
lastAuthKey = key;
|
||||
if (key == null) {
|
||||
warehouseAutomat.actions.clear();
|
||||
} else {
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
authAutomat.subscribe(syncWithAuth);
|
||||
syncWithAuth(authAutomat.state);
|
||||
|
||||
export default warehouseAutomat;
|
||||
@@ -8,11 +8,15 @@ import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import UserMenu from './UserMenu';
|
||||
import OnlinePrinterIcons from './OnlinePrinterIcons';
|
||||
import HelpBadge from './HelpBadge';
|
||||
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
||||
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
||||
import { defaultCategoryPath } from './category/categoryRoute';
|
||||
import { navigationAutomat } from '../automats';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import navigationAutomat from '../automats/navigationAutomat';
|
||||
import { initPwa } from '../automats/pwaAutomat';
|
||||
import PwaUpdateDialog from './PwaUpdateDialog';
|
||||
import {
|
||||
NAV_ITEM_ACTIVE_ACCENT,
|
||||
NAV_SUB_ACTIVE,
|
||||
@@ -22,9 +26,16 @@ import {
|
||||
import { ChatFab, ChatDrawer } from './chat';
|
||||
import { PinnedAppletFabs, PinnedAppletDialog } from './applet';
|
||||
|
||||
// Ensure companyAutomat is retained as part of the 1st-level navigation shell
|
||||
void companyAutomat;
|
||||
initPwa();
|
||||
|
||||
const BreakpointOverlay = 0 && import.meta.env.DEV
|
||||
? lazy(() => import('./BreakpointOverlay'))
|
||||
: null;
|
||||
const ChunkCacheMonitor = import.meta.env.DEV
|
||||
? lazy(() => import('../devtools/ChunkCacheMonitor'))
|
||||
: null;
|
||||
|
||||
|
||||
const SIDEBAR_WIDTH = 144;
|
||||
@@ -188,8 +199,20 @@ class AppSidebar extends PureComponent {
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
<Box
|
||||
sx={{
|
||||
ml: { xs: 'auto', lg: 0 },
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'row', lg: 'column' },
|
||||
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||
width: { xs: 'auto', lg: '100%' },
|
||||
gap: { xs: 0.5, lg: 0 },
|
||||
}}
|
||||
>
|
||||
<OnlinePrinterIcons />
|
||||
<UserMenu />
|
||||
<ChatFab />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -403,13 +426,20 @@ export default function AppShell() {
|
||||
minWidth: 0,
|
||||
})}
|
||||
>
|
||||
<Outlet />
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Box>
|
||||
<ChatFab />
|
||||
<ChatDrawer />
|
||||
<PinnedAppletFabs pathname={pathname} />
|
||||
<PinnedAppletDialog />
|
||||
<PwaUpdateDialog />
|
||||
{ChunkCacheMonitor ? (
|
||||
<Suspense fallback={null}>
|
||||
<ChunkCacheMonitor />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{BreakpointOverlay && (
|
||||
<Suspense fallback={null}>
|
||||
<BreakpointOverlay />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from 'react';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import authAutomat, { is502BadGateway } from '../automats/authAutomat';
|
||||
|
||||
let sessionBootstrapped = false;
|
||||
|
||||
@@ -10,15 +10,16 @@ class AuthBootstrap extends Component {
|
||||
if (sessionBootstrapped) return;
|
||||
sessionBootstrapped = true;
|
||||
|
||||
bootstrapAuthMe()
|
||||
authAutomat.actions.bootstrapSession()
|
||||
.then((session) => {
|
||||
if (session.forbidden) {
|
||||
if (session?.forbidden) {
|
||||
enqueueSnackbar(session.message, { variant: 'warning' });
|
||||
}
|
||||
return authAutomat.actions.bootstrapSession(session);
|
||||
})
|
||||
.catch((err) => {
|
||||
notifyError(err);
|
||||
if (!is502BadGateway(err)) {
|
||||
notifyError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
@@ -34,11 +34,10 @@ import TuneIcon from '@mui/icons-material/Tune';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
|
||||
import BackendError from './BackendError';
|
||||
import { company, number as numberApi } from '../api';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import authAutomat from '../automats/authAutomat';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import numberAutomat from '../automats/numberAutomat';
|
||||
import {
|
||||
defaultIso639,
|
||||
ISO639_OPTIONS,
|
||||
@@ -53,13 +52,15 @@ const NUMBER_SCHEMES = [
|
||||
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
||||
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
|
||||
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
|
||||
{ type: 'return', label: 'Rücksendung (Lieferant)', icon: AssignmentReturnIcon },
|
||||
{ type: 'retoure', label: 'Retoure (Kunde)', icon: AssignmentReturnIcon },
|
||||
];
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
id: 'standard',
|
||||
label: 'Standard',
|
||||
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-)',
|
||||
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-, RTN-, RET-)',
|
||||
values: {
|
||||
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
||||
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
||||
@@ -67,6 +68,8 @@ const PRESETS = [
|
||||
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
||||
quote: { prefix: 'ANG-', padding: 6, next: 1 },
|
||||
order: { prefix: 'AUF-', padding: 6, next: 1 },
|
||||
return: { prefix: 'RTN-', padding: 6, next: 1 },
|
||||
retoure: { prefix: 'RET-', padding: 6, next: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -80,12 +83,14 @@ const PRESETS = [
|
||||
adjustment: { prefix: '', padding: 5, next: 90001 },
|
||||
quote: { prefix: '', padding: 5, next: 40001 },
|
||||
order: { prefix: '', padding: 5, next: 50001 },
|
||||
return: { prefix: 'RTN', padding: 5, next: 1 },
|
||||
retoure: { prefix: 'RET', padding: 5, next: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'compact',
|
||||
label: 'Kurzkürzel',
|
||||
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-)',
|
||||
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-, RTN-, RET-)',
|
||||
values: {
|
||||
sku: { prefix: 'A-', padding: 5, next: 1 },
|
||||
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
||||
@@ -93,12 +98,14 @@ const PRESETS = [
|
||||
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
||||
quote: { prefix: 'AG-', padding: 5, next: 1 },
|
||||
order: { prefix: 'AU-', padding: 5, next: 1 },
|
||||
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
||||
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'international',
|
||||
label: 'International',
|
||||
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-)',
|
||||
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-, RTN-, RET-)',
|
||||
values: {
|
||||
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
||||
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
||||
@@ -106,6 +113,8 @@ const PRESETS = [
|
||||
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
||||
quote: { prefix: 'Q-', padding: 5, next: 1 },
|
||||
order: { prefix: 'ORD-', padding: 5, next: 1 },
|
||||
return: { prefix: 'RTN-', padding: 5, next: 1 },
|
||||
retoure: { prefix: 'RET-', padding: 5, next: 1 },
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -117,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
|
||||
return `${prefix ?? ''}${numStr}`;
|
||||
}
|
||||
|
||||
class CompanySetupDialog extends Component {
|
||||
function mapCompanyState(state) {
|
||||
return {
|
||||
companies: Array.isArray(state?.companies) ? state.companies : [],
|
||||
loading: state?.status === 'idle' || state?.status === 'loading',
|
||||
};
|
||||
}
|
||||
|
||||
class CompanySetupDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
companies: [],
|
||||
loading: true,
|
||||
...mapCompanyState(companyAutomat.state),
|
||||
busy: false,
|
||||
error: null,
|
||||
name: '',
|
||||
@@ -141,26 +156,25 @@ class CompanySetupDialog extends Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
void this.loadCompanies();
|
||||
this.unsubscribe = companyAutomat.subscribe(this, mapCompanyState);
|
||||
this.unsubscribeAuth = authAutomat.subscribe(this, (state) => ({
|
||||
selectedCompanyId: state.user?.selectedCompanyId ?? null,
|
||||
}));
|
||||
this.setState(mapCompanyState(companyAutomat.state));
|
||||
if (companyAutomat.state.status !== 'succeeded') {
|
||||
void companyAutomat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async loadCompanies() {
|
||||
this.setState({ loading: true });
|
||||
try {
|
||||
const data = await company.list();
|
||||
this.setState({ companies: data.companies ?? [] });
|
||||
} catch {
|
||||
this.setState({ companies: [] });
|
||||
} finally {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribeAuth?.();
|
||||
}
|
||||
|
||||
async handleSelect(companyId) {
|
||||
this.setState({ busy: true, error: null });
|
||||
try {
|
||||
await authAutomat.actions.selectCompany(companyId);
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
} finally {
|
||||
@@ -213,30 +227,8 @@ class CompanySetupDialog extends Component {
|
||||
iso639,
|
||||
taxrate: parsedTaxrate,
|
||||
reducedtaxrate: parsedReducedtaxrate,
|
||||
numbers,
|
||||
});
|
||||
|
||||
// Apply initial number sequences if set
|
||||
if (numbers) {
|
||||
try {
|
||||
const listRes = await numberApi.list();
|
||||
const serverNumbers = listRes?.numbers ?? [];
|
||||
for (const serverRow of serverNumbers) {
|
||||
const userConfig = numbers[serverRow.type];
|
||||
if (userConfig) {
|
||||
const prefix = String(userConfig.prefix ?? '');
|
||||
const padding = Math.max(0, Number(userConfig.padding) || 0);
|
||||
const next = Math.max(1, Number(userConfig.next) || 1);
|
||||
await numberApi.update(serverRow.id, { prefix, padding, next });
|
||||
}
|
||||
}
|
||||
} catch (numErr) {
|
||||
console.warn('Initial number sequences could not be saved:', numErr);
|
||||
}
|
||||
}
|
||||
|
||||
await bootstrapAuthMe();
|
||||
await companyAutomat.actions.load();
|
||||
await numberAutomat.actions.reload().catch(() => {});
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
} finally {
|
||||
@@ -247,7 +239,6 @@ class CompanySetupDialog extends Component {
|
||||
async handleLogout() {
|
||||
try {
|
||||
await authAutomat.actions.logout();
|
||||
await bootstrapAuthMe();
|
||||
} catch (err) {
|
||||
this.setState({ error: backendErrorFrom(err) });
|
||||
}
|
||||
@@ -258,6 +249,10 @@ class CompanySetupDialog extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (authAutomat.state.user?.selectedCompanyId != null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
companies,
|
||||
loading,
|
||||
@@ -286,22 +281,7 @@ class CompanySetupDialog extends Component {
|
||||
maxWidth={hasCompanies ? 'sm' : 'md'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
{loading
|
||||
? 'Unternehmen'
|
||||
: hasCompanies
|
||||
? 'Unternehmen auswählen'
|
||||
: 'Willkommen bei Dash'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{!loading ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
|
||||
{hasCompanies
|
||||
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
|
||||
: 'Legen Sie Ihr erstes Unternehmen an und wählen Sie das Format für Ihre Nummernkreise.'}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
isBackendFailure(error) ? (
|
||||
<BackendError error={error} sx={{ mb: 2 }} />
|
||||
@@ -410,13 +390,6 @@ class CompanySetupDialog extends Component {
|
||||
|
||||
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
||||
<Box sx={{ pt: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Nummernkreise & Belegnummern
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
|
||||
{PRESETS.map((p) => {
|
||||
const isSelected = selectedPreset === p.id;
|
||||
|
||||
@@ -7,7 +7,7 @@ import Paper from '@mui/material/Paper';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import { helpAutomat } from '../automats';
|
||||
import helpAutomat from '../automats/helpAutomat';
|
||||
import {
|
||||
RED,
|
||||
RED_HOVER,
|
||||
|
||||
318
src/components/OnlinePrinterIcons.jsx
Normal file
318
src/components/OnlinePrinterIcons.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import { getPrinterImage } from '../assets/printers';
|
||||
|
||||
export default class OnlinePrinterIcons extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
|
||||
? companyAutomat.state.agentprinters
|
||||
: [],
|
||||
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||
? companyAutomat.state.livePrinters
|
||||
: [],
|
||||
menuAnchor: null,
|
||||
};
|
||||
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||
this.handleTestPrint = this.handleTestPrint.bind(this);
|
||||
this.handleCancelAllJobs = this.handleCancelAllJobs.bind(this);
|
||||
this.handleOpenPrinterSettings = this.handleOpenPrinterSettings.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
|
||||
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
getOnlinePrinters() {
|
||||
const { agentprinters = [], livePrinters = [] } = this.state;
|
||||
const liveBySerial = new Map();
|
||||
for (const lp of livePrinters) {
|
||||
if (lp.serial) liveBySerial.set(String(lp.serial).toLowerCase(), lp);
|
||||
}
|
||||
|
||||
if (agentprinters.length > 0) {
|
||||
return agentprinters
|
||||
.filter((p) => {
|
||||
if (p.online === true) return true;
|
||||
const live =
|
||||
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||
livePrinters.find(
|
||||
(lp) =>
|
||||
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||
);
|
||||
if (!live) return false;
|
||||
return live.status !== 'offline' && !live.unreachable && !live.errors?.unreachable;
|
||||
})
|
||||
.map((p) => {
|
||||
const live =
|
||||
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||
livePrinters.find(
|
||||
(lp) =>
|
||||
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||
);
|
||||
return { ...p, live };
|
||||
});
|
||||
}
|
||||
|
||||
return livePrinters
|
||||
.filter((lp) => lp.status !== 'offline' && !lp.unreachable && !lp.errors?.unreachable)
|
||||
.map((lp) => ({
|
||||
id: lp.agentprinterId || lp.id || lp.serial,
|
||||
model: lp.model,
|
||||
serial: lp.serial,
|
||||
type: lp.type,
|
||||
online: true,
|
||||
open: Boolean(lp.errors?.headOpen || lp.headOpen),
|
||||
pause: Boolean(lp.errors?.pause || lp.pause),
|
||||
error: Boolean(lp.errors?.other || lp.error),
|
||||
labelLength: lp.labelLength,
|
||||
live: lp,
|
||||
}));
|
||||
}
|
||||
|
||||
handleOpenMenu(event, printer) {
|
||||
this.setState({
|
||||
menuAnchor: {
|
||||
el: event.currentTarget,
|
||||
printer,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleCloseMenu() {
|
||||
this.setState({ menuAnchor: null });
|
||||
}
|
||||
|
||||
handleTestPrint(printer) {
|
||||
if (!printer?.id) return;
|
||||
this.handleCloseMenu();
|
||||
companyAutomat.actions
|
||||
.sendTestPrint(printer.id, {
|
||||
model: printer.model || printer.type || 'Zebra',
|
||||
})
|
||||
.then(() => {
|
||||
enqueueSnackbar(
|
||||
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Warteschlange gesendet.`,
|
||||
{ variant: 'success' },
|
||||
);
|
||||
})
|
||||
.catch(notifyError);
|
||||
}
|
||||
|
||||
handleCancelAllJobs(printer) {
|
||||
if (!printer?.id) return;
|
||||
this.handleCloseMenu();
|
||||
companyAutomat.actions
|
||||
.cancelAllPrinterJobs(printer.id)
|
||||
.then(() => {
|
||||
enqueueSnackbar(`Druckaufträge für ${printer.model || 'Drucker'} abgebrochen.`, {
|
||||
variant: 'info',
|
||||
});
|
||||
})
|
||||
.catch(notifyError);
|
||||
}
|
||||
|
||||
handleOpenPrinterSettings() {
|
||||
this.handleCloseMenu();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('open-settings', { detail: { tab: 'printeragent' } }),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { menuAnchor } = this.state;
|
||||
const onlinePrinters = this.getOnlinePrinters();
|
||||
|
||||
if (onlinePrinters.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePrinter = menuAnchor?.printer;
|
||||
const activeHasError = Boolean(
|
||||
activePrinter?.error ||
|
||||
activePrinter?.open ||
|
||||
activePrinter?.live?.errors?.other ||
|
||||
activePrinter?.live?.errors?.headOpen,
|
||||
);
|
||||
const activeIsPaused = Boolean(activePrinter?.pause || activePrinter?.live?.errors?.pause);
|
||||
const activeStatusText = activeHasError
|
||||
? activePrinter?.open || activePrinter?.live?.errors?.headOpen
|
||||
? 'Druckkopf offen'
|
||||
: 'Fehler'
|
||||
: activeIsPaused
|
||||
? 'Pausiert'
|
||||
: 'Bereit';
|
||||
|
||||
const anchorEl = menuAnchor?.el;
|
||||
const isTop = Boolean(
|
||||
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: { xs: 'row', lg: 'column' },
|
||||
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||
alignSelf: { xs: 'center', lg: 'flex-start' },
|
||||
px: { xs: 0.5, sm: 1, lg: 1.5 },
|
||||
py: { xs: 0.5, lg: 0.5 },
|
||||
gap: { xs: 0.5, lg: 1 },
|
||||
}}
|
||||
>
|
||||
{onlinePrinters.map((p, index) => {
|
||||
const hasError = Boolean(
|
||||
p.error || p.open || p.live?.errors?.other || p.live?.errors?.headOpen,
|
||||
);
|
||||
const isPaused = Boolean(p.pause || p.live?.errors?.pause);
|
||||
const label = p.model || 'Drucker';
|
||||
const statusText = hasError
|
||||
? p.open || p.live?.errors?.headOpen
|
||||
? 'Druckkopf offen'
|
||||
: 'Fehler'
|
||||
: isPaused
|
||||
? 'Pausiert'
|
||||
: 'Bereit';
|
||||
const tooltipTitle = `${label}${p.serial ? ` (${p.serial})` : ''} • ${statusText}`;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
key={p.id || p.serial || index}
|
||||
onClick={(e) => this.handleOpenMenu(e, p)}
|
||||
title={tooltipTitle}
|
||||
sx={(theme) => ({
|
||||
p: 0,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
color: theme.nav?.item || 'inherit',
|
||||
'&:hover': {
|
||||
color: theme.nav?.itemHoverText || 'inherit',
|
||||
bgcolor: theme.nav?.itemHover || 'action.hover',
|
||||
},
|
||||
})}
|
||||
aria-label={`Drucker ${label}`}
|
||||
>
|
||||
<Badge
|
||||
variant="dot"
|
||||
color={hasError ? 'error' : isPaused ? 'warning' : 'success'}
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
>
|
||||
<PrintOutlinedIcon sx={{ fontSize: 20 }} />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(menuAnchor)}
|
||||
onClose={this.handleCloseMenu}
|
||||
anchorOrigin={{
|
||||
vertical: isTop ? 'bottom' : 'top',
|
||||
horizontal: isTop ? 'right' : 'left',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: isTop ? 'top' : 'bottom',
|
||||
horizontal: isTop ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{activePrinter && (
|
||||
<Box sx={{ px: 2, py: 1, minWidth: 210 }}>
|
||||
{getPrinterImage(activePrinter.model) ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={getPrinterImage(activePrinter.model)}
|
||||
alt={activePrinter.model}
|
||||
sx={{ display: 'block', mx: 'auto', mb: 1, maxWidth: 80, height: 'auto' }}
|
||||
/>
|
||||
) : null}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} noWrap>
|
||||
{activePrinter.model || 'Drucker'}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={activeStatusText}
|
||||
size="small"
|
||||
color={activeHasError ? 'error' : activeIsPaused ? 'warning' : 'success'}
|
||||
variant="outlined"
|
||||
sx={{ fontSize: '0.65rem', height: 18, fontWeight: 600 }}
|
||||
/>
|
||||
</Box>
|
||||
{activePrinter.serial ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', display: 'block' }}
|
||||
>
|
||||
S/N: {activePrinter.serial}
|
||||
</Typography>
|
||||
) : null}
|
||||
{activePrinter.labelLength ? (
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Etikett: {activePrinter.labelLength} px
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<MenuItem onClick={() => this.handleTestPrint(activePrinter)}>
|
||||
<ListItemIcon>
|
||||
<PrintOutlinedIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Testdruck"
|
||||
/>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={() => this.handleCancelAllJobs(activePrinter)}>
|
||||
<ListItemIcon>
|
||||
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Aufträge abbrechen"
|
||||
/>
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onClick={this.handleOpenPrinterSettings}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Einstellungen" />
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import { enqueueSnackbar } from 'notistack';
|
||||
import AppDialog from './AppDialog';
|
||||
import { thumb, article as articleApi } from '../api';
|
||||
import artikelAutomat from '../automats/artikelAutomat';
|
||||
import brandAutomat from '../automats/brandAutomat';
|
||||
import companyAutomat from '../automats/companyAutomat';
|
||||
import categoryAutomat from '../automats/categoryAutomat';
|
||||
import chatAutomat from '../automats/chatAutomat';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
@@ -238,7 +238,7 @@ export default class PicturesDialog extends PureComponent {
|
||||
if (onUpload) {
|
||||
await onUpload(file);
|
||||
} else if (brand?.id) {
|
||||
await brandAutomat.actions.addImage(brand.id, file);
|
||||
await companyAutomat.actions.addBrandImage(brand.id, file);
|
||||
enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' });
|
||||
} else if (article?.sku) {
|
||||
const uploadStarted = performance.now();
|
||||
@@ -318,7 +318,7 @@ export default class PicturesDialog extends PureComponent {
|
||||
if (onDelete) {
|
||||
await onDelete(pic, 0);
|
||||
} else if (brand?.id) {
|
||||
await brandAutomat.actions.deleteImage(brand.id);
|
||||
await companyAutomat.actions.deleteBrandImage(brand.id);
|
||||
enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' });
|
||||
} else if (category?.id) {
|
||||
await categoryAutomat.actions.deleteCategoryImage(category.id);
|
||||
|
||||
@@ -78,7 +78,7 @@ export default class PwaUpdateDialog extends PureComponent {
|
||||
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
|
||||
Eine neue Version von <strong>Dashfront</strong> ist bereit.
|
||||
Eine neue Version ist bereit.
|
||||
</DialogContentText>
|
||||
<DialogContentText variant="body2" color="text.secondary">
|
||||
Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Popover from '@mui/material/Popover';
|
||||
@@ -13,7 +13,9 @@ import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import PageLayout from './PageLayout';
|
||||
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
|
||||
import { isKategorienPath } from './category/categoryRoute';
|
||||
import { isKategorienPath, buildCategoryPath, KATEGORIEN_BASE } from './category/categoryRoute';
|
||||
import CategoryBreadcrumbs from './category/CategoryBreadcrumbs';
|
||||
import { getCategoryBreadcrumbs, resolveCategoryIdFromSlugPath } from '../utils/categoryUtils';
|
||||
import categoryAutomat from '../automats/categoryAutomat';
|
||||
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
|
||||
import { article, category } from '../api';
|
||||
@@ -41,6 +43,7 @@ function selectCatSlice(s) {
|
||||
|
||||
export default function SectionLayout({ sectionPath }) {
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const importInputRef = useRef(null);
|
||||
const [extrasAnchor, setExtrasAnchor] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -141,18 +144,39 @@ export default function SectionLayout({ sectionPath }) {
|
||||
importInputRef.current?.click();
|
||||
};
|
||||
|
||||
let crumbs = [];
|
||||
if (showsRenameSwitch && categories && rootCategoryId != null) {
|
||||
const slugPath = pathname
|
||||
.slice(KATEGORIEN_BASE.length)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((seg) => {
|
||||
try {
|
||||
return decodeURIComponent(seg);
|
||||
} catch {
|
||||
return seg;
|
||||
}
|
||||
})
|
||||
.join('/');
|
||||
const catId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath);
|
||||
if (catId != null) crumbs = getCategoryBreadcrumbs(categories, catId, rootCategoryId);
|
||||
}
|
||||
|
||||
const titleAction = showsRenameSwitch ? (
|
||||
<Box sx={{ display: 'inline-flex', gap: 1 }}>
|
||||
<ToggleElevatedPill
|
||||
label="Edit"
|
||||
checked={renameMode}
|
||||
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
|
||||
/>
|
||||
<ActionElevatedPill
|
||||
label="Extras"
|
||||
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
|
||||
onClick={(event) => setExtrasAnchor(event.currentTarget)}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
|
||||
<CategoryBreadcrumbs
|
||||
crumbs={crumbs}
|
||||
variant="h6"
|
||||
sx={{ m: 0, minWidth: 0 }}
|
||||
onNavigate={(id) => navigate(buildCategoryPath(categories, rootCategoryId, id))}
|
||||
/>
|
||||
<Box sx={{ ml: 'auto', display: 'inline-flex' }}>
|
||||
<ToggleElevatedPill
|
||||
label="Edit"
|
||||
checked={renameMode}
|
||||
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
|
||||
/>
|
||||
</Box>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
@@ -195,8 +219,9 @@ export default function SectionLayout({ sectionPath }) {
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title={pageTitle}
|
||||
title={showsRenameSwitch ? '' : pageTitle}
|
||||
showTitle={!isArticleDetail}
|
||||
titleWide={showsRenameSwitch ? 99 : undefined}
|
||||
titleAction={titleAction}
|
||||
>
|
||||
<Suspense
|
||||
|
||||
@@ -14,7 +14,8 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||
import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats';
|
||||
import helpAutomat, { toggleHelp } from '../automats/helpAutomat';
|
||||
import pwaAutomat, { openPwaDialog } from '../automats/pwaAutomat';
|
||||
import SettingsDialog from './settings/SettingsDialog';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
@@ -24,6 +25,7 @@ class UserMenu extends PureComponent {
|
||||
this.state = {
|
||||
anchorEl: null,
|
||||
settingsOpen: false,
|
||||
settingsInitialTab: null,
|
||||
user: authAutomat.state.user,
|
||||
helpEnabled: helpAutomat.state?.enabled ?? false,
|
||||
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
|
||||
@@ -36,6 +38,7 @@ class UserMenu extends PureComponent {
|
||||
this.handleToggleHelp = this.handleToggleHelp.bind(this);
|
||||
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
|
||||
this.handleLogout = this.handleLogout.bind(this);
|
||||
this.handleOpenSettingsEvent = this.handleOpenSettingsEvent.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -46,12 +49,23 @@ class UserMenu extends PureComponent {
|
||||
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
|
||||
pwaUpdateAvailable: Boolean(state?.needRefresh),
|
||||
}));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('open-settings', this.handleOpenSettingsEvent);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubscribeAuth?.();
|
||||
this.unsubscribeHelp?.();
|
||||
this.unsubscribePwa?.();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('open-settings', this.handleOpenSettingsEvent);
|
||||
}
|
||||
}
|
||||
|
||||
handleOpenSettingsEvent(e) {
|
||||
const tab = e?.detail?.tab;
|
||||
this.setState({ settingsOpen: true, settingsInitialTab: tab || null });
|
||||
}
|
||||
|
||||
handleOpenPwaUpdate() {
|
||||
@@ -73,11 +87,11 @@ class UserMenu extends PureComponent {
|
||||
}
|
||||
|
||||
handleOpenSettings() {
|
||||
this.setState({ anchorEl: null, settingsOpen: true });
|
||||
this.setState({ anchorEl: null, settingsOpen: true, settingsInitialTab: null });
|
||||
}
|
||||
|
||||
handleCloseSettings() {
|
||||
this.setState({ settingsOpen: false });
|
||||
this.setState({ settingsOpen: false, settingsInitialTab: null });
|
||||
}
|
||||
|
||||
handleToggleHelp(e) {
|
||||
@@ -97,7 +111,7 @@ class UserMenu extends PureComponent {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
|
||||
const { user, anchorEl, settingsOpen, settingsInitialTab, helpEnabled, pwaUpdateAvailable } = this.state;
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const pictureSrc = (() => {
|
||||
@@ -200,7 +214,7 @@ class UserMenu extends PureComponent {
|
||||
<ListItemText>Sign out</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
|
||||
<SettingsDialog open={settingsOpen} initialTab={settingsInitialTab} onClose={this.handleCloseSettings} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ class ArticleNameDialog extends Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
mode: SINGLE_MODE,
|
||||
name: '',
|
||||
name: props.initialName ?? '',
|
||||
bulkText: '',
|
||||
busy: false,
|
||||
error: null,
|
||||
@@ -46,7 +46,7 @@ class ArticleNameDialog extends Component {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
this.setState({
|
||||
mode: SINGLE_MODE,
|
||||
name: '',
|
||||
name: this.props.initialName ?? '',
|
||||
bulkText: '',
|
||||
busy: false,
|
||||
error: null,
|
||||
|
||||
@@ -9,14 +9,17 @@ import {
|
||||
class ArticlePhoto extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { thumbLoaded: false };
|
||||
this.state = { thumbLoaded: false, smallThumbError: false };
|
||||
this.handleThumbLoad = this.handleThumbLoad.bind(this);
|
||||
this.handleThumbError = this.handleThumbError.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) {
|
||||
this.setState({ thumbLoaded: false });
|
||||
if (
|
||||
prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
|
||||
prevProps.useSmallThumb !== this.props.useSmallThumb
|
||||
) {
|
||||
this.setState({ thumbLoaded: false, smallThumbError: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +28,11 @@ class ArticlePhoto extends Component {
|
||||
}
|
||||
|
||||
handleThumbError() {
|
||||
this.setState({ thumbLoaded: false });
|
||||
if (this.props.useSmallThumb && !this.state.smallThumbError) {
|
||||
this.setState({ smallThumbError: true, thumbLoaded: false });
|
||||
} else {
|
||||
this.setState({ thumbLoaded: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -34,9 +41,11 @@ class ArticlePhoto extends Component {
|
||||
size = 200,
|
||||
fill = false,
|
||||
objectFit = fill ? 'cover' : 'contain',
|
||||
useSmallThumb = false,
|
||||
sx,
|
||||
...rest
|
||||
} = this.props;
|
||||
const { thumbLoaded } = this.state;
|
||||
const { thumbLoaded, smallThumbError } = this.state;
|
||||
const fullHash = photo?.fullHash;
|
||||
const hasPreview = photo?.thumbHash != null;
|
||||
const aspectRatio =
|
||||
@@ -44,8 +53,15 @@ class ArticlePhoto extends Component {
|
||||
? `${photo.thumbW} / ${photo.thumbH}`
|
||||
: '1 / 1';
|
||||
|
||||
const imgSrc = fullHash
|
||||
? useSmallThumb && !smallThumbError
|
||||
? thumb.smallUrl(fullHash)
|
||||
: thumb.url(fullHash)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...rest}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
...(fill
|
||||
@@ -78,10 +94,10 @@ class ArticlePhoto extends Component {
|
||||
<ThumbHashImage thumbHash={photo.thumbHash} />
|
||||
</Box>
|
||||
) : null}
|
||||
{fullHash ? (
|
||||
{imgSrc ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={thumb.url(fullHash)}
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
onLoad={this.handleThumbLoad}
|
||||
onError={this.handleThumbError}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { PureComponent } from 'react';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { primaryPhoto } from '../../utils/articleDetail';
|
||||
import { sortedPhotos, primaryPhoto } from '../../utils/articleDetail';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
||||
|
||||
class ArticlePhotoTile extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { editOpen: false };
|
||||
this.state = {
|
||||
editOpen: false,
|
||||
selectedHash: null,
|
||||
};
|
||||
this.handleEditOpen = this.handleEditOpen.bind(this);
|
||||
this.handleEditClose = this.handleEditClose.bind(this);
|
||||
this.handleSelectPhoto = this.handleSelectPhoto.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.article?.sku !== this.props.article?.sku) {
|
||||
this.setState({ selectedHash: null });
|
||||
}
|
||||
}
|
||||
|
||||
handleEditOpen() {
|
||||
@@ -21,6 +32,10 @@ class ArticlePhotoTile extends PureComponent {
|
||||
this.setState({ editOpen: false });
|
||||
}
|
||||
|
||||
handleSelectPhoto(fullHash) {
|
||||
this.setState({ selectedHash: fullHash });
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
article: articleData,
|
||||
@@ -30,8 +45,14 @@ class ArticlePhotoTile extends PureComponent {
|
||||
sx,
|
||||
color,
|
||||
} = this.props;
|
||||
const { editOpen } = this.state;
|
||||
const photo = primaryPhoto(articleData);
|
||||
const { editOpen, selectedHash } = this.state;
|
||||
|
||||
const photos = sortedPhotos(articleData);
|
||||
const primary = primaryPhoto(articleData);
|
||||
const activePhoto =
|
||||
(selectedHash && photos.find((p) => p.fullHash === selectedHash)) ||
|
||||
primary;
|
||||
const otherPhotos = photos.filter((p) => p.fullHash !== activePhoto?.fullHash);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent {
|
||||
busy: false,
|
||||
}}
|
||||
>
|
||||
<ArticlePhoto photo={photo} />
|
||||
{otherPhotos.length === 0 ? (
|
||||
<ArticlePhoto photo={activePhoto} />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<ArticlePhoto photo={activePhoto} />
|
||||
|
||||
{/* Smallthumbs of all other images beside the main image */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.75,
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
mt: '32px',
|
||||
py: 0.25,
|
||||
px: 0.25,
|
||||
flexShrink: 0,
|
||||
scrollbarWidth: 'thin',
|
||||
'&::-webkit-scrollbar': { width: 4 },
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{otherPhotos.map((p) => (
|
||||
<Box
|
||||
key={p.fullHash}
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
this.handleSelectPhoto(p.fullHash);
|
||||
}}
|
||||
sx={{
|
||||
p: 0,
|
||||
m: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
border: '2px solid transparent',
|
||||
bgcolor: 'transparent',
|
||||
outline: 'none',
|
||||
display: 'block',
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.08)',
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: 2,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ArticlePhoto
|
||||
photo={p}
|
||||
size={40}
|
||||
useSmallThumb
|
||||
objectFit="cover"
|
||||
sx={{ width: '100%', height: '100%', borderRadius: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</PanelTile>
|
||||
<ArticlePhotosDialog
|
||||
open={editOpen}
|
||||
|
||||
@@ -228,7 +228,9 @@ class SearchResultRow extends PureComponent {
|
||||
{' · '}
|
||||
</>
|
||||
)}
|
||||
{article.sku}
|
||||
<Box component="span" sx={{ fontFamily: 'monospace' }}>
|
||||
{article.sku}
|
||||
</Box>
|
||||
{article.stock != null && (
|
||||
<>
|
||||
{' · '}
|
||||
@@ -781,16 +783,17 @@ export default class ArticleSearch extends PureComponent {
|
||||
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={article.sku}
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={article.sku}
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'monospace',
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
|
||||
{article.name || '–'}
|
||||
</Typography>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { stock } from '../../api';
|
||||
import { formatStock } from './searchFormat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import {
|
||||
BORDER,
|
||||
GREEN_BG,
|
||||
@@ -42,14 +42,6 @@ import {
|
||||
SURFACE_RAISED,
|
||||
} from '../../colors';
|
||||
|
||||
const REASON_PRESETS = [
|
||||
'Inventurdifferenz',
|
||||
'Korrektur',
|
||||
'Bruch / Beschädigung',
|
||||
'Schwund',
|
||||
'Eigenbedarf / Test',
|
||||
];
|
||||
|
||||
export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -64,7 +56,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
reason: '',
|
||||
submitting: false,
|
||||
error: null,
|
||||
warehouses: warehouseAutomat.state.warehouses || [],
|
||||
warehouses: companyAutomat.state.warehouses || [],
|
||||
trackStock: isTrackStock,
|
||||
initialTrackStock: isTrackStock,
|
||||
};
|
||||
@@ -79,14 +71,13 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
|
||||
this.unsubWarehouse = companyAutomat.subscribe((state) => {
|
||||
const warehouses = state.warehouses || [];
|
||||
this.setState((prev) => ({
|
||||
warehouses,
|
||||
warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''),
|
||||
}));
|
||||
});
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
}, ['warehouses']);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -95,7 +86,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
void warehouseAutomat.actions.load().catch(() => {});
|
||||
const initialWhId = this.resolveInitialWarehouseId(this.props);
|
||||
const isTrackStock =
|
||||
this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0;
|
||||
@@ -107,7 +97,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
reason: '',
|
||||
submitting: false,
|
||||
error: null,
|
||||
warehouses: warehouseAutomat.state.warehouses || [],
|
||||
warehouses: companyAutomat.state.warehouses || [],
|
||||
trackStock: isTrackStock,
|
||||
initialTrackStock: isTrackStock,
|
||||
});
|
||||
@@ -121,7 +111,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
if (articleWarehouses.length > 0) {
|
||||
return articleWarehouses[0].id;
|
||||
}
|
||||
const allWarehouses = warehouseAutomat.state.warehouses || [];
|
||||
const allWarehouses = companyAutomat.state.warehouses || [];
|
||||
return allWarehouses.length > 0 ? allWarehouses[0].id : '';
|
||||
}
|
||||
|
||||
@@ -257,7 +247,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
}
|
||||
|
||||
// Reload article in automat cache
|
||||
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
|
||||
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => { });
|
||||
|
||||
if (updatedArticle) {
|
||||
onArticleUpdated?.(updatedArticle);
|
||||
@@ -358,7 +348,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
Artikel ({article?.sku})
|
||||
Artikel (<Box component="span" sx={{ fontFamily: 'monospace' }}>{article?.sku}</Box>)
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{article?.name || '–'}
|
||||
@@ -381,11 +371,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
Bestand verfolgen
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{trackStock
|
||||
? 'Lagerbestand wird für diesen Artikel geführt und bei Buchungen aktualisiert.'
|
||||
: 'Keine Bestandsführung. Artikel hat unbegrenzte Verfügbarkeit.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Switch
|
||||
checked={Boolean(trackStock)}
|
||||
@@ -475,11 +460,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
>
|
||||
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
|
||||
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||
Menge zu-/abbuchen (+ / -)
|
||||
Zu-/abbuchen (+ / -)
|
||||
</ToggleButton>
|
||||
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
|
||||
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||
Neuen Zählbestand setzen
|
||||
Bestand setzen
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
@@ -493,7 +478,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
value={diffValue}
|
||||
onChange={this.handleDiffChange}
|
||||
disabled={submitting || !trackStock}
|
||||
placeholder="z.B. +5 oder -2"
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
@@ -507,28 +491,15 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
),
|
||||
},
|
||||
}}
|
||||
helperText={
|
||||
valid
|
||||
? `Neuer Bestand: ${formatStock(newStock)}${unit ? ` ${unit}` : ''}`
|
||||
: 'Geben Sie einen positiven oder negativen Wert ein'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
fullWidth
|
||||
label="Tatsächlicher Zählbestand (Ist-Bestand)"
|
||||
label="Bestand"
|
||||
type="number"
|
||||
value={targetValue}
|
||||
onChange={this.handleTargetChange}
|
||||
disabled={submitting || !trackStock}
|
||||
placeholder="z.B. 10"
|
||||
helperText={
|
||||
isZeroChange
|
||||
? 'Der eingegebene Zählbestand entspricht dem aktuellen Bestand (keine Änderung).'
|
||||
: valid
|
||||
? `Berechnete Korrektur: ${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`
|
||||
: 'Geben Sie den neu gezählten Bestand ein'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -566,26 +537,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
||||
<Box>
|
||||
<TextInput
|
||||
fullWidth
|
||||
label="Grund / Buchungstext"
|
||||
label="Buchungstext"
|
||||
value={reason}
|
||||
onChange={this.handleReasonChange}
|
||||
disabled={submitting || !trackStock}
|
||||
placeholder="z.B. Inventur, Bruch, Schwund"
|
||||
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
|
||||
{REASON_PRESETS.map((preset) => (
|
||||
<Chip
|
||||
key={preset}
|
||||
label={preset}
|
||||
size="small"
|
||||
clickable
|
||||
disabled={submitting || !trackStock}
|
||||
onClick={() => this.setState({ reason: preset })}
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
||||
import AppDialog from '../AppDialog';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import { article as articleApi } from '../../api';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import categoryAutomat from '../../automats/categoryAutomat';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
|
||||
@@ -195,7 +195,7 @@ export default class ArticleUnifyDialog extends Component {
|
||||
|
||||
resolveBrandName(article) {
|
||||
if (!article) return '–';
|
||||
const brands = brandAutomat.state.brands || [];
|
||||
const brands = companyAutomat.state.brands || [];
|
||||
if (article.brandId != null) {
|
||||
const b = brands.find((x) => x.id === article.brandId);
|
||||
if (b?.name) return b.name;
|
||||
|
||||
@@ -15,7 +15,7 @@ import ListItemButton from '@mui/material/ListItemButton';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import TextInput from '../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import unitAutomat from '../../automats/unitAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { unit as unitApi } from '../../api';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -68,28 +68,27 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
busy: false,
|
||||
units: [],
|
||||
unitsLoading: false,
|
||||
units: companyAutomat.state.units,
|
||||
unitsLoading: companyAutomat.state.unitsStatus === 'loading',
|
||||
form: emptyForm(),
|
||||
selectedSi: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||
units: state.units,
|
||||
unitsLoading: state.unitsStatus === 'loading',
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
this.setState({ form: emptyForm(), selectedSi: null });
|
||||
void this.loadUnits();
|
||||
}
|
||||
}
|
||||
|
||||
async loadUnits() {
|
||||
this.setState({ unitsLoading: true });
|
||||
try {
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({ units, unitsLoading: false });
|
||||
} catch (err) {
|
||||
this.setState({ unitsLoading: false });
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,23 +115,22 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
if (!formValid(form)) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const data = await unitApi.upsert({
|
||||
const payload = {
|
||||
si: String(form.si ?? '').trim(),
|
||||
basesi: String(form.basesi ?? '').trim(),
|
||||
basesifactor: parseFactor(form.basesifactor),
|
||||
symbol: String(form.symbol ?? '').trim(),
|
||||
name: String(form.name ?? '').trim(),
|
||||
});
|
||||
requireObject(data, 'PUT /api/unit');
|
||||
requireObject(data.unit, 'unit');
|
||||
const units = await unitAutomat.actions.load();
|
||||
};
|
||||
const units = await companyAutomat.actions.upsertUnit(payload);
|
||||
const saved = units.find((u) => u.si === payload.si) ?? payload;
|
||||
this.setState({
|
||||
busy: false,
|
||||
units,
|
||||
form: formFromUnit(data.unit),
|
||||
selectedSi: data.unit.si,
|
||||
form: formFromUnit(saved),
|
||||
selectedSi: saved.si,
|
||||
});
|
||||
this.props.onUnitUpserted?.(data.unit);
|
||||
this.props.onUnitUpserted?.(saved);
|
||||
} catch (err) {
|
||||
this.setState({ busy: false });
|
||||
notifyError(err);
|
||||
@@ -144,9 +142,13 @@ export default class ArticleUnitEditorDialog extends Component {
|
||||
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await unitApi.delete(selectedSi);
|
||||
const units = await unitAutomat.actions.load();
|
||||
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
|
||||
await companyAutomat.actions.deleteUnit(selectedSi);
|
||||
this.setState({
|
||||
busy: false,
|
||||
units: companyAutomat.state.units,
|
||||
form: emptyForm(),
|
||||
selectedSi: null,
|
||||
});
|
||||
this.props.onUnitDeleted?.(selectedSi);
|
||||
} catch (err) {
|
||||
this.setState({ busy: false });
|
||||
|
||||
@@ -101,7 +101,7 @@ class ArticleView extends PureComponent {
|
||||
article={article}
|
||||
/>
|
||||
|
||||
<ArticleViewStock article={article} />
|
||||
<ArticleViewStock article={article} navigate={this.props.navigate} />
|
||||
|
||||
<ArticleViewBrand article={article} />
|
||||
|
||||
|
||||
@@ -43,14 +43,12 @@ export default class CategoryPickerDialog extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
||||
this._unsub = categoryAutomat.subscribe((s) => {
|
||||
this.setState(() => ({
|
||||
categories: this.props.categories ?? s.categories,
|
||||
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
||||
}));
|
||||
this._unsub = categoryAutomat.subscribe((s) => {
|
||||
this.setState({
|
||||
categories: s.categories,
|
||||
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
|
||||
} else if (this.state.rootCategoryId != null) {
|
||||
nextSelectedId = this.state.rootCategoryId;
|
||||
}
|
||||
this.setState({ selectedId: nextSelectedId });
|
||||
}
|
||||
|
||||
if (
|
||||
this.setState({
|
||||
selectedId: nextSelectedId,
|
||||
categories: categoryAutomat.state.categories ?? this.props.categories,
|
||||
rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
|
||||
});
|
||||
} else if (
|
||||
this.props.categories !== prevProps.categories ||
|
||||
this.props.rootCategoryId !== prevProps.rootCategoryId
|
||||
) {
|
||||
|
||||
@@ -6,6 +6,8 @@ export function queryFromParams(searchParams) {
|
||||
filter: searchParams.get('filter') ?? '',
|
||||
brand: searchParams.get('brand') ?? '',
|
||||
supplier: searchParams.get('supplier') ?? '',
|
||||
empty: searchParams.get('empty') ?? '',
|
||||
hierarchy: searchParams.get('hierarchy') ?? '',
|
||||
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
|
||||
};
|
||||
}
|
||||
@@ -13,12 +15,16 @@ export function queryFromParams(searchParams) {
|
||||
export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
|
||||
let brand = '';
|
||||
let supplier = '';
|
||||
let empty = '';
|
||||
let hierarchy = '';
|
||||
if (typeof options === 'string') {
|
||||
brand = options;
|
||||
supplier = arguments[4] || '';
|
||||
} else if (options && typeof options === 'object') {
|
||||
brand = options.brand || '';
|
||||
supplier = options.supplier || '';
|
||||
empty = options.empty || '';
|
||||
hierarchy = options.hierarchy || '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -26,6 +32,8 @@ export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
|
||||
if (filter) params.set('filter', filter);
|
||||
if (brand) params.set('brand', brand);
|
||||
if (supplier) params.set('supplier', supplier);
|
||||
if (empty) params.set('empty', empty);
|
||||
if (hierarchy) params.set('hierarchy', hierarchy);
|
||||
if (offset > 0) params.set('offset', String(offset));
|
||||
const qs = params.toString();
|
||||
return qs ? `/artikel?${qs}` : '/artikel';
|
||||
@@ -46,6 +54,8 @@ export function writeArtikelQuery({
|
||||
offset = 0,
|
||||
brand = '',
|
||||
supplier = '',
|
||||
empty = '',
|
||||
hierarchy = '',
|
||||
}) {
|
||||
try {
|
||||
if (!target || target.trim().length < 3) {
|
||||
@@ -54,7 +64,7 @@ export function writeArtikelQuery({
|
||||
}
|
||||
localStorage.setItem(
|
||||
ARTIKEL_QUERY_KEY,
|
||||
JSON.stringify({ target, filter, offset, brand, supplier }),
|
||||
JSON.stringify({ target, filter, offset, brand, supplier, empty, hierarchy }),
|
||||
);
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
@@ -76,5 +86,7 @@ export function artikelQueryPath() {
|
||||
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
|
||||
brand: query.brand ?? '',
|
||||
supplier: query.supplier ?? '',
|
||||
empty: query.empty ?? '',
|
||||
hierarchy: query.hierarchy ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import TextInput from '../../TextInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
||||
import brandAutomat from '../../../automats/brandAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { requireObject } from '../../../utils/apiShape';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
@@ -119,16 +119,16 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
brands: brandAutomat.state.brands ?? [],
|
||||
brands: companyAutomat.state.brands ?? [],
|
||||
};
|
||||
this._cancelled = false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||
brands: state.brands ?? [],
|
||||
}));
|
||||
}), ['brands']);
|
||||
const parentSku = this.props.article?.parentSku || this.props.article?.parent_sku;
|
||||
if (parentSku && !getArticle(parentSku)) {
|
||||
void artikelAutomat.actions.loadArticle(parentSku).catch(() => {});
|
||||
@@ -168,8 +168,8 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
if (existing) {
|
||||
brandId = existing.id;
|
||||
} else {
|
||||
await brandAutomat.actions.create({ name });
|
||||
const nextBrands = await brandAutomat.actions.load();
|
||||
await companyAutomat.actions.createBrand({ name });
|
||||
const nextBrands = await companyAutomat.actions.loadBrands();
|
||||
this.setState({ brands: nextBrands });
|
||||
brandId = nextBrands.find(
|
||||
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
|
||||
|
||||
@@ -152,7 +152,7 @@ export default class ArticleViewEANs extends PureComponent {
|
||||
|
||||
return (
|
||||
<EditablePanelTile
|
||||
title={articleData.sku}
|
||||
title={<Box component="span" sx={{ fontFamily: 'monospace' }}>{articleData.sku}</Box>}
|
||||
wide={1}
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
color={BORDER}
|
||||
|
||||
@@ -32,9 +32,8 @@ import { PanelTile } from 'dashgui/panels';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { artikelDetailPath } from '../articleRoute';
|
||||
import { article as articleApi } from '../../../api';
|
||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
import companyAutomat, { selectAttributes } from '../../../automats/companyAutomat';
|
||||
import { formatStock } from '../searchFormat';
|
||||
import { formatPrice, getArticleGross } from '../../../utils/price';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
@@ -46,7 +45,7 @@ import {
|
||||
alpha,
|
||||
} from '../../../colors';
|
||||
|
||||
function getVariantDetails(child, allArticles, defaultTaxRate) {
|
||||
function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
|
||||
const cached = child?.sku ? getArticle(child.sku) : null;
|
||||
const fromIndex = !cached && child?.sku
|
||||
? (allArticles || []).find((a) => a.sku === child.sku)
|
||||
@@ -57,14 +56,30 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
||||
const gross = getArticleGross(merged, defaultTaxRate);
|
||||
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
|
||||
|
||||
// 2. Stock & Unit
|
||||
// 2. Stock tracking
|
||||
const rawTrack =
|
||||
merged.track_stock ??
|
||||
merged.trackStock ??
|
||||
child?.trackStock ??
|
||||
child?.track_stock ??
|
||||
parentArticle?.track_stock ??
|
||||
parentArticle?.trackStock;
|
||||
|
||||
const trackStock =
|
||||
rawTrack !== undefined
|
||||
? rawTrack !== false && rawTrack !== 0 && rawTrack !== '0'
|
||||
: true;
|
||||
|
||||
// 3. Stock & Unit
|
||||
let stockVal = null;
|
||||
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
|
||||
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
|
||||
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
|
||||
stockVal = Number(merged.stock);
|
||||
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
|
||||
stockVal = Number(child.stock);
|
||||
if (trackStock) {
|
||||
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
|
||||
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
|
||||
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
|
||||
stockVal = Number(merged.stock);
|
||||
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
|
||||
stockVal = Number(child.stock);
|
||||
}
|
||||
}
|
||||
|
||||
const rawUnit =
|
||||
@@ -77,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
||||
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
|
||||
? ` ${rawUnit}`
|
||||
: '';
|
||||
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
|
||||
const stockDisplay = trackStock && stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
|
||||
|
||||
// 3. EAN(s)
|
||||
// 4. EAN(s)
|
||||
const eanCandidates = [];
|
||||
const rawEans = merged.eans ?? child?.eans;
|
||||
if (Array.isArray(rawEans)) {
|
||||
@@ -104,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
||||
stockDisplay,
|
||||
primaryEan,
|
||||
allEans: eanCandidates,
|
||||
trackStock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
|
||||
this.state = {
|
||||
attributes: initialAttributes,
|
||||
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
|
||||
assignedAttributes: [],
|
||||
allArticles: [],
|
||||
busy: false,
|
||||
@@ -159,10 +176,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
company: state.current,
|
||||
}));
|
||||
this.unsubAttr = attributeAutomat.subscribe((state) => {
|
||||
this.unsubCompany = companyAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => {
|
||||
const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId));
|
||||
@@ -172,19 +186,21 @@ export default class ArticleViewLists extends PureComponent {
|
||||
nextSelectedId = selectable[0].id;
|
||||
}
|
||||
return {
|
||||
company: state.current,
|
||||
attributes: state.attributes,
|
||||
attributesStatus: state.attributesStatus,
|
||||
selectedAttributeId: nextSelectedId,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, ['current', 'attributes', 'attributesStatus']);
|
||||
|
||||
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
||||
if (this._cancelled) return;
|
||||
this.forceUpdate();
|
||||
});
|
||||
|
||||
if (attributeAutomat.state.status === 'idle') {
|
||||
void attributeAutomat.actions.load();
|
||||
if (companyAutomat.state.attributesStatus === 'idle') {
|
||||
void companyAutomat.actions.loadAttributes();
|
||||
}
|
||||
|
||||
if (this.props.article?.sku) {
|
||||
@@ -200,7 +216,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
if (!this._cancelled && parent?.children) {
|
||||
this.ensureChildren(parent.children);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
void this.loadAllArticles();
|
||||
@@ -221,7 +237,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
if (!this._cancelled && parent?.children) {
|
||||
this.ensureChildren(parent.children);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
} else {
|
||||
const parent = getArticle(this.props.article.parentSku);
|
||||
if (parent?.children) {
|
||||
@@ -262,7 +278,6 @@ export default class ArticleViewLists extends PureComponent {
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubCompany?.();
|
||||
this.unsubAttr?.();
|
||||
this.unsubArtikel?.();
|
||||
}
|
||||
|
||||
@@ -270,7 +285,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
if (!Array.isArray(children)) return;
|
||||
for (const child of children) {
|
||||
if (child?.sku && !getArticle(child.sku)) {
|
||||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
|
||||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
|
||||
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
|
||||
if (busy || !article?.sku) return;
|
||||
|
||||
const val = newChildText.trim();
|
||||
const payload = {};
|
||||
if (isCustomChildValue) {
|
||||
const val = newChildText.trim();
|
||||
if (!val) return;
|
||||
|
||||
if (val && (isCustomChildValue || !selectedChildValueId)) {
|
||||
payload.value = val;
|
||||
} else if (selectedChildValueId) {
|
||||
payload.attributeValueId = Number(selectedChildValueId);
|
||||
} else if (val) {
|
||||
payload.value = val;
|
||||
} else {
|
||||
if (!selectedChildValueId) return;
|
||||
payload.attributeValueId = Number(selectedChildValueId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const api = this.getApi();
|
||||
await api.addChild(article.sku, payload);
|
||||
const res = await api.addChild(article.sku, payload);
|
||||
if (this._cancelled) return;
|
||||
|
||||
if (res?.sku) {
|
||||
void artikelAutomat.actions.loadArticle(res.sku).catch(() => { });
|
||||
}
|
||||
|
||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||
if (this._cancelled) return;
|
||||
|
||||
void articleIndexAutomat.actions.reload().catch(() => { });
|
||||
void companyAutomat.actions.loadAttributes?.().catch?.(() => { });
|
||||
|
||||
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
|
||||
this.setState({
|
||||
busy: false,
|
||||
selectedChildValueId: '',
|
||||
newChildText: '',
|
||||
isCustomChildValue: false,
|
||||
});
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
@@ -493,17 +519,17 @@ export default class ArticleViewLists extends PureComponent {
|
||||
const nextItems =
|
||||
existingIndex >= 0
|
||||
? currentItems.map((item, idx) =>
|
||||
idx === existingIndex ? { ...item, count: item.count + count } : item
|
||||
)
|
||||
idx === existingIndex ? { ...item, count: item.count + count } : item
|
||||
)
|
||||
: [
|
||||
...currentItems,
|
||||
{
|
||||
articleId: stuecklisteTargetArticle.id,
|
||||
sku: stuecklisteTargetArticle.sku,
|
||||
count,
|
||||
sort: currentItems.length,
|
||||
},
|
||||
];
|
||||
...currentItems,
|
||||
{
|
||||
articleId: stuecklisteTargetArticle.id,
|
||||
sku: stuecklisteTargetArticle.sku,
|
||||
count,
|
||||
sort: currentItems.length,
|
||||
},
|
||||
];
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
@@ -617,20 +643,20 @@ export default class ArticleViewLists extends PureComponent {
|
||||
const nextOptions =
|
||||
existingIndex >= 0
|
||||
? currentOptions.map((opt, idx) =>
|
||||
idx === existingIndex
|
||||
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
|
||||
: opt
|
||||
)
|
||||
idx === existingIndex
|
||||
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
|
||||
: opt
|
||||
)
|
||||
: [
|
||||
...currentOptions,
|
||||
{
|
||||
articleId: optionTargetArticle.id,
|
||||
sku: optionTargetArticle.sku,
|
||||
count,
|
||||
sort: currentOptions.length,
|
||||
mandated: Boolean(optionMandated),
|
||||
},
|
||||
];
|
||||
...currentOptions,
|
||||
{
|
||||
articleId: optionTargetArticle.id,
|
||||
sku: optionTargetArticle.sku,
|
||||
count,
|
||||
sort: currentOptions.length,
|
||||
mandated: Boolean(optionMandated),
|
||||
},
|
||||
];
|
||||
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
@@ -836,69 +862,70 @@ export default class ArticleViewLists extends PureComponent {
|
||||
}}
|
||||
>
|
||||
{stuecklisteItems.map((item) => (
|
||||
<Box
|
||||
key={item.id || item.sku}
|
||||
<Box
|
||||
key={item.id || item.sku}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Chip
|
||||
label={`${item.count}x`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.name || item.sku}
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(item.sku)}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: 'monospace',
|
||||
color: 'text.secondary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{item.sku}
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
p: 0.25,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Chip
|
||||
label={`${item.count}x`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{item.name || item.sku}
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(item.sku)}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{item.sku}
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
|
||||
sx={{
|
||||
p: 0.25,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1045,80 +1072,81 @@ export default class ArticleViewLists extends PureComponent {
|
||||
}}
|
||||
>
|
||||
{optionsItems.map((opt) => (
|
||||
<Box
|
||||
key={opt.id || opt.sku}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Chip
|
||||
label={`${opt.count}x`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
<Box
|
||||
key={opt.id || opt.sku}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.4,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Chip
|
||||
label={`${opt.count}x`}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{opt.name || opt.sku}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(opt.sku)}
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
fontSize: '0.82rem',
|
||||
lineHeight: 1.2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: '0.72rem',
|
||||
fontFamily: 'monospace',
|
||||
color: 'text.secondary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{opt.name || opt.sku}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={artikelDetailPath(opt.sku)}
|
||||
sx={{
|
||||
fontSize: '0.72rem',
|
||||
color: 'text.secondary',
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
{opt.sku}
|
||||
</Link>
|
||||
{opt.mandated ? (
|
||||
<Chip
|
||||
label="Fest"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
{opt.sku}
|
||||
</Link>
|
||||
{opt.mandated ? (
|
||||
<Chip
|
||||
label="Fest"
|
||||
size="small"
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleRemoveOption(opt.sku)}
|
||||
sx={{
|
||||
p: 0.25,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleRemoveOption(opt.sku)}
|
||||
sx={{
|
||||
p: 0.25,
|
||||
color: 'text.secondary',
|
||||
'&:hover': { color: 'error.main' },
|
||||
}}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1211,14 +1239,14 @@ export default class ArticleViewLists extends PureComponent {
|
||||
const siblings = hasSelf
|
||||
? childrenList
|
||||
: [
|
||||
...childrenList,
|
||||
{
|
||||
id: articleData.id,
|
||||
sku: articleData.sku,
|
||||
name: articleData.name,
|
||||
attributeValue: variationValue,
|
||||
},
|
||||
];
|
||||
...childrenList,
|
||||
{
|
||||
id: articleData.id,
|
||||
sku: articleData.sku,
|
||||
name: articleData.name,
|
||||
attributeValue: variationValue,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
||||
@@ -1348,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
|
||||
stockDisplay,
|
||||
primaryEan,
|
||||
allEans,
|
||||
} = getVariantDetails(sibling, allArticles, defaultTaxRate);
|
||||
trackStock,
|
||||
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -1465,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
sx={{
|
||||
fontSize: '0.74rem',
|
||||
fontWeight: 600,
|
||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
@@ -1584,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
sx={{
|
||||
fontSize: '0.74rem',
|
||||
fontWeight: 600,
|
||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
@@ -1604,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
}
|
||||
|
||||
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
||||
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
|
||||
const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{activeCreatorMode === 'parent' ? (
|
||||
@@ -1626,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{selectableAttributes.length === 0 ? (
|
||||
{attributesStatus === 'loading' ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<CircularProgress size={14} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Merkmale werden geladen…
|
||||
</Typography>
|
||||
</Box>
|
||||
) : !attributes || attributes.length === 0 ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, py: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Im System sind noch keine Merkmale angelegt.
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/artikel/merkmale"
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Zu den Merkmalen →
|
||||
</Link>
|
||||
</Box>
|
||||
) : selectableAttributes.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
|
||||
</Typography>
|
||||
@@ -1879,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||
Varianten:
|
||||
</Typography>
|
||||
{children.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
|
||||
Noch keine Varianten angelegt.
|
||||
</Typography>
|
||||
) : (
|
||||
{children.length === 0 ? null : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{children.map((child) => {
|
||||
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
|
||||
@@ -1893,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
|
||||
stockDisplay,
|
||||
primaryEan,
|
||||
allEans,
|
||||
} = getVariantDetails(child, allArticles, defaultTaxRate);
|
||||
trackStock,
|
||||
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -2035,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
|
||||
sx={{
|
||||
fontSize: '0.74rem',
|
||||
fontWeight: 600,
|
||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
@@ -2048,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => this.handleOpenRemoveDialog(child)}
|
||||
title="Variante entfernen"
|
||||
sx={{
|
||||
p: 0.35,
|
||||
color: 'text.secondary',
|
||||
@@ -2069,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
|
||||
{isCustomChildValue || availableParentValues.length === 0 ? (
|
||||
<TextInput
|
||||
label="Neuer Wert"
|
||||
placeholder="Neuer Wert..."
|
||||
value={newChildText}
|
||||
disabled={busy}
|
||||
onChange={(e) => this.setState({ newChildText: e.target.value })}
|
||||
@@ -2114,34 +2167,31 @@ export default class ArticleViewLists extends PureComponent {
|
||||
</TextInput>
|
||||
)}
|
||||
|
||||
<Tooltip title="Variante hinzufügen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
disabled={
|
||||
busy ||
|
||||
(isCustomChildValue || availableParentValues.length === 0
|
||||
? !newChildText.trim()
|
||||
: !selectedChildValueId)
|
||||
}
|
||||
onClick={this.handleAddChild}
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
'&.Mui-disabled': {
|
||||
bgcolor: 'action.disabledBackground',
|
||||
color: 'action.disabled',
|
||||
},
|
||||
p: 0.6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
aria-label="Variante hinzufügen"
|
||||
disabled={
|
||||
busy ||
|
||||
(isCustomChildValue || availableParentValues.length === 0
|
||||
? !newChildText.trim()
|
||||
: !selectedChildValueId)
|
||||
}
|
||||
onClick={this.handleAddChild}
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': { bgcolor: 'primary.dark' },
|
||||
'&.Mui-disabled': {
|
||||
bgcolor: 'action.disabledBackground',
|
||||
color: 'action.disabled',
|
||||
},
|
||||
p: 0.6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Toggle custom value / predefined value if values exist */}
|
||||
@@ -2176,16 +2226,16 @@ export default class ArticleViewLists extends PureComponent {
|
||||
)}
|
||||
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
{isStueckliste ||
|
||||
hasOptions ||
|
||||
activeCreatorMode === 'stueckliste' ||
|
||||
activeCreatorMode === 'options'
|
||||
hasOptions ||
|
||||
activeCreatorMode === 'stueckliste' ||
|
||||
activeCreatorMode === 'options'
|
||||
? this.renderCompoundManager(
|
||||
isStueckliste,
|
||||
hasOptions,
|
||||
stuecklisteItems,
|
||||
optionsItems,
|
||||
selectableOtherArticles
|
||||
)
|
||||
isStueckliste,
|
||||
hasOptions,
|
||||
stuecklisteItems,
|
||||
optionsItems,
|
||||
selectableOtherArticles
|
||||
)
|
||||
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -2264,11 +2314,11 @@ export default class ArticleViewLists extends PureComponent {
|
||||
<DialogContentText sx={{ fontSize: '0.88rem' }}>
|
||||
{confirmClearType === 'stueckliste'
|
||||
? (hasOptions
|
||||
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
|
||||
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
|
||||
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
|
||||
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
|
||||
: (isStueckliste
|
||||
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
|
||||
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
|
||||
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
|
||||
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 2, pb: 2 }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, PureComponent } from 'react';
|
||||
import { EditablePanelTile } from 'dashgui/panels';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { getCategoryState } from '../../../automats/categoryAutomat';
|
||||
import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat';
|
||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||
import { Box, Chip, IconButton, Typography } from '@mui/material';
|
||||
@@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
|
||||
.filter((c) => c.label);
|
||||
}
|
||||
|
||||
class CategoryPills extends Component {
|
||||
class CategoryPills extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { pickerOpen: false, targetPillId: null };
|
||||
const catState = getCategoryState();
|
||||
this.state = {
|
||||
pickerOpen: false,
|
||||
targetPillId: null,
|
||||
categories: catState?.categories ?? categoryAutomat.state.categories,
|
||||
rootCategoryId: catState?.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unsubCat = categoryAutomat.subscribe((s) => {
|
||||
this.setState({
|
||||
categories: s.categories,
|
||||
rootCategoryId: s.rootCategoryId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unsubCat?.();
|
||||
}
|
||||
|
||||
handleRemoveCategory = (removeId) => {
|
||||
@@ -135,8 +154,7 @@ class CategoryPills extends Component {
|
||||
|
||||
render() {
|
||||
const { categoryIds, editMode } = this.props;
|
||||
const { pickerOpen, targetPillId } = this.state;
|
||||
const { categories, rootCategoryId } = getCategoryState() ?? {};
|
||||
const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state;
|
||||
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||
|
||||
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
||||
|
||||
@@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography';
|
||||
import { EditablePanelTile, PanelTile } from 'dashgui/panels';
|
||||
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
|
||||
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
|
||||
import unitAutomat from '../../../automats/unitAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
@@ -333,7 +332,7 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const company = companyFromStore();
|
||||
const units = unitAutomat.state.units;
|
||||
const units = companyAutomat.state.units;
|
||||
this.state = {
|
||||
tiersDialogOpen: false,
|
||||
unitEditorOpen: false,
|
||||
@@ -345,12 +344,10 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubUnits = unitAutomat.subscribe(this, (state) => ({
|
||||
units: state.units ?? [],
|
||||
}));
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
units: state.units ?? [],
|
||||
company: state.current ?? null,
|
||||
}));
|
||||
}), ['units', 'current']);
|
||||
this.unsubArtikel = artikelAutomat.subscribe(this, () => ({}));
|
||||
if (this.props.article?.isParent) {
|
||||
this.ensureChildren(this.props.article.children);
|
||||
@@ -369,8 +366,6 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubUnits?.();
|
||||
this.unsubUnits = null;
|
||||
this.unsubCompany?.();
|
||||
this.unsubCompany = null;
|
||||
this.unsubArtikel?.();
|
||||
@@ -462,7 +457,7 @@ export default class ArticleViewPrice extends PureComponent {
|
||||
}
|
||||
|
||||
refreshUnits = async () => {
|
||||
const units = await unitAutomat.actions.load();
|
||||
const units = await companyAutomat.actions.loadUnits();
|
||||
this.setState({ units });
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import Typography from '@mui/material/Typography';
|
||||
import WarehouseIcon from '@mui/icons-material/Warehouse';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { formatStock } from '../searchFormat';
|
||||
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { router } from '../../../appRouter';
|
||||
import {
|
||||
GREEN_PANEL,
|
||||
PETROL_PANEL,
|
||||
@@ -96,6 +98,58 @@ function extractIncoming(articleData, isParent) {
|
||||
return items;
|
||||
}
|
||||
|
||||
function extractUndeliveredOrders(articleData, isParent) {
|
||||
const items = [];
|
||||
|
||||
const collect = (article, variantInfo = null) => {
|
||||
if (!article) return;
|
||||
|
||||
if (Array.isArray(article.undeliveredOrders)) {
|
||||
for (const ord of article.undeliveredOrders) {
|
||||
const amount = Number(ord?.amount) || 0;
|
||||
if (amount > 0) {
|
||||
items.push({
|
||||
orderId: ord.orderId,
|
||||
orderNumber: ord.orderNumber || (ord.orderId ? `A-${ord.orderId}` : null),
|
||||
date: ord.date || null,
|
||||
customerName: ord.customerName || null,
|
||||
amount,
|
||||
variantSku: variantInfo?.sku || null,
|
||||
variantName: variantInfo?.name || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collect(articleData);
|
||||
|
||||
if (isParent) {
|
||||
const children = Array.isArray(articleData?.children) ? articleData.children : [];
|
||||
for (const child of children) {
|
||||
const fullChild = getArticle(child.sku) || child;
|
||||
collect(fullChild, {
|
||||
sku: fullChild.sku || child.sku,
|
||||
name: fullChild.name || child.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (a.date && b.date) {
|
||||
const diff = new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
} else if (a.date) {
|
||||
return -1;
|
||||
} else if (b.date) {
|
||||
return 1;
|
||||
}
|
||||
return (Number(a.orderId) || 0) - (Number(b.orderId) || 0);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default class ArticleViewStock extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -104,6 +158,17 @@ export default class ArticleViewStock extends PureComponent {
|
||||
};
|
||||
this.handleOpenAdjust = this.handleOpenAdjust.bind(this);
|
||||
this.handleCloseAdjust = this.handleCloseAdjust.bind(this);
|
||||
this.handleOpenOrder = this.handleOpenOrder.bind(this);
|
||||
}
|
||||
|
||||
handleOpenOrder(orderId, event) {
|
||||
if (!orderId) return;
|
||||
if (event?.metaKey || event?.ctrlKey) {
|
||||
window.open(`/auftrag/bestellung/${orderId}`, '_blank');
|
||||
return;
|
||||
}
|
||||
const nav = this.props.navigate || router.navigate;
|
||||
void nav(`/auftrag/bestellung/${orderId}`);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -131,7 +196,7 @@ export default class ArticleViewStock extends PureComponent {
|
||||
if (!Array.isArray(children)) return;
|
||||
for (const child of children) {
|
||||
if (child?.sku && !getArticle(child.sku)) {
|
||||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
|
||||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +270,10 @@ export default class ArticleViewStock extends PureComponent {
|
||||
const totalIncoming = incomingItems.reduce((sum, inc) => sum + inc.amount, 0);
|
||||
const hasIncoming = totalIncoming > 0;
|
||||
|
||||
const undeliveredOrders = extractUndeliveredOrders(articleData, isParent);
|
||||
const totalUndelivered = undeliveredOrders.reduce((sum, ord) => sum + ord.amount, 0);
|
||||
const hasUndelivered = totalUndelivered > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelTile
|
||||
@@ -215,10 +284,10 @@ export default class ArticleViewStock extends PureComponent {
|
||||
isParent || isStueckliste
|
||||
? null
|
||||
: {
|
||||
label: 'Bestand anpassen',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleOpenAdjust,
|
||||
}
|
||||
label: 'Bestand anpassen',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleOpenAdjust,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Box
|
||||
@@ -301,8 +370,7 @@ export default class ArticleViewStock extends PureComponent {
|
||||
onClick={this.handleOpenAdjust}
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'baseline',
|
||||
gap: 0.75,
|
||||
flexDirection: 'column',
|
||||
my: 0.5,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 1,
|
||||
@@ -314,27 +382,29 @@ export default class ArticleViewStock extends PureComponent {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
fontSize: '2.4rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{formatStock(totalStock)}
|
||||
</Typography>
|
||||
{unit ? (
|
||||
<Box sx={{ display: 'inline-flex', alignItems: 'baseline', gap: 0.75 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 500 }}
|
||||
component="div"
|
||||
sx={{
|
||||
fontSize: '2.4rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: '-0.02em',
|
||||
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{unit}
|
||||
{formatStock(totalStock)}
|
||||
</Typography>
|
||||
) : null}
|
||||
{unit ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{unit}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -374,8 +444,8 @@ export default class ArticleViewStock extends PureComponent {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Bottom section: Per-warehouse breakdown & incoming shipments */}
|
||||
{hasWarehouses || hasIncoming ? (
|
||||
{/* Bottom section: Per-warehouse breakdown, incoming shipments & open orders */}
|
||||
{hasWarehouses || hasIncoming || hasUndelivered ? (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
@@ -394,23 +464,6 @@ export default class ArticleViewStock extends PureComponent {
|
||||
{/* Warehouse stocks */}
|
||||
{hasWarehouses ? (
|
||||
<>
|
||||
{hasIncoming ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
color: 'text.secondary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
px: 0.5,
|
||||
mb: 0.1,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
Lagerorte
|
||||
</Typography>
|
||||
) : null}
|
||||
{warehouses.map((wh) => (
|
||||
<Box
|
||||
key={wh.id}
|
||||
@@ -476,30 +529,14 @@ export default class ArticleViewStock extends PureComponent {
|
||||
{/* Incoming shipments */}
|
||||
{hasIncoming ? (
|
||||
<>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
color: 'text.secondary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
px: 0.5,
|
||||
mt: hasWarehouses ? 0.75 : 0,
|
||||
mb: 0.1,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
Im Zulauf
|
||||
</Typography>
|
||||
{incomingItems.map((inc, idx) => {
|
||||
const dateLabel = inc.date ? formatDate(inc.date) : null;
|
||||
const label = [
|
||||
const labelParts = [
|
||||
inc.variantName,
|
||||
inc.supplierName || 'Lieferant',
|
||||
dateLabel,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' – ');
|
||||
].filter(Boolean);
|
||||
const label = labelParts.join(' – ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -561,6 +598,89 @@ export default class ArticleViewStock extends PureComponent {
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Undelivered / Open Customer Orders */}
|
||||
{hasUndelivered ? (
|
||||
<>
|
||||
{undeliveredOrders.map((ord, idx) => {
|
||||
const dateLabel = ord.date ? formatDate(ord.date) : null;
|
||||
const orderLabel = ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag');
|
||||
const labelParts = [
|
||||
ord.variantName,
|
||||
orderLabel,
|
||||
ord.customerName,
|
||||
dateLabel,
|
||||
].filter(Boolean);
|
||||
const label = labelParts.join(' – ');
|
||||
const tooltipText = ord.orderId
|
||||
? `Auftrag ${orderLabel}${ord.customerName ? ` (${ord.customerName})` : ''} öffnen`
|
||||
: label;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={ord.orderId ? `order-${ord.orderId}` : `ord-${idx}`}
|
||||
onClick={(e) => this.handleOpenOrder(ord.orderId, e)}
|
||||
title={tooltipText}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
py: 0.35,
|
||||
px: 0.75,
|
||||
borderRadius: 1,
|
||||
backgroundColor: VEIL_PANEL,
|
||||
cursor: ord.orderId ? 'pointer' : 'default',
|
||||
transition: 'background-color 0.15s ease',
|
||||
'&:hover': ord.orderId ? {
|
||||
backgroundColor: TINT_ROW_HOVER,
|
||||
} : undefined,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: 0,
|
||||
mr: 1,
|
||||
}}
|
||||
>
|
||||
<ShoppingCartOutlinedIcon
|
||||
sx={{
|
||||
fontSize: 15,
|
||||
color: 'text.secondary',
|
||||
mr: 0.6,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
fontSize: '0.82rem',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: '0.82rem',
|
||||
flexShrink: 0,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
-{formatStock(ord.amount)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import Link from '@mui/material/Link';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -10,8 +12,7 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
|
||||
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
|
||||
import companyAutomat, { selectAttributes, selectProperties } from '../../../automats/companyAutomat';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { article as articleApi } from '../../../api';
|
||||
|
||||
@@ -81,26 +82,27 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
|
||||
componentDidMount() {
|
||||
this._cancelled = false;
|
||||
this.unsubAttr = attributeAutomat.subscribe((state) => {
|
||||
this.unsubCompany = companyAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => {
|
||||
let nextKey = prev.selectedMerkmalKey;
|
||||
let nextItemId = prev.selectedItemId;
|
||||
|
||||
const selectableAttrs = state.attributes;
|
||||
const globalProps = state.properties;
|
||||
|
||||
const isCurrentKeyValid =
|
||||
nextKey &&
|
||||
(selectableAttrs.some((a) => `attr-${a.id}` === nextKey) ||
|
||||
prev.globalProperties.some((p) => `prop-${p.id}` === nextKey));
|
||||
globalProps.some((p) => `prop-${p.id}` === nextKey));
|
||||
|
||||
if (!isCurrentKeyValid) {
|
||||
const first = selectableAttrs[0];
|
||||
if (first) {
|
||||
nextKey = `attr-${first.id}`;
|
||||
nextItemId = first.values?.[0]?.id ?? '';
|
||||
} else if (prev.globalProperties[0]) {
|
||||
nextKey = `prop-${prev.globalProperties[0].id}`;
|
||||
} else if (globalProps[0]) {
|
||||
nextKey = `prop-${globalProps[0].id}`;
|
||||
nextItemId = '';
|
||||
} else {
|
||||
nextKey = '';
|
||||
@@ -108,38 +110,24 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
}
|
||||
}
|
||||
return {
|
||||
globalAttributes: state.attributes,
|
||||
globalAttributes: selectableAttrs,
|
||||
globalProperties: globalProps,
|
||||
selectedMerkmalKey: nextKey,
|
||||
selectedItemId: nextItemId,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
this.unsubProp = propertyAutomat.subscribe((state) => {
|
||||
if (this._cancelled) return;
|
||||
this.setState((prev) => {
|
||||
const selectableAttrs = prev.globalAttributes;
|
||||
let nextKey = prev.selectedMerkmalKey;
|
||||
if (!nextKey && !selectableAttrs.length && state.properties[0]) {
|
||||
nextKey = `prop-${state.properties[0].id}`;
|
||||
}
|
||||
return {
|
||||
globalProperties: state.properties,
|
||||
selectedMerkmalKey: nextKey,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, ['attributes', 'properties']);
|
||||
|
||||
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
||||
if (this._cancelled) return;
|
||||
this.forceUpdate();
|
||||
});
|
||||
|
||||
if (attributeAutomat.state.status === 'idle') {
|
||||
void attributeAutomat.actions.load();
|
||||
if (companyAutomat.state.attributesStatus === 'idle') {
|
||||
void companyAutomat.actions.loadAttributes();
|
||||
}
|
||||
if (propertyAutomat.state.status === 'idle') {
|
||||
void propertyAutomat.actions.load();
|
||||
if (companyAutomat.state.propertiesStatus === 'idle') {
|
||||
void companyAutomat.actions.loadProperties();
|
||||
}
|
||||
|
||||
void this.loadArticleData(this.props.sku);
|
||||
@@ -153,8 +141,7 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
this.unsubAttr?.();
|
||||
this.unsubProp?.();
|
||||
this.unsubCompany?.();
|
||||
this.unsubArtikel?.();
|
||||
}
|
||||
|
||||
@@ -577,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{merkmalOptions.length === 0 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Im System sind noch keine Merkmale angelegt.
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to="/artikel/merkmale"
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
'&:hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
>
|
||||
Zu den Merkmalen →
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Unified Assigned Merkmale List */}
|
||||
{assignedGroups.length > 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,70 @@ import Grid from '@mui/material/Grid';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import PrintIcon from '@mui/icons-material/Print';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import { labeltemplate, printer, article } from '../../../api';
|
||||
import { labeltemplate, article } from '../../../api';
|
||||
import { companyAutomat } from '../../../automats';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import BackendError from '../../BackendError';
|
||||
|
||||
function filterZplPrinters(agentprinters) {
|
||||
return (Array.isArray(agentprinters) ? agentprinters : []).filter(
|
||||
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
|
||||
);
|
||||
}
|
||||
|
||||
function getPrinterLabel(p) {
|
||||
let label;
|
||||
if (p.model && p.serial) {
|
||||
label = `${p.model} (${p.serial})`;
|
||||
} else if (p.model) {
|
||||
label = p.model;
|
||||
} else if (p.serial) {
|
||||
label = `Drucker (${p.serial})`;
|
||||
} else if (p.desc && p.address) {
|
||||
label = `${p.desc} (${p.address})`;
|
||||
} else if (p.address) {
|
||||
label = p.address;
|
||||
} else if (p.desc) {
|
||||
label = p.desc;
|
||||
} else {
|
||||
label = `Drucker #${p.id}`;
|
||||
}
|
||||
|
||||
if (p.online === false) {
|
||||
return `${label} (offline)`;
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function getPrinterLabelLength(printer, livePrinters = []) {
|
||||
if (!printer) return null;
|
||||
const live = (livePrinters || []).find(
|
||||
(lp) =>
|
||||
(lp.agentprinterId && lp.agentprinterId === printer.id) ||
|
||||
(lp.id && lp.id === printer.id) ||
|
||||
(printer.serial && lp.serial && String(printer.serial).toLowerCase() === String(lp.serial).toLowerCase()),
|
||||
);
|
||||
const raw = printer.labelLength ?? live?.labelLength;
|
||||
if (raw == null) return null;
|
||||
const num = Number(raw);
|
||||
return Number.isFinite(num) && num > 0 ? num : null;
|
||||
}
|
||||
|
||||
export default class ArticleLabelTab extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
|
||||
this.state = {
|
||||
templates: null,
|
||||
printers: null,
|
||||
printers: initialPrinters,
|
||||
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||
? companyAutomat.state.livePrinters
|
||||
: [],
|
||||
selectedTemplateId: '',
|
||||
selectedPrinterId: '',
|
||||
selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
|
||||
quantity: 1,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -30,27 +80,61 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = companyAutomat.subscribe(this, (state) => {
|
||||
const zplPrinters = filterZplPrinters(state.agentprinters);
|
||||
this.setState((prev) => ({
|
||||
printers: zplPrinters,
|
||||
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
||||
selectedPrinterId:
|
||||
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
|
||||
? prev.selectedPrinterId
|
||||
: (zplPrinters[0]?.id ?? ''),
|
||||
}));
|
||||
});
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.article?.sku !== this.props.article?.sku ||
|
||||
prevProps.article?.name !== this.props.article?.name ||
|
||||
prevProps.article?.prices !== this.props.article?.prices ||
|
||||
prevProps.article?.eans !== this.props.article?.eans
|
||||
) {
|
||||
this.loadData();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
this.setState({ loading: true, error: null });
|
||||
const sku = this.props.article?.sku;
|
||||
try {
|
||||
const [resTemplates, resPrinters] = await Promise.all([
|
||||
labeltemplate.list(),
|
||||
printer.list(),
|
||||
]);
|
||||
|
||||
const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
|
||||
const templates = resTemplates.labeltemplates || [];
|
||||
const zplPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl');
|
||||
const zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
|
||||
|
||||
this.setState({
|
||||
this.setState((prev) => ({
|
||||
templates,
|
||||
printers: zplPrinters,
|
||||
selectedTemplateId: templates.length > 0 ? templates[0].id : '',
|
||||
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '',
|
||||
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||
? companyAutomat.state.livePrinters
|
||||
: prev.livePrinters,
|
||||
selectedTemplateId:
|
||||
templates.length > 0
|
||||
? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId)
|
||||
? prev.selectedTemplateId
|
||||
: templates[0].id
|
||||
: '',
|
||||
selectedPrinterId:
|
||||
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
|
||||
? prev.selectedPrinterId
|
||||
: (zplPrinters[0]?.id ?? ''),
|
||||
loading: false,
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
this.setState({ error, loading: false });
|
||||
}
|
||||
@@ -68,7 +152,7 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
try {
|
||||
await article.printLabel(articleData.sku, {
|
||||
templateId: selectedTemplateId,
|
||||
printerId: selectedPrinterId,
|
||||
agentprinterId: selectedPrinterId,
|
||||
quantity,
|
||||
});
|
||||
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
|
||||
@@ -77,10 +161,24 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
handleCancelAllJobs = async () => {
|
||||
const { selectedPrinterId, printers } = this.state;
|
||||
const p = (printers || []).find((item) => item.id === selectedPrinterId);
|
||||
const printerName = p ? getPrinterLabel(p) : 'Drucker';
|
||||
|
||||
try {
|
||||
await companyAutomat.actions.cancelAllPrinterJobs(selectedPrinterId || undefined);
|
||||
enqueueSnackbar(`Druckaufträge für ${printerName} abgebrochen.`, { variant: 'info' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
templates,
|
||||
printers,
|
||||
livePrinters,
|
||||
selectedTemplateId,
|
||||
selectedPrinterId,
|
||||
quantity,
|
||||
@@ -88,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
error,
|
||||
} = this.state;
|
||||
|
||||
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
|
||||
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -130,8 +231,18 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
<Grid container spacing={2}>
|
||||
{templates.map((t) => {
|
||||
const isSelected = t.id === selectedTemplateId;
|
||||
const dpi = t.dpi === 300 ? 300 : 203;
|
||||
const tmplHeight = t.height || 200;
|
||||
const tmplWidth = t.width || 448;
|
||||
const hasPrinterLength = printerLabelLength != null && printerLabelLength > 0;
|
||||
const totalHeight = hasPrinterLength ? Math.max(tmplHeight, printerLabelLength) : tmplHeight;
|
||||
const isCutoff = hasPrinterLength && printerLabelLength < tmplHeight;
|
||||
const outlineColor = isCutoff ? '#d32f2f' : '#1976d2';
|
||||
const tmplHeightPercent = (tmplHeight / totalHeight) * 100;
|
||||
const printerHeightPercent = hasPrinterLength ? (printerLabelLength / totalHeight) * 100 : 100;
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
|
||||
<Paper
|
||||
variant="outlined"
|
||||
onClick={() => this.setState({ selectedTemplateId: t.id })}
|
||||
@@ -175,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
bgcolor: 'background.default',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{t.thumb ? (
|
||||
<img
|
||||
src={`data:image/png;base64,${t.thumb}`}
|
||||
alt={t.name}
|
||||
style={{
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
aspectRatio: `${tmplWidth} / ${totalHeight}`,
|
||||
boxSizing: 'border-box',
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 0.5,
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* Template preview image */}
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: `${tmplHeightPercent}%`,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 0.5,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`data:image/png;base64,${t.thumb}`}
|
||||
alt={t.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
{/* If template exceeds printer label length, shade the cutoff overflow */}
|
||||
{isCutoff && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: `${(printerLabelLength / tmplHeight) * 100}%`,
|
||||
bottom: 0,
|
||||
bgcolor: 'rgba(211, 47, 47, 0.22)',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Dotted outline for printer label length */}
|
||||
{hasPrinterLength && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: `${printerHeightPercent}%`,
|
||||
border: `2px dotted ${outlineColor}`,
|
||||
borderRadius: 0.5,
|
||||
pointerEvents: 'none',
|
||||
boxSizing: 'border-box',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.disabled">
|
||||
Keine Vorschau
|
||||
@@ -198,7 +372,7 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
{t.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{t.width}x{t.height} ({t.dpi} dpi)
|
||||
{t.width}x{t.height} ({dpi} dpi)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
@@ -217,11 +391,14 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
|
||||
sx={{ minWidth: 200 }}
|
||||
>
|
||||
{printers.map((p) => (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{p.desc ? `${p.desc} (${p.address})` : p.address}
|
||||
</MenuItem>
|
||||
))}
|
||||
{printers.map((p) => {
|
||||
const len = getPrinterLabelLength(p, livePrinters);
|
||||
return (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</TextInput>
|
||||
|
||||
<TextInput
|
||||
@@ -246,6 +423,18 @@ export default class ArticleLabelTab extends PureComponent {
|
||||
>
|
||||
Etiketten drucken
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<CancelScheduleSendOutlinedIcon />}
|
||||
onClick={this.handleCancelAllJobs}
|
||||
disabled={!selectedPrinterId}
|
||||
sx={{ height: 40 }}
|
||||
>
|
||||
Druckaufträge abbrechen
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@ import GoogleIcon from '@mui/icons-material/Google';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import { article as articleApi } from '../../../api';
|
||||
import { artikelAutomat, getArticle } from '../../../automats';
|
||||
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import {
|
||||
articleEans,
|
||||
|
||||
@@ -23,7 +23,6 @@ import { enqueueSnackbar } from 'notistack';
|
||||
import { article as articleApi } from '../../../api';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import { formatPrice } from '../../../utils/price';
|
||||
import supplierAutomat from '../../../automats/supplierAutomat';
|
||||
import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat';
|
||||
import sseAutomat from '../../../automats/sseAutomat';
|
||||
import companyAutomat from '../../../automats/companyAutomat';
|
||||
@@ -39,8 +38,8 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
this.state = {
|
||||
loading: true,
|
||||
assignedSuppliers: [],
|
||||
companySuppliers: [],
|
||||
company: null,
|
||||
companySuppliers: companyAutomat.state.suppliers ?? [],
|
||||
company: companyAutomat.state.current ?? null,
|
||||
dialogOpen: false,
|
||||
editItem: null,
|
||||
busyId: null,
|
||||
@@ -48,9 +47,14 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||
company: state.current ?? null,
|
||||
}));
|
||||
this.unsubCompany = companyAutomat.subscribe(
|
||||
this,
|
||||
(state) => ({
|
||||
company: state.current ?? null,
|
||||
companySuppliers: state.suppliers ?? [],
|
||||
}),
|
||||
['current', 'suppliers'],
|
||||
);
|
||||
this.initData();
|
||||
}
|
||||
|
||||
@@ -90,15 +94,6 @@ export default class ArticleSupplierTab extends PureComponent {
|
||||
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
|
||||
void articleSupplierAutomat.reload(String(sku)).catch(() => {});
|
||||
});
|
||||
|
||||
// 2. Fetch available company suppliers
|
||||
try {
|
||||
const companySuppliers = await supplierAutomat.actions.load();
|
||||
this.setState({ companySuppliers: companySuppliers ?? [] });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-fetches the assigned suppliers for the current article. */
|
||||
|
||||
@@ -9,7 +9,7 @@ import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import BrandThumb from './BrandThumb';
|
||||
import BrandImageSearchDialog from './BrandImageSearchDialog';
|
||||
@@ -56,7 +56,7 @@ class ArtikelMarke extends PureComponent {
|
||||
|
||||
async renameBrand(brand, name) {
|
||||
try {
|
||||
await brandAutomat.actions.rename(brand.id, { name });
|
||||
await companyAutomat.actions.renameBrand(brand.id, { name });
|
||||
enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
this.setState({ draft: brand?.name ?? '' });
|
||||
@@ -99,7 +99,7 @@ class ArtikelMarke extends PureComponent {
|
||||
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await brandAutomat.actions.remove(brand.id);
|
||||
await companyAutomat.actions.removeBrand(brand.id);
|
||||
enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
|
||||
@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
// Own component so typing a new brand name never rerenders ArtikelMarkenPage
|
||||
@@ -19,7 +19,7 @@ class ArtikelMarkeNew extends PureComponent {
|
||||
const name = this.state.newName.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const row = await brandAutomat.actions.create({ name });
|
||||
const row = await companyAutomat.actions.createBrand({ name });
|
||||
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
|
||||
this.setState({ newName: '' });
|
||||
} catch (err) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { serper } from '../../api';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo';
|
||||
import {
|
||||
CHECKERBOARD,
|
||||
@@ -211,8 +211,8 @@ export default class BrandImageSearchDialog extends PureComponent {
|
||||
|
||||
this.setState({ processingStep: 'Logo wird gespeichert...' });
|
||||
|
||||
// 2. Upload as brandpic and update brandAutomat
|
||||
await brandAutomat.actions.addImage(brand.id, avifFile);
|
||||
// 2. Upload as brandpic and update companyAutomat
|
||||
await companyAutomat.actions.addBrandImage(brand.id, avifFile);
|
||||
|
||||
enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, {
|
||||
variant: 'success',
|
||||
|
||||
@@ -9,7 +9,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import carrierAutomat from '../../automats/carrierAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
class CarrierItem extends PureComponent {
|
||||
@@ -40,6 +40,7 @@ class CarrierItem extends PureComponent {
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.carrier ?? this.props.item;
|
||||
if (!item) return;
|
||||
const draft = this.state.draft.trim();
|
||||
@@ -54,7 +55,7 @@ class CarrierItem extends PureComponent {
|
||||
|
||||
async updateCarrier(item, patch) {
|
||||
try {
|
||||
await carrierAutomat.actions.update(item.id, patch);
|
||||
await companyAutomat.actions.updateCarrier(item.id, patch);
|
||||
enqueueSnackbar('Versandart gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
this.setState({ draft: item.name ?? '' });
|
||||
@@ -63,6 +64,7 @@ class CarrierItem extends PureComponent {
|
||||
}
|
||||
|
||||
handleToggleHasPackages = (e) => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.carrier ?? this.props.item;
|
||||
if (!item) return;
|
||||
const nextVal = e.target.checked;
|
||||
@@ -70,6 +72,7 @@ class CarrierItem extends PureComponent {
|
||||
};
|
||||
|
||||
handleToggleIsReverse = (e) => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.carrier ?? this.props.item;
|
||||
if (!item) return;
|
||||
const nextVal = e.target.checked;
|
||||
@@ -77,6 +80,7 @@ class CarrierItem extends PureComponent {
|
||||
};
|
||||
|
||||
handleKeyDown = (event) => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.carrier ?? this.props.item;
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -90,12 +94,13 @@ class CarrierItem extends PureComponent {
|
||||
};
|
||||
|
||||
handleDelete = () => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.carrier ?? this.props.item;
|
||||
if (!item) return;
|
||||
if (!window.confirm(`Versandart „${item.name}“ wirklich löschen?`)) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await carrierAutomat.actions.remove(item.id);
|
||||
await companyAutomat.actions.removeCarrier(item.id);
|
||||
enqueueSnackbar('Versandart gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
|
||||
@@ -8,18 +8,18 @@ class CategoryBreadcrumbs extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { crumbs } = this.props;
|
||||
const { crumbs, sx, variant = 'body2' } = this.props;
|
||||
const visible = (crumbs ?? []).filter((crumb) => crumb.name);
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Breadcrumbs sx={{ mb: 2 }} aria-label="Kategoriepfad">
|
||||
<Breadcrumbs sx={sx ?? { mb: 2 }} aria-label="Kategoriepfad">
|
||||
{visible.map((crumb, index) => (
|
||||
<Link
|
||||
key={crumb.id}
|
||||
component="button"
|
||||
type="button"
|
||||
variant="body2"
|
||||
variant={variant}
|
||||
underline="always"
|
||||
color="inherit"
|
||||
aria-current={index === visible.length - 1 ? 'page' : undefined}
|
||||
|
||||
@@ -118,6 +118,7 @@ class CategoryNameDialog extends Component {
|
||||
(mode !== 'edit' || name.trim() !== (this.props.initialName ?? '').trim());
|
||||
const bulkNames = parseBulkNames(bulkText);
|
||||
const canSubmitBulk = bulkNames.length > 0;
|
||||
const canBulk = mode === 'create';
|
||||
|
||||
return (
|
||||
<AppDialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>
|
||||
|
||||
@@ -416,14 +416,12 @@ export default class CategoryTree extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
||||
this._unsub = categoryAutomat.subscribe((s) => {
|
||||
this.setState({
|
||||
categories: this.props.categories ?? s.categories,
|
||||
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
||||
});
|
||||
this._unsub = categoryAutomat.subscribe((s) => {
|
||||
this.setState({
|
||||
categories: s.categories,
|
||||
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent {
|
||||
name,
|
||||
});
|
||||
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
|
||||
this.setState({ addDialogOpen: false });
|
||||
const nextCategories = categoryAutomat.state.categories ?? {
|
||||
...(this.state.categories || {}),
|
||||
[String(created.id)]: created,
|
||||
};
|
||||
this.setState({
|
||||
addDialogOpen: false,
|
||||
categories: nextCategories,
|
||||
});
|
||||
this.props.onSelect?.(created.id, created);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
@@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent {
|
||||
if (!name || !renameNode) return;
|
||||
this.setState({ isSubmitting: true });
|
||||
try {
|
||||
await categoryAutomat.actions.renameCategory({
|
||||
const updated = await categoryAutomat.actions.renameCategory({
|
||||
categoryId: renameNode.id,
|
||||
name,
|
||||
});
|
||||
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
|
||||
this.setState({ renameDialogOpen: false });
|
||||
const nextCategories = categoryAutomat.state.categories ?? {
|
||||
...(this.state.categories || {}),
|
||||
[String(updated.id)]: updated,
|
||||
};
|
||||
this.setState({
|
||||
renameDialogOpen: false,
|
||||
categories: nextCategories,
|
||||
});
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
@@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent {
|
||||
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
|
||||
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
|
||||
}
|
||||
this.setState({ deleteDialogOpen: false });
|
||||
this.setState({
|
||||
deleteDialogOpen: false,
|
||||
categories: categoryAutomat.state.categories,
|
||||
});
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
|
||||
@@ -40,7 +40,7 @@ import { setting as settingApi } from '../../api';
|
||||
import chatAutomat, {
|
||||
selectPendingAskUser,
|
||||
} from '../../automats/chatAutomat';
|
||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
||||
import ChatFlowList from './ChatFlowList';
|
||||
@@ -130,7 +130,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const s = chatAutomat.state;
|
||||
const settings = userSettingsAutomat.state;
|
||||
const settings = companyAutomat.state;
|
||||
const vfs = vfsAutomat.state;
|
||||
this.state = {
|
||||
isOpen: s.isOpen,
|
||||
@@ -293,12 +293,12 @@ export default class ChatDrawer extends PureComponent {
|
||||
return patch;
|
||||
});
|
||||
|
||||
this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => {
|
||||
this.unsubSettings = companyAutomat.subscribe(this, (state) => {
|
||||
if (!this.state.isResizing) {
|
||||
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, ['chatDrawerWidth']);
|
||||
|
||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||
isVfsOpen: state.isOpen,
|
||||
@@ -465,7 +465,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
|
||||
this.setState({ isResizing: false });
|
||||
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
||||
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||
companyAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove, { passive: false });
|
||||
@@ -531,7 +531,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
if (isVfs) {
|
||||
vfsAutomat.actions.setVfsWidth(currentVfsWidth);
|
||||
} else {
|
||||
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||
companyAutomat.actions.setChatDrawerWidth(currentChatWidth);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,7 +546,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
|
||||
handleResizeDoubleClick = () => {
|
||||
this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH });
|
||||
userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH);
|
||||
companyAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH);
|
||||
};
|
||||
|
||||
cleanupResizeListeners = () => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
|
||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||
@@ -70,144 +69,161 @@ export default class ChatFab extends PureComponent {
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
position: 'fixed',
|
||||
bottom: { xs: 20, sm: 28 },
|
||||
left: { xs: 20, lg: 164 },
|
||||
zIndex: theme.zIndex.speedDial || 1050,
|
||||
})}
|
||||
sx={{
|
||||
px: { xs: 0.5, sm: 1, lg: 1.5 },
|
||||
py: { xs: 0.5, lg: 1 },
|
||||
alignSelf: { xs: 'center', lg: 'flex-start' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{/* Animated pulsing halo when awaiting user answer */}
|
||||
{hasPendingAsk && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -8,
|
||||
left: -8,
|
||||
right: -8,
|
||||
bottom: -8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: CHAT_BUSY,
|
||||
opacity: 0.5,
|
||||
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabAskPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated pulsating halo when model is busy thinking/working */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
left: -6,
|
||||
right: -6,
|
||||
bottom: -6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'warning.main',
|
||||
opacity: 0.4,
|
||||
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Circular spinner ring when model is busy */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<CircularProgress
|
||||
size={66}
|
||||
thickness={2.5}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -6,
|
||||
left: -6,
|
||||
color: CHAT_BUSY,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
badgeContent={hasPendingAsk ? '?' : undefined}
|
||||
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
|
||||
variant={hasPendingAsk ? 'standard' : 'dot'}
|
||||
invisible={!hasPendingAsk && !isBusy}
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': hasPendingAsk
|
||||
? {
|
||||
bgcolor: RED,
|
||||
color: WHITE,
|
||||
fontWeight: 900,
|
||||
fontSize: '0.85rem',
|
||||
minWidth: 22,
|
||||
height: 22,
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
{/* Animated pulsing halo when awaiting user answer */}
|
||||
{hasPendingAsk && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
borderRadius: '50%',
|
||||
boxShadow: `0 2px 10px ${RED_GLOW}`,
|
||||
animation: 'badgePop 1.5s ease infinite',
|
||||
'@keyframes badgePop': {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.2)' },
|
||||
bgcolor: CHAT_BUSY,
|
||||
opacity: 0.5,
|
||||
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabAskPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label={ariaLabel}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Animated pulsating halo when model is busy thinking/working */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
left: -4,
|
||||
right: -4,
|
||||
bottom: -4,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'warning.main',
|
||||
opacity: 0.4,
|
||||
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'@keyframes fabPulse': {
|
||||
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
|
||||
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Circular spinner ring when model is busy */}
|
||||
{!hasPendingAsk && isBusy && !isOpen && (
|
||||
<CircularProgress
|
||||
size={38}
|
||||
thickness={3}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -3,
|
||||
left: -3,
|
||||
color: CHAT_BUSY,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
onClick={this.handleClick}
|
||||
sx={(theme) => ({
|
||||
width: 54,
|
||||
height: 54,
|
||||
boxShadow: hasPendingAsk
|
||||
? CHAT_FAB_SHADOW_PENDING
|
||||
: isBusy
|
||||
? CHAT_FAB_SHADOW_BUSY
|
||||
: CHAT_FAB_SHADOW_IDLE,
|
||||
background: hasPendingAsk
|
||||
? CHAT_FAB_PENDING
|
||||
: isBusy
|
||||
? CHAT_FAB_BUSY
|
||||
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||
})}
|
||||
aria-label={ariaLabel}
|
||||
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
|
||||
>
|
||||
{isOpen ? (
|
||||
<CloseRoundedIcon sx={{ fontSize: 24 }} />
|
||||
) : hasPendingAsk ? (
|
||||
<HelpOutlineRoundedIcon
|
||||
sx={{
|
||||
fontSize: 26,
|
||||
animation: 'fabWiggle 2s ease infinite',
|
||||
'@keyframes fabWiggle': {
|
||||
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||
'15%': { transform: 'rotate(-12deg)' },
|
||||
'30%': { transform: 'rotate(12deg)' },
|
||||
'45%': { transform: 'rotate(-8deg)' },
|
||||
'60%': { transform: 'rotate(0deg)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : isBusy ? (
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
|
||||
) : (
|
||||
<ChatRoundedIcon sx={{ fontSize: 24 }} />
|
||||
)}
|
||||
</Fab>
|
||||
</Badge>
|
||||
<Badge
|
||||
badgeContent={hasPendingAsk ? '?' : undefined}
|
||||
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
|
||||
variant={hasPendingAsk ? 'standard' : 'dot'}
|
||||
invisible={!hasPendingAsk && !isBusy}
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': hasPendingAsk
|
||||
? {
|
||||
bgcolor: RED,
|
||||
color: WHITE,
|
||||
fontWeight: 900,
|
||||
fontSize: '0.75rem',
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
boxShadow: `0 2px 8px ${RED_GLOW}`,
|
||||
animation: 'badgePop 1.5s ease infinite',
|
||||
'@keyframes badgePop': {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.2)' },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
sx={(theme) => ({
|
||||
width: 32,
|
||||
height: 32,
|
||||
bgcolor: hasPendingAsk
|
||||
? CHAT_FAB_PENDING
|
||||
: isBusy
|
||||
? CHAT_FAB_BUSY
|
||||
: isOpen
|
||||
? theme.palette.primary.main
|
||||
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||
background: hasPendingAsk
|
||||
? CHAT_FAB_PENDING
|
||||
: isBusy
|
||||
? CHAT_FAB_BUSY
|
||||
: isOpen
|
||||
? theme.palette.primary.main
|
||||
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||
color: WHITE,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: hasPendingAsk
|
||||
? CHAT_FAB_SHADOW_PENDING
|
||||
: isBusy
|
||||
? CHAT_FAB_SHADOW_BUSY
|
||||
: isOpen
|
||||
? `0 2px 8px rgba(0,0,0,0.25)`
|
||||
: 'none',
|
||||
})}
|
||||
>
|
||||
{isOpen ? (
|
||||
<CloseRoundedIcon sx={{ fontSize: 18 }} />
|
||||
) : hasPendingAsk ? (
|
||||
<HelpOutlineRoundedIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
animation: 'fabWiggle 2s ease infinite',
|
||||
'@keyframes fabWiggle': {
|
||||
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||
'15%': { transform: 'rotate(-12deg)' },
|
||||
'30%': { transform: 'rotate(12deg)' },
|
||||
'45%': { transform: 'rotate(-8deg)' },
|
||||
'60%': { transform: 'rotate(0deg)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : isBusy ? (
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</Avatar>
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,12 @@ import CalculateIcon from '@mui/icons-material/Calculate';
|
||||
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import PaidRoundedIcon from '@mui/icons-material/PaidRounded';
|
||||
import Button from '@mui/material/Button';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { deliveryEditorAutomat, setLastDeliveryPath } from '../../automats';
|
||||
import { router } from '../../appRouter';
|
||||
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
|
||||
import { setLastDeliveryPath } from '../../automats/navigationAutomat';
|
||||
import HealingMarkdown from './HealingMarkdown';
|
||||
import {
|
||||
VfsAttachmentLink,
|
||||
@@ -84,6 +87,59 @@ function formatUsageText(usage) {
|
||||
return `${fmt(input)} Prompt${cachedPart} · ${fmt(output)} Antwort${cost}`;
|
||||
}
|
||||
|
||||
function formatUsageTooltip(usage) {
|
||||
if (!usage) return '';
|
||||
const input = Number(usage.input ?? usage.prompt_tokens ?? 0);
|
||||
const output = Number(usage.output ?? usage.completion_tokens ?? 0);
|
||||
const cached = Number(usage.cached ?? 0);
|
||||
const cost = typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null;
|
||||
const fmt = (n) => n.toLocaleString('de-DE');
|
||||
|
||||
const lines = [
|
||||
`Prompt: ${fmt(input)} Tokens`,
|
||||
];
|
||||
if (cached > 0) {
|
||||
lines.push(`Cache: ${fmt(cached)} Tokens`);
|
||||
}
|
||||
lines.push(`Antwort: ${fmt(output)} Tokens`);
|
||||
if (cost) {
|
||||
lines.push(`Kosten: ${cost}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function aggregateUsage(turns = []) {
|
||||
if (!Array.isArray(turns) || turns.length === 0) return null;
|
||||
let hasUsage = false;
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let totalCached = 0;
|
||||
let totalCost = 0;
|
||||
let hasCost = false;
|
||||
|
||||
for (const turn of turns) {
|
||||
const u = turn?.usage;
|
||||
if (!u) continue;
|
||||
hasUsage = true;
|
||||
totalInput += Number(u.input ?? u.prompt_tokens ?? 0);
|
||||
totalOutput += Number(u.output ?? u.completion_tokens ?? 0);
|
||||
totalCached += Number(u.cached ?? 0);
|
||||
if (typeof u.cost === 'number' && !Number.isNaN(u.cost)) {
|
||||
totalCost += u.cost;
|
||||
hasCost = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUsage) return null;
|
||||
|
||||
return {
|
||||
input: totalInput,
|
||||
output: totalOutput,
|
||||
cached: totalCached,
|
||||
cost: hasCost ? totalCost : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function formatToolTooltip(content, result, parameters, prefix, error) {
|
||||
const parts = [];
|
||||
|
||||
@@ -455,12 +511,12 @@ function tryExtractObject(val) {
|
||||
const trimmed = val.trim();
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {}
|
||||
} catch { }
|
||||
const match = trimmed.match(/\{[\s\S]*\}/);
|
||||
if (match) {
|
||||
try {
|
||||
return JSON.parse(match[0]);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -523,7 +579,6 @@ class DraftDeliveryManifestUseButton extends PureComponent {
|
||||
);
|
||||
|
||||
try {
|
||||
const { router } = await import('../../appRouter');
|
||||
if (router?.navigate) {
|
||||
const curPath =
|
||||
router.state?.location?.pathname ||
|
||||
@@ -652,7 +707,7 @@ function WorkIconView({ tool }) {
|
||||
return iconElement;
|
||||
}
|
||||
|
||||
function UsageChipView({ usage }) {
|
||||
function UsageChipView({ usage, isSummary = false }) {
|
||||
const text = formatUsageText(usage);
|
||||
if (!text) return null;
|
||||
|
||||
@@ -664,8 +719,11 @@ function UsageChipView({ usage }) {
|
||||
alignItems: 'center',
|
||||
fontSize: '0.68rem',
|
||||
fontFamily: 'monospace',
|
||||
color: 'text.secondary',
|
||||
bgcolor: 'action.hover',
|
||||
color: isSummary ? 'text.primary' : 'text.secondary',
|
||||
fontWeight: isSummary ? 400 : 400,
|
||||
bgcolor: isSummary ? 'action.selected' : 'action.hover',
|
||||
border: isSummary ? '1px solid' : 'none',
|
||||
borderColor: isSummary ? 'divider' : 'transparent',
|
||||
px: 0.8,
|
||||
py: 0.25,
|
||||
borderRadius: 1.5,
|
||||
@@ -677,88 +735,202 @@ function UsageChipView({ usage }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function TurnRow({ turn, isLive = false }) {
|
||||
if (!turn) return null;
|
||||
function UsageIconView({ usage }) {
|
||||
if (!usage) return null;
|
||||
const tooltipText = formatUsageTooltip(usage);
|
||||
if (!tooltipText) return null;
|
||||
|
||||
return (
|
||||
<ClickTooltip
|
||||
title={
|
||||
<Box
|
||||
sx={{
|
||||
p: 0.5,
|
||||
maxWidth: 320,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.72rem',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{tooltipText}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: 'text.secondary',
|
||||
borderRadius: 1,
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.primary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PaidRoundedIcon sx={{ fontSize: 18, color: 'inherit' }} />
|
||||
</Box>
|
||||
</ClickTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function turnHasIcons(turn) {
|
||||
if (!turn) return false;
|
||||
const hasReasonings = Boolean(
|
||||
turn.reasonings &&
|
||||
turn.reasonings.some((r) => Boolean(r.text && r.text.trim()))
|
||||
);
|
||||
const hasTools = Boolean(turn.tools && turn.tools.length > 0);
|
||||
const hasUsage = Boolean(turn.usage);
|
||||
const hasContent = Boolean(turn.content);
|
||||
const showHandshake = !hasReasonings && !hasTools && !hasContent;
|
||||
const showStatusLine = showHandshake || hasReasonings || hasTools;
|
||||
const showHandshake = !hasReasonings && !hasTools && !hasContent && !hasUsage;
|
||||
return showHandshake || hasReasonings || hasTools || hasUsage;
|
||||
}
|
||||
|
||||
export function TurnIcons({ turn }) {
|
||||
if (!turn) return null;
|
||||
const hasReasonings = Boolean(
|
||||
turn.reasonings &&
|
||||
turn.reasonings.some((r) => Boolean(r.text && r.text.trim()))
|
||||
);
|
||||
const hasTools = Boolean(turn.tools && turn.tools.length > 0);
|
||||
const hasUsage = Boolean(turn.usage);
|
||||
const hasContent = Boolean(turn.content);
|
||||
const showHandshake = !hasReasonings && !hasTools && !hasContent && !hasUsage;
|
||||
|
||||
const showStatus = showHandshake || hasReasonings || hasTools || hasUsage;
|
||||
if (!showStatus) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
px: 2,
|
||||
py: 0.4,
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{/* Turn status line: left of the chat */}
|
||||
{showStatusLine ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
minHeight: 24,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{showHandshake ? <HandshakeIconView state={turn.handshake} /> : null}
|
||||
{showHandshake ? <HandshakeIconView state={turn.handshake} /> : null}
|
||||
|
||||
{turn.reasonings?.map((r, i) => (
|
||||
<BrainIconView key={r.id || i} reasoning={r} />
|
||||
))}
|
||||
{turn.reasonings?.map((r, i) => (
|
||||
<BrainIconView key={r.id || i} reasoning={r} />
|
||||
))}
|
||||
|
||||
{turn.tools?.map((t, i) => (
|
||||
<WorkIconView key={t.id || i} tool={t} />
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{turn.tools?.map((t, i) => (
|
||||
<WorkIconView key={t.id || i} tool={t} />
|
||||
))}
|
||||
|
||||
{/* Assistant message content for this turn (if any) */}
|
||||
{hasContent ? (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
maxWidth: '85%',
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderRadius: '16px 16px 16px 4px',
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? 'background.paper'
|
||||
: SURFACE_RAISED,
|
||||
color: theme.palette.text.primary,
|
||||
boxShadow: SHADOW_BUBBLE_ASSISTANT,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
wordBreak: 'break-word',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
})}
|
||||
>
|
||||
<HealingMarkdown text={turn.content} streaming={isLive && !turn.sealed} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Pricing / usage is always on a new line */}
|
||||
{turn.usage ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<UsageChipView usage={turn.usage} />
|
||||
</Box>
|
||||
{hasUsage ? (
|
||||
<UsageIconView usage={turn.usage} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TurnsView({ turns = [], isLive = false }) {
|
||||
if (!Array.isArray(turns) || turns.length === 0) return null;
|
||||
|
||||
// Segment turns into chunks where icons precede content
|
||||
const segments = [];
|
||||
let pendingTurns = [];
|
||||
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
const turn = turns[i];
|
||||
pendingTurns.push(turn);
|
||||
|
||||
if (turn.content) {
|
||||
segments.push({
|
||||
statusTurns: [...pendingTurns],
|
||||
contentTurn: turn,
|
||||
});
|
||||
pendingTurns = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTurns.length > 0) {
|
||||
segments.push({
|
||||
statusTurns: pendingTurns,
|
||||
contentTurn: null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, width: '100%' }}>
|
||||
{segments.map((seg, sIdx) => {
|
||||
const hasAnyIcons = seg.statusTurns.some(turnHasIcons);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={sIdx}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
px: 2,
|
||||
py: 0.4,
|
||||
gap: 0.75,
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
{/* All turn icons in ONE line */}
|
||||
{hasAnyIcons ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
minHeight: 24,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{seg.statusTurns.map((t, tIdx) => (
|
||||
<TurnIcons key={t.id || tIdx} turn={t} />
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Assistant message content for this segment (if any) */}
|
||||
{seg.contentTurn?.content ? (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
maxWidth: '85%',
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderRadius: '16px 16px 16px 4px',
|
||||
bgcolor:
|
||||
theme.palette.mode === 'dark'
|
||||
? 'background.paper'
|
||||
: SURFACE_RAISED,
|
||||
color: theme.palette.text.primary,
|
||||
boxShadow: SHADOW_BUBBLE_ASSISTANT,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
wordBreak: 'break-word',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: 1.5,
|
||||
})}
|
||||
>
|
||||
<HealingMarkdown
|
||||
text={seg.contentTurn.content}
|
||||
streaming={isLive && !seg.contentTurn.sealed}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function TurnRow({ turn, isLive = false }) {
|
||||
if (!turn) return null;
|
||||
return <TurnsView turns={[turn]} isLive={isLive} />;
|
||||
}
|
||||
|
||||
export function normalizeChatMessages(rawMessages = []) {
|
||||
if (!Array.isArray(rawMessages) || rawMessages.length === 0) return [];
|
||||
|
||||
@@ -960,11 +1132,20 @@ export function normalizeChatMessages(rawMessages = []) {
|
||||
function MessageRow({ msg, showTime = true }) {
|
||||
// If the message has structured turns, render each turn
|
||||
if (msg.turns && Array.isArray(msg.turns) && msg.turns.length > 0) {
|
||||
const summaryUsage = aggregateUsage(msg.turns);
|
||||
const turnCount = msg.turns.length;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
||||
{msg.turns.map((t, idx) => (
|
||||
<TurnRow key={t.id || idx} turn={t} isLive={false} />
|
||||
))}
|
||||
<TurnsView turns={msg.turns} isLive={false} />
|
||||
{summaryUsage ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 0.4 }}>
|
||||
<UsageChipView
|
||||
usage={summaryUsage}
|
||||
isSummary={true}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1198,11 +1379,23 @@ export default class ChatFlowList extends PureComponent {
|
||||
})}
|
||||
|
||||
{/* Live streaming turns */}
|
||||
{activeTurns && activeTurns.length > 0
|
||||
? activeTurns.map((turn, idx) => (
|
||||
<TurnRow key={turn.id || idx} turn={turn} isLive={true} />
|
||||
))
|
||||
: null}
|
||||
{activeTurns && activeTurns.length > 0 ? (
|
||||
<>
|
||||
<TurnsView turns={activeTurns} isLive={true} />
|
||||
{(() => {
|
||||
const activeSummary = aggregateUsage(activeTurns);
|
||||
if (!activeSummary) return null;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 0.4 }}>
|
||||
<UsageChipView
|
||||
usage={activeSummary}
|
||||
isSummary={true}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div ref={this.bottomRef} style={{ height: 1, flexShrink: 0 }} />
|
||||
</Box>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { router } from '../../appRouter';
|
||||
|
||||
/**
|
||||
* Resolves the active frontend route (path + search query).
|
||||
* Falls back to window.location if router is not directly accessible.
|
||||
@@ -126,7 +128,6 @@ export async function navigateToClientRoute(route, toolCallId) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { router } = await import('../../appRouter');
|
||||
if (router?.navigate) {
|
||||
const current = router.state?.location
|
||||
? `${router.state.location.pathname}${router.state.location.search || ''}`
|
||||
|
||||
@@ -165,6 +165,7 @@ class Kunde extends PureComponent {
|
||||
height: 24,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
fontFamily: 'monospace',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
bgcolor: 'action.hover',
|
||||
color: 'text.primary',
|
||||
|
||||
@@ -6,7 +6,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import customergroupAutomat from '../../automats/customergroupAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
class KundeGruppe extends PureComponent {
|
||||
@@ -37,6 +37,7 @@ class KundeGruppe extends PureComponent {
|
||||
};
|
||||
|
||||
handleBlur = () => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.group ?? this.props.item;
|
||||
if (!item) return;
|
||||
const editable = item.editable !== false;
|
||||
@@ -54,7 +55,7 @@ class KundeGruppe extends PureComponent {
|
||||
|
||||
async renameGroup(item, name) {
|
||||
try {
|
||||
await customergroupAutomat.actions.update(item.id, { name });
|
||||
await companyAutomat.actions.updateCustomergroup(item.id, { name });
|
||||
enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
this.setState({ draft: item.name ?? '' });
|
||||
@@ -79,6 +80,7 @@ class KundeGruppe extends PureComponent {
|
||||
};
|
||||
|
||||
handleDelete = () => {
|
||||
if (this.props.disabled) return;
|
||||
const item = this.props.group ?? this.props.item;
|
||||
if (!item) return;
|
||||
const editable = item.editable !== false;
|
||||
@@ -87,7 +89,7 @@ class KundeGruppe extends PureComponent {
|
||||
if (!window.confirm(`Kundengruppe „${item.name}“ wirklich löschen?`)) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await customergroupAutomat.actions.remove(item.id);
|
||||
await companyAutomat.actions.removeCustomergroup(item.id);
|
||||
enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
|
||||
@@ -19,17 +19,18 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
this.state = {
|
||||
assigningSku: null,
|
||||
error: null,
|
||||
showAllArticles: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.open && this.props.open) {
|
||||
this.setState({ assigningSku: null, error: null });
|
||||
this.setState({ assigningSku: null, error: null, showAllArticles: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleClose = () => {
|
||||
this.setState({ assigningSku: null, error: null });
|
||||
this.setState({ assigningSku: null, error: null, showAllArticles: false });
|
||||
this.props.onClose?.();
|
||||
};
|
||||
|
||||
@@ -61,9 +62,9 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
|
||||
render() {
|
||||
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props;
|
||||
const { assigningSku, error } = this.state;
|
||||
const { assigningSku, error, showAllArticles } = this.state;
|
||||
const isDelivery = kind === 'delivery';
|
||||
const onlySupplierArticles = isDelivery && !line;
|
||||
const onlySupplierArticles = isDelivery && !line && !showAllArticles;
|
||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||
|
||||
const supplierArticlesList = onlySupplierArticles
|
||||
@@ -122,7 +123,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
<Box>
|
||||
<Typography variant="h6" component="div" fontWeight={600}>
|
||||
{line
|
||||
? 'Existierenden Artikel zuordnen'
|
||||
? (isDelivery ? 'Artikel neu zuordnen' : 'Existierenden Artikel zuordnen')
|
||||
: isDelivery
|
||||
? 'Lieferanten-Artikel auswählen'
|
||||
: 'Artikel auswählen'}
|
||||
@@ -130,11 +131,25 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{line ? (
|
||||
<>
|
||||
{isDelivery ? 'Lieferanten-ArtNr' : 'ArtNr'}: <strong>{rawSku || '–'}</strong>
|
||||
{isDelivery ? 'Lieferanten-ArtNr' : 'ArtNr'}: <strong style={{ fontFamily: 'monospace' }}>{rawSku || '–'}</strong>
|
||||
{line?.name ? ` · ${line.name}` : ''}
|
||||
</>
|
||||
) : isDelivery ? (
|
||||
'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'
|
||||
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||
<span>
|
||||
{showAllArticles
|
||||
? 'Alle Artikel im Katalog durchsuchen'
|
||||
: 'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => this.setState({ showAllArticles: !showAllArticles })}
|
||||
sx={{ fontSize: '0.72rem', py: 0, px: 0.75, textTransform: 'none', minWidth: 'auto', fontWeight: 600 }}
|
||||
>
|
||||
{showAllArticles ? 'Nur Lieferanten-Artikel' : 'Alle Artikel durchsuchen'}
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
'Artikel für Beleg auswählen'
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
} from '../../utils/deliveryLedger';
|
||||
import { formatStock } from '../article/searchFormat';
|
||||
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
|
||||
import warehouseAutomat from '../../automats/warehouseAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { supplier as supplierApi } from '../../api';
|
||||
import deliveryEditorAutomat, { buildSupplierArticlesMap } from '../../automats/deliveryEditorAutomat';
|
||||
import {
|
||||
@@ -65,7 +65,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
const initialWarehouses = warehouseAutomat.state.warehouses || [];
|
||||
const initialWarehouses = companyAutomat.state.warehouses || [];
|
||||
const initialSupplierMap =
|
||||
props.supplierArticlesMap ||
|
||||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? {});
|
||||
@@ -98,8 +98,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
loadSupplierArticlesIfNeeded() {
|
||||
const supplierId = this.props.delivery?.supplierId ?? this.props.delivery?.partyId;
|
||||
if (supplierId && Object.keys(this.state.supplierArticlesMap).length === 0) {
|
||||
void supplierApi.getArticles(supplierId).then((res) => {
|
||||
const articles = res?.articles || res?.supplier?.articles || [];
|
||||
void companyAutomat.actions.loadArticlesForSupplier(supplierId).then((articles) => {
|
||||
const map = buildSupplierArticlesMap(articles);
|
||||
this.setState({ supplierArticlesMap: map });
|
||||
}).catch(() => { });
|
||||
@@ -107,7 +106,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubWarehouse = warehouseAutomat.subscribe((state) => {
|
||||
this.unsubWarehouse = companyAutomat.subscribe((state) => {
|
||||
const warehouses = state.warehouses || [];
|
||||
this.setState((prev) => {
|
||||
const selectedId =
|
||||
@@ -118,11 +117,10 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
selectedWarehouseId: selectedId,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, ['warehouses']);
|
||||
this.unsubArticle = artikelAutomat.subscribe(() => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
void warehouseAutomat.actions.load().catch(() => { });
|
||||
this.loadSupplierArticlesIfNeeded();
|
||||
|
||||
if (this.props.open) {
|
||||
@@ -205,7 +203,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.open && !prevProps.open) {
|
||||
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || [];
|
||||
const currentWarehouses = this.state.warehouses || companyAutomat.state.warehouses || [];
|
||||
const currentSupplierMap =
|
||||
this.props.supplierArticlesMap ||
|
||||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? this.state.supplierArticlesMap ?? {});
|
||||
@@ -225,7 +223,6 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
supplierArticlesMap: currentSupplierMap,
|
||||
},
|
||||
() => {
|
||||
void warehouseAutomat.actions.load().catch(() => { });
|
||||
this.loadSupplierArticlesIfNeeded();
|
||||
if (this.props.summary) {
|
||||
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);
|
||||
@@ -780,7 +777,16 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
{prefillLine && (
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Vorausgewählt zum Verbuchen: Position{' '}
|
||||
<strong>{prefillLine.sku ? `ArtNr ${prefillLine.sku}` : prefillLine.name}</strong> ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0.
|
||||
{prefillLine.sku ? (
|
||||
<>
|
||||
{'ArtNr '}
|
||||
<Box component="span" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||
{prefillLine.sku}
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<strong>{prefillLine.name}</strong>
|
||||
)} ({amountsToBook[prefillLine.id] || 0} Stk.). Andere Positionen stehen auf 0.
|
||||
</Alert>
|
||||
)}
|
||||
{this.renderLinesTable()}
|
||||
|
||||
@@ -23,7 +23,7 @@ import FillSpace from 'dashgui/FillSpace';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
import BackendError from '../BackendError';
|
||||
import deliveryAutomat, { CHUNK_SIZE } from '../../automats/deliveryAutomat';
|
||||
import numberAutomat from '../../automats/numberAutomat';
|
||||
import companyAutomat from '../../automats/companyAutomat';
|
||||
import { delivery as deliveryApi } from '../../api';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
@@ -122,8 +122,8 @@ function renderInvisibleDummyRow(dummy) {
|
||||
return (
|
||||
<>
|
||||
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>{d.number}</Typography>
|
||||
<Typography variant="body2">{d.externalNumber || 'Nr. / Ref.'}</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>{d.number}</Typography>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{d.externalNumber || 'Nr. / Ref.'}</Typography>
|
||||
</Box>
|
||||
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||
<Typography variant="body2">{d.date ? formatDate(d.date) : '–'}</Typography>
|
||||
@@ -241,10 +241,10 @@ class DeliveryRow extends Component {
|
||||
{renderInvisibleDummyRow(dummy)}
|
||||
|
||||
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace', ...ellipsisSx }}>
|
||||
{delivery.number}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', ...ellipsisSx }}>
|
||||
{numberDisplay(delivery.externalNumber)}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -693,11 +693,15 @@ export class DeliveryList extends PureComponent {
|
||||
supplierId: deliveryAutomat.state.supplierId ?? null,
|
||||
});
|
||||
}
|
||||
this.unsubNumber = numberAutomat.subscribe(this, () => {
|
||||
this.headerItem = createDummyTestDelivery();
|
||||
this.forceUpdate();
|
||||
});
|
||||
void numberAutomat.actions.ensureLoaded().catch(() => { });
|
||||
this.unsubNumber = companyAutomat.subscribe(
|
||||
this,
|
||||
() => {
|
||||
this.headerItem = createDummyTestDelivery();
|
||||
this.forceUpdate();
|
||||
},
|
||||
['numbers'],
|
||||
);
|
||||
void companyAutomat.actions.ensureNumbersLoaded().catch(() => { });
|
||||
void this.loadSuppliers();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user