Compare commits

...

57 Commits

Author SHA1 Message Date
seb
90936a449d u 2026-11-04 21:42:37 +01:00
seb
283ce3dc5f u 2026-11-03 10:18:12 +01:00
seb
863422df8e u 2026-11-03 06:46:36 +01:00
seb
9841002398 u 2026-11-03 06:20:29 +01:00
seb
bc11a2c3ab u 2026-11-03 05:34:29 +01:00
seb
ec9f2d9816 u 2026-11-02 00:53:48 +01:00
seb
bc284c6d42 u 2026-11-02 00:19:03 +01:00
seb
0181e08657 u 2026-11-01 23:56:34 +01:00
seb
6071e59d30 u 2026-11-01 23:42:25 +01:00
seb
e7103ac340 u 2026-11-01 20:26:53 +01:00
seb
9c6d5d79e4 u 2026-11-01 06:35:08 +01:00
seb
7c33c097d4 u 2026-11-01 06:27:57 +01:00
seb
76e911ebaf u 2026-11-01 05:59:09 +01:00
seb
d2139fee45 u 2026-11-01 05:57:38 +01:00
seb
8a416ce384 u 2026-11-01 04:33:15 +01:00
seb
7fe9f90f2c u 2026-11-01 00:52:51 +01:00
seb
456389be1b u 2026-11-01 00:47:50 +01:00
seb
e788c960d5 u 2026-11-01 00:47:29 +01:00
seb
b7046d3bc6 u 2026-11-01 00:30:39 +01:00
seb
fcaf3d5821 u 2026-10-31 23:45:47 +01:00
seb
ca28f56f3f u 2026-10-31 23:39:12 +01:00
seb
545eb98191 u 2026-10-31 22:15:03 +01:00
seb
ee80646c1c u 2026-10-31 21:57:47 +01:00
seb
df015da8fc u 2026-10-31 21:53:21 +01:00
seb
2d1a7364f2 u 2026-10-31 21:45:02 +01:00
seb
f6eb31b70d u 2026-10-31 21:19:13 +01:00
seb
6719cf9d51 u 2026-10-31 20:37:21 +01:00
seb
cdb031921b u 2026-10-31 20:28:32 +01:00
seb
9cb82745e3 u 2026-10-30 17:40:15 +01:00
seb
56d376c9bd u 2026-10-30 17:30:10 +01:00
seb
72f168f188 u 2026-10-30 16:50:15 +01:00
seb
eed979bf11 u 2026-10-30 16:26:37 +01:00
seb
28d6fece46 u 2026-10-30 16:22:03 +01:00
seb
1b60965cea u 2026-10-30 15:46:23 +01:00
seb
d3e16fd2b7 u 2026-10-30 15:45:59 +01:00
seb
3bb861907e u 2026-10-30 06:45:09 +01:00
seb
6ca7597c59 u 2026-10-30 05:02:47 +01:00
seb
05f1311892 u 2026-10-30 04:48:35 +01:00
seb
dba9ae5cf0 u 2026-10-30 04:16:56 +01:00
seb
8b680b5a1a u 2026-10-29 17:52:14 +01:00
seb
f555a1c9dd u 2026-10-29 17:32:39 +01:00
seb
867d550814 u 2026-10-29 03:37:04 +01:00
seb
afb33cde38 u 2026-10-29 03:31:06 +01:00
seb
5fcc5670d9 u 2026-10-28 23:25:54 +01:00
seb
6851e7c16f fix(lieferung): lazy load neu component in deliveryId route 2026-10-28 22:27:34 +01:00
seb
6e9d706bd6 u 2026-10-28 22:26:54 +01:00
seb
d21def186e u 2026-10-28 22:26:21 +01:00
seb
6d299c1f43 u 2026-10-28 22:25:58 +01:00
seb
87ef79bea3 u 2026-10-28 21:55:24 +01:00
seb
2e69f49e4d u 2026-10-28 21:44:52 +01:00
seb
003b198b86 u 2026-10-28 20:12:13 +01:00
seb
b13d994058 u 2026-10-28 19:37:28 +01:00
seb
550586f46e u 2026-10-28 19:32:19 +01:00
seb
3c51de06c2 u 2026-10-28 18:33:35 +01:00
seb
e45f35e97b u 2026-10-28 00:46:23 +01:00
seb
0453680c59 u 2026-10-28 00:41:31 +01:00
seb
23e4a0951c u 2026-10-28 00:27:12 +01:00
139 changed files with 9890 additions and 2512 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 284 API endpoints across all modules. Overview of all 314 API endpoints across all modules.
### Notation ### Notation
@@ -140,44 +140,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types ### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T18[], externalNumber?: str|null, createdAt?: str, shipping?: T20|null, shippings?: T20[], payment?: T29|null, payments?: T29[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T9[] }` - `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: T18[], shippings: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T9[], state?: str }` - `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: T18[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T9[] }` - `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: T21[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }` - `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 }` - `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 }` - `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
- `T7` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }` - `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, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] }, thumb?: str|null }` - `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, 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 }` - `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
- `T10` = `{ 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[] }` - `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` = `{ vfs: T37, storage: T37, database: T31 }` - `T11` = `{ vfs: T41, storage: T41, database: T35 }`
- `T12` = `{ 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 }` - `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, 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 }` - `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, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }` - `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, name: str, sort: int, exclusive: bool, values: T27[], articleCount?: int }` - `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, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: 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, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }` - `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T18` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }` - `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` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }` - `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, 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, shippable: bool }`
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }` - `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }` - `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
- `T23` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }` - `T23` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T24` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }` - `T24` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T25` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }` - `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T26` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }` - `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T27` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }` - `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }` - `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }` - `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }` - `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T31` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }` - `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
- `T32` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }` - `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T33` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T34` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T35` = `{ id: int, name: str, createdAt: str, updatedAt: str }` - `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T36` = `{ totalPositions: int, completelyOnLedgerPositions: int }` - `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T37` = `{ name: str, sizeBytes: int, sourceExists: bool }` - `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T38` = `{ net: num, taxId: int, taxRate?: num|null }` - `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 ### Routes
@@ -203,6 +207,39 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - 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 ### Section: /article
#### Create article in category — `POST /article` #### Create article in category — `POST /article`
@@ -219,7 +256,7 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}` #### Get article by SKU — `GET /article/{sku}`
- Params: `sku: str` - Params: `sku: str`
- 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: T26, brand: T25|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: T7[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }` - 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}` #### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }` - Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void` - Returns: `204 void`
@@ -277,11 +314,11 @@ _none_
#### Toggle parent mode for an article — `POST /article/{sku}/parent` #### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `204 void` - Returns: `204 void`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices` #### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }` - Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
- Returns: `204 void` - Returns: `204 void`
#### Print article label — `POST /article/{sku}/print` #### 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 }` - Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property` #### List article properties — `GET /article/{sku}/property`
- Params: `sku: str` - Params: `sku: str`
@@ -294,10 +331,10 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier` #### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str` - Params: `sku: str`
- Returns: `200 { suppliers: T7[] }` - Returns: `200 { suppliers: T8[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier` #### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }` - Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `201 { supplier: T7 }` - Returns: `201 { supplier: T8 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order` #### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }` - Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }` - Returns: `200 { ids: int[] }`
@@ -306,7 +343,7 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}` #### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }` - Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
- Returns: `200 { supplier: T7 }` - Returns: `200 { supplier: T8 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock` #### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }` - Params: `sku: str, body: { track_stock: bool }`
- Returns: `204 void` - Returns: `204 void`
@@ -318,19 +355,19 @@ _none_
#### List attribute names — `GET /attributename` #### List attribute names — `GET /attributename`
_none_ _none_
- Returns: `200 { attributenames: T15[] }` - Returns: `200 { attributenames: T17[] }`
#### Create attribute name — `POST /attributename` #### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T15 }` - Returns: `201 { attributename: T17 }`
#### Reorder attribute names — `PUT /attributename/order` #### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }` - Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value` #### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int` - Params: `attributeId: int`
- Returns: `200 { values: T27[] }` - Returns: `200 { values: T30[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value` #### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }` - Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T27 }` - Returns: `201 { attributevalue: T30 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -339,7 +376,7 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}` #### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T15 }` - Returns: `200 { attributename: T17 }`
### Section: /attributevalue ### Section: /attributevalue
@@ -348,28 +385,40 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}` #### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }` - Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T27 }` - Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image` #### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }` - 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` #### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }` - Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /auth ### Section: /auth
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T32 }` - Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### Current user — `GET /auth/me` #### Current user — `GET /auth/me`
_none_ _none_
- Returns: `200 { user: T32, googleClientId: str }` - Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
#### Set selected company — `PUT /auth/selected-company` #### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T32 }` - 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 ### Section: /backup
@@ -390,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 }` - 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` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T33[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -399,19 +448,19 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }` - Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }` - 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` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }` - Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /carrier ### Section: /carrier
#### List carriers — `GET /carrier` #### List carriers — `GET /carrier`
_none_ _none_
- Returns: `200 { carriers: T30[] }` - Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier` #### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T30 }` - Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order` #### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }` - Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }` - Returns: `200 { carrierIds: int[] }`
@@ -420,10 +469,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}` #### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { carrier: T30 }` - Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}` #### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T30 }` - Returns: `200 { carrier: T34 }`
### Section: /category ### Section: /category
@@ -446,8 +495,8 @@ _none_
- Params: `id: int, body: { name?: str, parentId?: int }` - Params: `id: int, body: { name?: str, parentId?: int }`
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - 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?: T25|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 }[] }[] }` - 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` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }` - Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -477,10 +526,10 @@ _none_
#### List companies — `GET /company` #### List companies — `GET /company`
_none_ _none_
- Returns: `200 { companies: T17[] }` - Returns: `200 { companies: T19[] }`
#### Create company — `POST /company` #### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T17, user: T32 }` - Returns: `201 { company: T19, user: T36 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export` #### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str` - Params: `query: stream?: str`
- Returns: `200 SSE` - Returns: `200 SSE`
@@ -510,7 +559,22 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update company — `PUT /company/{id}` #### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T17 }` - 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 ### Section: /customergroup
@@ -534,7 +598,7 @@ _none_
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int` - 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?: T36 }[], 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` #### Create delivery — `POST /delivery`
- 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 } }[] }` - 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 }` - Returns: `201 { delivery: T4 }`
@@ -555,52 +619,52 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf` #### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /entity ### Section: /entity
#### List entities — `GET /entity` #### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str` - Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T24[] }` - Returns: `200 { entities: T27[] }`
#### Create entity — `POST /entity` #### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }` - Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 { entity: T24 }` - Returns: `201 { entity: T27 }`
#### Peek next customer number — `GET /entity/next-number` #### Peek next customer number — `GET /entity/next-number`
_none_ _none_
- Returns: `200 { number: str }` - Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address` #### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { addresses: T12[] }` - Returns: `200 { addresses: T14[] }`
#### Create entity address — `POST /entity/{entityId}/address` #### 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 }` - 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: T12 }` - Returns: `201 { address: T14 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}` #### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}` #### 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 }` - 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: T12 }` - Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact` #### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { contacts: T34[] }` - Returns: `200 { contacts: T38[] }`
#### Create entity contact — `POST /entity/{entityId}/contact` #### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }` - Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T34 }` - Returns: `201 { contact: T38 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` - Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T34 }` - Returns: `200 { contact: T38 }`
#### Delete entity — `DELETE /entity/{id}` #### Delete entity — `DELETE /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get entity — `GET /entity/{id}` #### Get entity — `GET /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { entity: T24 }` - Returns: `200 { entity: T27 }`
#### Update entity — `PATCH /entity/{id}` #### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }` - Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 { entity: T24 }` - Returns: `200 { entity: T27 }`
### Section: /events ### Section: /events
@@ -609,6 +673,7 @@ _none_
- Returns: `200 SSE` - Returns: `200 SSE`
- Events: - Events:
- `user`: `{ selectedCompanyId }`
- `company`: `{ companyId }` - `company`: `{ companyId }`
- `category`: `{}` - `category`: `{}`
- `articles`: `{}` - `articles`: `{}`
@@ -617,9 +682,15 @@ _none_
- `ledger`: `{}` - `ledger`: `{}`
- `attributename`: `{}` - `attributename`: `{}`
- `propertyname`: `{}` - `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}` - `labeltemplate`: `{}`
- `printer`: `{}` - `printer`: `{}`
- `printeragent`: `{}`
- `agentprinter`: `{ printers } or {}`
- `printerjob`: `{ job } or { jobId, state }`
- `warehouse`: `{}` - `warehouse`: `{}`
- `supplier`: `{}`
- `vfs`: `{}` - `vfs`: `{}`
- `modelconversation`: `{}` - `modelconversation`: `{}`
- `modelconversation-<id>`: `{}` - `modelconversation-<id>`: `{}`
@@ -646,35 +717,38 @@ _none_
_none_ _none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }` - Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate` #### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] } }` - Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] } }`
- Returns: `201 { labeltemplate: T8 }` - 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}` #### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}` #### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { labeltemplate: T8 }` - Returns: `200 { labeltemplate: T9 }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}` #### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T8 }` - Returns: `200 { labeltemplate: T9 }`
#### Update label template — `PUT /labeltemplate/{id}` #### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T8 }` - Returns: `200 { labeltemplate: T9 }`
### Section: /ledger ### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T14[] }` - Returns: `200 { ledger: T16[] }`
#### Query company-wide ledger history — `GET /ledger/history` #### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int` - 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 }` - 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}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T14[] }` - Returns: `200 { ledger: T16[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}` #### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str` - Params: `sku: str, query: transaction_type?: str`
- Returns: `200 { ledger: T14[] }` - Returns: `200 { ledger: T16[] }`
### Section: /mcp ### Section: /mcp
@@ -704,16 +778,16 @@ _none_
#### List conversations — `GET /modelconversation` #### List conversations — `GET /modelconversation`
_none_ _none_
- Returns: `200 { conversations: T16[] }` - Returns: `200 { conversations: T18[] }`
#### Create conversation — `POST /modelconversation` #### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }` - Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T16 }` - Returns: `201 { conversation: T18 }`
#### Delete conversation — `DELETE /modelconversation/{id}` #### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}` #### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { conversation: T16 }` - Returns: `200 { conversation: T18 }`
#### List conversation messages — `GET /modelconversation/{id}/message` #### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }` - Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -754,7 +828,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int` - 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 }` - Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order` #### Create order — `POST /order`
- Params: `body: { number: str, date: str, entityId: int, lines: T19[], externalNumber?: str|null }` - Params: `body: { number: str, date: str, entityId: int, lines: T21[], externalNumber?: str|null }`
- Returns: `201 { order: T1 }` - Returns: `201 { order: T1 }`
#### Delete order — `DELETE /order/{id}` #### Delete order — `DELETE /order/{id}`
- Params: `id: int` - Params: `id: int`
@@ -763,7 +837,7 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 { order: T1 }` - Returns: `200 { order: T1 }`
#### Update order — `PATCH /order/{id}` #### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `200 { order: T1 }` - Returns: `200 { order: T1 }`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}` #### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int` - Params: `id: int, fileId: int`
@@ -773,13 +847,13 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment` #### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { payments: T29[] }` - Returns: `200 { payments: T33[] }`
#### Add payment to an order — `POST /order/{id}/payment` #### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }` - Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T29 }` - Returns: `201 { payment: T33 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf` #### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
#### List shipments of an order — `GET /order/{id}/shipment` #### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { shipments: T2[] }` - Returns: `200 { shipments: T2[] }`
@@ -788,10 +862,10 @@ _none_
- Returns: `201 { shipment: T2 }` - Returns: `201 { shipment: T2 }`
#### List shippings of an order — `GET /order/{id}/shipping` #### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { shippings: T20[] }` - Returns: `200 { shippings: T23[] }`
#### Create shipping for an order — `POST /order/{id}/shipping` #### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }` - Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T20 }` - Returns: `201 { shipping: T23 }`
### Section: /package ### Section: /package
@@ -809,16 +883,16 @@ _none_
#### List POS terminals — `GET /posterminal` #### List POS terminals — `GET /posterminal`
_none_ _none_
- Returns: `200 { posterminals: T10[] }` - Returns: `200 { posterminals: T7[] }`
#### Create POS terminal — `PUT /posterminal` #### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }` - Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool }`
- Returns: `200 { posterminal: T10 }` - Returns: `200 { posterminal: T7 }`
#### Delete POS terminal — `DELETE /posterminal/{id}` #### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update POS terminal — `PATCH /posterminal/{id}` #### Update POS terminal — `PATCH /posterminal/{id}`
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }` - Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, pairingpin?: str|null }`
- Returns: `200 { posterminal: T10 }` - Returns: `200 { posterminal: T7 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all` #### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { id: int, categoryIds: int[] }` - Returns: `200 { id: int, categoryIds: int[] }`
@@ -836,34 +910,76 @@ _none_
#### List printers — `GET /printer` #### List printers — `GET /printer`
_none_ _none_
- Returns: `200 { printers: T23[] }` - Returns: `200 { printers: T26[] }`
#### Create printer — `POST /printer` #### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 { printer: T23 }` - Returns: `201 { printer: T26 }`
#### Reorder printers — `PUT /printer/order` #### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }` - Params: `body: { printerIds: int[] }`
- Returns: `200 { 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}` #### Delete printer — `DELETE /printer/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}` #### Get printer by ID — `GET /printer/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { printer: T23 }` - Returns: `200 { printer: T26 }`
#### Update printer (partial) — `PATCH /printer/{id}` #### 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 }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T23 }` - Returns: `200 { printer: T26 }`
#### Update printer — `PUT /printer/{id}` #### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T23 }` - 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 ### Section: /propertyname
#### List property names — `GET /propertyname` #### List property names — `GET /propertyname`
_none_ _none_
- Returns: `200 { propertynames: T28[] }` - Returns: `200 { propertynames: T32[] }`
#### Create property name — `POST /propertyname` #### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T28 }` - Returns: `201 { propertyname: T32 }`
#### Reorder property names — `PUT /propertyname/order` #### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }` - Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -872,7 +988,7 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}` #### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T28 }` - Returns: `200 { propertyname: T32 }`
### Section: /public ### Section: /public
@@ -884,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 }[] }` - 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}` #### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str` - 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: T26|null, brand: T25|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 }[] } }` - 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` #### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: int` - 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 }[] }` - 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 }[] }`
@@ -895,7 +1011,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int` - Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }` - Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote` #### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T19[], entityId?: int|null, externalNumber?: str|null }` - Params: `body: { number: str, date: str, lines: T21[], entityId?: int|null, externalNumber?: str|null }`
- Returns: `201 { quote: T3 }` - Returns: `201 { quote: T3 }`
#### Delete quote — `DELETE /quote/{id}` #### Delete quote — `DELETE /quote/{id}`
- Params: `id: int` - Params: `id: int`
@@ -904,7 +1020,7 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 { quote: T3 }` - Returns: `200 { quote: T3 }`
#### Update quote — `PATCH /quote/{id}` #### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `200 { quote: T3 }` - Returns: `200 { quote: T3 }`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}` #### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int` - Params: `id: int, fileId: int`
@@ -913,11 +1029,11 @@ _none_
- Params: `id: int, fileId: int, query: download?: bool` - Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str` - Returns: `200 str`
#### Create order from quote — `POST /quote/{id}/order` #### 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?: T19[] }` - Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `201 { order: T1 }` - Returns: `201 { order: T1 }`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf` #### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
### Section: /retoure ### Section: /retoure
@@ -1004,13 +1120,13 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf` #### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 { attachment: T9, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping` #### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { shippings: T20[] }` - Returns: `200 { shippings: T23[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping` #### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }` - Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T20 }` - Returns: `201 { shipping: T23 }`
### Section: /shipping ### Section: /shipping
@@ -1019,7 +1135,7 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}` #### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }` - Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
- Returns: `200 { shipping: T20 }` - Returns: `200 { shipping: T23 }`
#### List packages of a shipping — `GET /shipping/{id}/package` #### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }` - Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -1040,7 +1156,7 @@ _none_
- Returns: `200` - Returns: `200`
#### Adjust stock manually — `POST /stock/adjust` #### 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 }[] }` - 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: T14[] }` - 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` #### Ship shipment manifest item(s) — `POST /stock/ship`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }` - Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200` - Returns: `200`
@@ -1049,40 +1165,40 @@ _none_
#### List subagent schedules — `GET /subagentschedule` #### List subagent schedules — `GET /subagentschedule`
_none_ _none_
- Returns: `200 { schedules: T13[] }` - Returns: `200 { schedules: T15[] }`
#### Create subagent schedule — `POST /subagentschedule` #### Create subagent schedule — `POST /subagentschedule`
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }` - Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
- Returns: `201 { schedule: T13 }` - Returns: `201 { schedule: T15 }`
#### Delete subagent schedule — `DELETE /subagentschedule/{id}` #### Delete subagent schedule — `DELETE /subagentschedule/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get subagent schedule by ID — `GET /subagentschedule/{id}` #### Get subagent schedule by ID — `GET /subagentschedule/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { schedule: T13 }` - Returns: `200 { schedule: T15 }`
#### Update subagent schedule — `PUT /subagentschedule/{id}` #### 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 }` - 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: T13 }` - Returns: `200 { schedule: T15 }`
### Section: /supplier ### Section: /supplier
#### List suppliers — `GET /supplier` #### List suppliers — `GET /supplier`
_none_ _none_
- Returns: `200 { suppliers: T35[] }` - Returns: `200 { suppliers: T39[] }`
#### Create supplier — `POST /supplier` #### Create supplier — `POST /supplier`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { supplier: T35 }` - Returns: `201 { supplier: T39 }`
#### Delete supplier — `DELETE /supplier/{id}` #### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}` #### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T35 }` - Returns: `200 { supplier: T39 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles` #### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: str` - 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?: T38|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` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }` - 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?: T38|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 ### Section: /thumb
@@ -1097,10 +1213,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### List units — `GET /unit` #### List units — `GET /unit`
_none_ _none_
- Returns: `200 { units: T26[] }` - Returns: `200 { units: T29[] }`
#### Upsert unit — `PUT /unit` #### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T26 }` - Returns: `200 { unit: T29 }`
### Section: /v1 ### Section: /v1
@@ -1111,7 +1227,7 @@ _none_
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str` - Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg` - Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client` #### 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 }` - 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` #### JTL-POS customer catalog synchronization — `GET /v1/customer`
- Params: `query: lastChangedCustomer?: str, limit?: int` - Params: `query: lastChangedCustomer?: str, limit?: int`
@@ -1191,3 +1307,9 @@ _none_
#### Update warehouse — `PUT /warehouse/{id}` #### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 { warehouse: { id: int, 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

View File

@@ -11,8 +11,8 @@
<polygon fill="#1e2430" stroke="none" points="-36,36 -36,-2323.9 6416.78,-2323.9 6416.78,36 -36,36"/> <polygon fill="#1e2430" stroke="none" points="-36,36 -36,-2323.9 6416.78,-2323.9 6416.78,36 -36,36"/>
<g id="clust1" class="cluster"> <g id="clust1" class="cluster">
<title>cluster_session_realtime</title> <title>cluster_session_realtime</title>
<path fill="#202a3a" stroke="#3f4c5f" d="M1200.78,-1631C1200.78,-1631 2347.78,-1631 2347.78,-1631 2353.78,-1631 2359.78,-1637 2359.78,-1643 2359.78,-1643 2359.78,-1940 2359.78,-1940 2359.78,-1946 2353.78,-1952 2347.78,-1952 2347.78,-1952 1200.78,-1952 1200.78,-1952 1194.78,-1952 1188.78,-1946 1188.78,-1940 1188.78,-1940 1188.78,-1643 1188.78,-1643 1188.78,-1637 1194.78,-1631 1200.78,-1631"/> <path fill="#202a3a" stroke="#3f4c5f" d="M1200.78,-1611C1200.78,-1611 2347.78,-1611 2347.78,-1611 2353.78,-1611 2359.78,-1617 2359.78,-1623 2359.78,-1623 2359.78,-1960 2359.78,-1960 2359.78,-1966 2353.78,-1972 2347.78,-1972 2347.78,-1972 1200.78,-1972 1200.78,-1972 1194.78,-1972 1188.78,-1966 1188.78,-1960 1188.78,-1960 1188.78,-1623 1188.78,-1623 1188.78,-1617 1194.78,-1611 1200.78,-1611"/>
<text xml:space="preserve" text-anchor="middle" x="1283.06" y="-1934.5" font-family="Helvetica,sans-Serif" font-size="15.00" fill="#e2e8f0">Session &amp; Real&#45;Time Hub</text> <text xml:space="preserve" text-anchor="middle" x="1283.06" y="-1954.5" font-family="Helvetica,sans-Serif" font-size="15.00" fill="#e2e8f0">Session &amp; Real&#45;Time Hub</text>
</g> </g>
<g id="clust2" class="cluster"> <g id="clust2" class="cluster">
<title>cluster_company_basedata</title> <title>cluster_company_basedata</title>
@@ -57,64 +57,76 @@
<!-- authAutomat --> <!-- authAutomat -->
<g id="node1" class="node"> <g id="node1" class="node">
<title>authAutomat</title> <title>authAutomat</title>
<polygon fill="#33415c" stroke="none" points="1210.95,-1849 1210.95,-1880 1530.61,-1880 1530.61,-1849 1210.95,-1849"/> <polygon fill="#33415c" stroke="none" points="1210.95,-1871 1210.95,-1902 1530.61,-1902 1530.61,-1871 1210.95,-1871"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1849 1210.95,-1880 1530.61,-1880 1530.61,-1849 1210.95,-1849"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1871 1210.95,-1902 1530.61,-1902 1530.61,-1871 1210.95,-1871"/>
<text xml:space="preserve" text-anchor="start" x="1333.78" y="-1865.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">authAutomat</text> <text xml:space="preserve" text-anchor="start" x="1333.78" y="-1887.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">authAutomat</text>
<text xml:space="preserve" text-anchor="start" x="1282.24" y="-1855.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">Always&#45;On Session &amp; Multi&#45;Tenant Gateway</text> <text xml:space="preserve" text-anchor="start" x="1282.24" y="-1877.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">Always&#45;On Session &amp; Multi&#45;Tenant Gateway</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/> <polygon fill="#2a3242" stroke="none" points="1210.95,-1849 1210.95,-1871 1530.61,-1871 1530.61,-1849 1210.95,-1849"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1849 1210.95,-1871 1530.61,-1871 1530.61,-1849 1210.95,-1849"/>
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1850 1211.95,-1870 1286.71,-1870 1286.71,-1850 1211.95,-1850"/>
<text xml:space="preserve" text-anchor="start" x="1236.22" y="-1858.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
<text xml:space="preserve" text-anchor="start" x="1290.71" y="-1858" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasAuth: boolean</text>
<text xml:space="preserve" text-anchor="start" x="1436.66" y="-1856.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Authenticated session bit</text>
<polygon fill="#272e3d" stroke="none" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1828 1211.95,-1848 1286.71,-1848 1286.71,-1828 1211.95,-1828"/> <polygon fill="#2b1a42" stroke="none" points="1211.95,-1828 1211.95,-1848 1270.36,-1848 1270.36,-1828 1211.95,-1828"/>
<text xml:space="preserve" text-anchor="start" x="1236.22" y="-1836.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text> <text xml:space="preserve" text-anchor="start" x="1228.05" y="-1836.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
<text xml:space="preserve" text-anchor="start" x="1290.71" y="-1836" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasAuth: boolean</text> <text xml:space="preserve" text-anchor="start" x="1274.36" y="-1836" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasCompany: boolean</text>
<text xml:space="preserve" text-anchor="start" x="1436.66" y="-1834.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Authenticated session bit</text> <text xml:space="preserve" text-anchor="start" x="1410.43" y="-1834.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">hasAuth &amp;&amp; selectedCompanyId</text>
<polygon fill="#272e3d" stroke="none" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/> <polygon fill="#2a3242" stroke="none" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1806 1211.95,-1826 1270.36,-1826 1270.36,-1806 1211.95,-1806"/> <polygon fill="#2b1a42" stroke="none" points="1211.95,-1806 1211.95,-1826 1291.52,-1826 1291.52,-1806 1211.95,-1806"/>
<text xml:space="preserve" text-anchor="start" x="1228.05" y="-1814.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text> <text xml:space="preserve" text-anchor="start" x="1238.63" y="-1814.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
<text xml:space="preserve" text-anchor="start" x="1274.36" y="-1814" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasCompany: boolean</text> <text xml:space="preserve" text-anchor="start" x="1295.52" y="-1814" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">user: AuthUser</text>
<text xml:space="preserve" text-anchor="start" x="1410.43" y="-1812.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">hasAuth &amp;&amp; selectedCompanyId</text> <text xml:space="preserve" text-anchor="start" x="1438.9" y="-1812.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Current user &amp; company</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/> <polygon fill="#272e3d" stroke="none" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1784 1211.95,-1804 1291.52,-1804 1291.52,-1784 1211.95,-1784"/> <polygon fill="#2b1a42" stroke="none" points="1211.95,-1784 1211.95,-1804 1264.51,-1804 1264.51,-1784 1211.95,-1784"/>
<text xml:space="preserve" text-anchor="start" x="1238.63" y="-1792.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text> <text xml:space="preserve" text-anchor="start" x="1225.12" y="-1792.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
<text xml:space="preserve" text-anchor="start" x="1295.52" y="-1792" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">user: AuthUser</text> <text xml:space="preserve" text-anchor="start" x="1268.51" y="-1792" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">schemaVersion: number|null</text>
<text xml:space="preserve" text-anchor="start" x="1438.9" y="-1790.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Current user &amp; company</text> <text xml:space="preserve" text-anchor="start" x="1422.89" y="-1790.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Backend DB schema version</text>
<polygon fill="#272e3d" stroke="none" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/> <polygon fill="#2a3242" stroke="none" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1762 1211.95,-1782 1304.59,-1782 1304.59,-1762 1211.95,-1762"/> <polygon fill="#2b1a42" stroke="none" points="1211.95,-1762 1211.95,-1782 1304.59,-1782 1304.59,-1762 1211.95,-1762"/>
<text xml:space="preserve" text-anchor="start" x="1245.16" y="-1770.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text> <text xml:space="preserve" text-anchor="start" x="1245.16" y="-1770.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
<text xml:space="preserve" text-anchor="start" x="1308.59" y="-1770" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">status</text> <text xml:space="preserve" text-anchor="start" x="1308.59" y="-1770" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">status</text>
<text xml:space="preserve" text-anchor="start" x="1435.33" y="-1768.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">&#39;anon&#39; | &#39;authed&#39; | &#39;loading&#39;</text> <text xml:space="preserve" text-anchor="start" x="1435.33" y="-1768.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">&#39;anon&#39; | &#39;authed&#39; | &#39;loading&#39;</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/> <polygon fill="#272e3d" stroke="none" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
<polygon fill="#143826" stroke="none" points="1211.95,-1740 1211.95,-1760 1285.38,-1760 1285.38,-1740 1211.95,-1740"/> <polygon fill="#143826" stroke="none" points="1211.95,-1740 1211.95,-1760 1285.38,-1760 1285.38,-1740 1211.95,-1740"/>
<text xml:space="preserve" text-anchor="start" x="1233.33" y="-1748.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text> <text xml:space="preserve" text-anchor="start" x="1233.33" y="-1748.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
<text xml:space="preserve" text-anchor="start" x="1289.38" y="-1748" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">bootstrapSession()</text> <text xml:space="preserve" text-anchor="start" x="1289.38" y="-1748" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">bootstrapSession()</text>
<text xml:space="preserve" text-anchor="start" x="1439.35" y="-1746.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Restore or fetch session</text> <text xml:space="preserve" text-anchor="start" x="1439.35" y="-1746.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Restore or fetch session</text>
<polygon fill="#272e3d" stroke="none" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/> <polygon fill="#2a3242" stroke="none" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
<polygon fill="#143826" stroke="none" points="1211.95,-1718 1211.95,-1738 1291.68,-1738 1291.68,-1718 1211.95,-1718"/> <polygon fill="#143826" stroke="none" points="1211.95,-1718 1211.95,-1738 1291.68,-1738 1291.68,-1718 1211.95,-1718"/>
<text xml:space="preserve" text-anchor="start" x="1236.48" y="-1726.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text> <text xml:space="preserve" text-anchor="start" x="1236.48" y="-1726.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
<text xml:space="preserve" text-anchor="start" x="1295.68" y="-1726" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">switchCompany(id)</text> <text xml:space="preserve" text-anchor="start" x="1295.68" y="-1726" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">switchCompany(id)</text>
<text xml:space="preserve" text-anchor="start" x="1459.36" y="-1724.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Multi&#45;tenant switch</text> <text xml:space="preserve" text-anchor="start" x="1459.36" y="-1724.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Multi&#45;tenant switch</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/> <polygon fill="#272e3d" stroke="none" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
<polygon fill="#143826" stroke="none" points="1211.95,-1696 1211.95,-1716 1307.5,-1716 1307.5,-1696 1211.95,-1696"/> <polygon fill="#143826" stroke="none" points="1211.95,-1696 1211.95,-1716 1307.5,-1716 1307.5,-1696 1211.95,-1696"/>
<text xml:space="preserve" text-anchor="start" x="1244.39" y="-1704.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text> <text xml:space="preserve" text-anchor="start" x="1244.39" y="-1704.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
<text xml:space="preserve" text-anchor="start" x="1311.5" y="-1704" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">logout()</text> <text xml:space="preserve" text-anchor="start" x="1311.5" y="-1704" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">logout()</text>
<text xml:space="preserve" text-anchor="start" x="1451.8" y="-1702.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Clear session cookie</text> <text xml:space="preserve" text-anchor="start" x="1451.8" y="-1702.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Clear session cookie</text>
<polygon fill="#272e3d" stroke="none" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/> <polygon fill="#2a3242" stroke="none" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
<polygon fill="#182b40" stroke="none" points="1211.95,-1674 1211.95,-1694 1259.95,-1694 1259.95,-1674 1211.95,-1674"/> <polygon fill="#182b40" stroke="none" points="1211.95,-1674 1211.95,-1694 1259.95,-1694 1259.95,-1674 1211.95,-1674"/>
<text xml:space="preserve" text-anchor="start" x="1220.17" y="-1682.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text> <text xml:space="preserve" text-anchor="start" x="1220.17" y="-1682.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text>
<text xml:space="preserve" text-anchor="start" x="1263.95" y="-1682" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectHasAuth() / selectHasCompany()</text> <text xml:space="preserve" text-anchor="start" x="1263.95" y="-1682" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectHasAuth() / selectHasCompany()</text>
<text xml:space="preserve" text-anchor="start" x="1455.33" y="-1680.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Auth &amp; tenant gates</text> <text xml:space="preserve" text-anchor="start" x="1455.33" y="-1680.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Auth &amp; tenant gates</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/> <polygon fill="#272e3d" stroke="none" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/> <polygon fill="none" stroke="#3f4c5f" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
<polygon fill="#3b2c12" stroke="none" points="1211.95,-1652 1211.95,-1672 1283.89,-1672 1283.89,-1652 1211.95,-1652"/> <polygon fill="#182b40" stroke="none" points="1211.95,-1652 1211.95,-1672 1276.7,-1672 1276.7,-1652 1211.95,-1652"/>
<text xml:space="preserve" text-anchor="start" x="1241.25" y="-1660.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#fbbf24">API</text> <text xml:space="preserve" text-anchor="start" x="1228.54" y="-1660.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text>
<text xml:space="preserve" text-anchor="start" x="1287.89" y="-1660" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">GET /api/auth/session</text> <text xml:space="preserve" text-anchor="start" x="1280.7" y="-1660" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectSchemaVersion()</text>
<text xml:space="preserve" text-anchor="start" x="1448.24" y="-1658.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Cookie session check</text> <text xml:space="preserve" text-anchor="start" x="1432.24" y="-1658.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Active DB schema version</text>
<polygon fill="#2a3242" stroke="none" points="1210.95,-1629 1210.95,-1651 1530.61,-1651 1530.61,-1629 1210.95,-1629"/>
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1629 1210.95,-1651 1530.61,-1651 1530.61,-1629 1210.95,-1629"/>
<polygon fill="#3b2c12" stroke="none" points="1211.95,-1630 1211.95,-1650 1283.89,-1650 1283.89,-1630 1211.95,-1630"/>
<text xml:space="preserve" text-anchor="start" x="1241.25" y="-1638.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#fbbf24">API</text>
<text xml:space="preserve" text-anchor="start" x="1287.89" y="-1638" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">GET /api/auth/session</text>
<text xml:space="preserve" text-anchor="start" x="1448.24" y="-1636.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Cookie session check</text>
</g> </g>
<!-- sseAutomat --> <!-- sseAutomat -->
<g id="node2" class="node"> <g id="node2" class="node">
@@ -184,11 +196,11 @@
<g id="edge1" class="edge"> <g id="edge1" class="edge">
<title>authAutomat:e&#45;&gt;sseAutomat:n</title> <title>authAutomat:e&#45;&gt;sseAutomat:n</title>
<g id="a_edge1"><a xlink:title="authAutomat → sseAutomat (hasAuth &#45;&gt; connect SSE)"> <g id="a_edge1"><a xlink:title="authAutomat → sseAutomat (hasAuth &#45;&gt; connect SSE)">
<path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1531.61,-1838C1597.66,-1838 2098.6,-1938.58 2155.13,-1891.19"/> <path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1531.61,-1860C1667.44,-1860 2132.91,-2011.81 2159.16,-1892.16"/>
<polygon fill="#5aa7ff" stroke="#5aa7ff" stroke-width="1.4" points="2158.36,-1890.24 2159.36,-1882.89 2154,-1888.02 2158.36,-1890.24"/> <polygon fill="#5aa7ff" stroke="#5aa7ff" stroke-width="1.4" points="2161.81,-1890.32 2160.1,-1883.11 2156.94,-1889.82 2161.81,-1890.32"/>
</a> </a>
</g> </g>
<text xml:space="preserve" text-anchor="middle" x="1815.23" y="-1886.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#5aa7ff"> &#160;hasAuth &#45;&gt; connect SSE &#160;</text> <text xml:space="preserve" text-anchor="middle" x="1815.23" y="-1919.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#5aa7ff"> &#160;hasAuth &#45;&gt; connect SSE &#160;</text>
</g> </g>
<!-- companyAutomat --> <!-- companyAutomat -->
<g id="node3" class="node"> <g id="node3" class="node">
@@ -301,7 +313,7 @@
<g id="edge2" class="edge"> <g id="edge2" class="edge">
<title>authAutomat:e&#45;&gt;companyAutomat:n</title> <title>authAutomat:e&#45;&gt;companyAutomat:n</title>
<g id="a_edge2"><a xlink:title="authAutomat → companyAutomat (hasCompany &#45;&gt; loadAll())"> <g id="a_edge2"><a xlink:title="authAutomat → companyAutomat (hasCompany &#45;&gt; loadAll())">
<path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1531.61,-1816C1778.34,-1816 1814.29,-1953.13 2057.78,-1992.98 2197.7,-2015.88 2235.25,-2001.4 2376.78,-1992.98 2504.73,-1985.38 2662.43,-2084.44 2669.75,-1966.85"/> <path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1531.61,-1838C1775.4,-1838 1816.49,-1958.19 2057.78,-1992.98 2198.11,-2013.22 2235.25,-2001.4 2376.78,-1992.98 2504.73,-1985.38 2662.43,-2084.44 2669.75,-1966.85"/>
<polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="2672.19,-1967.19 2669.95,-1960.12 2667.29,-1967.04 2672.19,-1967.19"/> <polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="2672.19,-1967.19 2669.95,-1960.12 2667.29,-1967.04 2672.19,-1967.19"/>
</a> </a>
</g> </g>
@@ -379,7 +391,7 @@
<g id="edge3" class="edge"> <g id="edge3" class="edge">
<title>authAutomat:w&#45;&gt;categoryAutomat:n</title> <title>authAutomat:w&#45;&gt;categoryAutomat:n</title>
<g id="a_edge3"><a xlink:title="authAutomat → categoryAutomat (sync tree on hasCompany)"> <g id="a_edge3"><a xlink:title="authAutomat → categoryAutomat (sync tree on hasCompany)">
<path fill="none" stroke="#f5c15c" stroke-width="1.4" d="M1209.95,-1816C1048.33,-1816 1105.62,-1619.29 957.78,-1554 867.57,-1514.16 132.3,-1590.62 71.22,-1513.2 -17.14,-1401.19 -29.32,-1293.41 71.22,-1192.2 136.5,-1126.49 3340.5,-1235.91 3405.78,-1170.2 3409.16,-1166.79 3409.13,-1162.83 3405.78,-1159.4 3399.24,-1152.7 3078.14,-1151.63 3068.78,-1151.4 3003.45,-1149.82 786.43,-1158.3 647.93,-1097.5"/> <path fill="none" stroke="#f5c15c" stroke-width="1.4" d="M1209.95,-1838C1041.15,-1838 1110.79,-1625.28 957.78,-1554 868.39,-1512.36 132.3,-1590.62 71.22,-1513.2 -17.14,-1401.19 -29.32,-1293.41 71.22,-1192.2 136.5,-1126.49 3340.5,-1235.91 3405.78,-1170.2 3409.16,-1166.79 3409.13,-1162.83 3405.78,-1159.4 3399.24,-1152.7 3078.14,-1151.63 3068.78,-1151.4 3003.45,-1149.82 786.43,-1158.3 647.93,-1097.5"/>
<polygon fill="#f5c15c" stroke="#f5c15c" stroke-width="1.4" points="649.57,-1095.68 642.73,-1092.82 646.29,-1099.32 649.57,-1095.68"/> <polygon fill="#f5c15c" stroke="#f5c15c" stroke-width="1.4" points="649.57,-1095.68 642.73,-1092.82 646.29,-1099.32 649.57,-1095.68"/>
</a> </a>
</g> </g>
@@ -429,7 +441,7 @@
<g id="edge4" class="edge"> <g id="edge4" class="edge">
<title>authAutomat:e&#45;&gt;deliveryAutomat:n</title> <title>authAutomat:e&#45;&gt;deliveryAutomat:n</title>
<g id="a_edge4"><a xlink:title="authAutomat → deliveryAutomat (sync deliveries on authed)"> <g id="a_edge4"><a xlink:title="authAutomat → deliveryAutomat (sync deliveries on authed)">
<path fill="none" stroke="#ff7fc2" stroke-width="1.4" d="M1531.61,-1772C1587.17,-1772 1566.78,-1689.17 1530.61,-1647 1362.69,-1451.22 1214.44,-1579.47 957.78,-1554 924.81,-1550.73 384.77,-1555.86 361.78,-1532 250.74,-1416.78 350.13,-1284.6 480.78,-1192.2 514.55,-1168.32 3418.71,-1180.47 3458.78,-1170.2 3744.41,-1097 3758.45,-933.49 4042.78,-855.4 4135.32,-829.98 4839.35,-888.66 4905.78,-819.4 4974.8,-747.44 4704.08,-842.24 4687.09,-754.66"/> <path fill="none" stroke="#ff7fc2" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1574.04,-1673.81 1530.61,-1625 1445.34,-1529.17 1085.64,-1564.26 957.78,-1554 924.75,-1551.35 384.77,-1555.86 361.78,-1532 250.74,-1416.78 350.13,-1284.6 480.78,-1192.2 514.55,-1168.32 3418.71,-1180.47 3458.78,-1170.2 3744.41,-1097 3758.45,-933.49 4042.78,-855.4 4135.32,-829.98 4839.35,-888.66 4905.78,-819.4 4974.8,-747.44 4704.08,-842.24 4687.09,-754.66"/>
<polygon fill="#ff7fc2" stroke="#ff7fc2" stroke-width="1.4" points="4689.54,-754.46 4686.46,-747.71 4684.66,-754.9 4689.54,-754.46"/> <polygon fill="#ff7fc2" stroke="#ff7fc2" stroke-width="1.4" points="4689.54,-754.46 4686.46,-747.71 4684.66,-754.9 4689.54,-754.46"/>
</a> </a>
</g> </g>
@@ -541,7 +553,7 @@
<g id="edge6" class="edge"> <g id="edge6" class="edge">
<title>authAutomat:e&#45;&gt;orderAutomat:n</title> <title>authAutomat:e&#45;&gt;orderAutomat:n</title>
<g id="a_edge6"><a xlink:title="authAutomat → orderAutomat (sync orders on authed)"> <g id="a_edge6"><a xlink:title="authAutomat → orderAutomat (sync orders on authed)">
<path fill="none" stroke="#5cd6d6" stroke-width="1.4" d="M1531.61,-1772C1790.16,-1772 2107.92,-1668.89 957.78,-1554 925.91,-1550.82 403.93,-1555.07 381.72,-1532 378.39,-1528.54 378.33,-1524.6 381.72,-1521.2 393.82,-1509.06 2832.79,-1515.48 2849.78,-1513.2 2985.29,-1495.01 3146.2,-1567.68 3153.36,-1440.3"/> <path fill="none" stroke="#5cd6d6" stroke-width="1.4" d="M1531.61,-1772C1873.92,-1772 2250.85,-1658.64 957.78,-1554 925.86,-1551.42 403.93,-1555.07 381.72,-1532 378.39,-1528.54 378.33,-1524.6 381.72,-1521.2 393.82,-1509.06 2832.79,-1515.48 2849.78,-1513.2 2985.29,-1495.01 3146.2,-1567.68 3153.36,-1440.3"/>
<polygon fill="#5cd6d6" stroke="#5cd6d6" stroke-width="1.4" points="3155.81,-1440.38 3153.55,-1433.32 3150.91,-1440.25 3155.81,-1440.38"/> <polygon fill="#5cd6d6" stroke="#5cd6d6" stroke-width="1.4" points="3155.81,-1440.38 3153.55,-1433.32 3150.91,-1440.25 3155.81,-1440.38"/>
</a> </a>
</g> </g>
@@ -585,7 +597,7 @@
<g id="edge7" class="edge"> <g id="edge7" class="edge">
<title>authAutomat:e&#45;&gt;entityAutomat:n</title> <title>authAutomat:e&#45;&gt;entityAutomat:n</title>
<g id="a_edge7"><a xlink:title="authAutomat → entityAutomat (sync entities on authed)"> <g id="a_edge7"><a xlink:title="authAutomat → entityAutomat (sync entities on authed)">
<path fill="none" stroke="#ff9f5a" stroke-width="1.4" d="M1531.61,-1772C1779.83,-1772 2059.97,-1668.89 957.78,-1554 931.78,-1551.29 505.83,-1550.91 487.78,-1532 484.47,-1528.53 484.41,-1524.62 487.78,-1521.2 496.93,-1511.89 945.01,-1515.93 957.78,-1513.2 1312.73,-1437.36 1353.55,-1267.01 1708.72,-1192.2 1761.92,-1180.99 3627.55,-1208.86 3665.78,-1170.2 3815.97,-1018.31 3214.43,-1246.44 3197.36,-1045.33"/> <path fill="none" stroke="#ff9f5a" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1574.04,-1673.81 1530.61,-1625 1445.34,-1529.17 1085.58,-1564.9 957.78,-1554 931.73,-1551.78 505.83,-1550.91 487.78,-1532 484.47,-1528.53 484.41,-1524.62 487.78,-1521.2 496.93,-1511.89 945.01,-1515.93 957.78,-1513.2 1312.73,-1437.36 1353.55,-1267.01 1708.72,-1192.2 1761.92,-1180.99 3627.55,-1208.86 3665.78,-1170.2 3815.97,-1018.31 3214.43,-1246.44 3197.36,-1045.33"/>
<polygon fill="#ff9f5a" stroke="#ff9f5a" stroke-width="1.4" points="3199.82,-1045.41 3197.08,-1038.52 3194.92,-1045.61 3199.82,-1045.41"/> <polygon fill="#ff9f5a" stroke="#ff9f5a" stroke-width="1.4" points="3199.82,-1045.41 3197.08,-1038.52 3194.92,-1045.61 3199.82,-1045.41"/>
</a> </a>
</g> </g>
@@ -723,7 +735,7 @@
<g id="edge9" class="edge"> <g id="edge9" class="edge">
<title>authAutomat:e&#45;&gt;vfsAutomat:n</title> <title>authAutomat:e&#45;&gt;vfsAutomat:n</title>
<g id="a_edge9"><a xlink:title="authAutomat → vfsAutomat (load company vfs)"> <g id="a_edge9"><a xlink:title="authAutomat → vfsAutomat (load company vfs)">
<path fill="none" stroke="#ff6b6b" stroke-width="1.4" d="M1531.61,-1772C1587.17,-1772 1565.57,-1690.18 1530.61,-1647 1381.03,-1462.24 843.22,-1689.87 1010.74,-1521.2 1022.22,-1509.64 2167.97,-1517.14 2183.78,-1513.2 2267.24,-1492.38 2355.1,-1508.15 2361.17,-1429.21"/> <path fill="none" stroke="#ff6b6b" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1572.71,-1674.96 1530.61,-1625 1378.77,-1444.84 844.71,-1688.37 1010.74,-1521.2 1022.22,-1509.64 2167.97,-1517.14 2183.78,-1513.2 2267.24,-1492.38 2355.1,-1508.15 2361.17,-1429.21"/>
<polygon fill="#ff6b6b" stroke="#ff6b6b" stroke-width="1.4" points="2363.62,-1429.4 2361.43,-1422.32 2358.72,-1429.22 2363.62,-1429.4"/> <polygon fill="#ff6b6b" stroke="#ff6b6b" stroke-width="1.4" points="2363.62,-1429.4 2361.43,-1422.32 2358.72,-1429.22 2363.62,-1429.4"/>
</a> </a>
</g> </g>

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -2,6 +2,67 @@ import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks'; 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 [ export default [
{ {
ignores: [ ignores: [
@@ -32,10 +93,16 @@ export default [
}, },
plugins: { plugins: {
'react-hooks': reactHooks, 'react-hooks': reactHooks,
react: {
rules: {
'no-mui-tooltip': banMuiTooltipRule,
},
},
}, },
rules: { rules: {
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
'react/no-mui-tooltip': 'error',
'no-unused-vars': [ 'no-unused-vars': [
'warn', 'warn',
{ {

View File

@@ -126,11 +126,13 @@ const AUTOMAT_SPECS = [
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' }, { 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: '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: '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: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' }, { 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: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' }, { type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' }, { 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' }, { type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
], ],
}, },

1081
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs", "dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
"cert": "./scripts/generate-cert.sh", "cert": "./scripts/generate-cert.sh",
"build": "vite build", "build": "vite build",
"lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"preview:https": "VITE_HTTPS=1 vite preview", "preview:https": "VITE_HTTPS=1 vite preview",
"rig:collect": "node test/collect.mjs", "rig:collect": "node test/collect.mjs",
@@ -40,7 +41,7 @@
"pdfconvert-wasm": "^0.3.0", "pdfconvert-wasm": "^0.3.0",
"react": "^19.3.1", "react": "^19.3.1",
"react-automat": "file:../automat", "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-dom": "^19.3.1",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
"react-window": "^2.3.4", "react-window": "^2.3.4",
@@ -61,6 +62,7 @@
}, },
"allowScripts": { "allowScripts": {
"canvas@3.2.3": true, "canvas@3.2.3": true,
"react-canvas-label-editor@20.0.0": true,
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true "github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
} }
} }

View File

@@ -1,11 +1,12 @@
import { PureComponent, lazy, Suspense } from 'react'; import { PureComponent, lazy, Suspense } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import authAutomat from './automats/authAutomat'; import authAutomat from './automats/authAutomat';
import LoginPage from './components/LoginPage'; import LoginPage from './components/LoginPage';
import AppRouter from './appRouter';
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog')); const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
const AppRouter = lazy(() => import('./appRouter'));
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage')); const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
const SseMonitor = import.meta.env.DEV const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor')) ? lazy(() => import('./devtools/SseMonitor'))
@@ -34,7 +35,13 @@ class App extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user', 'hasAuth', 'hasCompany']); this.unsubscribe = authAutomat.subscribe(this, [
'status',
'user',
'hasAuth',
'hasCompany',
'retryNotification',
]);
} }
componentWillUnmount() { componentWillUnmount() {
@@ -42,7 +49,7 @@ class App extends PureComponent {
} }
render() { render() {
const { status, user, hasAuth, hasCompany } = this.state; const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user)); const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null)); const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
@@ -51,12 +58,29 @@ class App extends PureComponent {
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
minHeight: 'var(--df-vh, 100vh)', minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default', 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 /> <CircularProgress />
</Box> </Box>
); );

69
src/api/agentprinter.js Normal file
View 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',
});
},
};

View File

@@ -8,12 +8,18 @@ export const auth = {
return { return {
user: data.user ?? null, user: data.user ?? null,
googleClientId: data.googleClientId ?? null, googleClientId: data.googleClientId ?? null,
schemaVersion: data.schemaVersion ?? null,
vhost: Boolean(data.vhost),
vhostSettings: data.vhostSettings ?? null,
}; };
} catch (err) { } catch (err) {
if (err.status === 401 || err.status === 403) { if (err.status === 401 || err.status === 403) {
return { return {
user: null, user: null,
googleClientId: err.data?.googleClientId ?? 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, forbidden: err.status === 403,
message: err.message, message: err.message,
}; };

24
src/api/autoinvite.js Normal file
View 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
};

30
src/api/companyuser.js Normal file
View 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
};

View File

@@ -15,6 +15,9 @@ export { number } from './number.js';
export { order } from './order.js'; export { order } from './order.js';
export { posterminal } from './posterminal.js'; export { posterminal } from './posterminal.js';
export { printer } from './printer.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 { propertyname } from './propertyname.js';
export { quote } from './quote.js'; export { quote } from './quote.js';
export { retoure } from './retoure.js'; export { retoure } from './retoure.js';
@@ -31,6 +34,8 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js'; export { shipping } from './shipping.js';
export { payment } from './payment.js'; export { payment } from './payment.js';
export { account } from './account.js'; export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js'; export { mcp } from './mcp.js';
export { backup } from './backup.js'; export { backup } from './backup.js';
export { carrier } from './carrier.js'; export { carrier } from './carrier.js';

View File

@@ -5,10 +5,11 @@ import { http } from './http.js';
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb } * Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
*/ */
export const labeltemplate = { export const labeltemplate = {
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */ /** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
// #region GET /api/labeltemplate — list // #region GET /api/labeltemplate — list
list() { list(params) {
return http('/api/labeltemplate'); const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
return http(`/api/labeltemplate${search}`);
}, },
// #endregion // #endregion
@@ -48,5 +49,23 @@ export const labeltemplate = {
}, },
// #endregion // #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 // thumb is returned inline as a base64 string in list/get responses
}; };

View File

@@ -2,8 +2,8 @@ import { http } from './http.js';
export const posterminal = { export const posterminal = {
/** /**
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] } * GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
* List all terminals for the selected company. * List all terminals for the selected company, sorted by sort ASC, id ASC.
*/ */
// #region GET /api/posterminal — list // #region GET /api/posterminal — list
list() { list() {
@@ -13,24 +13,29 @@ export const posterminal = {
/** /**
* PUT /api/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). * 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 // #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', { return http('/api/posterminal', {
method: 'PUT', method: 'PUT',
body: { name }, body,
}); });
}, },
// #endregion // #endregion
/** /**
* PATCH /api/posterminal/:id * 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. * Update posterminal configuration.
* → 200 { posterminal } * → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
*/ */
// #region PATCH /api/posterminal/:id — update // #region PATCH /api/posterminal/:id — update
update(terminalId, patch) { update(terminalId, patch) {

View File

@@ -51,4 +51,29 @@ export const printer = {
return http('/api/printer/order', { method: 'PUT', body: { printerIds } }); return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
}, },
// #endregion // #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
View 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
View 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',
});
},
};

View File

@@ -12,4 +12,10 @@ export const thumb = {
return `/api/thumb/${encodeURIComponent(fullHash)}`; return `/api/thumb/${encodeURIComponent(fullHash)}`;
}, },
// #endregion // #endregion
// #region GET /api/smallthumb/:fullHash — smallUrl
smallUrl(fullHash) {
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
}; };

View File

@@ -38,6 +38,9 @@ const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'
const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage')); const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage'));
const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage')); const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage'));
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage')); const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
const CompanyAutomatDebugPage = import.meta.env.DEV
? lazy(() => import('./routes/debug/CompanyAutomatPage'))
: null;
function LoginRedirect() { function LoginRedirect() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
@@ -131,6 +134,19 @@ const routes = [
{ path: ':entityId', element: <KundeEditPage /> }, { 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 /> }, { path: '*', element: <Navigate to="/artikel" replace /> },
], ],
}, },

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -11,7 +11,7 @@
* history or supplier details of a specific article. * 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 { article as articleApi } from '../api';
import { requireArray, requireObject } from '../utils/apiShape'; import { requireArray, requireObject } from '../utils/apiShape';
@@ -50,14 +50,15 @@ const skuHistoryAutomats = new Map();
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU. * @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
*/ */
export function getArticleHistoryAutomat(sku) { export function getArticleHistoryAutomat(sku) {
const key = String(sku); const rawKey = String(sku);
const key = rawKey.trim().toLowerCase();
let inst = skuHistoryAutomats.get(key); let inst = skuHistoryAutomats.get(key);
if (!inst) { if (!inst) {
inst = createChunkListAutomat({ inst = createChunkListAutomat({
name: `articleHistory:${key}`, name: `articleHistory:${rawKey}`,
chunkSize: 50, chunkSize: 50,
maxLruChunks: 50, maxLruChunks: 50,
fetchPage: (params) => articleApi.getBySkuHistory(key, params), fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
}); });
skuHistoryAutomats.set(key, inst); skuHistoryAutomats.set(key, inst);
} }
@@ -74,14 +75,14 @@ export const articleHistoryAutomat = {
get: getArticleHistoryAutomat, get: getArticleHistoryAutomat,
key: getArticleHistoryAutomat, key: getArticleHistoryAutomat,
has(sku) { has(sku) {
return skuHistoryAutomats.has(String(sku)); return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
}, },
async load(sku) { async load(sku) {
const inst = getArticleHistoryAutomat(sku); const inst = getArticleHistoryAutomat(sku);
return inst.getItems(); return inst.getItems();
}, },
async reload(sku) { async reload(sku) {
const inst = skuHistoryAutomats.get(String(sku)); const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
if (inst) { if (inst) {
await inst.actions.reload(); await inst.actions.reload();
} }
@@ -114,7 +115,12 @@ export const articleSupplierAutomat = new KeyedAutomat(
loader: async (sku) => { loader: async (sku) => {
const res = await articleApi.getSuppliers(String(sku)); const res = await articleApi.getSuppliers(String(sku));
requireObject(res, 'GET /api/article/:sku/supplier'); 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;
}, },
}, },
); );

View File

@@ -29,12 +29,17 @@
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat'; import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
import sseAutomat from './sseAutomat';
import { import {
categoryViews, categoryViews,
collectLoadedCategoryViewData, collectLoadedCategoryViewData,
loadAllCategoryViews, loadAllCategoryViews,
invalidateAllCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
/** /**
@@ -87,10 +92,6 @@ function scheduleRebuild() {
/** /**
* Normalizes a category-view article row into an index record. * 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 {object} article Raw category-view article row.
* @param {number|string|null} categoryId Category the article was loaded from. * @param {number|string|null} categoryId Category the article was loaded from.
* @returns {ArticleIndexRecord} Index record. * @returns {ArticleIndexRecord} Index record.
@@ -98,7 +99,7 @@ function scheduleRebuild() {
function toIndexRecord(article, categoryId) { function toIndexRecord(article, categoryId) {
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
const childCount = Number(article.childCount) || 0; 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 warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
const trackStock = article.track_stock ?? article.trackStock; const trackStock = article.track_stock ?? article.trackStock;
const stock = trackStock === false const stock = trackStock === false
@@ -111,12 +112,25 @@ function toIndexRecord(article, categoryId) {
const available = article.available != null && !Number.isNaN(Number(article.available)) const available = article.available != null && !Number.isNaN(Number(article.available))
? Number(article.available) ? Number(article.available)
: stock; : 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 { return {
...article, ...article,
id: article.id, id: article.id,
sku: article.sku, sku: article.sku,
name: article.name, name: article.name,
descLength,
seo: article.seo ?? null,
price, price,
net: article.net ?? price, net: article.net ?? price,
stock, stock,
@@ -126,9 +140,15 @@ function toIndexRecord(article, categoryId) {
? article.articlewarehousesummary ? article.articlewarehousesummary
: [], : [],
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [], 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)), isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
childCount, 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; 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; 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 to idle state (logout / company switch). */
reset() { reset() {
inflightEnsure = null; inflightEnsure = null;
@@ -294,6 +358,82 @@ authAutomat.subscribe((auth) => {
if (auth.status === 'anon') actions.reset(); 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). * Patches a single record's name in the derived index (optimistic update).
* *

View File

@@ -67,6 +67,8 @@ const initialState = {
filter: '', filter: '',
brand: '', brand: '',
supplier: '', supplier: '',
empty: '',
hierarchy: '',
offset: 0, offset: 0,
pageSize: null, pageSize: null,
loading: false, loading: false,
@@ -343,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
extraArticles = [], extraArticles = [],
allowParent = false, allowParent = false,
categories = [], categories = [],
hierarchy = '',
empty = '',
offset = 0, offset = 0,
pageSize = 10, pageSize = 10,
limit, limit,
@@ -367,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase())); const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
let pool = rawMatches; let pool = rawMatches;
if (!allowParent) { if (!allowParent && !hierarchy) {
pool = pool.filter((a) => !a.isParent && !a.is_parent); pool = pool.filter((a) => !a.isParent && !a.is_parent);
} }
if (excludedSet.size > 0) { if (excludedSet.size > 0) {
@@ -378,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase())); 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; let matchesList = pool;
if (categories && categories.length > 0) { if (categories && categories.length > 0) {
@@ -669,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
return facets; 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
// #endregion // #endregion
@@ -689,6 +759,8 @@ const actions = {
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? ''); const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? ''); 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 pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
@@ -752,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 sortedArticles = sortArticlesByRelevance(filteredArticles, query);
const totalCount = sortedArticles.length; const totalCount = sortedArticles.length;
@@ -781,6 +911,8 @@ const actions = {
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset, offset,
pageSize: safePageSize, pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading', loading: articleIndexAutomat.state.status === 'loading',

View File

@@ -20,7 +20,12 @@ import { Automat } from 'react-automat';
import { article as articleApi } from '../api'; import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape'; import { requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import articleIndexAutomat, { setArticleIndexName, setArticleIndexStock } from './articleIndexAutomat'; import articleIndexAutomat, {
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
} from './articleIndexAutomat';
import sseAutomat from './sseAutomat';
/** /**
* @typedef {Object} ArtikelState * @typedef {Object} ArtikelState
@@ -113,12 +118,7 @@ const actions = {
artikelAutomat.setState({ artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article }, bySku: { ...artikelAutomat.state.bySku, [key]: article },
}); });
if (article.warehouses || article.stock != null) { updateArticleIndexRecord(article);
setArticleIndexStock(article.sku, {
stock: article.stock,
warehouses: article.warehouses,
});
}
return article; return article;
}, },
@@ -325,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; export default artikelAutomat;

View File

@@ -30,9 +30,13 @@ import { auth, company, number as numberApi } from '../api';
* @typedef {Object} AuthState * @typedef {Object} AuthState
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions. * @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
* @property {string|null} googleClientId Google Identity Services client ID. * @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 {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
* @property {boolean} hasAuth True when the session has a valid authenticated user. * @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 {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. */ /** Emitted after an authenticated session has been established. */
@@ -46,14 +50,30 @@ export const AUTH_LOGGED_OUT = 'auth/loggedOut';
const initialState = { const initialState = {
user: null, user: null,
googleClientId: null, googleClientId: null,
schemaVersion: null,
vhost: false,
vhostSettings: null,
status: 'loading', status: 'loading',
hasAuth: false, hasAuth: false,
hasCompany: false, hasCompany: false,
retryNotification: null,
}; };
let emit = () => { }; let emit = () => { };
// #region helpers // #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. * Registers the application-level dispatcher used for authentication events.
* *
@@ -64,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
emit = dispatch; emit = dispatch;
} }
function normalizePicture(picture) { export function normalizePicture(picture) {
if (!picture || typeof picture !== 'string') return null; if (!picture || typeof picture !== 'string') return null;
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) { if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
return picture; return picture;
@@ -108,70 +128,111 @@ function buildAuthState(user, status, extra = {}) {
status, status,
hasAuth, hasAuth,
hasCompany, hasCompany,
vhost: authAutomat?.state?.vhost ?? false,
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
retryNotification: null,
...extra, ...extra,
}; };
} }
function syncSse(state) { function syncSse(state) {
if (state.hasAuth) {
void import('./sseAutomat')
.then(({ default: sseAutomat }) => {
sseAutomat.actions.syncWithAuth?.(state);
})
.catch(() => { });
} else {
const sseAutomat = Automat.get('sse'); const sseAutomat = Automat.get('sse');
if (sseAutomat?.actions?.syncWithAuth) { if (sseAutomat?.actions?.syncWithAuth) {
sseAutomat.actions.syncWithAuth(state); sseAutomat.actions.syncWithAuth(state);
} }
} }
}
// #endregion
let authAutomat; function applySession(nextSession) {
const actions = {
// #region bootstrapSession
// MARK: calls auth.me from ../api
/**
* Restores an existing session or requests it from the authentication API.
*
* @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 = const googleClientId =
nextSession.googleClientId ?? authAutomat.state.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 (!nextSession.user) {
if (authAutomat.state.status !== 'authed') { if (authAutomat.state.status !== 'authed') {
const nextState = authAutomat.setState( const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { googleClientId }) buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
); );
syncSse(nextState); syncSse(nextState);
} else { } else {
authAutomat.setState({ googleClientId }); authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
} }
return nextSession; return nextSession;
} }
const user = mergeUser(authAutomat.state.user, nextSession.user); const user = mergeUser(authAutomat.state.user, nextSession.user);
const nextState = authAutomat.setState( const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { googleClientId }) buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
); );
syncSse(nextState); syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED }); emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession; 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) {
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) { } 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') { if (authAutomat.state.status === 'loading') {
const nextState = authAutomat.setState(buildAuthState(null, 'anon')); const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { retryNotification: null })
);
syncSse(nextState); syncSse(nextState);
} }
throw error; throw error;
} }
}
})().finally(() => {
inFlightBootstrap = null;
});
return inFlightBootstrap;
}, },
// #endregion // #endregion
@@ -193,6 +254,7 @@ const actions = {
const nextState = authAutomat.setState( const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { buildAuthState(user, 'authed', {
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
}) })
); );
syncSse(nextState); syncSse(nextState);
@@ -302,6 +364,10 @@ export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany); export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
export const selectSelectedCompanyId = () => export const selectSelectedCompanyId = () =>
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null; 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. * Re-reads `GET /api/auth/me` from the server.
@@ -309,10 +375,10 @@ export const selectSelectedCompanyId = () =>
* Called after login, logout, and company switches so the automat reflects * Called after login, logout, and company switches so the automat reflects
* the current server session rather than an optimistic guess. * 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() { export function bootstrapAuthMe() {
return auth.me(); return authAutomat.actions.bootstrapSession();
} }
// #endregion // #endregion

View File

@@ -30,6 +30,7 @@ import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api'; import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat'; import companyAutomat from './companyAutomat';
import sseAutomat from './sseAutomat';
import { import {
attachCategoryTree, attachCategoryTree,
categoryViews, categoryViews,
@@ -37,6 +38,7 @@ import {
disposeAllCategoryViewAutomats, disposeAllCategoryViewAutomats,
refreshCategoryViewChildren, refreshCategoryViewChildren,
appendArticlesToCategoryViews, appendArticlesToCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku, removeArticleBySku,
waitForCategoryTree, waitForCategoryTree,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
@@ -512,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 // Re-export pure helpers for consumers that previously imported from categorySlice
export { export {
ROOT_CATEGORY_NAME, ROOT_CATEGORY_NAME,
@@ -532,6 +542,7 @@ export {
disposeAllCategoryViewAutomats, disposeAllCategoryViewAutomats,
loadAllCategoryViews, loadAllCategoryViews,
collectLoadedCategoryViewData, collectLoadedCategoryViewData,
updateArticleInCategoryViews,
} from './categoryViewAutomat'; } from './categoryViewAutomat';
export default categoryAutomat; export default categoryAutomat;

View File

@@ -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. * Removes a deleted article from every loaded view that contains it.
* *

View File

@@ -15,6 +15,7 @@
* - Brands (`brands`, `filteredBrands`, `brandFilter`) * - Brands (`brands`, `filteredBrands`, `brandFilter`)
* - Product attributes & values (`attributes`) * - Product attributes & values (`attributes`)
* - Payment accounts (`accounts`) * - Payment accounts (`accounts`)
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
* *
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when * Lives with the 1st-level navigation shell (`AppShell`) and triggers when
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
@@ -25,6 +26,10 @@ import {
company as companyApi, company as companyApi,
number as numberApi, number as numberApi,
posterminal as posterminalApi, posterminal as posterminalApi,
printer as printerApi,
printeragent as printeragentApi,
agentprinter as agentprinterApi,
printerjob as printerjobApi,
supplier as supplierApi, supplier as supplierApi,
unit as unitApi, unit as unitApi,
warehouse as warehouseApi, warehouse as warehouseApi,
@@ -35,10 +40,13 @@ import {
attributename as attributenameApi, attributename as attributenameApi,
attributevalue as attributevalueApi, attributevalue as attributevalueApi,
account as accountApi, account as accountApi,
companyuser as companyuserApi,
autoinvite as autoinviteApi,
} from '../api'; } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { normalizeCompany } from '../utils/companyForm'; import { normalizeCompany } from '../utils/companyForm';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import sseAutomat from './sseAutomat';
// #region userSettings persistence helpers // #region userSettings persistence helpers
const USER_SETTINGS_STORAGE_KEY = 'dash.userSettings'; const USER_SETTINGS_STORAGE_KEY = 'dash.userSettings';
@@ -158,6 +166,33 @@ const sortBrands = (brands) =>
const sortGroups = (items) => const sortGroups = (items) =>
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)); [...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
const sortTerminals = (terminals) => {
if (!Array.isArray(terminals)) return [];
return [...terminals].sort((a, b) => {
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
if (sDiff !== 0) return sDiff;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
};
const sortPrinterAgents = (agents) => {
if (!Array.isArray(agents)) return [];
return [...agents].sort((a, b) => {
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
if (sDiff !== 0) return sDiff;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
};
const sortAgentPrinters = (printers) => {
if (!Array.isArray(printers)) return [];
return [...printers].sort((a, b) => {
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
if (sDiff !== 0) return sDiff;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
};
const sortAccounts = (items) => const sortAccounts = (items) =>
[...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); [...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
@@ -211,6 +246,7 @@ const initialUserSettings = readInitialUserSettings();
const initialState = { const initialState = {
// 1. Company profile // 1. Company profile
companies: [],
current: null, current: null,
status: 'idle', status: 'idle',
error: null, error: null,
@@ -226,6 +262,19 @@ const initialState = {
terminalsStatus: 'idle', terminalsStatus: 'idle',
terminalsError: null, terminalsError: null,
// Printer agents & agent printers
printeragents: [],
printeragentsCompanyId: null,
printeragentsStatus: 'idle',
printeragentsError: null,
agentprinters: [],
agentprintersCompanyId: null,
agentprintersStatus: 'idle',
agentprintersError: null,
livePrinters: [], // LivePrinter[] from /api/printer/state or SSE printer/printerstate
// 4. Suppliers // 4. Suppliers
suppliers: [], suppliers: [],
articlesBySupplier: {}, articlesBySupplier: {},
@@ -284,6 +333,14 @@ const initialState = {
accounts: [], accounts: [],
accountsStatus: 'idle', accountsStatus: 'idle',
accountsError: null, accountsError: null,
// 14. Members & invites (admin only; never part of loadAll)
members: [],
membersStatus: 'idle',
membersError: null,
invites: [],
invitesStatus: 'idle',
invitesError: null,
}; };
let companyAutomat; let companyAutomat;
@@ -292,6 +349,9 @@ let companyAutomat;
let inflightCompany = null; let inflightCompany = null;
let inflightNumbers = null; let inflightNumbers = null;
let inflightTerminals = null; let inflightTerminals = null;
let inflightPrinterAgents = null;
let inflightAgentPrinters = null;
let inflightLivePrinters = null;
let inflightSuppliers = null; let inflightSuppliers = null;
let inflightUnits = null; let inflightUnits = null;
let inflightWarehouses = null; let inflightWarehouses = null;
@@ -301,6 +361,8 @@ let inflightCarriers = null;
let inflightBrands = null; let inflightBrands = null;
let inflightAttributes = null; let inflightAttributes = null;
let inflightAccounts = null; let inflightAccounts = null;
let inflightMembers = null;
let inflightInvites = null;
// #endregion // #endregion
// #region internal state updaters & fetchers // #region internal state updaters & fetchers
@@ -385,18 +447,33 @@ async function fetchCarriersList() {
const actions = { const actions = {
// #region 1. Company profile & orchestration // #region 1. Company profile & orchestration
// MARK: calls company.getById from ../api/index.js // MARK: calls company.list from ../api/index.js
async load() { async load() {
const companyId = authAutomat.state.user?.selectedCompanyId; const hasAuth = Boolean(
if (!companyId) return null; authAutomat.state.hasAuth ??
(authAutomat.state.status === 'authed' && authAutomat.state.user),
);
if (!hasAuth) return null;
if (inflightCompany) return inflightCompany; if (inflightCompany) return inflightCompany;
companyAutomat.setState({ status: 'loading', error: null }); companyAutomat.setState({ status: 'loading', error: null });
inflightCompany = (async () => { inflightCompany = (async () => {
try { try {
const data = await companyApi.getById(companyId); const data = await companyApi.list();
const normalized = normalizeCompany(data.company); const companies = Array.isArray(data?.companies)
companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); ? data.companies.map((item) => normalizeCompany(item)).filter(Boolean)
return normalized; : [];
const companyId = authAutomat.state.user?.selectedCompanyId;
const current =
companyId != null
? (companies.find(
(item) => item.id === companyId || String(item.id) === String(companyId),
) ?? null)
: null;
if (companyId != null && !current) {
throw new Error('Unternehmen nicht gefunden.');
}
companyAutomat.setState({ companies, current, status: 'succeeded', error: null });
return current;
} catch (err) { } catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err; throw err;
@@ -407,16 +484,55 @@ const actions = {
return inflightCompany; return inflightCompany;
}, },
// MARK: calls company.update and company.getById from ../api/index.js // MARK: calls company.list from ../api/index.js
async reload() {
const hasAuth = Boolean(
authAutomat.state.hasAuth ??
(authAutomat.state.status === 'authed' && authAutomat.state.user),
);
if (!hasAuth) return null;
try {
const data = await companyApi.list();
const companies = Array.isArray(data?.companies)
? data.companies.map((item) => normalizeCompany(item)).filter(Boolean)
: [];
const companyId = authAutomat.state.user?.selectedCompanyId;
const current =
companyId != null
? (companies.find(
(item) => item.id === companyId || String(item.id) === String(companyId),
) ?? companyAutomat.state.current)
: null;
companyAutomat.setState({ companies, current, status: 'succeeded', error: null });
return current;
} catch (err) {
companyAutomat.setState({ error: err?.message ?? String(err) });
throw err;
}
},
// MARK: calls company.update and company.list from ../api/index.js
async update(payload) { async update(payload) {
const companyId = authAutomat.state.user?.selectedCompanyId; const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
companyAutomat.setState({ status: 'loading', error: null }); companyAutomat.setState({ status: 'loading', error: null });
try { try {
await companyApi.update(companyId, payload); await companyApi.update(companyId, payload);
const data = await companyApi.getById(companyId); const data = await companyApi.list();
const normalized = normalizeCompany(data.company, payload); const companies = Array.isArray(data?.companies)
companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); ? data.companies
.map((item) =>
item.id === companyId || String(item.id) === String(companyId)
? normalizeCompany(item, payload)
: normalizeCompany(item),
)
.filter(Boolean)
: [];
const normalized =
companies.find((item) => item.id === companyId || String(item.id) === String(companyId)) ??
null;
if (!normalized) throw new Error('Unternehmen nicht gefunden.');
companyAutomat.setState({ companies, current: normalized, status: 'succeeded', error: null });
return normalized; return normalized;
} catch (err) { } catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
@@ -431,6 +547,9 @@ const actions = {
actions.load(), actions.load(),
actions.loadNumbers(), actions.loadNumbers(),
actions.loadTerminals(), actions.loadTerminals(),
actions.loadPrinterAgents(),
actions.loadAgentPrinters(),
actions.loadLivePrinters(),
actions.loadSuppliers(), actions.loadSuppliers(),
actions.loadUnits(), actions.loadUnits(),
actions.loadWarehouses(), actions.loadWarehouses(),
@@ -447,6 +566,9 @@ const actions = {
inflightCompany = null; inflightCompany = null;
inflightNumbers = null; inflightNumbers = null;
inflightTerminals = null; inflightTerminals = null;
inflightPrinterAgents = null;
inflightAgentPrinters = null;
inflightLivePrinters = null;
inflightSuppliers = null; inflightSuppliers = null;
inflightUnits = null; inflightUnits = null;
inflightWarehouses = null; inflightWarehouses = null;
@@ -456,6 +578,8 @@ const actions = {
inflightBrands = null; inflightBrands = null;
inflightAttributes = null; inflightAttributes = null;
inflightAccounts = null; inflightAccounts = null;
inflightMembers = null;
inflightInvites = null;
companyAutomat.setState({ companyAutomat.setState({
...initialState, ...initialState,
viewerPosition: companyAutomat.state.viewerPosition, viewerPosition: companyAutomat.state.viewerPosition,
@@ -522,7 +646,7 @@ const actions = {
inflightTerminals = (async () => { inflightTerminals = (async () => {
try { try {
const res = await posterminalApi.list(); const res = await posterminalApi.list();
const terminals = Array.isArray(res.posterminals) ? res.posterminals : []; const terminals = sortTerminals(Array.isArray(res.posterminals) ? res.posterminals : []);
companyAutomat.setState({ companyAutomat.setState({
terminals, terminals,
terminalsCompanyId: companyId, terminalsCompanyId: companyId,
@@ -547,11 +671,15 @@ const actions = {
return actions.loadTerminals(); return actions.loadTerminals();
}, },
async createTerminal({ name }) { reloadTerminals() {
const res = await posterminalApi.create({ name }); return actions.loadTerminals();
},
async createTerminal(payload) {
const res = await posterminalApi.create(payload);
const terminal = res.posterminal; const terminal = res.posterminal;
companyAutomat.setState({ companyAutomat.setState({
terminals: [...companyAutomat.state.terminals, terminal], terminals: sortTerminals([...companyAutomat.state.terminals, terminal]),
}); });
return terminal; return terminal;
}, },
@@ -564,9 +692,11 @@ const actions = {
const res = await posterminalApi.update(terminalId, patch); const res = await posterminalApi.update(terminalId, patch);
const updated = res.posterminal; const updated = res.posterminal;
companyAutomat.setState({ companyAutomat.setState({
terminals: companyAutomat.state.terminals.map((t) => terminals: sortTerminals(
companyAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, ...updated } : t, t.id === terminalId ? { ...t, ...updated } : t,
), ),
),
}); });
return updated; return updated;
}, },
@@ -627,6 +757,180 @@ const actions = {
}, },
// #endregion // #endregion
// #region Printer agents (printeragent)
// MARK: calls printeragent.list from ../api/index.js
async loadPrinterAgents() {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightPrinterAgents) return inflightPrinterAgents;
companyAutomat.setState({ printeragentsStatus: 'loading', printeragentsError: null });
inflightPrinterAgents = (async () => {
try {
const res = await printeragentApi.list();
const printeragents = sortPrinterAgents(Array.isArray(res.printeragents) ? res.printeragents : []);
companyAutomat.setState({
printeragents,
printeragentsCompanyId: companyId,
printeragentsStatus: 'succeeded',
printeragentsError: null,
});
return printeragents;
} catch (err) {
companyAutomat.setState({
printeragentsStatus: 'failed',
printeragentsError: err?.message ?? String(err),
});
throw err;
} finally {
inflightPrinterAgents = null;
}
})();
return inflightPrinterAgents;
},
reloadPrinterAgents() {
return actions.loadPrinterAgents();
},
async createPrinterAgent(payload) {
const res = await printeragentApi.create(payload);
const agent = res.printeragent;
companyAutomat.setState({
printeragents: sortPrinterAgents([...companyAutomat.state.printeragents, agent]),
});
return agent;
},
async updatePrinterAgent(agentId, patch) {
const res = await printeragentApi.update(agentId, patch);
const updated = res.printeragent;
companyAutomat.setState({
printeragents: sortPrinterAgents(
companyAutomat.state.printeragents.map((a) =>
a.id === agentId ? { ...a, ...updated } : a,
),
),
});
return updated;
},
async deletePrinterAgent(agentId) {
await printeragentApi.delete(agentId);
companyAutomat.setState({
printeragents: companyAutomat.state.printeragents.filter((a) => a.id !== agentId),
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.printeragentId !== agentId),
});
},
// #endregion
// #region Agent printers (agentprinter) & live telemetry
// MARK: calls agentprinter.list from ../api/index.js
async loadAgentPrinters(printeragentId) {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightAgentPrinters) return inflightAgentPrinters;
companyAutomat.setState({ agentprintersStatus: 'loading', agentprintersError: null });
inflightAgentPrinters = (async () => {
try {
const res = await agentprinterApi.list(printeragentId);
const agentprinters = sortAgentPrinters(Array.isArray(res.agentprinters) ? res.agentprinters : []);
companyAutomat.setState({
agentprinters,
agentprintersCompanyId: companyId,
agentprintersStatus: 'succeeded',
agentprintersError: null,
});
return agentprinters;
} catch (err) {
companyAutomat.setState({
agentprintersStatus: 'failed',
agentprintersError: err?.message ?? String(err),
});
throw err;
} finally {
inflightAgentPrinters = null;
}
})();
return inflightAgentPrinters;
},
reloadAgentPrinters(printeragentId) {
return actions.loadAgentPrinters(printeragentId);
},
async updateAgentPrinter(id, patch) {
const res = await agentprinterApi.update(id, patch);
const updated = res.agentprinter;
companyAutomat.setState({
agentprinters: sortAgentPrinters(
companyAutomat.state.agentprinters.map((p) =>
p.id === id ? { ...p, ...updated } : p,
),
),
});
return updated;
},
async reorderAgentPrinters({ printeragentId, agentprinterIds }) {
await agentprinterApi.order({ printeragentId, agentprinterIds });
const idOrder = new Map(agentprinterIds.map((id, index) => [id, index]));
companyAutomat.setState({
agentprinters: sortAgentPrinters(
companyAutomat.state.agentprinters.map((p) =>
idOrder.has(p.id) ? { ...p, sort: idOrder.get(p.id) } : p,
),
),
});
},
async deleteAgentPrinter(id) {
await agentprinterApi.delete(id);
companyAutomat.setState({
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.id !== id),
});
},
async sendTestPrint(agentprinterId, options = {}) {
return printerjobApi.sendTestZpl(agentprinterId, options);
},
async cancelAllPrinterJobs(agentprinterId) {
return printerjobApi.cancelAll({ agentprinterId });
},
async createZplJob(payload) {
return printerjobApi.createZplJob(payload);
},
async loadLivePrinters() {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightLivePrinters) return inflightLivePrinters;
inflightLivePrinters = (async () => {
try {
const res = await printerApi.state();
const livePrinters = Array.isArray(res.printers) ? res.printers : [];
companyAutomat.setState({ livePrinters });
return livePrinters;
} catch (err) {
return [];
} finally {
inflightLivePrinters = null;
}
})();
return inflightLivePrinters;
},
setLivePrinters(printers) {
companyAutomat.setState({
livePrinters: Array.isArray(printers) ? printers : [],
});
},
// #endregion
// #region 4. Suppliers (supplier) // #region 4. Suppliers (supplier)
// MARK: calls supplier.list from ../api/index.js // MARK: calls supplier.list from ../api/index.js
loadSuppliers() { loadSuppliers() {
@@ -649,6 +953,8 @@ const actions = {
}, },
async reloadSuppliers() { async reloadSuppliers() {
if (inflightSuppliers) return inflightSuppliers;
inflightSuppliers = (async () => {
try { try {
const suppliers = await fetchSuppliersList(); const suppliers = await fetchSuppliersList();
companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null }); companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null });
@@ -657,7 +963,11 @@ const actions = {
} catch (error) { } catch (error) {
companyAutomat.setState({ suppliersError: error }); companyAutomat.setState({ suppliersError: error });
throw error; throw error;
} finally {
inflightSuppliers = null;
} }
})();
return inflightSuppliers;
}, },
async loadArticlesForSupplier(supplierId) { async loadArticlesForSupplier(supplierId) {
@@ -701,6 +1011,44 @@ const actions = {
} }
}, },
syncArticleSuppliers(sku, suppliers) {
const artSku = String(sku).trim().toLowerCase();
const assignedSupplierIds = new Set(
(suppliers || []).map((s) => String(s.entityId ?? s.supplierId ?? s.id))
);
const prevMap = companyAutomat.state.articlesBySupplier || {};
let changed = false;
const nextMap = { ...prevMap };
for (const sId of assignedSupplierIds) {
const currentList = nextMap[sId] ? [...nextMap[sId]] : [];
const hasSku = currentList.some(
(item) => String(item.article?.sku ?? item.sku ?? '').toLowerCase() === artSku
);
if (!hasSku) {
currentList.push({ sku, article: { sku } });
nextMap[sId] = currentList;
changed = true;
}
}
for (const [sId, list] of Object.entries(nextMap)) {
if (!assignedSupplierIds.has(sId) && Array.isArray(list)) {
const filtered = list.filter(
(item) => String(item.article?.sku ?? item.sku ?? '').toLowerCase() !== artSku
);
if (filtered.length !== list.length) {
nextMap[sId] = filtered;
changed = true;
}
}
}
if (changed) {
companyAutomat.setState({ articlesBySupplier: nextMap });
}
},
async createSupplier(body) { async createSupplier(body) {
const data = await supplierApi.create(body); const data = await supplierApi.create(body);
requireObject(data, 'POST /api/supplier'); requireObject(data, 'POST /api/supplier');
@@ -1376,6 +1724,122 @@ const actions = {
return accounts; return accounts;
}, },
// #endregion // #endregion
// #region 14. Members (companyuser) & invites (autoinvite) — admin only
// MARK: calls companyuser.* / autoinvite.* from ../api/index.js
loadMembers() {
if (inflightMembers) return inflightMembers;
inflightMembers = (async () => {
companyAutomat.setState({ membersStatus: 'loading', membersError: null });
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersStatus: 'failed', membersError: error });
throw error;
} finally {
inflightMembers = null;
}
})();
return inflightMembers;
},
async reloadMembers() {
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersError: error });
throw error;
}
},
ensureMembersLoaded() {
if (companyAutomat.state.membersStatus === 'ready') {
return Promise.resolve(companyAutomat.state.members);
}
return actions.loadMembers();
},
/** Adds an already registered user by email. Rejects with 404/409 from the backend. */
async addMember(email, role = 'user') {
const res = await companyuserApi.create({ email, role });
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({ members: [...companyAutomat.state.members, member] });
return member;
},
/** Rejects with 409 when demoting the last admin. */
async setMemberRole(id, role) {
const res = await companyuserApi.setRole(id, role);
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({
members: companyAutomat.state.members.map((m) => (m.id === member.id ? member : m)),
});
return member;
},
/** Rejects with 409 when removing the last admin. */
async removeMember(id) {
await companyuserApi.delete(id);
companyAutomat.setState({ members: companyAutomat.state.members.filter((m) => m.id !== id) });
},
loadInvites() {
if (inflightInvites) return inflightInvites;
inflightInvites = (async () => {
companyAutomat.setState({ invitesStatus: 'loading', invitesError: null });
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesStatus: 'failed', invitesError: error });
throw error;
} finally {
inflightInvites = null;
}
})();
return inflightInvites;
},
async reloadInvites() {
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesError: error });
throw error;
}
},
ensureInvitesLoaded() {
if (companyAutomat.state.invitesStatus === 'ready') {
return Promise.resolve(companyAutomat.state.invites);
}
return actions.loadInvites();
},
/** Rejects with 409 when the email is already registered (use addMember then). */
async createInvite(email, role = 'user') {
const res = await autoinviteApi.create({ email, role });
const invite = requireObject(res?.autoinvite, 'autoinvite');
companyAutomat.setState({ invites: [...companyAutomat.state.invites, invite] });
return invite;
},
async deleteInvite(id) {
await autoinviteApi.delete(id);
companyAutomat.setState({ invites: companyAutomat.state.invites.filter((i) => i.id !== id) });
},
// #endregion
}; };
/** /**
@@ -1391,17 +1855,24 @@ companyAutomat = new Automat(initialState, actions, { name: 'company' });
hydrateUserSettingsFromIdb(companyAutomat); hydrateUserSettingsFromIdb(companyAutomat);
// #region authAutomat synchronization // #region authAutomat synchronization
let lastCompanyKey; let lastAuthKey;
function syncWithAuth(auth) { function syncWithAuth(auth) {
const hasAuth = Boolean(
auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user),
);
const hasCompany = Boolean( const hasCompany = Boolean(
auth?.hasCompany ?? (auth?.status === 'authed' && auth?.user?.selectedCompanyId != null), auth?.hasCompany ?? (hasAuth && auth?.user?.selectedCompanyId != null),
); );
const companyId = hasCompany ? String(auth.user.selectedCompanyId) : null; const companyId = hasCompany ? String(auth.user.selectedCompanyId) : null;
if (companyId === lastCompanyKey) return; const authKey = hasAuth ? `authed:${companyId ?? ''}` : 'anon';
lastCompanyKey = companyId; if (authKey === lastAuthKey) return;
if (companyId == null) { lastAuthKey = authKey;
if (!hasAuth) {
companyAutomat.actions.reset(); companyAutomat.actions.reset();
} else if (companyId == null) {
companyAutomat.actions.reset();
void companyAutomat.actions.load().catch(() => {});
} else { } else {
companyAutomat.actions.reset(); companyAutomat.actions.reset();
void companyAutomat.actions.loadAll(); void companyAutomat.actions.loadAll();
@@ -1412,8 +1883,78 @@ authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state); syncWithAuth(authAutomat.state);
// #endregion // #endregion
// #region sseAutomat synchronization
sseAutomat.actions.on('supplier', () => {
void companyAutomat.actions.reloadSuppliers().catch(() => {});
});
sseAutomat.actions.on('warehouse', () => {
void companyAutomat.actions.reloadWarehouses().catch(() => {});
});
sseAutomat.actions.on('brand', () => {
void companyAutomat.actions.loadBrands(true).catch(() => {});
});
sseAutomat.actions.on('unit', () => {
void companyAutomat.actions.loadUnits().catch(() => {});
});
sseAutomat.actions.on('carrier', () => {
void companyAutomat.actions.reloadCarriers().catch(() => {});
});
sseAutomat.actions.on('posterminal', () => {
void companyAutomat.actions.loadTerminals().catch(() => {});
});
sseAutomat.actions.on('printeragent', () => {
void companyAutomat.actions.loadPrinterAgents().catch(() => {});
});
sseAutomat.actions.on('agentprinter', () => {
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
});
sseAutomat.actions.on('printerstate', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
return;
}
} catch {}
void companyAutomat.actions.loadLivePrinters().catch(() => {});
});
sseAutomat.actions.on('printer', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
}
} catch {}
});
sseAutomat.actions.on('customergroup', () => {
void companyAutomat.actions.reloadCustomergroups().catch(() => {});
});
sseAutomat.actions.on('propertyname', () => {
void companyAutomat.actions.loadProperties().catch(() => {});
});
sseAutomat.actions.on('attributename', () => {
void companyAutomat.actions.loadAttributes().catch(() => {});
});
sseAutomat.actions.on('account', () => {
void companyAutomat.actions.reloadAccounts().catch(() => {});
});
// Admin-only lists: only refresh once an admin view has loaded them.
sseAutomat.actions.on('companyuser', () => {
if (companyAutomat.state.membersStatus !== 'ready') return;
void companyAutomat.actions.reloadMembers().catch(() => {});
});
sseAutomat.actions.on('autoinvite', () => {
if (companyAutomat.state.invitesStatus !== 'ready') return;
void companyAutomat.actions.reloadInvites().catch(() => {});
});
sseAutomat.actions.on('company', () => {
void companyAutomat.actions.reload().catch(() => {});
});
// #endregion
// #region selectors & exported action helpers // #region selectors & exported action helpers
// Company profile selectors // Company profile selectors
export const selectCompanies = () => companyAutomat.state.companies;
export const selectCurrentCompany = () => companyAutomat.state.current; export const selectCurrentCompany = () => companyAutomat.state.current;
export const selectCompanyId = () => companyAutomat.state.current?.id ?? null; export const selectCompanyId = () => companyAutomat.state.current?.id ?? null;
export const selectCompanyStatus = () => companyAutomat.state.status; export const selectCompanyStatus = () => companyAutomat.state.status;
@@ -1434,6 +1975,16 @@ export const selectPosterminals = () => companyAutomat.state.terminals;
export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus; export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus;
export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0; export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0;
// Printer agents selectors
export const selectPrinterAgents = () => companyAutomat.state.printeragents;
export const selectPrinterAgentStatus = () => companyAutomat.state.printeragentsStatus;
export const selectHasPrinterAgents = () => companyAutomat.state.printeragents.length > 0;
// Agent printers selectors
export const selectAgentPrinters = () => companyAutomat.state.agentprinters;
export const selectAgentPrinterStatus = () => companyAutomat.state.agentprintersStatus;
export const selectLivePrinters = () => companyAutomat.state.livePrinters;
// Suppliers selectors // Suppliers selectors
export const selectSuppliers = () => companyAutomat.state.suppliers; export const selectSuppliers = () => companyAutomat.state.suppliers;
export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus; export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus;
@@ -1474,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus
// Accounts selectors // Accounts selectors
export const selectAccounts = () => companyAutomat.state.accounts; export const selectAccounts = () => companyAutomat.state.accounts;
export const selectAccountsStatus = () => companyAutomat.state.accountsStatus; export const selectAccountsStatus = () => companyAutomat.state.accountsStatus;
// Own role in the selected company ('admin' | 'user' | undefined)
export const selectCompanyRole = () => companyAutomat.state.current?.role;
export const selectIsCompanyAdmin = () => companyAutomat.state.current?.role === 'admin';
// Members & invites selectors (admin only)
export const selectMembers = () => companyAutomat.state.members;
export const selectMembersStatus = () => companyAutomat.state.membersStatus;
export const selectInvites = () => companyAutomat.state.invites;
export const selectInvitesStatus = () => companyAutomat.state.invitesStatus;
// #endregion // #endregion
export default companyAutomat; export default companyAutomat;

View File

@@ -56,6 +56,7 @@ export {
default as articleIndexAutomat, default as articleIndexAutomat,
setArticleIndexName, setArticleIndexName,
setArticleIndexStock, setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex, buildArticleIndex,
selectArticleIndexItems, selectArticleIndexItems,
} from './articleIndexAutomat'; } from './articleIndexAutomat';
@@ -134,6 +135,7 @@ export {
export { default as companyAutomat } from './companyAutomat'; export { default as companyAutomat } from './companyAutomat';
export { export {
selectCompanies,
selectCurrentCompany, selectCurrentCompany,
selectCompanyId, selectCompanyId,
selectCompanyStatus, selectCompanyStatus,
@@ -149,6 +151,12 @@ export {
selectPosterminals, selectPosterminals,
selectPosterminalStatus, selectPosterminalStatus,
selectHasPosterminals, selectHasPosterminals,
selectPrinterAgents,
selectPrinterAgentStatus,
selectHasPrinterAgents,
selectAgentPrinters,
selectAgentPrinterStatus,
selectLivePrinters,
selectSuppliers, selectSuppliers,
selectSuppliersStatus, selectSuppliersStatus,
selectUnits, selectUnits,
@@ -170,6 +178,12 @@ export {
selectAttributeStatus, selectAttributeStatus,
selectAccounts, selectAccounts,
selectAccountsStatus, selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat'; } from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat'; export { default as categoryAutomat } from './categoryAutomat';

View File

@@ -21,6 +21,7 @@
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
/** /**
* @typedef {Object} SseState * @typedef {Object} SseState
@@ -28,6 +29,7 @@ import authAutomat from './authAutomat';
* @property {string|null} companyId Company the stream is bound to. * @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 {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 {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 EVENT_HISTORY_LIMIT = 200;
@@ -68,6 +70,7 @@ const initialState = {
companyId: null, companyId: null,
clientId: sseClientId, clientId: sseClientId,
events: [], events: [],
lastEvent: null,
}; };
/** Event name prefix for per-article signals. */ /** Event name prefix for per-article signals. */
@@ -76,18 +79,20 @@ const ARTICLE_EVENT_PREFIX = 'article-';
let sseAutomat; let sseAutomat;
/** @type {EventSource | null} */ /** @type {EventSource | null} */
let eventSource = null; let eventSource = null;
/** @type {AbortController | null} */
let streamAbortController = null;
let reconnectTimer = null;
/** /**
* Handlers for statically named events (`category`, `articles`, …). * Handlers for event subscriptions (`category`, `articles`, `article-*`, …).
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>} * @type {Map<string, Set<{handler: Function, detach: (() => void)|null}>>}
*/ */
const handlers = new Map(); const handlers = new Map();
let nextEventId = 0; let nextEventId = 0;
const seenEvents = new WeakSet(); const seenEvents = new WeakSet();
/** /**
* Handlers per watched SKU, so one `article-<sku>` listener is attached while * Handlers per watched SKU.
* at least one view is open on that SKU.
* @type {Map<string, Set<(sku: string) => void>>} * @type {Map<string, Set<(sku: string) => void>>}
*/ */
const articleWatchers = new Map(); const articleWatchers = new Map();
@@ -99,25 +104,41 @@ function recordEvent(event) {
const entry = { const entry = {
id: ++nextEventId, id: ++nextEventId,
type: event.type, 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 ?? '', lastEventId: event.lastEventId ?? '',
time: Date.now(), time: Date.now(),
}; };
sseAutomat.setState((state) => ({ sseAutomat.setState((state) => ({
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry], events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
lastEvent: entry,
})); }));
} }
function addTrackedListener(es, type, handler) { function clearReconnectTimer() {
const listener = (event) => { if (reconnectTimer) {
recordEvent(event); clearTimeout(reconnectTimer);
handler(event); reconnectTimer = null;
}; }
es.addEventListener(type, listener); }
return () => es.removeEventListener(type, listener);
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() { function disconnect() {
clearReconnectTimer();
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
if (eventSource) { if (eventSource) {
console.log('[sse] disconnect'); console.log('[sse] disconnect');
try { try {
@@ -125,21 +146,155 @@ function disconnect() {
} catch {} } catch {}
eventSource = null; eventSource = null;
} }
if (sseAutomat.state.status !== 'disconnected') { if (sseAutomat && sseAutomat.state.status !== 'disconnected') {
sseAutomat.setState({ 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. * @param {{ type: string, data: any, lastEventId?: string }} event
* @returns {void}
*/ */
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) { function attachHandlers(es) {
for (const [event, set] of handlers) { for (const [event, set] of handlers) {
if (event === '*' || event === 'article-*') continue;
for (const entry of set) { 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) { for (const [sku] of articleWatchers) {
@@ -147,25 +302,127 @@ function attachHandlers(es) {
} }
} }
/**
* Binds the single `article-<sku>` listener for a watched SKU.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
function attachArticleListener(sku) { function attachArticleListener(sku) {
if (!eventSource) return; if (!eventSource) return;
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => { 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); const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
const set = articleWatchers.get(sku);
if (set) {
for (const handler of set) handler(changed); for (const handler of set) handler(changed);
}
}); });
} }
async function startFetchStream(url, companyId) {
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
const controller = new AbortController();
streamAbortController = controller;
const signal = controller.signal;
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 });
eventSource = es;
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected', companyId });
});
addTrackedListener(es, 'user', (event) => {
dispatchSseEvent(event);
});
addTrackedListener(es, 'message', (event) => {
dispatchSseEvent(event);
});
es.onerror = (e) => {
console.error('[sse] event stream error', e);
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
attachHandlers(es);
}
// #region connect // #region connect
// MARK: opens GET /api/events EventSource when hasAuth is true // MARK: opens GET /api/events stream when hasAuth is true
function connect() { function connect() {
clearReconnectTimer();
const auth = authAutomat.state; const auth = authAutomat.state;
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user)); const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
if (!hasAuth) { if (!hasAuth) {
@@ -180,170 +437,11 @@ function connect() {
console.log('[sse] connecting'); console.log('[sse] connecting');
sseAutomat.setState({ status: 'connecting', companyId }); sseAutomat.setState({ status: 'connecting', companyId });
const es = new EventSource(url.href, { withCredentials: true }); if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
void startFetchStream(url, companyId);
// Category tree changed: refresh the tree and every open category view. } else {
addTrackedListener(es, 'category', () => { startEventSource(url, companyId);
const cat = Automat.get('category');
if (cat?.actions?.loadTree) {
void cat.actions.loadTree().catch((err) => {
console.error('[sse] category tree refresh failed', err);
});
} }
cat?.actions?.invalidateAllCategoryViews?.();
});
// Article index changed: refresh the index and open category views.
addTrackedListener(es, 'articles', () => {
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
const idx = Automat.get('articleIndex');
if (idx?.actions?.reload) {
void idx.actions.reload().catch((err) => {
console.error('[sse] article index refresh failed', err);
});
}
});
addTrackedListener(es, 'warehouse', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadWarehouses) {
void comp.actions.reloadWarehouses().catch((err) => {
console.error('[sse] warehouse refresh failed', err);
});
}
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
const idx = Automat.get('articleIndex');
if (idx?.actions?.reload) {
void idx.actions.reload().catch((err) => {
console.error('[sse] article index refresh on warehouse event failed', err);
});
}
});
addTrackedListener(es, 'carrier', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadCarriers) {
void comp.actions.reloadCarriers().catch((err) => {
console.error('[sse] carrier refresh failed', err);
});
}
});
addTrackedListener(es, 'attributename', () => {
const comp = Automat.get('company');
if (comp?.actions?.loadAttributes) {
void comp.actions.loadAttributes().catch((err) => {
console.error('[sse] attributename refresh failed', err);
});
}
});
addTrackedListener(es, 'propertyname', () => {
const comp = Automat.get('company');
if (comp?.actions?.loadProperties) {
void comp.actions.loadProperties().catch((err) => {
console.error('[sse] propertyname refresh failed', err);
});
}
});
addTrackedListener(es, 'supplier', () => {
const comp = Automat.get('company');
if (comp?.actions?.reloadSuppliers) {
void comp.actions.reloadSuppliers().catch((err) => {
console.error('[sse] supplier refresh failed', err);
});
}
});
addTrackedListener(es, 'brand', (event) => {
try {
const payload = JSON.parse(event?.data ?? '{}');
if (payload?.sender && payload.sender === getSseClientId()) return;
} catch {}
const comp = Automat.get('company');
if (comp?.actions?.loadBrands) {
void comp.actions.loadBrands(true).catch(() => {});
}
});
addTrackedListener(es, 'order', () => {
const ord = Automat.get('order');
if (ord?.actions?.reload) {
void ord.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
}
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
});
addTrackedListener(es, 'quote', () => {
const q = Automat.get('quote');
if (q?.actions?.reload) {
void q.actions.reload().catch((err) => {
console.error('[sse] quote refresh failed', err);
});
}
});
addTrackedListener(es, 'shipment', () => {
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
const ord = Automat.get('order');
if (ord?.actions?.reload) {
void ord.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
}
});
addTrackedListener(es, 'delivery', () => {
const del = Automat.get('delivery');
if (del?.actions?.reload) {
void del.actions.reload().catch((err) => {
console.error('[sse] delivery refresh failed', err);
});
}
});
addTrackedListener(es, 'retoure', () => {
const ret = Automat.get('retoure');
if (ret?.actions?.reload) {
void ret.actions.reload().catch((err) => {
console.error('[sse] retoure refresh failed', err);
});
}
const shp = Automat.get('shipment');
if (shp?.actions?.reload) {
void shp.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
}
});
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' });
});
addTrackedListener(es, 'message', () => {});
es.onerror = (e) => {
console.error('[sse] event stream error', e);
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
};
eventSource = es;
attachHandlers(es);
} }
// #endregion // #endregion
@@ -364,7 +462,7 @@ function handleAuthSync(auth) {
return; return;
} }
if (!relogin && eventSource && companyId === sseAutomat.state.companyId) { if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) {
return; return;
} }
@@ -394,14 +492,15 @@ const actions = {
}, },
clearEvents() { 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 `*`.
* *
* @param {string} event Event name (e.g. `category`, `articles`). * @param {string} event Event name or pattern.
* @param {(event: MessageEvent) => void} handler Signal handler. * @param {Function} handler Signal handler.
* @returns {() => void} Unsubscribe callback. * @returns {() => void} Unsubscribe callback.
*/ */
on(event, handler) { on(event, handler) {
@@ -414,7 +513,9 @@ const actions = {
if (!entry) { if (!entry) {
entry = { handler, detach: null }; entry = { handler, detach: null };
set.add(entry); set.add(entry);
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler); if (eventSource && event !== '*' && event !== 'article-*') {
entry.detach = addTrackedListener(eventSource, event, handler);
}
} }
return () => { return () => {
entry.detach?.(); entry.detach?.();
@@ -424,29 +525,56 @@ const actions = {
}, },
/** /**
* Subscribes to `article-<sku>` signals for one SKU. * Subscribes to any article mutation event (`article-<sku>`).
*
* @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 {string} sku Article SKU to watch.
* @param {(sku: string) => void} handler Called with the changed SKU. * @param {(sku: string) => void} handler Called with the changed SKU.
* @returns {() => void} Unsubscribe callback. * @returns {() => void} Unsubscribe callback.
*/ */
watchArticle(sku, handler) { watchArticle(sku, handler) {
const key = String(sku); const rawKey = String(sku).trim();
let set = articleWatchers.get(key); const lowerKey = rawKey.toLowerCase();
let set = articleWatchers.get(lowerKey);
if (!set) { if (!set) {
set = new Set(); set = new Set();
articleWatchers.set(key, set); articleWatchers.set(lowerKey, set);
attachArticleListener(key); attachArticleListener(rawKey);
} }
set.add(handler); set.add(handler);
return () => { return () => {
const current = articleWatchers.get(key); const current = articleWatchers.get(lowerKey);
if (!current) return; if (!current) return;
current.delete(handler); 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,
});
},
}; };
/** /**
@@ -468,14 +596,11 @@ export function invalidateArticle(sku) {
if (art?.actions?.reloadArticle) { if (art?.actions?.reloadArticle) {
void art.actions.reloadArticle(key).catch(() => {}); void art.actions.reloadArticle(key).catch(() => {});
} }
const hist = Automat.get('articleHistory'); if (articleHistoryAutomat.has(key)) {
if (hist?.has?.(key)) { void articleHistoryAutomat.reload(key).catch(() => {});
void hist.reload(key).catch(() => {});
}
const sup = Automat.get('articleSupplier');
if (sup?.has?.(key)) {
void sup.reload(key).catch(() => {});
} }
// Reload assigned suppliers and sync with companyAutomat
void articleSupplierAutomat.reload(key).catch(() => {});
} }
authAutomat.subscribe(handleAuthSync); authAutomat.subscribe(handleAuthSync);

View File

@@ -8,6 +8,7 @@ import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import OnlinePrinterIcons from './OnlinePrinterIcons';
import HelpBadge from './HelpBadge'; import HelpBadge from './HelpBadge';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery'; import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
@@ -198,8 +199,20 @@ class AppSidebar extends PureComponent {
})} })}
</List> </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 /> <UserMenu />
<ChatFab />
</Box> </Box>
</Box> </Box>
); );
@@ -418,7 +431,6 @@ export default function AppShell() {
</Suspense> </Suspense>
</Box> </Box>
</Box> </Box>
<ChatFab />
<ChatDrawer /> <ChatDrawer />
<PinnedAppletFabs pathname={pathname} /> <PinnedAppletFabs pathname={pathname} />
<PinnedAppletDialog /> <PinnedAppletDialog />

View File

@@ -1,7 +1,7 @@
import { Component } from 'react'; import { Component } from 'react';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import authAutomat, { is502BadGateway } from '../automats/authAutomat';
let sessionBootstrapped = false; let sessionBootstrapped = false;
@@ -10,15 +10,16 @@ class AuthBootstrap extends Component {
if (sessionBootstrapped) return; if (sessionBootstrapped) return;
sessionBootstrapped = true; sessionBootstrapped = true;
bootstrapAuthMe() authAutomat.actions.bootstrapSession()
.then((session) => { .then((session) => {
if (session.forbidden) { if (session?.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' }); enqueueSnackbar(session.message, { variant: 'warning' });
} }
return authAutomat.actions.bootstrapSession(session);
}) })
.catch((err) => { .catch((err) => {
if (!is502BadGateway(err)) {
notifyError(err); notifyError(err);
}
}); });
} }

View File

@@ -1,4 +1,4 @@
import { Component } from 'react'; import { PureComponent } from 'react';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -34,9 +34,10 @@ import TuneIcon from '@mui/icons-material/Tune';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
import BackendError from './BackendError'; import BackendError from './BackendError';
import { company } from '../api'; import authAutomat from '../automats/authAutomat';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import companyAutomat from '../automats/companyAutomat';
import { import {
defaultIso639, defaultIso639,
ISO639_OPTIONS, ISO639_OPTIONS,
@@ -51,13 +52,15 @@ const NUMBER_SCHEMES = [
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon }, { type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon }, { type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon }, { type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
{ type: 'return', label: 'Rücksendung (Lieferant)', icon: AssignmentReturnIcon },
{ type: 'retoure', label: 'Retoure (Kunde)', icon: AssignmentReturnIcon },
]; ];
const PRESETS = [ const PRESETS = [
{ {
id: 'standard', id: 'standard',
label: '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: { values: {
sku: { prefix: 'ART-', padding: 6, next: 1 }, sku: { prefix: 'ART-', padding: 6, next: 1 },
entity: { prefix: 'KD-', padding: 6, next: 1001 }, entity: { prefix: 'KD-', padding: 6, next: 1001 },
@@ -65,6 +68,8 @@ const PRESETS = [
adjustment: { prefix: 'KORR-', padding: 5, next: 1 }, adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
quote: { prefix: 'ANG-', padding: 6, next: 1 }, quote: { prefix: 'ANG-', padding: 6, next: 1 },
order: { prefix: 'AUF-', 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 },
}, },
}, },
{ {
@@ -78,12 +83,14 @@ const PRESETS = [
adjustment: { prefix: '', padding: 5, next: 90001 }, adjustment: { prefix: '', padding: 5, next: 90001 },
quote: { prefix: '', padding: 5, next: 40001 }, quote: { prefix: '', padding: 5, next: 40001 },
order: { prefix: '', padding: 5, next: 50001 }, order: { prefix: '', padding: 5, next: 50001 },
return: { prefix: 'RTN', padding: 5, next: 1 },
retoure: { prefix: 'RET', padding: 5, next: 1 },
}, },
}, },
{ {
id: 'compact', id: 'compact',
label: 'Kurzkürzel', 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: { values: {
sku: { prefix: 'A-', padding: 5, next: 1 }, sku: { prefix: 'A-', padding: 5, next: 1 },
entity: { prefix: 'K-', padding: 5, next: 1001 }, entity: { prefix: 'K-', padding: 5, next: 1001 },
@@ -91,12 +98,14 @@ const PRESETS = [
adjustment: { prefix: 'B-', padding: 5, next: 1 }, adjustment: { prefix: 'B-', padding: 5, next: 1 },
quote: { prefix: 'AG-', padding: 5, next: 1 }, quote: { prefix: 'AG-', padding: 5, next: 1 },
order: { prefix: 'AU-', 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', id: 'international',
label: '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: { values: {
sku: { prefix: 'SKU-', padding: 5, next: 1 }, sku: { prefix: 'SKU-', padding: 5, next: 1 },
entity: { prefix: 'C-', padding: 5, next: 1001 }, entity: { prefix: 'C-', padding: 5, next: 1001 },
@@ -104,6 +113,8 @@ const PRESETS = [
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 }, adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
quote: { prefix: 'Q-', padding: 5, next: 1 }, quote: { prefix: 'Q-', padding: 5, next: 1 },
order: { prefix: 'ORD-', 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 },
}, },
}, },
]; ];
@@ -115,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
return `${prefix ?? ''}${numStr}`; 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) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
companies: [], ...mapCompanyState(companyAutomat.state),
loading: true,
busy: false, busy: false,
error: null, error: null,
name: '', name: '',
@@ -139,26 +156,25 @@ class CompanySetupDialog extends Component {
} }
componentDidMount() { 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() { componentWillUnmount() {
this.setState({ loading: true }); this.unsubscribe?.();
try { this.unsubscribeAuth?.();
const data = await company.list();
this.setState({ companies: data.companies ?? [] });
} catch {
this.setState({ companies: [] });
} finally {
this.setState({ loading: false });
}
} }
async handleSelect(companyId) { async handleSelect(companyId) {
this.setState({ busy: true, error: null }); this.setState({ busy: true, error: null });
try { try {
await authAutomat.actions.selectCompany(companyId); await authAutomat.actions.selectCompany(companyId);
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -213,8 +229,6 @@ class CompanySetupDialog extends Component {
reducedtaxrate: parsedReducedtaxrate, reducedtaxrate: parsedReducedtaxrate,
numbers, numbers,
}); });
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -225,7 +239,6 @@ class CompanySetupDialog extends Component {
async handleLogout() { async handleLogout() {
try { try {
await authAutomat.actions.logout(); await authAutomat.actions.logout();
await bootstrapAuthMe();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} }
@@ -236,6 +249,10 @@ class CompanySetupDialog extends Component {
} }
render() { render() {
if (authAutomat.state.user?.selectedCompanyId != null) {
return null;
}
const { const {
companies, companies,
loading, loading,

View 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>
</>
);
}
}

View File

@@ -25,6 +25,7 @@ class UserMenu extends PureComponent {
this.state = { this.state = {
anchorEl: null, anchorEl: null,
settingsOpen: false, settingsOpen: false,
settingsInitialTab: null,
user: authAutomat.state.user, user: authAutomat.state.user,
helpEnabled: helpAutomat.state?.enabled ?? false, helpEnabled: helpAutomat.state?.enabled ?? false,
pwaUpdateAvailable: pwaAutomat.state.needRefresh, pwaUpdateAvailable: pwaAutomat.state.needRefresh,
@@ -37,6 +38,7 @@ class UserMenu extends PureComponent {
this.handleToggleHelp = this.handleToggleHelp.bind(this); this.handleToggleHelp = this.handleToggleHelp.bind(this);
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this); this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
this.handleOpenSettingsEvent = this.handleOpenSettingsEvent.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -47,12 +49,23 @@ class UserMenu extends PureComponent {
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({ this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
pwaUpdateAvailable: Boolean(state?.needRefresh), pwaUpdateAvailable: Boolean(state?.needRefresh),
})); }));
if (typeof window !== 'undefined') {
window.addEventListener('open-settings', this.handleOpenSettingsEvent);
}
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribeAuth?.(); this.unsubscribeAuth?.();
this.unsubscribeHelp?.(); this.unsubscribeHelp?.();
this.unsubscribePwa?.(); 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() { handleOpenPwaUpdate() {
@@ -74,11 +87,11 @@ class UserMenu extends PureComponent {
} }
handleOpenSettings() { handleOpenSettings() {
this.setState({ anchorEl: null, settingsOpen: true }); this.setState({ anchorEl: null, settingsOpen: true, settingsInitialTab: null });
} }
handleCloseSettings() { handleCloseSettings() {
this.setState({ settingsOpen: false }); this.setState({ settingsOpen: false, settingsInitialTab: null });
} }
handleToggleHelp(e) { handleToggleHelp(e) {
@@ -98,7 +111,7 @@ class UserMenu extends PureComponent {
} }
render() { render() {
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state; const { user, anchorEl, settingsOpen, settingsInitialTab, helpEnabled, pwaUpdateAvailable } = this.state;
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const pictureSrc = (() => { const pictureSrc = (() => {
@@ -201,7 +214,7 @@ class UserMenu extends PureComponent {
<ListItemText>Sign out</ListItemText> <ListItemText>Sign out</ListItemText>
</MenuItem> </MenuItem>
</Menu> </Menu>
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} /> <SettingsDialog open={settingsOpen} initialTab={settingsInitialTab} onClose={this.handleCloseSettings} />
</> </>
); );
} }

View File

@@ -31,7 +31,7 @@ class ArticleNameDialog extends Component {
super(props); super(props);
this.state = { this.state = {
mode: SINGLE_MODE, mode: SINGLE_MODE,
name: '', name: props.initialName ?? '',
bulkText: '', bulkText: '',
busy: false, busy: false,
error: null, error: null,
@@ -46,7 +46,7 @@ class ArticleNameDialog extends Component {
if (this.props.open && !prevProps.open) { if (this.props.open && !prevProps.open) {
this.setState({ this.setState({
mode: SINGLE_MODE, mode: SINGLE_MODE,
name: '', name: this.props.initialName ?? '',
bulkText: '', bulkText: '',
busy: false, busy: false,
error: null, error: null,

View File

@@ -9,14 +9,17 @@ import {
class ArticlePhoto extends Component { class ArticlePhoto extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { thumbLoaded: false }; this.state = { thumbLoaded: false, smallThumbError: false };
this.handleThumbLoad = this.handleThumbLoad.bind(this); this.handleThumbLoad = this.handleThumbLoad.bind(this);
this.handleThumbError = this.handleThumbError.bind(this); this.handleThumbError = this.handleThumbError.bind(this);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) { if (
this.setState({ thumbLoaded: false }); prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
prevProps.useSmallThumb !== this.props.useSmallThumb
) {
this.setState({ thumbLoaded: false, smallThumbError: false });
} }
} }
@@ -25,8 +28,12 @@ class ArticlePhoto extends Component {
} }
handleThumbError() { handleThumbError() {
if (this.props.useSmallThumb && !this.state.smallThumbError) {
this.setState({ smallThumbError: true, thumbLoaded: false });
} else {
this.setState({ thumbLoaded: false }); this.setState({ thumbLoaded: false });
} }
}
render() { render() {
const { const {
@@ -34,9 +41,11 @@ class ArticlePhoto extends Component {
size = 200, size = 200,
fill = false, fill = false,
objectFit = fill ? 'cover' : 'contain', objectFit = fill ? 'cover' : 'contain',
useSmallThumb = false,
sx, sx,
...rest
} = this.props; } = this.props;
const { thumbLoaded } = this.state; const { thumbLoaded, smallThumbError } = this.state;
const fullHash = photo?.fullHash; const fullHash = photo?.fullHash;
const hasPreview = photo?.thumbHash != null; const hasPreview = photo?.thumbHash != null;
const aspectRatio = const aspectRatio =
@@ -44,8 +53,15 @@ class ArticlePhoto extends Component {
? `${photo.thumbW} / ${photo.thumbH}` ? `${photo.thumbW} / ${photo.thumbH}`
: '1 / 1'; : '1 / 1';
const imgSrc = fullHash
? useSmallThumb && !smallThumbError
? thumb.smallUrl(fullHash)
: thumb.url(fullHash)
: null;
return ( return (
<Box <Box
{...rest}
sx={{ sx={{
position: 'relative', position: 'relative',
...(fill ...(fill
@@ -78,10 +94,10 @@ class ArticlePhoto extends Component {
<ThumbHashImage thumbHash={photo.thumbHash} /> <ThumbHashImage thumbHash={photo.thumbHash} />
</Box> </Box>
) : null} ) : null}
{fullHash ? ( {imgSrc ? (
<Box <Box
component="img" component="img"
src={thumb.url(fullHash)} src={imgSrc}
alt="" alt=""
onLoad={this.handleThumbLoad} onLoad={this.handleThumbLoad}
onError={this.handleThumbError} onError={this.handleThumbError}

View File

@@ -1,16 +1,27 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import Box from '@mui/material/Box';
import { PanelTile } from 'dashgui/panels'; import { PanelTile } from 'dashgui/panels';
import { primaryPhoto } from '../../utils/articleDetail'; import { sortedPhotos, primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto'; import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog'; import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends PureComponent { class ArticlePhotoTile extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { editOpen: false }; this.state = {
editOpen: false,
selectedHash: null,
};
this.handleEditOpen = this.handleEditOpen.bind(this); this.handleEditOpen = this.handleEditOpen.bind(this);
this.handleEditClose = this.handleEditClose.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() { handleEditOpen() {
@@ -21,6 +32,10 @@ class ArticlePhotoTile extends PureComponent {
this.setState({ editOpen: false }); this.setState({ editOpen: false });
} }
handleSelectPhoto(fullHash) {
this.setState({ selectedHash: fullHash });
}
render() { render() {
const { const {
article: articleData, article: articleData,
@@ -30,8 +45,14 @@ class ArticlePhotoTile extends PureComponent {
sx, sx,
color, color,
} = this.props; } = this.props;
const { editOpen } = this.state; const { editOpen, selectedHash } = this.state;
const photo = primaryPhoto(articleData);
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 ( return (
<> <>
@@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent {
busy: false, 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> </PanelTile>
<ArticlePhotosDialog <ArticlePhotosDialog
open={editOpen} open={editOpen}

View File

@@ -42,14 +42,6 @@ import {
SURFACE_RAISED, SURFACE_RAISED,
} from '../../colors'; } from '../../colors';
const REASON_PRESETS = [
'Inventurdifferenz',
'Korrektur',
'Bruch / Beschädigung',
'Schwund',
'Eigenbedarf / Test',
];
export default class ArticleStockAdjustDialog extends PureComponent { export default class ArticleStockAdjustDialog extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -86,7 +78,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''), warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''),
})); }));
}, ['warehouses']); }, ['warehouses']);
void companyAutomat.actions.loadWarehouses().catch(() => {});
} }
componentWillUnmount() { componentWillUnmount() {
@@ -95,7 +86,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) { if (this.props.open && !prevProps.open) {
void companyAutomat.actions.loadWarehouses().catch(() => {});
const initialWhId = this.resolveInitialWarehouseId(this.props); const initialWhId = this.resolveInitialWarehouseId(this.props);
const isTrackStock = const isTrackStock =
this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0; this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0;
@@ -381,11 +371,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Bestand verfolgen Bestand verfolgen
</Typography> </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> </Box>
<Switch <Switch
checked={Boolean(trackStock)} checked={Boolean(trackStock)}
@@ -475,11 +460,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
> >
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}> <ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} /> <TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
Menge zu-/abbuchen (+ / -) Zu-/abbuchen (+ / -)
</ToggleButton> </ToggleButton>
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}> <ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} /> <TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
Neuen Zählbestand setzen Bestand setzen
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
</Box> </Box>
@@ -493,7 +478,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
value={diffValue} value={diffValue}
onChange={this.handleDiffChange} onChange={this.handleDiffChange}
disabled={submitting || !trackStock} disabled={submitting || !trackStock}
placeholder="z.B. +5 oder -2"
slotProps={{ slotProps={{
input: { input: {
startAdornment: ( 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 <TextInput
fullWidth fullWidth
label="Tatsächlicher Zählbestand (Ist-Bestand)" label="Bestand"
type="number" type="number"
value={targetValue} value={targetValue}
onChange={this.handleTargetChange} onChange={this.handleTargetChange}
disabled={submitting || !trackStock} 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> <Box>
<TextInput <TextInput
fullWidth fullWidth
label="Grund / Buchungstext" label="Buchungstext"
value={reason} value={reason}
onChange={this.handleReasonChange} onChange={this.handleReasonChange}
disabled={submitting || !trackStock} 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>
</Box> </Box>
)} )}

View File

@@ -68,28 +68,27 @@ export default class ArticleUnitEditorDialog extends Component {
super(props); super(props);
this.state = { this.state = {
busy: false, busy: false,
units: [], units: companyAutomat.state.units,
unitsLoading: false, unitsLoading: companyAutomat.state.unitsStatus === 'loading',
form: emptyForm(), form: emptyForm(),
selectedSi: null, selectedSi: null,
}; };
} }
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
units: state.units,
unitsLoading: state.unitsStatus === 'loading',
}));
}
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) { if (this.props.open && !prevProps.open) {
this.setState({ form: emptyForm(), selectedSi: null }); this.setState({ form: emptyForm(), selectedSi: null });
void this.loadUnits();
}
}
async loadUnits() {
this.setState({ unitsLoading: true });
try {
const units = await companyAutomat.actions.loadUnits();
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; if (!formValid(form)) return;
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
const data = await unitApi.upsert({ const payload = {
si: String(form.si ?? '').trim(), si: String(form.si ?? '').trim(),
basesi: String(form.basesi ?? '').trim(), basesi: String(form.basesi ?? '').trim(),
basesifactor: parseFactor(form.basesifactor), basesifactor: parseFactor(form.basesifactor),
symbol: String(form.symbol ?? '').trim(), symbol: String(form.symbol ?? '').trim(),
name: String(form.name ?? '').trim(), name: String(form.name ?? '').trim(),
}); };
requireObject(data, 'PUT /api/unit'); const units = await companyAutomat.actions.upsertUnit(payload);
requireObject(data.unit, 'unit'); const saved = units.find((u) => u.si === payload.si) ?? payload;
const units = await companyAutomat.actions.loadUnits();
this.setState({ this.setState({
busy: false, busy: false,
units, units,
form: formFromUnit(data.unit), form: formFromUnit(saved),
selectedSi: data.unit.si, selectedSi: saved.si,
}); });
this.props.onUnitUpserted?.(data.unit); this.props.onUnitUpserted?.(saved);
} catch (err) { } catch (err) {
this.setState({ busy: false }); this.setState({ busy: false });
notifyError(err); notifyError(err);
@@ -144,9 +142,13 @@ export default class ArticleUnitEditorDialog extends Component {
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return; if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
await unitApi.delete(selectedSi); await companyAutomat.actions.deleteUnit(selectedSi);
const units = await companyAutomat.actions.loadUnits(); this.setState({
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null }); busy: false,
units: companyAutomat.state.units,
form: emptyForm(),
selectedSi: null,
});
this.props.onUnitDeleted?.(selectedSi); this.props.onUnitDeleted?.(selectedSi);
} catch (err) { } catch (err) {
this.setState({ busy: false }); this.setState({ busy: false });

View File

@@ -43,14 +43,12 @@ export default class CategoryPickerDialog extends PureComponent {
} }
componentDidMount() { componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => { this._unsub = categoryAutomat.subscribe((s) => {
this.setState(() => ({ this.setState({
categories: this.props.categories ?? s.categories, categories: s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId, rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
})); });
}); });
}
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
} else if (this.state.rootCategoryId != null) { } else if (this.state.rootCategoryId != null) {
nextSelectedId = this.state.rootCategoryId; nextSelectedId = this.state.rootCategoryId;
} }
this.setState({ selectedId: nextSelectedId }); this.setState({
} selectedId: nextSelectedId,
categories: categoryAutomat.state.categories ?? this.props.categories,
if ( rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
});
} else if (
this.props.categories !== prevProps.categories || this.props.categories !== prevProps.categories ||
this.props.rootCategoryId !== prevProps.rootCategoryId this.props.rootCategoryId !== prevProps.rootCategoryId
) { ) {

View File

@@ -6,6 +6,8 @@ export function queryFromParams(searchParams) {
filter: searchParams.get('filter') ?? '', filter: searchParams.get('filter') ?? '',
brand: searchParams.get('brand') ?? '', brand: searchParams.get('brand') ?? '',
supplier: searchParams.get('supplier') ?? '', supplier: searchParams.get('supplier') ?? '',
empty: searchParams.get('empty') ?? '',
hierarchy: searchParams.get('hierarchy') ?? '',
offset: Math.max(0, Number(searchParams.get('offset')) || 0), 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 = {}) { export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
let brand = ''; let brand = '';
let supplier = ''; let supplier = '';
let empty = '';
let hierarchy = '';
if (typeof options === 'string') { if (typeof options === 'string') {
brand = options; brand = options;
supplier = arguments[4] || ''; supplier = arguments[4] || '';
} else if (options && typeof options === 'object') { } else if (options && typeof options === 'object') {
brand = options.brand || ''; brand = options.brand || '';
supplier = options.supplier || ''; supplier = options.supplier || '';
empty = options.empty || '';
hierarchy = options.hierarchy || '';
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -26,6 +32,8 @@ export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
if (filter) params.set('filter', filter); if (filter) params.set('filter', filter);
if (brand) params.set('brand', brand); if (brand) params.set('brand', brand);
if (supplier) params.set('supplier', supplier); 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)); if (offset > 0) params.set('offset', String(offset));
const qs = params.toString(); const qs = params.toString();
return qs ? `/artikel?${qs}` : '/artikel'; return qs ? `/artikel?${qs}` : '/artikel';
@@ -46,6 +54,8 @@ export function writeArtikelQuery({
offset = 0, offset = 0,
brand = '', brand = '',
supplier = '', supplier = '',
empty = '',
hierarchy = '',
}) { }) {
try { try {
if (!target || target.trim().length < 3) { if (!target || target.trim().length < 3) {
@@ -54,7 +64,7 @@ export function writeArtikelQuery({
} }
localStorage.setItem( localStorage.setItem(
ARTIKEL_QUERY_KEY, ARTIKEL_QUERY_KEY,
JSON.stringify({ target, filter, offset, brand, supplier }), JSON.stringify({ target, filter, offset, brand, supplier, empty, hierarchy }),
); );
} catch { } catch {
// ignore quota / private mode // ignore quota / private mode
@@ -76,5 +86,7 @@ export function artikelQueryPath() {
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, { return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
brand: query.brand ?? '', brand: query.brand ?? '',
supplier: query.supplier ?? '', supplier: query.supplier ?? '',
empty: query.empty ?? '',
hierarchy: query.hierarchy ?? '',
}); });
} }

View File

@@ -45,7 +45,7 @@ import {
alpha, alpha,
} from '../../../colors'; } from '../../../colors';
function getVariantDetails(child, allArticles, defaultTaxRate) { function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
const cached = child?.sku ? getArticle(child.sku) : null; const cached = child?.sku ? getArticle(child.sku) : null;
const fromIndex = !cached && child?.sku const fromIndex = !cached && child?.sku
? (allArticles || []).find((a) => a.sku === child.sku) ? (allArticles || []).find((a) => a.sku === child.sku)
@@ -56,8 +56,23 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
const gross = getArticleGross(merged, defaultTaxRate); const gross = getArticleGross(merged, defaultTaxRate);
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null; 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; let stockVal = null;
if (trackStock) {
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) { if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0); stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) { } else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
@@ -65,6 +80,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) { } else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
stockVal = Number(child.stock); stockVal = Number(child.stock);
} }
}
const rawUnit = const rawUnit =
merged.unit?.symbol || merged.unit?.symbol ||
@@ -76,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase()) rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
? ` ${rawUnit}` ? ` ${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 eanCandidates = [];
const rawEans = merged.eans ?? child?.eans; const rawEans = merged.eans ?? child?.eans;
if (Array.isArray(rawEans)) { if (Array.isArray(rawEans)) {
@@ -103,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans: eanCandidates, allEans: eanCandidates,
trackStock,
}; };
} }
@@ -123,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
this.state = { this.state = {
attributes: initialAttributes, attributes: initialAttributes,
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
assignedAttributes: [], assignedAttributes: [],
allArticles: [], allArticles: [],
busy: false, busy: false,
@@ -170,10 +188,11 @@ export default class ArticleViewLists extends PureComponent {
return { return {
company: state.current, company: state.current,
attributes: state.attributes, attributes: state.attributes,
attributesStatus: state.attributesStatus,
selectedAttributeId: nextSelectedId, selectedAttributeId: nextSelectedId,
}; };
}); });
}, ['current', 'attributes']); }, ['current', 'attributes', 'attributesStatus']);
this.unsubArtikel = artikelAutomat.subscribe(() => { this.unsubArtikel = artikelAutomat.subscribe(() => {
if (this._cancelled) return; if (this._cancelled) return;
@@ -391,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state; const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
if (busy || !article?.sku) return; if (busy || !article?.sku) return;
const payload = {};
if (isCustomChildValue) {
const val = newChildText.trim(); const val = newChildText.trim();
if (!val) return; const payload = {};
if (val && (isCustomChildValue || !selectedChildValueId)) {
payload.value = val;
} else if (selectedChildValueId) {
payload.attributeValueId = Number(selectedChildValueId);
} else if (val) {
payload.value = val; payload.value = val;
} else { } else {
if (!selectedChildValueId) return; return;
payload.attributeValueId = Number(selectedChildValueId);
} }
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
const api = this.getApi(); const api = this.getApi();
await api.addChild(article.sku, payload); const res = await api.addChild(article.sku, payload);
if (this._cancelled) return; if (this._cancelled) return;
if (res?.sku) {
void artikelAutomat.actions.loadArticle(res.sku).catch(() => { });
}
await artikelAutomat.actions.reloadArticle(article.sku); await artikelAutomat.actions.reloadArticle(article.sku);
if (this._cancelled) return; if (this._cancelled) return;
void articleIndexAutomat.actions.reload().catch(() => { });
void companyAutomat.actions.loadAttributes?.().catch?.(() => { });
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' }); enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
this.setState({ this.setState({
busy: false, busy: false,
selectedChildValueId: '', selectedChildValueId: '',
newChildText: '', newChildText: '',
isCustomChildValue: false,
}); });
} catch (err) { } catch (err) {
if (this._cancelled) return; if (this._cancelled) return;
@@ -1346,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans, allEans,
} = getVariantDetails(sibling, allArticles, defaultTaxRate); trackStock,
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
return ( return (
<Box <Box
@@ -1463,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, 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', whiteSpace: 'nowrap',
}} }}
> >
@@ -1582,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, 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', whiteSpace: 'nowrap',
}} }}
> >
@@ -1602,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
} }
renderStandardView(selectableAttributes, assignedAttributeIds) { renderStandardView(selectableAttributes, assignedAttributeIds) {
const { busy, activeCreatorMode, selectedAttributeId } = this.state; const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{activeCreatorMode === 'parent' ? ( {activeCreatorMode === 'parent' ? (
@@ -1624,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
</Button> </Button>
</Box> </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"> <Typography variant="caption" color="text.secondary">
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen. Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
</Typography> </Typography>
@@ -1877,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Varianten: Varianten:
</Typography> </Typography>
{children.length === 0 ? ( {children.length === 0 ? null : (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => { {children.map((child) => {
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19; const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
@@ -1891,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans, allEans,
} = getVariantDetails(child, allArticles, defaultTaxRate); trackStock,
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
return ( return (
<Box <Box
@@ -2033,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, 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', whiteSpace: 'nowrap',
}} }}
> >
@@ -2046,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
size="small" size="small"
disabled={busy} disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)} onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{ sx={{
p: 0.35, p: 0.35,
color: 'text.secondary', color: 'text.secondary',
@@ -2067,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
{isCustomChildValue || availableParentValues.length === 0 ? ( {isCustomChildValue || availableParentValues.length === 0 ? (
<TextInput <TextInput
label="Neuer Wert" label="Neuer Wert"
placeholder="Neuer Wert..."
value={newChildText} value={newChildText}
disabled={busy} disabled={busy}
onChange={(e) => this.setState({ newChildText: e.target.value })} onChange={(e) => this.setState({ newChildText: e.target.value })}
@@ -2112,11 +2167,10 @@ export default class ArticleViewLists extends PureComponent {
</TextInput> </TextInput>
)} )}
<Tooltip title="Variante hinzufügen">
<span>
<IconButton <IconButton
size="small" size="small"
color="primary" color="primary"
aria-label="Variante hinzufügen"
disabled={ disabled={
busy || busy ||
(isCustomChildValue || availableParentValues.length === 0 (isCustomChildValue || availableParentValues.length === 0
@@ -2138,8 +2192,6 @@ export default class ArticleViewLists extends PureComponent {
> >
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />} {busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
</IconButton> </IconButton>
</span>
</Tooltip>
</Box> </Box>
{/* Toggle custom value / predefined value if values exist */} {/* Toggle custom value / predefined value if values exist */}

View File

@@ -1,7 +1,7 @@
import { Component, PureComponent } from 'react'; import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels'; import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { getCategoryState } from '../../../automats/categoryAutomat'; import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat';
import artikelAutomat from '../../../automats/artikelAutomat'; import artikelAutomat from '../../../automats/artikelAutomat';
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils'; import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton, Typography } from '@mui/material'; import { Box, Chip, IconButton, Typography } from '@mui/material';
@@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
.filter((c) => c.label); .filter((c) => c.label);
} }
class CategoryPills extends Component { class CategoryPills extends PureComponent {
constructor(props) { constructor(props) {
super(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) => { handleRemoveCategory = (removeId) => {
@@ -135,8 +154,7 @@ class CategoryPills extends Component {
render() { render() {
const { categoryIds, editMode } = this.props; const { categoryIds, editMode } = this.props;
const { pickerOpen, targetPillId } = this.state; const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state;
const { categories, rootCategoryId } = getCategoryState() ?? {};
const labels = categoryLabels(categoryIds, categories, rootCategoryId); const labels = categoryLabels(categoryIds, categories, rootCategoryId);
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null; const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;

View File

@@ -1,4 +1,6 @@
import { PureComponent } from 'react'; 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 Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -562,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
</Tooltip> </Tooltip>
</Box> </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 */} {/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && ( {assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>

View File

@@ -9,20 +9,70 @@ import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from '@mui/icons-material/Print';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import { enqueueSnackbar } from 'notistack'; 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 { notifyError } from '../../../utils/notify';
import BackendError from '../../BackendError'; 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 { export default class ArticleLabelTab extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.state = { this.state = {
templates: null, templates: null,
printers: null, printers: initialPrinters,
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
selectedTemplateId: '', selectedTemplateId: '',
selectedPrinterId: '', selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
quantity: 1, quantity: 1,
loading: true, loading: true,
error: null, error: null,
@@ -30,27 +80,61 @@ export default class ArticleLabelTab extends PureComponent {
} }
componentDidMount() { 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(); 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() { async loadData() {
this.setState({ loading: true, error: null }); this.setState({ loading: true, error: null });
const sku = this.props.article?.sku;
try { try {
const [resTemplates, resPrinters] = await Promise.all([ const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
labeltemplate.list(),
printer.list(),
]);
const templates = resTemplates.labeltemplates || []; 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, templates,
printers: zplPrinters, printers: zplPrinters,
selectedTemplateId: templates.length > 0 ? templates[0].id : '', livePrinters: Array.isArray(companyAutomat.state.livePrinters)
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '', ? 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, loading: false,
}); }));
} catch (error) { } catch (error) {
this.setState({ error, loading: false }); this.setState({ error, loading: false });
} }
@@ -68,7 +152,7 @@ export default class ArticleLabelTab extends PureComponent {
try { try {
await article.printLabel(articleData.sku, { await article.printLabel(articleData.sku, {
templateId: selectedTemplateId, templateId: selectedTemplateId,
printerId: selectedPrinterId, agentprinterId: selectedPrinterId,
quantity, quantity,
}); });
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' }); 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() { render() {
const { const {
templates, templates,
printers, printers,
livePrinters,
selectedTemplateId, selectedTemplateId,
selectedPrinterId, selectedPrinterId,
quantity, quantity,
@@ -88,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
error, error,
} = this.state; } = this.state;
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
if (loading) { if (loading) {
return ( return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}> <Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
@@ -130,8 +231,18 @@ export default class ArticleLabelTab extends PureComponent {
<Grid container spacing={2}> <Grid container spacing={2}>
{templates.map((t) => { {templates.map((t) => {
const isSelected = t.id === selectedTemplateId; 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 ( return (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
<Paper <Paper
variant="outlined" variant="outlined"
onClick={() => this.setState({ selectedTemplateId: t.id })} onClick={() => this.setState({ selectedTemplateId: t.id })}
@@ -175,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
bgcolor: 'background.default', bgcolor: 'background.default',
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}} }}
> >
{t.thumb ? ( {t.thumb ? (
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '100%',
maxHeight: '100%',
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 <img
src={`data:image/png;base64,${t.thumb}`} src={`data:image/png;base64,${t.thumb}`}
alt={t.name} alt={t.name}
style={{ style={{
maxWidth: '100%', width: '100%',
maxHeight: '100%', height: '100%',
objectFit: 'contain', 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"> <Typography variant="caption" color="text.disabled">
Keine Vorschau Keine Vorschau
@@ -198,7 +372,7 @@ export default class ArticleLabelTab extends PureComponent {
{t.name} {t.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{t.width}x{t.height} ({t.dpi} dpi) {t.width}x{t.height} ({dpi} dpi)
</Typography> </Typography>
</Box> </Box>
</Paper> </Paper>
@@ -217,11 +391,14 @@ export default class ArticleLabelTab extends PureComponent {
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })} onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
> >
{printers.map((p) => ( {printers.map((p) => {
const len = getPrinterLabelLength(p, livePrinters);
return (
<MenuItem key={p.id} value={p.id}> <MenuItem key={p.id} value={p.id}>
{p.desc ? `${p.desc} (${p.address})` : p.address} {getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
</MenuItem> </MenuItem>
))} );
})}
</TextInput> </TextInput>
<TextInput <TextInput
@@ -246,6 +423,18 @@ export default class ArticleLabelTab extends PureComponent {
> >
Etiketten drucken Etiketten drucken
</Button> </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> </Box>
</Box> </Box>

View File

@@ -38,8 +38,8 @@ export default class ArticleSupplierTab extends PureComponent {
this.state = { this.state = {
loading: true, loading: true,
assignedSuppliers: [], assignedSuppliers: [],
companySuppliers: [], companySuppliers: companyAutomat.state.suppliers ?? [],
company: null, company: companyAutomat.state.current ?? null,
dialogOpen: false, dialogOpen: false,
editItem: null, editItem: null,
busyId: null, busyId: null,
@@ -47,9 +47,14 @@ export default class ArticleSupplierTab extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ this.unsubCompany = companyAutomat.subscribe(
this,
(state) => ({
company: state.current ?? null, company: state.current ?? null,
})); companySuppliers: state.suppliers ?? [],
}),
['current', 'suppliers'],
);
this.initData(); this.initData();
} }
@@ -89,15 +94,6 @@ export default class ArticleSupplierTab extends PureComponent {
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => { this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
void articleSupplierAutomat.reload(String(sku)).catch(() => {}); void articleSupplierAutomat.reload(String(sku)).catch(() => {});
}); });
// 2. Fetch available company suppliers
try {
const companySuppliers = await companyAutomat.actions.loadSuppliers();
this.setState({ companySuppliers: companySuppliers ?? [] });
} catch (err) {
notifyError(err);
this.setState({ loading: false });
}
} }
/** Re-fetches the assigned suppliers for the current article. */ /** Re-fetches the assigned suppliers for the current article. */

View File

@@ -40,6 +40,7 @@ class CarrierItem extends PureComponent {
}; };
handleBlur = () => { handleBlur = () => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item; const item = this.props.carrier ?? this.props.item;
if (!item) return; if (!item) return;
const draft = this.state.draft.trim(); const draft = this.state.draft.trim();
@@ -63,6 +64,7 @@ class CarrierItem extends PureComponent {
} }
handleToggleHasPackages = (e) => { handleToggleHasPackages = (e) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item; const item = this.props.carrier ?? this.props.item;
if (!item) return; if (!item) return;
const nextVal = e.target.checked; const nextVal = e.target.checked;
@@ -70,6 +72,7 @@ class CarrierItem extends PureComponent {
}; };
handleToggleIsReverse = (e) => { handleToggleIsReverse = (e) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item; const item = this.props.carrier ?? this.props.item;
if (!item) return; if (!item) return;
const nextVal = e.target.checked; const nextVal = e.target.checked;
@@ -77,6 +80,7 @@ class CarrierItem extends PureComponent {
}; };
handleKeyDown = (event) => { handleKeyDown = (event) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item; const item = this.props.carrier ?? this.props.item;
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -90,6 +94,7 @@ class CarrierItem extends PureComponent {
}; };
handleDelete = () => { handleDelete = () => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item; const item = this.props.carrier ?? this.props.item;
if (!item) return; if (!item) return;
if (!window.confirm(`Versandart „${item.name}“ wirklich löschen?`)) return; if (!window.confirm(`Versandart „${item.name}“ wirklich löschen?`)) return;

View File

@@ -416,15 +416,13 @@ export default class CategoryTree extends PureComponent {
} }
componentDidMount() { componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => { this._unsub = categoryAutomat.subscribe((s) => {
this.setState({ this.setState({
categories: this.props.categories ?? s.categories, categories: s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId, rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
}); });
}); });
} }
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if ( if (
@@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent {
name, name,
}); });
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' }); 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); this.props.onSelect?.(created.id, created);
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
@@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent {
if (!name || !renameNode) return; if (!name || !renameNode) return;
this.setState({ isSubmitting: true }); this.setState({ isSubmitting: true });
try { try {
await categoryAutomat.actions.renameCategory({ const updated = await categoryAutomat.actions.renameCategory({
categoryId: renameNode.id, categoryId: renameNode.id,
name, name,
}); });
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' }); 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) { } catch (err) {
notifyError(err); notifyError(err);
} finally { } finally {
@@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent {
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) { if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId); this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
} }
this.setState({ deleteDialogOpen: false }); this.setState({
deleteDialogOpen: false,
categories: categoryAutomat.state.categories,
});
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
} finally { } finally {

View File

@@ -1,10 +1,9 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import Box from '@mui/material/Box'; 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 Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
@@ -70,23 +69,24 @@ export default class ChatFab extends PureComponent {
return ( return (
<Box <Box
sx={(theme) => ({ sx={{
position: 'fixed', px: { xs: 0.5, sm: 1, lg: 1.5 },
bottom: { xs: 20, sm: 28 }, py: { xs: 0.5, lg: 1 },
left: { xs: 20, lg: 164 }, alignSelf: { xs: 'center', lg: 'flex-start' },
zIndex: theme.zIndex.speedDial || 1050, display: 'flex',
})} alignItems: 'center',
}}
> >
<Box sx={{ position: 'relative' }}> <Box sx={{ position: 'relative', display: 'inline-flex' }}>
{/* Animated pulsing halo when awaiting user answer */} {/* Animated pulsing halo when awaiting user answer */}
{hasPendingAsk && !isOpen && ( {hasPendingAsk && !isOpen && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -8, top: -4,
left: -8, left: -4,
right: -8, right: -4,
bottom: -8, bottom: -4,
borderRadius: '50%', borderRadius: '50%',
bgcolor: CHAT_BUSY, bgcolor: CHAT_BUSY,
opacity: 0.5, opacity: 0.5,
@@ -105,10 +105,10 @@ export default class ChatFab extends PureComponent {
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -6, top: -4,
left: -6, left: -4,
right: -6, right: -4,
bottom: -6, bottom: -4,
borderRadius: '50%', borderRadius: '50%',
bgcolor: 'warning.main', bgcolor: 'warning.main',
opacity: 0.4, opacity: 0.4,
@@ -125,18 +125,23 @@ export default class ChatFab extends PureComponent {
{/* Circular spinner ring when model is busy */} {/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && ( {!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress <CircularProgress
size={66} size={38}
thickness={2.5} thickness={3}
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: -6, top: -3,
left: -6, left: -3,
color: CHAT_BUSY, color: CHAT_BUSY,
pointerEvents: 'none', pointerEvents: 'none',
}} }}
/> />
)} )}
<IconButton
onClick={this.handleClick}
aria-label={ariaLabel}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
>
<Badge <Badge
badgeContent={hasPendingAsk ? '?' : undefined} badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'} color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
@@ -150,11 +155,11 @@ export default class ChatFab extends PureComponent {
bgcolor: RED, bgcolor: RED,
color: WHITE, color: WHITE,
fontWeight: 900, fontWeight: 900,
fontSize: '0.85rem', fontSize: '0.75rem',
minWidth: 22, minWidth: 18,
height: 22, height: 18,
borderRadius: '50%', borderRadius: '50%',
boxShadow: `0 2px 10px ${RED_GLOW}`, boxShadow: `0 2px 8px ${RED_GLOW}`,
animation: 'badgePop 1.5s ease infinite', animation: 'badgePop 1.5s ease infinite',
'@keyframes badgePop': { '@keyframes badgePop': {
'0%, 100%': { transform: 'scale(1)' }, '0%, 100%': { transform: 'scale(1)' },
@@ -164,31 +169,41 @@ export default class ChatFab extends PureComponent {
: undefined, : undefined,
}} }}
> >
<Fab <Avatar
color="primary"
aria-label={ariaLabel}
onClick={this.handleClick}
sx={(theme) => ({ sx={(theme) => ({
width: 54, width: 32,
height: 54, height: 32,
boxShadow: hasPendingAsk bgcolor: hasPendingAsk
? CHAT_FAB_SHADOW_PENDING ? CHAT_FAB_PENDING
: isBusy : isBusy
? CHAT_FAB_SHADOW_BUSY ? CHAT_FAB_BUSY
: CHAT_FAB_SHADOW_IDLE, : isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
background: hasPendingAsk background: hasPendingAsk
? CHAT_FAB_PENDING ? CHAT_FAB_PENDING
: isBusy : isBusy
? CHAT_FAB_BUSY ? CHAT_FAB_BUSY
: isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`, : `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 ? ( {isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 24 }} /> <CloseRoundedIcon sx={{ fontSize: 18 }} />
) : hasPendingAsk ? ( ) : hasPendingAsk ? (
<HelpOutlineRoundedIcon <HelpOutlineRoundedIcon
sx={{ sx={{
fontSize: 26, fontSize: 18,
animation: 'fabWiggle 2s ease infinite', animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': { '@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' }, '0%, 100%': { transform: 'rotate(0deg)' },
@@ -200,12 +215,13 @@ export default class ChatFab extends PureComponent {
}} }}
/> />
) : isBusy ? ( ) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} /> <AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
) : ( ) : (
<ChatRoundedIcon sx={{ fontSize: 24 }} /> <AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
)} )}
</Fab> </Avatar>
</Badge> </Badge>
</IconButton>
</Box> </Box>
</Box> </Box>
); );

View File

@@ -30,6 +30,7 @@ import EditNoteIcon from '@mui/icons-material/EditNote';
import PaidRoundedIcon from '@mui/icons-material/PaidRounded'; import PaidRoundedIcon from '@mui/icons-material/PaidRounded';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { router } from '../../appRouter';
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat'; import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats/navigationAutomat'; import { setLastDeliveryPath } from '../../automats/navigationAutomat';
import HealingMarkdown from './HealingMarkdown'; import HealingMarkdown from './HealingMarkdown';
@@ -578,7 +579,6 @@ class DraftDeliveryManifestUseButton extends PureComponent {
); );
try { try {
const { router } = await import('../../appRouter');
if (router?.navigate) { if (router?.navigate) {
const curPath = const curPath =
router.state?.location?.pathname || router.state?.location?.pathname ||

View File

@@ -1,3 +1,5 @@
import { router } from '../../appRouter';
/** /**
* Resolves the active frontend route (path + search query). * Resolves the active frontend route (path + search query).
* Falls back to window.location if router is not directly accessible. * Falls back to window.location if router is not directly accessible.
@@ -126,7 +128,6 @@ export async function navigateToClientRoute(route, toolCallId) {
} }
try { try {
const { router } = await import('../../appRouter');
if (router?.navigate) { if (router?.navigate) {
const current = router.state?.location const current = router.state?.location
? `${router.state.location.pathname}${router.state.location.search || ''}` ? `${router.state.location.pathname}${router.state.location.search || ''}`

View File

@@ -37,6 +37,7 @@ class KundeGruppe extends PureComponent {
}; };
handleBlur = () => { handleBlur = () => {
if (this.props.disabled) return;
const item = this.props.group ?? this.props.item; const item = this.props.group ?? this.props.item;
if (!item) return; if (!item) return;
const editable = item.editable !== false; const editable = item.editable !== false;
@@ -79,6 +80,7 @@ class KundeGruppe extends PureComponent {
}; };
handleDelete = () => { handleDelete = () => {
if (this.props.disabled) return;
const item = this.props.group ?? this.props.item; const item = this.props.group ?? this.props.item;
if (!item) return; if (!item) return;
const editable = item.editable !== false; const editable = item.editable !== false;

View File

@@ -19,17 +19,18 @@ export default class ArticleSearchDialog extends PureComponent {
this.state = { this.state = {
assigningSku: null, assigningSku: null,
error: null, error: null,
showAllArticles: false,
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) { if (!prevProps.open && this.props.open) {
this.setState({ assigningSku: null, error: null }); this.setState({ assigningSku: null, error: null, showAllArticles: false });
} }
} }
handleClose = () => { handleClose = () => {
this.setState({ assigningSku: null, error: null }); this.setState({ assigningSku: null, error: null, showAllArticles: false });
this.props.onClose?.(); this.props.onClose?.();
}; };
@@ -61,9 +62,9 @@ export default class ArticleSearchDialog extends PureComponent {
render() { render() {
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props; 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 isDelivery = kind === 'delivery';
const onlySupplierArticles = isDelivery && !line; const onlySupplierArticles = isDelivery && !line && !showAllArticles;
const rawSku = line?.sku != null ? String(line.sku).trim() : ''; const rawSku = line?.sku != null ? String(line.sku).trim() : '';
const supplierArticlesList = onlySupplierArticles const supplierArticlesList = onlySupplierArticles
@@ -122,7 +123,7 @@ export default class ArticleSearchDialog extends PureComponent {
<Box> <Box>
<Typography variant="h6" component="div" fontWeight={600}> <Typography variant="h6" component="div" fontWeight={600}>
{line {line
? 'Existierenden Artikel zuordnen' ? (isDelivery ? 'Artikel neu zuordnen' : 'Existierenden Artikel zuordnen')
: isDelivery : isDelivery
? 'Lieferanten-Artikel auswählen' ? 'Lieferanten-Artikel auswählen'
: 'Artikel auswählen'} : 'Artikel auswählen'}
@@ -134,7 +135,21 @@ export default class ArticleSearchDialog extends PureComponent {
{line?.name ? ` · ${line.name}` : ''} {line?.name ? ` · ${line.name}` : ''}
</> </>
) : isDelivery ? ( ) : 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' 'Artikel für Beleg auswählen'
)} )}

View File

@@ -98,8 +98,7 @@ export default class BookDeliveryDialog extends PureComponent {
loadSupplierArticlesIfNeeded() { loadSupplierArticlesIfNeeded() {
const supplierId = this.props.delivery?.supplierId ?? this.props.delivery?.partyId; const supplierId = this.props.delivery?.supplierId ?? this.props.delivery?.partyId;
if (supplierId && Object.keys(this.state.supplierArticlesMap).length === 0) { if (supplierId && Object.keys(this.state.supplierArticlesMap).length === 0) {
void supplierApi.getArticles(supplierId).then((res) => { void companyAutomat.actions.loadArticlesForSupplier(supplierId).then((articles) => {
const articles = res?.articles || res?.supplier?.articles || [];
const map = buildSupplierArticlesMap(articles); const map = buildSupplierArticlesMap(articles);
this.setState({ supplierArticlesMap: map }); this.setState({ supplierArticlesMap: map });
}).catch(() => { }); }).catch(() => { });
@@ -122,7 +121,6 @@ export default class BookDeliveryDialog extends PureComponent {
this.unsubArticle = artikelAutomat.subscribe(() => { this.unsubArticle = artikelAutomat.subscribe(() => {
this.forceUpdate(); this.forceUpdate();
}); });
void companyAutomat.actions.loadWarehouses().catch(() => { });
this.loadSupplierArticlesIfNeeded(); this.loadSupplierArticlesIfNeeded();
if (this.props.open) { if (this.props.open) {
@@ -225,7 +223,6 @@ export default class BookDeliveryDialog extends PureComponent {
supplierArticlesMap: currentSupplierMap, supplierArticlesMap: currentSupplierMap,
}, },
() => { () => {
void companyAutomat.actions.loadWarehouses().catch(() => { });
this.loadSupplierArticlesIfNeeded(); this.loadSupplierArticlesIfNeeded();
if (this.props.summary) { if (this.props.summary) {
this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId); this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId);

View File

@@ -242,10 +242,6 @@ class ManifestForm extends PureComponent {
['current', 'suppliers', 'suppliersStatus', 'customergroups', 'customergroupsStatus'], ['current', 'suppliers', 'suppliersStatus', 'customergroups', 'customergroupsStatus'],
); );
if (companyAutomat.state.warehousesStatus === 'idle') {
void companyAutomat.actions.loadWarehouses();
}
if (this.props.mode === 'edit') { if (this.props.mode === 'edit') {
void this.loadEdit(); void this.loadEdit();
if (this.props.kind === 'order' || this.props.kind === 'retoure') { if (this.props.kind === 'order' || this.props.kind === 'retoure') {

View File

@@ -766,6 +766,30 @@ class ManifestFormLine extends PureComponent {
/> />
)} )}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel neu zuordnen
</Button>
)}
{/* Brand */} {/* Brand */}
{cachedArticle.brand?.name && ( {cachedArticle.brand?.name && (
<BrandLabel <BrandLabel
@@ -1124,6 +1148,30 @@ class ManifestFormLine extends PureComponent {
/> />
)} )}
{this.props.onSearchArticle && (
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel neu zuordnen
</Button>
)}
{/* Brand */} {/* Brand */}
{(fullArticle.brand?.name || article.brand?.name) && ( {(fullArticle.brand?.name || article.brand?.name) && (
<BrandLabel <BrandLabel
@@ -1307,6 +1355,34 @@ class ManifestFormLine extends PureComponent {
); );
} }
if (this.props.onSearchArticle) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
<Button
size="small"
variant="text"
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
disabled={this.isFullyLedgered()}
onClick={this.handleSearchArticle}
sx={{
fontSize: '0.7rem',
fontWeight: 600,
textTransform: 'none',
minWidth: 'auto',
py: 0.25,
px: 1,
color: 'primary.main',
'&:hover': {
bgcolor: 'primary.50',
},
}}
>
Artikel suchen
</Button>
</Box>
);
}
return null; return null;
} }

View File

@@ -82,9 +82,7 @@ class AuswahlMerkmaleTab extends PureComponent {
} }
}, ['attributes', 'attributesStatus', 'attributesError']); }, ['attributes', 'attributesStatus', 'attributesError']);
if (companyAutomat.state.attributesStatus !== 'succeeded') { if (companyAutomat.state.attributesStatus === 'succeeded') {
void this.load();
} else {
this._initAttrSortable(); this._initAttrSortable();
this._initValSortable(); this._initValSortable();
} }

View File

@@ -65,9 +65,7 @@ class TextMerkmaleTab extends PureComponent {
} }
}, ['properties', 'propertiesStatus', 'propertiesError']); }, ['properties', 'propertiesStatus', 'propertiesError']);
if (companyAutomat.state.propertiesStatus !== 'succeeded') { if (companyAutomat.state.propertiesStatus === 'succeeded') {
void this.load();
} else {
this._initPropSortable(); this._initPropSortable();
} }
} }

View File

@@ -44,6 +44,8 @@ import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { vfs } from '../../api'; import { vfs } from '../../api';
import backupAutomat from '../../automats/backupAutomat'; import backupAutomat from '../../automats/backupAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { isProtectedVfsFile } from '../vfs/vfsUtils';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { import {
BORDER, BORDER,
@@ -93,6 +95,7 @@ export default class BackupSettingsPanel extends Component {
super(props); super(props);
const { status, error, backupStatus, backups, vfsFilesByBackup, vfsLoadingByBackup } = backupAutomat.state; const { status, error, backupStatus, backups, vfsFilesByBackup, vfsLoadingByBackup } = backupAutomat.state;
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status, status,
error, error,
backupStatus, backupStatus,
@@ -115,6 +118,9 @@ export default class BackupSettingsPanel extends Component {
} }
componentDidMount() { componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
this.unsub = backupAutomat.subscribe(this, (state) => ({ this.unsub = backupAutomat.subscribe(this, (state) => ({
status: state.status, status: state.status,
error: state.error, error: state.error,
@@ -130,6 +136,7 @@ export default class BackupSettingsPanel extends Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubCompany?.();
this.unsub?.(); this.unsub?.();
} }
@@ -155,6 +162,7 @@ export default class BackupSettingsPanel extends Component {
} }
handleOpenConfirm(type) { handleOpenConfirm(type) {
if (!this.state.isAdmin) return;
this.setState({ confirmRestore: type }); this.setState({ confirmRestore: type });
} }
@@ -215,6 +223,7 @@ export default class BackupSettingsPanel extends Component {
render() { render() {
const { const {
isAdmin,
status, status,
error, error,
backupStatus, backupStatus,
@@ -463,6 +472,7 @@ export default class BackupSettingsPanel extends Component {
<BackupTableRow <BackupTableRow
key={item.name} key={item.name}
item={item} item={item}
isAdmin={isAdmin}
isExpanded={isExpanded} isExpanded={isExpanded}
manifest={manifest} manifest={manifest}
vfsFiles={vfsFiles} vfsFiles={vfsFiles}
@@ -549,7 +559,7 @@ export default class BackupSettingsPanel extends Component {
variant="contained" variant="contained"
startIcon={restoringSkills ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />} startIcon={restoringSkills ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={() => this.handleOpenConfirm('skills')} onClick={() => this.handleOpenConfirm('skills')}
disabled={restoringSkills} disabled={!isAdmin || restoringSkills}
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }} sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
> >
Skills wiederherstellen Skills wiederherstellen
@@ -607,7 +617,7 @@ export default class BackupSettingsPanel extends Component {
variant="contained" variant="contained"
startIcon={restoringApplets ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />} startIcon={restoringApplets ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={() => this.handleOpenConfirm('applets')} onClick={() => this.handleOpenConfirm('applets')}
disabled={restoringApplets} disabled={!isAdmin || restoringApplets}
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }} sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
> >
Applets wiederherstellen Applets wiederherstellen
@@ -740,6 +750,7 @@ class BackupTableRow extends Component {
// MARK: calls POST /backup/:id/vfs/recover via backupAutomat.actions.recoverBackupVfs from ./api/index.js // MARK: calls POST /backup/:id/vfs/recover via backupAutomat.actions.recoverBackupVfs from ./api/index.js
async handleRecoverFile(e, path) { async handleRecoverFile(e, path) {
e?.stopPropagation?.(); e?.stopPropagation?.();
if (!this.props.isAdmin && isProtectedVfsFile(path)) return;
if (this.state.recoveringPath) return; if (this.state.recoveringPath) return;
this.setState({ recoveringPath: path }); this.setState({ recoveringPath: path });
@@ -991,7 +1002,7 @@ class BackupTableRow extends Component {
color="primary" color="primary"
startIcon={isRecovering ? <CircularProgress size={12} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />} startIcon={isRecovering ? <CircularProgress size={12} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={(e) => this.handleRecoverFile(e, f.name)} onClick={(e) => this.handleRecoverFile(e, f.name)}
disabled={Boolean(recoveringPath)} disabled={Boolean(recoveringPath) || (!this.props.isAdmin && isProtectedVfsFile(f.name))}
sx={{ textTransform: 'none', py: 0.25, px: 1, minHeight: 26, fontSize: '0.75rem' }} sx={{ textTransform: 'none', py: 0.25, px: 1, minHeight: 26, fontSize: '0.75rem' }}
> >
{isRecovering ? 'Wiederherstellen...' : 'Wiederherstellen'} {isRecovering ? 'Wiederherstellen...' : 'Wiederherstellen'}

View File

@@ -23,6 +23,7 @@ export default class CompanySettingsPanel extends Component {
super(props); super(props);
const current = companyAutomat.state.current; const current = companyAutomat.state.current;
this.state = { this.state = {
isAdmin: current?.role === 'admin',
status: current ? 'ready' : 'loading', status: current ? 'ready' : 'loading',
busy: false, busy: false,
error: null, error: null,
@@ -42,8 +43,13 @@ export default class CompanySettingsPanel extends Component {
if (state.current && this.state.status === 'loading') { if (state.current && this.state.status === 'loading') {
this.syncFormFromCompany(state.current); this.syncFormFromCompany(state.current);
} }
return {
isAdmin: state.current?.role === 'admin',
};
}); });
void this.loadCompany(); if (companyAutomat.state.current) {
this.syncFormFromCompany(companyAutomat.state.current);
}
} }
componentWillUnmount() { componentWillUnmount() {
@@ -84,6 +90,7 @@ export default class CompanySettingsPanel extends Component {
async handleSubmit(event) { async handleSubmit(event) {
event?.preventDefault?.(); event?.preventDefault?.();
if (!this.state.isAdmin) return;
const { name, iso639, taxrate, reducedtaxrate } = this.state; const { name, iso639, taxrate, reducedtaxrate } = this.state;
const parsedTaxrate = parseTaxRate(taxrate); const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
@@ -121,7 +128,7 @@ export default class CompanySettingsPanel extends Component {
} }
render() { render() {
const { status, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state; const { isAdmin, status, busy, error, name, iso639, taxrate, reducedtaxrate } = this.state;
if (status === 'loading' && !name) { if (status === 'loading' && !name) {
return ( return (
@@ -144,6 +151,7 @@ export default class CompanySettingsPanel extends Component {
taxrate.trim() !== this._initial.taxrate.trim() || taxrate.trim() !== this._initial.taxrate.trim() ||
reducedtaxrate.trim() !== this._initial.reducedtaxrate.trim()); reducedtaxrate.trim() !== this._initial.reducedtaxrate.trim());
const canSave = const canSave =
isAdmin &&
isDirty && isDirty &&
name.trim() !== '' && name.trim() !== '' &&
ISO639_OPTIONS.includes(iso639) && ISO639_OPTIONS.includes(iso639) &&
@@ -173,7 +181,7 @@ export default class CompanySettingsPanel extends Component {
size="small" size="small"
label="Name" label="Name"
value={name} value={name}
disabled={busy} disabled={!isAdmin || busy}
onChange={(nextName) => this.setState({ name: nextName })} onChange={(nextName) => this.setState({ name: nextName })}
autoComplete="off" autoComplete="off"
autoFocus autoFocus
@@ -186,7 +194,7 @@ export default class CompanySettingsPanel extends Component {
required required
size="small" size="small"
value={iso639} value={iso639}
disabled={busy} disabled={!isAdmin || busy}
onChange={(_event, value) => { onChange={(_event, value) => {
if (value) this.setState({ iso639: value }); if (value) this.setState({ iso639: value });
}} }}
@@ -204,7 +212,7 @@ export default class CompanySettingsPanel extends Component {
type="number" type="number"
label="Normalsatz" label="Normalsatz"
value={taxrate} value={taxrate}
disabled={busy} disabled={!isAdmin || busy}
onChange={(e) => this.setState({ taxrate: e.target.value })} onChange={(e) => this.setState({ taxrate: e.target.value })}
error={taxrate !== '' && parsedTaxrate == null} error={taxrate !== '' && parsedTaxrate == null}
slotProps={{ slotProps={{
@@ -221,7 +229,7 @@ export default class CompanySettingsPanel extends Component {
type="number" type="number"
label="Ermäßigter Satz" label="Ermäßigter Satz"
value={reducedtaxrate} value={reducedtaxrate}
disabled={busy} disabled={!isAdmin || busy}
onChange={(e) => this.setState({ reducedtaxrate: e.target.value })} onChange={(e) => this.setState({ reducedtaxrate: e.target.value })}
error={reducedtaxrate !== '' && parsedReducedtaxrate == null} error={reducedtaxrate !== '' && parsedReducedtaxrate == null}
slotProps={{ slotProps={{
@@ -238,7 +246,7 @@ export default class CompanySettingsPanel extends Component {
type="submit" type="submit"
variant="contained" variant="contained"
color={canSave ? 'success' : 'inherit'} color={canSave ? 'success' : 'inherit'}
disabled={busy || !canSave} disabled={!isAdmin || busy || !canSave}
startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null} startIcon={busy ? <CircularProgress size={16} color="inherit" /> : null}
sx={ sx={
canSave canSave

View File

@@ -50,6 +50,7 @@ export default class ImportExportSettingsPanel extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
// Export state // Export state
downloading: false, downloading: false,
downloadProgress: null, // null = unknown, number 0-100 downloadProgress: null, // null = unknown, number 0-100
@@ -84,7 +85,14 @@ export default class ImportExportSettingsPanel extends Component {
this.handleCancelUpload = this.handleCancelUpload.bind(this); this.handleCancelUpload = this.handleCancelUpload.bind(this);
} }
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() { componentWillUnmount() {
this.unsub?.();
if (this.uploadAbortController) { if (this.uploadAbortController) {
this.uploadAbortController.abort(); this.uploadAbortController.abort();
} }
@@ -147,6 +155,7 @@ export default class ImportExportSettingsPanel extends Component {
} }
handleFileChange(event) { handleFileChange(event) {
if (!this.state.isAdmin) return;
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
if (!isZipFile(file)) { if (!isZipFile(file)) {
@@ -169,6 +178,7 @@ export default class ImportExportSettingsPanel extends Component {
} }
async handleStartUpload() { async handleStartUpload() {
if (!this.state.isAdmin) return;
const { selectedFile } = this.state; const { selectedFile } = this.state;
if (!selectedFile) return; if (!selectedFile) return;
@@ -282,6 +292,7 @@ export default class ImportExportSettingsPanel extends Component {
render() { render() {
const { const {
isAdmin,
downloading, downloading,
downloadProgress, downloadProgress,
downloadStatus, downloadStatus,
@@ -583,7 +594,7 @@ export default class ImportExportSettingsPanel extends Component {
accept=".zip,application/zip,application/x-zip-compressed" accept=".zip,application/zip,application/x-zip-compressed"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={this.handleFileChange} onChange={this.handleFileChange}
disabled={uploading} disabled={!isAdmin || uploading}
/> />
{!selectedFile ? ( {!selectedFile ? (
@@ -592,7 +603,7 @@ export default class ImportExportSettingsPanel extends Component {
variant="outlined" variant="outlined"
startIcon={<InsertDriveFileOutlinedIcon />} startIcon={<InsertDriveFileOutlinedIcon />}
onClick={() => this.fileInputRef.current?.click()} onClick={() => this.fileInputRef.current?.click()}
disabled={uploading} disabled={!isAdmin || uploading}
> >
ZIP-Datei auswählen ZIP-Datei auswählen
</Button> </Button>
@@ -625,6 +636,7 @@ export default class ImportExportSettingsPanel extends Component {
{!uploading && ( {!uploading && (
<IconButton <IconButton
size="small" size="small"
disabled={!isAdmin}
onClick={() => { onClick={() => {
this.setState({ selectedFile: null }); this.setState({ selectedFile: null });
if (this.fileInputRef.current) this.fileInputRef.current.value = ''; if (this.fileInputRef.current) this.fileInputRef.current.value = '';
@@ -760,7 +772,7 @@ export default class ImportExportSettingsPanel extends Component {
<CloudUploadOutlinedIcon /> <CloudUploadOutlinedIcon />
) )
} }
disabled={uploading || !selectedFile} disabled={!isAdmin || uploading || !selectedFile}
onClick={this.handleStartUpload} onClick={this.handleStartUpload}
> >
{uploading ? 'Import läuft…' : 'Upload & Import starten'} {uploading ? 'Import läuft…' : 'Upload & Import starten'}

View File

@@ -0,0 +1,219 @@
import { PureComponent } from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined';
import MailOutlinedIcon from '@mui/icons-material/MailOutlined';
import { enqueueSnackbar } from 'notistack';
import companyAutomat, { selectIsCompanyAdmin } from '../../automats/companyAutomat';
import { normalizePicture } from '../../automats/authAutomat';
import { notifyError } from '../../utils/notify';
import { backendErrorFrom } from '../../utils/backendError';
const ROLES = [
{ value: 'user', label: 'Benutzer' },
{ value: 'admin', label: 'Administrator' },
];
const selectState = (state) => ({
isAdmin: state.current?.role === 'admin',
members: state.members,
membersStatus: state.membersStatus,
invites: state.invites,
invitesStatus: state.invitesStatus,
});
/**
* Company members and pending auto-invites. Visible and usable by admins only.
*/
export default class MembersSettingsPanel extends PureComponent {
constructor(props) {
super(props);
this.state = {
...selectState(companyAutomat.state),
memberEmail: '',
inviteEmail: '',
inviteRole: 'user',
busy: false,
};
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, selectState);
this.loadIfAdmin();
}
componentDidUpdate(_prevProps, prevState) {
if (!prevState.isAdmin && this.state.isAdmin) this.loadIfAdmin();
}
componentWillUnmount() {
this.unsub?.();
}
loadIfAdmin() {
if (!selectIsCompanyAdmin()) return;
companyAutomat.actions.ensureMembersLoaded().catch(notifyError);
companyAutomat.actions.ensureInvitesLoaded().catch(notifyError);
}
// Runs a mutation, mapping the backend's 404/409 to friendly messages.
run = async (fn, messages = {}) => {
this.setState({ busy: true });
try {
await fn();
return true;
} catch (err) {
const normalized = backendErrorFrom(err);
const text = messages[normalized.status];
if (text) enqueueSnackbar(text, { variant: 'warning' });
else notifyError(err);
return false;
} finally {
this.setState({ busy: false });
}
};
handleAddMember = async (event) => {
event.preventDefault();
const email = this.state.memberEmail.trim();
if (!email) return;
const ok = await this.run(() => companyAutomat.actions.addMember(email, 'user'), {
404: 'Kein registrierter Benutzer mit dieser E-Mail. Bitte stattdessen einladen.',
409: 'Benutzer gehört bereits zum Unternehmen.',
});
if (ok) this.setState({ memberEmail: '' });
};
handleSetRole = (member, role) =>
this.run(() => companyAutomat.actions.setMemberRole(member.id, role), {
409: 'Der letzte Administrator kann nicht herabgestuft werden.',
});
handleRemoveMember = (member) =>
this.run(() => companyAutomat.actions.removeMember(member.id), {
409: 'Der letzte Administrator kann nicht entfernt werden.',
});
handleCreateInvite = async (event) => {
event.preventDefault();
const email = this.state.inviteEmail.trim();
if (!email) return;
const ok = await this.run(() => companyAutomat.actions.createInvite(email, this.state.inviteRole), {
409: 'Diese E-Mail ist bereits eingeladen oder registriert (dann direkt als Mitglied hinzufügen).',
});
if (ok) this.setState({ inviteEmail: '' });
};
handleDeleteInvite = (invite) => this.run(() => companyAutomat.actions.deleteInvite(invite.id));
renderRoleSelect(value, onChange, disabled) {
return (
<Select size="small" value={value} disabled={disabled} onChange={(e) => onChange(e.target.value)}>
{ROLES.map((r) => (
<MenuItem key={r.value} value={r.value}>
{r.label}
</MenuItem>
))}
</Select>
);
}
render() {
const { isAdmin, members, membersStatus, invites, invitesStatus, memberEmail, inviteEmail, inviteRole, busy } =
this.state;
if (!isAdmin) {
return <Typography color="text.secondary">Nur Administratoren können Mitglieder verwalten.</Typography>;
}
const loading = membersStatus === 'loading' || invitesStatus === 'loading';
return (
<Stack spacing={4}>
<Box>
<Typography variant="h6" gutterBottom>
Mitglieder
</Typography>
<Stack component="form" direction="row" spacing={1} onSubmit={this.handleAddMember} sx={{ mb: 2 }}>
<TextField
size="small"
type="email"
label="E-Mail"
value={memberEmail}
onChange={(e) => this.setState({ memberEmail: e.target.value })}
sx={{ flex: 1 }}
/>
<Button type="submit" variant="contained" disabled={busy || !memberEmail.trim()}>
Hinzufügen
</Button>
</Stack>
{loading && members.length === 0 && <CircularProgress size={20} />}
{members.map((m) => (
<Stack key={m.id} direction="row" spacing={1.5} sx={{ alignItems: 'center', py: 0.75 }}>
<Avatar
src={normalizePicture(m.picture) || undefined}
alt={m.email}
sx={{ width: 32, height: 32, fontSize: '0.875rem' }}
>
{m.email?.[0]?.toUpperCase()}
</Avatar>
<Typography sx={{ flex: 1 }}>{m.email}</Typography>
{this.renderRoleSelect(m.role, (role) => this.handleSetRole(m, role), busy)}
<IconButton aria-label="Entfernen" disabled={busy} onClick={() => this.handleRemoveMember(m)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Box>
<Box>
<Typography variant="h6" gutterBottom>
Einladungen
</Typography>
<Stack component="form" direction="row" spacing={1} onSubmit={this.handleCreateInvite} sx={{ mb: 2 }}>
<TextField
size="small"
type="email"
label="E-Mail"
value={inviteEmail}
onChange={(e) => this.setState({ inviteEmail: e.target.value })}
sx={{ flex: 1 }}
/>
{this.renderRoleSelect(inviteRole, (role) => this.setState({ inviteRole: role }), busy)}
<Button type="submit" variant="contained" disabled={busy || !inviteEmail.trim()}>
Einladen
</Button>
</Stack>
{invites.map((i) => (
<Stack key={i.id} direction="row" spacing={1.5} sx={{ alignItems: 'center', py: 0.75 }}>
<Avatar
sx={{
width: 32,
height: 32,
fontSize: '0.875rem',
bgcolor: 'action.selected',
color: 'text.secondary',
}}
>
<MailOutlinedIcon sx={{ fontSize: 18 }} />
</Avatar>
<Typography sx={{ flex: 1 }}>{i.email}</Typography>
<Typography color="text.secondary">{ROLES.find((r) => r.value === i.role)?.label ?? i.role}</Typography>
<IconButton aria-label="Einladung löschen" disabled={busy} onClick={() => this.handleDeleteInvite(i)}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Stack>
))}
</Box>
</Stack>
);
}
}

View File

@@ -1,4 +1,4 @@
import { Component } from 'react'; import { PureComponent } from 'react';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
@@ -17,7 +17,7 @@ import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import { number as numberApi } from '../../api'; import { number as numberApi } from '../../api';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -31,44 +31,78 @@ function parseNext(value) {
return Number.isInteger(n) && n >= 0 ? n : null; return Number.isInteger(n) && n >= 0 ? n : null;
} }
export default class NumberSettingsPanel extends Component { function buildDrafts(numbers, prevDrafts = {}) {
constructor(props) { return Object.fromEntries(
super(props); (Array.isArray(numbers) ? numbers : []).map((row) => {
this.state = { const existing = prevDrafts[row.id];
status: 'loading', return [
error: null,
numbers: [],
// id → { prefix, padding, next, busy, saveError }
drafts: {},
};
this.handleRetry = this.handleRetry.bind(this);
this.handleSave = this.handleSave.bind(this);
}
componentDidMount() {
void this.load();
}
async load() {
this.setState({ status: 'loading', error: null });
try {
const numbers = requireArray(
await companyAutomat.actions.reloadNumbers(),
'numbers',
);
const drafts = Object.fromEntries(
numbers.map((row) => [
row.id, row.id,
{ existing?.busy
? existing
: {
prefix: row.prefix ?? '', prefix: row.prefix ?? '',
padding: row.padding != null ? String(row.padding) : '', padding: row.padding != null ? String(row.padding) : '',
next: row.next != null ? String(row.next) : '', next: row.next != null ? String(row.next) : '',
busy: false, busy: false,
saveError: null, saveError: null,
}, },
]), ];
}),
); );
this.setState({ status: 'ready', numbers, drafts }); }
export default class NumberSettingsPanel extends PureComponent {
constructor(props) {
super(props);
const numbers = Array.isArray(companyAutomat.state.numbers)
? companyAutomat.state.numbers
: [];
const numbersStatus = companyAutomat.state.numbersStatus;
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status:
numbersStatus === 'ready'
? 'ready'
: numbersStatus === 'failed'
? 'failed'
: 'loading',
error: companyAutomat.state.numbersError
? backendErrorFrom(companyAutomat.state.numbersError)
: null,
numbers,
// id → { prefix, padding, next, busy, saveError }
drafts: buildDrafts(numbers),
};
this.handleRetry = this.handleRetry.bind(this);
this.handleSave = this.handleSave.bind(this);
}
componentDidMount() {
this._unmounted = false;
this.unsub = companyAutomat.subscribe(this, (state) => {
const numbers = Array.isArray(state.numbers) ? state.numbers : [];
return {
isAdmin: state.current?.role === 'admin',
status:
state.numbersStatus === 'ready'
? 'ready'
: state.numbersStatus === 'failed'
? 'failed'
: 'loading',
error: state.numbersError ? backendErrorFrom(state.numbersError) : null,
numbers,
drafts:
numbers !== this.state.numbers
? buildDrafts(numbers, this.state.drafts)
: this.state.drafts,
};
});
}
async load() {
this.setState({ status: 'loading', error: null });
try {
await companyAutomat.actions.reloadNumbers();
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
this.setState({ status: 'failed', error: backendErrorFrom(err) }); this.setState({ status: 'failed', error: backendErrorFrom(err) });
@@ -89,6 +123,7 @@ export default class NumberSettingsPanel extends Component {
} }
async handleSave(id) { async handleSave(id) {
if (!this.state.isAdmin) return;
const draft = this.state.drafts[id]; const draft = this.state.drafts[id];
if (!draft || draft.busy) return; if (!draft || draft.busy) return;
const row = this.state.numbers.find((r) => r.id === id); const row = this.state.numbers.find((r) => r.id === id);
@@ -145,8 +180,9 @@ export default class NumberSettingsPanel extends Component {
requireObject(data.number, 'number'); requireObject(data.number, 'number');
const updated = requireDefined(data.number, 'number'); const updated = requireDefined(data.number, 'number');
const numbers = this.state.numbers.map((n) => (n.id === id ? { ...n, ...body } : n)); const numbers = companyAutomat.actions.replaceNumbers(
await companyAutomat.actions.reloadNumbers(); this.state.numbers.map((n) => (n.id === id ? { ...n, ...updated } : n)),
);
this.setState({ this.setState({
numbers, numbers,
drafts: { drafts: {
@@ -174,10 +210,11 @@ export default class NumberSettingsPanel extends Component {
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this._unmounted = true;
this.unsub?.();
} }
render() { render() {
const { status, error, numbers, drafts } = this.state; const { isAdmin, status, error, numbers, drafts } = this.state;
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -215,7 +252,7 @@ export default class NumberSettingsPanel extends Component {
<TextInput <TextInput
size="small" size="small"
value={draft.prefix} value={draft.prefix}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)} onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
/> />
@@ -225,7 +262,7 @@ export default class NumberSettingsPanel extends Component {
size="small" size="small"
type="number" type="number"
value={draft.padding} value={draft.padding}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 1, step: 1 } }} slotProps={{ htmlInput: { min: 1, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)} onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
@@ -236,7 +273,7 @@ export default class NumberSettingsPanel extends Component {
size="small" size="small"
type="number" type="number"
value={draft.next} value={draft.next}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 0, step: 1 } }} slotProps={{ htmlInput: { min: 0, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)} onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
@@ -261,7 +298,7 @@ export default class NumberSettingsPanel extends Component {
size="small" size="small"
aria-label="Speichern" aria-label="Speichern"
color="primary" color="primary"
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onClick={() => void this.handleSave(row.id)} onClick={() => void this.handleSave(row.id)}
> >
<SaveOutlinedIcon fontSize="small" /> <SaveOutlinedIcon fontSize="small" />

View File

@@ -11,18 +11,37 @@ import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import CategoryPickerDialog from '../article/CategoryPickerDialog'; import CategoryPickerDialog from '../article/CategoryPickerDialog';
import authAutomat from '../../automats/authAutomat';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat'; import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
import { categoryDisplayName } from '../../utils/categoryUtils'; import { categoryDisplayName } from '../../utils/categoryUtils';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
function parseVhostSettings(str) {
if (!str || typeof str !== 'string') return {};
const result = {};
for (const part of str.split(';')) {
const trimmed = part.trim();
if (!trimmed) continue;
const colonIdx = trimmed.indexOf(':');
if (colonIdx !== -1) {
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
const val = trimmed.slice(colonIdx + 1).trim();
result[key] = val;
}
}
return result;
}
function formatDateTime(val) { function formatDateTime(val) {
if (!val) return null; if (!val) return null;
try { try {
@@ -45,17 +64,20 @@ export default class PosTerminalSettingsPanel extends Component {
super(props); super(props);
const terminals = companyAutomat.state.terminals ?? []; const terminals = companyAutomat.state.terminals ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading', status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
error: null, error: null,
terminals, terminals,
categories: categoryAutomat.state.categories, categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId, rootCategoryId: categoryAutomat.state.rootCategoryId,
vhostSettings: authAutomat.state.vhostSettings,
newTerminalName: '', newTerminalName: '',
creatingTerminal: false, creatingTerminal: false,
enableAllForNew: true, enableAllForNew: true,
deletingId: null, deletingId: null,
pickerTerminal: null, pickerTerminal: null,
patchingTerminalId: null, patchingTerminalId: null,
sortDrafts: {},
}; };
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.loadTerminals = this.loadTerminals.bind(this); this.loadTerminals = this.loadTerminals.bind(this);
@@ -63,11 +85,16 @@ export default class PosTerminalSettingsPanel extends Component {
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this); this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
this.handleSetRootCategory = this.handleSetRootCategory.bind(this); this.handleSetRootCategory = this.handleSetRootCategory.bind(this);
this.handleToggleSyncRootArticles = this.handleToggleSyncRootArticles.bind(this); this.handleToggleSyncRootArticles = this.handleToggleSyncRootArticles.bind(this);
this.handleToggleEnabled = this.handleToggleEnabled.bind(this);
this.handleSortDraftChange = this.handleSortDraftChange.bind(this);
this.handleSortSubmit = this.handleSortSubmit.bind(this);
this.handleMoveTerminal = this.handleMoveTerminal.bind(this);
this.getCategoryLabel = this.getCategoryLabel.bind(this); this.getCategoryLabel = this.getCategoryLabel.bind(this);
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
terminals: Array.isArray(state.terminals) ? state.terminals : [], terminals: Array.isArray(state.terminals) ? state.terminals : [],
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status, status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
})); }));
@@ -75,15 +102,18 @@ export default class PosTerminalSettingsPanel extends Component {
categories: state.categories, categories: state.categories,
rootCategoryId: state.rootCategoryId, rootCategoryId: state.rootCategoryId,
})); }));
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
vhostSettings: state.vhostSettings,
}));
if (categoryAutomat.state.treeStatus === 'idle') { if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree(); void categoryAutomat.actions.loadTree();
} }
void this.loadTerminals();
} }
componentWillUnmount() { componentWillUnmount() {
this.unsub?.(); this.unsub?.();
this.unsubCategory?.(); this.unsubCategory?.();
this.unsubAuth?.();
} }
async loadTerminals() { async loadTerminals() {
@@ -105,20 +135,27 @@ export default class PosTerminalSettingsPanel extends Component {
} }
async handleCreateTerminal() { async handleCreateTerminal() {
const { newTerminalName, enableAllForNew } = this.state; if (!this.state.isAdmin) return;
const { newTerminalName, enableAllForNew, terminals } = this.state;
const trimmed = newTerminalName.trim(); const trimmed = newTerminalName.trim();
if (!trimmed) return; if (!trimmed) return;
this.setState({ creatingTerminal: true }); this.setState({ creatingTerminal: true });
try { try {
const created = await companyAutomat.actions.createTerminal({ name: trimmed }); const nextSort = terminals.length > 0
? Math.max(...terminals.map((t) => Number(t.sort) || 0)) + 1
: 0;
const created = await companyAutomat.actions.createTerminal({
name: trimmed,
sort: nextSort,
enabled: true,
});
if (enableAllForNew && created?.id != null) { if (enableAllForNew && created?.id != null) {
await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id }); await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id });
await whenCategoryTreeReady(); await whenCategoryTreeReady();
} }
await companyAutomat.actions.loadTerminals();
this.setState({ newTerminalName: '' }); this.setState({ newTerminalName: '' });
enqueueSnackbar('Terminal hinzugefügt.', { variant: 'success' }); enqueueSnackbar('Terminal hinzugefügt.', { variant: 'success' });
} catch (err) { } catch (err) {
@@ -128,11 +165,88 @@ export default class PosTerminalSettingsPanel extends Component {
} }
} }
async handleToggleEnabled(terminal, nextValue) {
this.setState({ patchingTerminalId: terminal.id });
try {
await companyAutomat.actions.updateTerminal(terminal.id, {
enabled: Boolean(nextValue),
});
enqueueSnackbar(nextValue ? 'Terminal aktiviert.' : 'Terminal deaktiviert.', {
variant: 'success',
});
} catch (err) {
notifyError(err);
} finally {
this.setState({ patchingTerminalId: null });
}
}
handleSortDraftChange(terminalId, value) {
this.setState((prev) => ({
sortDrafts: { ...prev.sortDrafts, [terminalId]: value },
}));
}
async handleSortSubmit(terminal) {
const draft = this.state.sortDrafts[terminal.id];
if (draft === undefined) return;
const nextSort = Number(draft);
if (isNaN(nextSort) || nextSort === (terminal.sort ?? 0)) {
this.setState((prev) => {
const next = { ...prev.sortDrafts };
delete next[terminal.id];
return { sortDrafts: next };
});
return;
}
this.setState({ patchingTerminalId: terminal.id });
try {
await companyAutomat.actions.updateTerminal(terminal.id, { sort: nextSort });
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState((prev) => {
const next = { ...prev.sortDrafts };
delete next[terminal.id];
return { sortDrafts: next, patchingTerminalId: null };
});
}
}
async handleMoveTerminal(terminal, direction) {
const { terminals } = this.state;
const currentIndex = terminals.findIndex((t) => t.id === terminal.id);
if (currentIndex === -1) return;
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= terminals.length) return;
const other = terminals[targetIndex];
const currentSort = terminal.sort ?? 0;
const otherSort = other.sort ?? 0;
let newSort;
if (direction === 'up') {
newSort = otherSort <= currentSort ? otherSort - 1 : otherSort;
} else {
newSort = otherSort >= currentSort ? otherSort + 1 : otherSort;
}
this.setState({ patchingTerminalId: terminal.id });
try {
await companyAutomat.actions.updateTerminal(terminal.id, { sort: newSort });
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ patchingTerminalId: null });
}
}
async handleDeleteTerminal(terminal) { async handleDeleteTerminal(terminal) {
this.setState({ deletingId: terminal.id }); this.setState({ deletingId: terminal.id });
try { try {
await companyAutomat.actions.deleteTerminal(terminal.id); await companyAutomat.actions.deleteTerminal(terminal.id);
await companyAutomat.actions.loadTerminals();
enqueueSnackbar('Terminal gelöscht.', { variant: 'info' }); enqueueSnackbar('Terminal gelöscht.', { variant: 'info' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
@@ -183,9 +297,11 @@ export default class PosTerminalSettingsPanel extends Component {
render() { render() {
const { const {
isAdmin,
status, status,
error, error,
terminals, terminals,
vhostSettings,
newTerminalName, newTerminalName,
creatingTerminal, creatingTerminal,
enableAllForNew, enableAllForNew,
@@ -195,6 +311,11 @@ export default class PosTerminalSettingsPanel extends Component {
rootCategoryId, rootCategoryId,
} = this.state; } = this.state;
const parsedVhost = parseVhostSettings(vhostSettings);
const posIp = parsedVhost.posip || parsedVhost.ip || null;
const posPort = parsedVhost.posport || parsedVhost.port || null;
const hasVhostSettings = Boolean(posIp || posPort || vhostSettings);
if (status === 'loading' && terminals.length === 0) { if (status === 'loading' && terminals.length === 0) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -209,13 +330,43 @@ export default class PosTerminalSettingsPanel extends Component {
return ( return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}> <Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
{hasVhostSettings && (
<Box
sx={{
mb: 2,
p: 1.5,
px: 2,
borderRadius: 2,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Pos Sync IP:{' '}
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
{posIp ?? '—'}
</Box>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Pos Sync Port:{' '}
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
{posPort ?? '—'}
</Box>
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TextField <TextField
size="small" size="small"
placeholder="Neues Terminal..." placeholder="Neues Terminal..."
value={newTerminalName} value={newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })} onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
autoComplete="off" autoComplete="off"
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
@@ -240,7 +391,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
checked={enableAllForNew} checked={enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })} onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
/> />
</Box> </Box>
</Tooltip> </Tooltip>
@@ -249,7 +400,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />} startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />}
onClick={this.handleCreateTerminal} onClick={this.handleCreateTerminal}
disabled={creatingTerminal || !newTerminalName.trim()} disabled={!isAdmin || creatingTerminal || !newTerminalName.trim()}
> >
Hinzufügen Hinzufügen
</Button> </Button>
@@ -264,9 +415,10 @@ export default class PosTerminalSettingsPanel extends Component {
</Box> </Box>
) : ( ) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{terminals.map((terminal) => { {terminals.map((terminal, index) => {
const isDeleting = deletingId === terminal.id; const isDeleting = deletingId === terminal.id;
const isPatching = patchingTerminalId === terminal.id; const isPatching = patchingTerminalId === terminal.id;
const isEnabled = terminal.enabled !== false && terminal.enabled !== 0 && terminal.enabled != null;
const pairedDateText = formatDateTime(terminal.pairedAt); const pairedDateText = formatDateTime(terminal.pairedAt);
const lastseenText = formatDateTime(terminal.lastseenAt) ?? 'Nie'; const lastseenText = formatDateTime(terminal.lastseenAt) ?? 'Nie';
const updatedText = formatDateTime(terminal.updatedAt) ?? '—'; const updatedText = formatDateTime(terminal.updatedAt) ?? '—';
@@ -278,18 +430,24 @@ export default class PosTerminalSettingsPanel extends Component {
p: 2, p: 2,
borderRadius: 2, borderRadius: 2,
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: isEnabled ? 'divider' : 'action.disabledBackground',
bgcolor: 'background.paper', bgcolor: 'background.paper',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: 1.5, gap: 1.5,
opacity: isEnabled ? 1 : 0.72,
transition: 'opacity 0.2s ease, border-color 0.2s ease',
}} }}
> >
{/* Header: Icon, Name, PIN, Delete action */} {/* Header: Icon, Name, PIN, Version, Status Switch, Delete action */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
<PointOfSaleIcon sx={{ color: 'primary.main', fontSize: 24 }} /> <PointOfSaleIcon sx={{ color: isEnabled ? 'primary.main' : 'text.disabled', fontSize: 24 }} />
<Typography variant="subtitle1" fontWeight={600}> <Typography
variant="subtitle1"
fontWeight={600}
sx={{ color: isEnabled ? 'text.primary' : 'text.secondary' }}
>
{terminal.name} {terminal.name}
</Typography> </Typography>
{terminal.pairingpin ? ( {terminal.pairingpin ? (
@@ -313,12 +471,56 @@ export default class PosTerminalSettingsPanel extends Component {
({terminal.clientserial}) ({terminal.clientserial})
</Typography> </Typography>
) : null} ) : null}
{terminal.clientversion ? (
<Tooltip title="POS Client Software-Version">
<Chip
label={`v${terminal.clientversion}`}
size="small"
variant="outlined"
color="info"
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '0.75rem' }}
/>
</Tooltip>
) : null}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip
title={
isEnabled
? 'Terminal aktiv (kann synchronisieren)'
: 'Terminal deaktiviert (POS-Zugriff blockiert)'
}
>
<FormControlLabel
control={
<Switch
size="small"
checked={isEnabled}
disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
color="success"
/>
}
label={
<Typography
variant="body2"
sx={{
fontSize: '0.8125rem',
fontWeight: 500,
color: isEnabled ? 'text.primary' : 'text.disabled',
}}
>
{isEnabled ? 'Aktiv' : 'Inaktiv'}
</Typography>
}
sx={{ m: 0 }}
/>
</Tooltip>
<IconButton <IconButton
size="small" size="small"
aria-label={`Terminal ${terminal.name} löschen`} aria-label={`Terminal ${terminal.name} löschen`}
onClick={() => void this.handleDeleteTerminal(terminal)} onClick={() => void this.handleDeleteTerminal(terminal)}
disabled={isDeleting || creatingTerminal || isPatching} disabled={!isAdmin || isDeleting || creatingTerminal || isPatching}
color="error" color="error"
> >
{isDeleting ? ( {isDeleting ? (
@@ -328,8 +530,9 @@ export default class PosTerminalSettingsPanel extends Component {
)} )}
</IconButton> </IconButton>
</Box> </Box>
</Box>
{/* Settings row: Root category picker and syncRootArticles toggle */} {/* Settings row: Root category picker, sort order controls, and syncRootArticles toggle */}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -347,13 +550,14 @@ export default class PosTerminalSettingsPanel extends Component {
<Chip <Chip
icon={<FolderIcon />} icon={<FolderIcon />}
label={this.getCategoryLabel(terminal)} label={this.getCategoryLabel(terminal)}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={isAdmin ? () => this.setState({ pickerTerminal: terminal }) : undefined}
onDelete={ onDelete={
terminal.rootCategoryId != null isAdmin && terminal.rootCategoryId != null
? () => void this.handleSetRootCategory(terminal, null) ? () => void this.handleSetRootCategory(terminal, null)
: undefined : undefined
} }
clickable clickable={isAdmin}
disabled={!isAdmin || isPatching || isDeleting}
variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'} variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'}
color={terminal.rootCategoryId != null ? 'primary' : 'default'} color={terminal.rootCategoryId != null ? 'primary' : 'default'}
size="small" size="small"
@@ -361,6 +565,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Button <Button
size="small" size="small"
variant="text" variant="text"
disabled={!isAdmin || isPatching || isDeleting}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={() => this.setState({ pickerTerminal: terminal })}
sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }} sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }}
> >
@@ -368,12 +573,64 @@ export default class PosTerminalSettingsPanel extends Component {
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2" color="text.secondary">
Reihenfolge:
</Typography>
<Tooltip title="Nach oben verschieben">
<span>
<IconButton
size="small"
disabled={!isAdmin || index === 0 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'up')}
sx={{ p: 0.25 }}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Nach unten verschieben">
<span>
<IconButton
size="small"
disabled={!isAdmin || index === terminals.length - 1 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'down')}
sx={{ p: 0.25 }}
>
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<TextField
type="number"
size="small"
disabled={!isAdmin || isPatching || isDeleting}
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
onBlur={() => void this.handleSortSubmit(terminal)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.target.blur();
}
}}
slotProps={{
htmlInput: {
step: 1,
style: { width: 44, padding: '3px 6px', textAlign: 'center', fontSize: '0.8125rem' },
},
}}
sx={{ width: 56 }}
/>
</Box>
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
size="small" size="small"
checked={Boolean(terminal.syncRootArticles)} checked={Boolean(terminal.syncRootArticles)}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)} onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
/> />
} }
@@ -385,6 +642,7 @@ export default class PosTerminalSettingsPanel extends Component {
sx={{ m: 0 }} sx={{ m: 0 }}
/> />
</Box> </Box>
</Box>
{/* Dates row */} {/* Dates row */}
<Divider sx={{ my: 0.25 }} /> <Divider sx={{ my: 0.25 }} />
@@ -405,6 +663,11 @@ export default class PosTerminalSettingsPanel extends Component {
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
<strong>Zuletzt online:</strong> {lastseenText} <strong>Zuletzt online:</strong> {lastseenText}
</Typography> </Typography>
{terminal.clientversion && (
<Typography variant="caption" color="text.secondary">
<strong>POS-Software:</strong> {terminal.clientversion}
</Typography>
)}
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
<strong>Aktualisiert:</strong> {updatedText} <strong>Aktualisiert:</strong> {updatedText}
</Typography> </Typography>

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@ import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import { printer as printerApi } from '../../api'; import { printer as printerApi } from '../../api';
import companyAutomat from '../../automats/companyAutomat';
import { requireArray, requireObject } from '../../utils/apiShape'; import { requireArray, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -189,6 +190,7 @@ export default class PrinterSettingsPanel extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: 'loading', status: 'loading',
error: null, error: null,
printers: [], printers: [],
@@ -208,6 +210,9 @@ export default class PrinterSettingsPanel extends Component {
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
void this.load(); void this.load();
} }
@@ -326,10 +331,12 @@ export default class PrinterSettingsPanel extends Component {
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this._unmounted = true;
this.unsub?.();
} }
render() { render() {
const { const {
isAdmin,
status, error, printers, status, error, printers,
draggingId, dragOverId, draggingId, dragOverId,
addOpen, editPrinter, addOpen, editPrinter,
@@ -354,6 +361,7 @@ export default class PrinterSettingsPanel extends Component {
size="small" size="small"
variant="outlined" variant="outlined"
startIcon={<AddIcon />} startIcon={<AddIcon />}
disabled={!isAdmin}
onClick={this.handleAdd} onClick={this.handleAdd}
> >
Drucker hinzufügen Drucker hinzufügen
@@ -386,17 +394,17 @@ export default class PrinterSettingsPanel extends Component {
<TableRow <TableRow
key={printer.id} key={printer.id}
hover hover
draggable draggable={isAdmin}
onDragStart={() => this.handleDragStart(printer.id)} onDragStart={isAdmin ? () => this.handleDragStart(printer.id) : undefined}
onDragOver={(e) => this.handleDragOver(e, printer.id)} onDragOver={isAdmin ? (e) => this.handleDragOver(e, printer.id) : undefined}
onDragLeave={() => this.handleDragLeave()} onDragLeave={isAdmin ? () => this.handleDragLeave() : undefined}
onDrop={() => this.handleDrop(printer.id)} onDrop={isAdmin ? () => this.handleDrop(printer.id) : undefined}
onDragEnd={() => this.handleDragEnd()} onDragEnd={isAdmin ? () => this.handleDragEnd() : undefined}
sx={{ sx={{
opacity: isDragging ? 0.4 : 1, opacity: isDragging ? 0.4 : 1,
outline: isDragOver ? '2px solid' : 'none', outline: isDragOver ? '2px solid' : 'none',
outlineColor: 'primary.main', outlineColor: 'primary.main',
cursor: 'grab', cursor: isAdmin ? 'grab' : 'default',
transition: 'opacity 0.15s', transition: 'opacity 0.15s',
}} }}
> >
@@ -426,7 +434,7 @@ export default class PrinterSettingsPanel extends Component {
<IconButton <IconButton
size="small" size="small"
aria-label="Bearbeiten" aria-label="Bearbeiten"
disabled={isDeleting} disabled={!isAdmin || isDeleting}
onClick={() => this.setState({ editPrinter: printer })} onClick={() => this.setState({ editPrinter: printer })}
> >
<EditOutlinedIcon fontSize="small" /> <EditOutlinedIcon fontSize="small" />
@@ -435,7 +443,7 @@ export default class PrinterSettingsPanel extends Component {
size="small" size="small"
aria-label="Löschen" aria-label="Löschen"
color="error" color="error"
disabled={isDeleting} disabled={!isAdmin || isDeleting}
onClick={() => void this.handleDelete(printer.id)} onClick={() => void this.handleDelete(printer.id)}
> >
{isDeleting ? ( {isDeleting ? (

View File

@@ -13,18 +13,25 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import authAutomat from '../../automats/authAutomat';
import CompanySettingsPanel from './CompanySettingsPanel'; import CompanySettingsPanel from './CompanySettingsPanel';
import NumberSettingsPanel from './NumberSettingsPanel'; import NumberSettingsPanel from './NumberSettingsPanel';
import PrinterSettingsPanel from './PrinterSettingsPanel'; import PrinterSettingsPanel from './PrinterSettingsPanel';
import PrinterAgentSettingsPanel from './PrinterAgentSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel'; import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel'; import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import MembersSettingsPanel from './MembersSettingsPanel';
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
import companyAutomat from '../../automats/companyAutomat';
import BackupSettingsPanel from './BackupSettingsPanel'; import BackupSettingsPanel from './BackupSettingsPanel';
import HealthSettingsPanel from './HealthSettingsPanel'; import HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'; import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
import SecurityIcon from '@mui/icons-material/Security'; import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined'; import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import LocalPrintshopOutlinedIcon from '@mui/icons-material/LocalPrintshopOutlined';
import { SURFACE_BASE } from '../../colors'; import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = { const TAB_KEYS = {
@@ -33,10 +40,12 @@ const TAB_KEYS = {
keys: 'keys', keys: 'keys',
numbers: 'numbers', numbers: 'numbers',
printers: 'printers', printers: 'printers',
printeragent: 'printeragent',
importExport: 'importExport', importExport: 'importExport',
backup: 'backup', backup: 'backup',
posterminal: 'posterminal', posterminal: 'posterminal',
security: 'security', security: 'security',
members: 'members',
}; };
const TABS = [ const TABS = [
@@ -45,23 +54,83 @@ const TABS = [
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel }, { id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel }, { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.printeragent, label: 'Drucker-Agent', icon: <LocalPrintshopOutlinedIcon />, Panel: PrinterAgentSettingsPanel },
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel }, { id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
{ id: TAB_KEYS.members, label: 'Mitglieder', icon: <GroupOutlinedIcon />, Panel: MembersSettingsPanel },
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel }, { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
]; ];
function getDefaultTab(vhost) {
return vhost ? TAB_KEYS.company : TAB_KEYS.health;
}
function getVisibleTabs(vhost, isAdmin) {
const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
if (!vhost) return tabs;
return tabs.filter(
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers
);
}
export default class SettingsDialog extends Component { export default class SettingsDialog extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { tab: TAB_KEYS.health }; const vhost = Boolean(authAutomat.state.vhost);
this.state = {
tab: getDefaultTab(vhost),
schemaVersion: authAutomat.state.schemaVersion,
vhost,
isAdmin: companyAutomat.state.current?.role === 'admin',
};
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
} }
componentDidMount() {
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
schemaVersion: state.schemaVersion,
vhost: Boolean(state.vhost),
}));
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsubAuth?.();
this.unsubCompany?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { open } = this.props; const { open } = this.props;
const { vhost } = this.state;
if (!open && prevProps.open) { if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.health }); this.setState({ tab: getDefaultTab(vhost) });
}
if (open && !prevProps.open) {
const currentVhost = Boolean(authAutomat.state.vhost);
const updates = {};
if (this.props.initialTab) {
updates.tab = this.props.initialTab;
}
if (this.state.schemaVersion !== authAutomat.state.schemaVersion) {
updates.schemaVersion = authAutomat.state.schemaVersion;
}
if (this.state.vhost !== currentVhost) {
updates.vhost = currentVhost;
}
if (
currentVhost &&
(this.state.tab === TAB_KEYS.health ||
this.state.tab === TAB_KEYS.backup ||
this.state.tab === TAB_KEYS.printers)
) {
updates.tab = getDefaultTab(currentVhost);
}
if (Object.keys(updates).length > 0) {
this.setState(updates);
}
} }
} }
@@ -71,7 +140,9 @@ export default class SettingsDialog extends Component {
render() { render() {
const { open, onClose } = this.props; const { open, onClose } = this.props;
const { tab } = this.state; const { tab, schemaVersion, vhost, isAdmin } = this.state;
const visibleTabs = getVisibleTabs(vhost, isAdmin);
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
const paperSx = { const paperSx = {
height: '85vh', height: '85vh',
@@ -120,12 +191,15 @@ export default class SettingsDialog extends Component {
borderColor: 'divider', borderColor: 'divider',
bgcolor: SURFACE_BASE, bgcolor: SURFACE_BASE,
py: 2, py: 2,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
overflowY: 'auto', overflowY: 'auto',
}} }}
> >
<Tabs <Tabs
orientation="vertical" orientation="vertical"
value={tab} value={activeTab}
onChange={this.handleTabChange} onChange={this.handleTabChange}
sx={{ sx={{
'& .MuiTabs-indicator': { '& .MuiTabs-indicator': {
@@ -161,7 +235,7 @@ export default class SettingsDialog extends Component {
}, },
}} }}
> >
{TABS.map((t) => ( {visibleTabs.map((t) => (
<Tab <Tab
key={t.id} key={t.id}
disableRipple disableRipple
@@ -172,20 +246,27 @@ export default class SettingsDialog extends Component {
/> />
))} ))}
</Tabs> </Tabs>
{schemaVersion != null && (
<Box sx={{ px: 2.5, py: 1.5, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', px: 0.5 }}>
DB-Version: {schemaVersion}
</Typography>
</Box>
)}
</Box> </Box>
{/* Right content panel */} {/* Right content panel */}
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}> <DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
<Box sx={{ display: 'grid' }}> <Box sx={{ display: 'grid' }}>
{TABS.map((t) => ( {visibleTabs.map((t) => (
<Box <Box
key={t.id} key={t.id}
sx={{ sx={{
gridArea: '1 / 1', gridArea: '1 / 1',
display: t.id === tab ? 'block' : 'none', display: t.id === activeTab ? 'block' : 'none',
}} }}
> >
<t.Panel active={t.id === tab} /> <t.Panel active={t.id === activeTab} />
</Box> </Box>
))} ))}
</Box> </Box>

View File

@@ -16,6 +16,7 @@ import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import { setting as settingApi } from '../../api'; import { setting as settingApi } from '../../api';
import companyAutomat from '../../automats/companyAutomat';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -68,6 +69,7 @@ export default class SettingsKeysPanel extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: 'loading', status: 'loading',
error: null, error: null,
settings: [], settings: [],
@@ -79,6 +81,9 @@ export default class SettingsKeysPanel extends Component {
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
void this.load(); void this.load();
} }
@@ -124,6 +129,7 @@ export default class SettingsKeysPanel extends Component {
} }
async handleSave(key) { async handleSave(key) {
if (!this.state.isAdmin) return;
const draft = this.state.drafts[key]; const draft = this.state.drafts[key];
if (!draft || draft.busy) return; if (!draft || draft.busy) return;
const row = this.state.settings.find((s) => s.key === key); const row = this.state.settings.find((s) => s.key === key);
@@ -172,10 +178,11 @@ export default class SettingsKeysPanel extends Component {
componentWillUnmount() { componentWillUnmount() {
this._unmounted = true; this._unmounted = true;
this.unsub?.();
} }
render() { render() {
const { status, error, settings, drafts } = this.state; const { isAdmin, status, error, settings, drafts } = this.state;
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -224,7 +231,7 @@ export default class SettingsKeysPanel extends Component {
size="small" size="small"
fullWidth fullWidth
value={draft.value !== undefined && draft.value !== null ? draft.value : (DEFAULT_SETTINGS[row.key] ?? '')} value={draft.value !== undefined && draft.value !== null ? draft.value : (DEFAULT_SETTINGS[row.key] ?? '')}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)} onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
slotProps={{ htmlInput: { spellCheck: false } }} slotProps={{ htmlInput: { spellCheck: false } }}
> >
@@ -247,7 +254,7 @@ export default class SettingsKeysPanel extends Component {
maxRows={isMultiline ? 6 : undefined} maxRows={isMultiline ? 6 : undefined}
placeholder={DEFAULT_SETTINGS[row.key] ?? undefined} placeholder={DEFAULT_SETTINGS[row.key] ?? undefined}
value={draft.value} value={draft.value}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)} onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (!isMultiline && e.key === 'Enter') { if (!isMultiline && e.key === 'Enter') {
@@ -279,7 +286,7 @@ export default class SettingsKeysPanel extends Component {
size="small" size="small"
aria-label="Speichern" aria-label="Speichern"
color="primary" color="primary"
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onClick={() => void this.handleSave(row.key)} onClick={() => void this.handleSave(row.key)}
> >
<SaveOutlinedIcon fontSize="small" /> <SaveOutlinedIcon fontSize="small" />

View File

@@ -8,3 +8,4 @@ export { default as ImportExportSettingsPanel } from './ImportExportSettingsPane
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel'; export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
export { default as BackupSettingsPanel } from './BackupSettingsPanel'; export { default as BackupSettingsPanel } from './BackupSettingsPanel';
export { default as HealthSettingsPanel } from './HealthSettingsPanel'; export { default as HealthSettingsPanel } from './HealthSettingsPanel';
export { default as MembersSettingsPanel } from './MembersSettingsPanel';

View File

@@ -5,19 +5,21 @@ import IconButton from '@mui/material/IconButton';
import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded'; import FolderOpenRoundedIcon from '@mui/icons-material/FolderOpenRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import companyAutomat from '../../automats/companyAutomat';
import vfsAutomat from '../../automats/vfsAutomat'; import vfsAutomat from '../../automats/vfsAutomat';
import VfsFileExplorer from './VfsFileExplorer'; import VfsFileExplorer from './VfsFileExplorer';
import VfsTextEditor from './VfsTextEditor'; import VfsTextEditor from './VfsTextEditor';
import VfsFilePreview from './VfsFilePreview'; import VfsFilePreview from './VfsFilePreview';
import VfsAppletRunner from './VfsAppletRunner'; import VfsAppletRunner from './VfsAppletRunner';
import VfsNewFileDialog from './VfsNewFileDialog'; import VfsNewFileDialog from './VfsNewFileDialog';
import { isAppletFile } from './vfsUtils'; import { isAppletFile, isProtectedVfsFile } from './vfsUtils';
export default class VfsEditorPanel extends PureComponent { export default class VfsEditorPanel extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = vfsAutomat.state; const s = vfsAutomat.state;
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
files: s.files, files: s.files,
loadingFiles: s.loadingFiles, loadingFiles: s.loadingFiles,
loadError: s.loadError, loadError: s.loadError,
@@ -39,6 +41,9 @@ export default class VfsEditorPanel extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
this.unsub = vfsAutomat.subscribe(this, (state) => ({ this.unsub = vfsAutomat.subscribe(this, (state) => ({
files: state.files, files: state.files,
loadingFiles: state.loadingFiles, loadingFiles: state.loadingFiles,
@@ -72,6 +77,7 @@ export default class VfsEditorPanel extends PureComponent {
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubCompany?.();
this.unsub?.(); this.unsub?.();
} }
@@ -108,6 +114,7 @@ export default class VfsEditorPanel extends PureComponent {
render() { render() {
const { const {
isAdmin,
files, files,
loadingFiles, loadingFiles,
selectedFileName, selectedFileName,
@@ -124,6 +131,7 @@ export default class VfsEditorPanel extends PureComponent {
} = this.state; } = this.state;
const isApplet = Boolean(selectedFileName && isAppletFile(selectedFileName)); const isApplet = Boolean(selectedFileName && isAppletFile(selectedFileName));
const isProtectedFile = Boolean(selectedFileName && isProtectedVfsFile(selectedFileName));
const showAppletRunner = isApplet && selectedFile?.isText && appletView === 'run'; const showAppletRunner = isApplet && selectedFile?.isText && appletView === 'run';
return ( return (
@@ -161,6 +169,8 @@ export default class VfsEditorPanel extends PureComponent {
isSaving={isSaving} isSaving={isSaving}
saveError={saveError} saveError={saveError}
loadingContent={loadingContent} loadingContent={loadingContent}
isAdmin={isAdmin}
readOnly={!isAdmin && isProtectedFile}
onChange={(c) => vfsAutomat.actions.updateContent(c)} onChange={(c) => vfsAutomat.actions.updateContent(c)}
onSave={() => vfsAutomat.actions.saveCurrentFile()} onSave={() => vfsAutomat.actions.saveCurrentFile()}
onClose={() => vfsAutomat.actions.selectFile(null)} onClose={() => vfsAutomat.actions.selectFile(null)}
@@ -216,6 +226,7 @@ export default class VfsEditorPanel extends PureComponent {
<VfsFileExplorer <VfsFileExplorer
files={files} files={files}
loading={loadingFiles} loading={loadingFiles}
isAdmin={isAdmin}
selectedFileName={selectedFileName} selectedFileName={selectedFileName}
searchQuery={searchQuery} searchQuery={searchQuery}
onSelectFile={(name) => vfsAutomat.actions.selectFile(name)} onSelectFile={(name) => vfsAutomat.actions.selectFile(name)}
@@ -238,6 +249,7 @@ export default class VfsEditorPanel extends PureComponent {
<VfsNewFileDialog <VfsNewFileDialog
open={newFileDialogOpen} open={newFileDialogOpen}
defaultFolder={this.state.newFileDefaultFolder} defaultFolder={this.state.newFileDefaultFolder}
isAdmin={isAdmin}
onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)} onClose={() => vfsAutomat.actions.setNewFileDialogOpen(false)}
onCreate={(name) => vfsAutomat.actions.createFile(name)} onCreate={(name) => vfsAutomat.actions.createFile(name)}
isCreating={isSaving} isCreating={isSaving}

View File

@@ -27,11 +27,25 @@ import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRound
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded'; import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import companyAutomat from '../../automats/companyAutomat';
import VfsFileIcon from './VfsFileIcon'; import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize, formatVfsDate, isAppletFile } from './vfsUtils'; import { formatVfsSize, formatVfsDate, isAppletFile, isProtectedVfsFile } from './vfsUtils';
import VfsNewFolderDialog from './VfsNewFolderDialog'; import VfsNewFolderDialog from './VfsNewFolderDialog';
import VfsRenameDialog from './VfsRenameDialog'; import VfsRenameDialog from './VfsRenameDialog';
/**
* Returns true if the given folder path contains any admin-protected VFS files
* (`.skill` or `.applet.js`, case-insensitive).
*/
function folderContainsProtectedFiles(folderPath, files) {
if (!folderPath || !Array.isArray(files)) return false;
const prefix = `${String(folderPath).replace(/^\/+/, '').replace(/\/+$/, '')}/`;
return files.some((f) => {
const clean = String(f?.name || '').replace(/^\/+/, '');
return clean.startsWith(prefix) && isProtectedVfsFile(clean);
});
}
/** /**
* Returns all ancestor directory paths for a file or folder path. * Returns all ancestor directory paths for a file or folder path.
* e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"] * e.g. "docs/api/v1/spec.json" -> ["docs", "docs/api", "docs/api/v1"]
@@ -126,6 +140,7 @@ export default class VfsFileExplorer extends PureComponent {
} }
} }
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
menuAnchor: null, menuAnchor: null,
menuFile: null, menuFile: null,
folderMenuAnchor: null, folderMenuAnchor: null,
@@ -143,6 +158,16 @@ export default class VfsFileExplorer extends PureComponent {
this.fileInputRef = React.createRef(); this.fileInputRef = React.createRef();
} }
componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsubCompany?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) { if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) {
const parents = getParentFolderPaths(this.props.selectedFileName); const parents = getParentFolderPaths(this.props.selectedFileName);
@@ -632,7 +657,12 @@ export default class VfsFileExplorer extends PureComponent {
isRenaming, isRenaming,
isDraggingOver, isDraggingOver,
} = this.state; } = this.state;
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const isText = menuFile?.isText; const isText = menuFile?.isText;
const isMenuFileProtected = Boolean(menuFile && isProtectedVfsFile(menuFile.name));
const isFolderProtected = Boolean(
folderMenuNode && folderContainsProtectedFiles(folderMenuNode.path, this.props.files)
);
return ( return (
<Box <Box
@@ -832,6 +862,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isAdmin && isMenuFileProtected}
onClick={() => { onClick={() => {
const file = menuFile; const file = menuFile;
this.handleCloseMenu(); this.handleCloseMenu();
@@ -860,6 +891,7 @@ export default class VfsFileExplorer extends PureComponent {
)} )}
<MenuItem <MenuItem
disabled={!isAdmin && isMenuFileProtected}
onClick={() => { onClick={() => {
if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) { if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) {
this.props.onDelete(menuFile.name); this.props.onDelete(menuFile.name);
@@ -915,6 +947,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isAdmin && isFolderProtected}
onClick={() => { onClick={() => {
const folder = folderMenuNode; const folder = folderMenuNode;
this.handleCloseFolderMenu(); this.handleCloseFolderMenu();
@@ -934,6 +967,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem> </MenuItem>
<MenuItem <MenuItem
disabled={!isAdmin && isFolderProtected}
onClick={() => { onClick={() => {
const folder = folderMenuNode; const folder = folderMenuNode;
this.handleCloseFolderMenu(); this.handleCloseFolderMenu();
@@ -963,6 +997,7 @@ export default class VfsFileExplorer extends PureComponent {
<VfsRenameDialog <VfsRenameDialog
open={renameDialogOpen} open={renameDialogOpen}
item={renameItem} item={renameItem}
isAdmin={isAdmin}
onClose={() => this.setState({ renameDialogOpen: false, renameItem: null })} onClose={() => this.setState({ renameDialogOpen: false, renameItem: null })}
onRename={this.handleRenameItem} onRename={this.handleRenameItem}
isRenaming={isRenaming} isRenaming={isRenaming}

View File

@@ -7,17 +7,30 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded'; import NoteAddRoundedIcon from '@mui/icons-material/NoteAddRounded';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import companyAutomat from '../../automats/companyAutomat';
import { isProtectedVfsFile } from './vfsUtils';
export default class VfsNewFileDialog extends PureComponent { export default class VfsNewFileDialog extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const folder = props.defaultFolder ? `${String(props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : ''; const folder = props.defaultFolder ? `${String(props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : '';
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
filename: folder, filename: folder,
error: '', error: '',
}; };
} }
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) { if (!prevProps.open && this.props.open) {
const folder = this.props.defaultFolder ? `${String(this.props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : ''; const folder = this.props.defaultFolder ? `${String(this.props.defaultFolder).replace(/^\/+/, '').replace(/\/+$/, '')}/` : '';
@@ -27,6 +40,7 @@ export default class VfsNewFileDialog extends PureComponent {
handleSubmit = (e) => { handleSubmit = (e) => {
e?.preventDefault(); e?.preventDefault();
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const clean = this.state.filename.trim().replace(/^[/\\]+/, ''); const clean = this.state.filename.trim().replace(/^[/\\]+/, '');
if (!clean) { if (!clean) {
this.setState({ error: 'Bitte gib einen Dateinamen ein.' }); this.setState({ error: 'Bitte gib einen Dateinamen ein.' });
@@ -36,12 +50,21 @@ export default class VfsNewFileDialog extends PureComponent {
this.setState({ error: 'Ungültige Zeichen im Dateinamen.' }); this.setState({ error: 'Ungültige Zeichen im Dateinamen.' });
return; return;
} }
if (!isAdmin && isProtectedVfsFile(clean)) {
this.setState({ error: 'Nur Administratoren dürfen .SKILL- und .applet.js-Dateien erstellen.' });
return;
}
this.props.onCreate(clean); this.props.onCreate(clean);
}; };
render() { render() {
const { open, onClose, isCreating } = this.props; const { open, onClose, isCreating } = this.props;
const { filename, error } = this.state; const { filename, error } = this.state;
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const clean = filename.trim().replace(/^[/\\]+/, '');
const isProtected = Boolean(!isAdmin && clean && isProtectedVfsFile(clean));
const displayError =
error || (isProtected ? 'Nur Administratoren dürfen .SKILL- und .applet.js-Dateien erstellen.' : '');
return ( return (
<Dialog <Dialog
@@ -70,8 +93,8 @@ export default class VfsNewFileDialog extends PureComponent {
label="" label=""
value={filename} value={filename}
onChange={(nextVal) => this.setState({ filename: nextVal, error: '' })} onChange={(nextVal) => this.setState({ filename: nextVal, error: '' })}
error={Boolean(error)} error={Boolean(displayError)}
helperText={error} helperText={displayError}
disabled={isCreating} disabled={isCreating}
/> />
</DialogContent> </DialogContent>
@@ -83,7 +106,7 @@ export default class VfsNewFileDialog extends PureComponent {
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
disabled={isCreating || !filename.trim()} disabled={isCreating || !clean || isProtected}
sx={{ borderRadius: 2 }} sx={{ borderRadius: 2 }}
> >
{isCreating ? 'Erstelle…' : 'Erstellen & Öffnen'} {isCreating ? 'Erstelle…' : 'Erstellen & Öffnen'}

View File

@@ -7,16 +7,29 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded'; import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import companyAutomat from '../../automats/companyAutomat';
import { isProtectedVfsFile } from './vfsUtils';
export default class VfsRenameDialog extends PureComponent { export default class VfsRenameDialog extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
targetPath: props.item?.path || '', targetPath: props.item?.path || '',
error: '', error: '',
}; };
} }
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) { if (!prevProps.open && this.props.open) {
this.setState({ this.setState({
@@ -28,6 +41,7 @@ export default class VfsRenameDialog extends PureComponent {
handleSubmit = (e) => { handleSubmit = (e) => {
e?.preventDefault(); e?.preventDefault();
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const raw = this.state.targetPath.trim(); const raw = this.state.targetPath.trim();
if (!raw) { if (!raw) {
this.setState({ error: 'Bitte gib einen neuen Namen bzw. Pfad ein.' }); this.setState({ error: 'Bitte gib einen neuen Namen bzw. Pfad ein.' });
@@ -41,6 +55,11 @@ export default class VfsRenameDialog extends PureComponent {
const cleanNew = raw.replace(/^[/\\]+/, '').replace(/[/\\]+$/, ''); const cleanNew = raw.replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
const cleanOld = String(this.props.item?.path || '').replace(/^[/\\]+/, '').replace(/[/\\]+$/, ''); const cleanOld = String(this.props.item?.path || '').replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
if (!isAdmin && (isProtectedVfsFile(cleanOld) || isProtectedVfsFile(cleanNew))) {
this.setState({ error: 'Nur Administratoren dürfen .SKILL- und .applet.js-Dateien umbenennen.' });
return;
}
if (cleanNew === cleanOld) { if (cleanNew === cleanOld) {
this.props.onClose?.(); this.props.onClose?.();
return; return;
@@ -53,6 +72,11 @@ export default class VfsRenameDialog extends PureComponent {
const { open, onClose, isRenaming, item } = this.props; const { open, onClose, isRenaming, item } = this.props;
const { targetPath, error } = this.state; const { targetPath, error } = this.state;
const isDir = Boolean(item?.isDirectory); const isDir = Boolean(item?.isDirectory);
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const cleanNew = targetPath.trim().replace(/^[/\\]+/, '').replace(/[/\\]+$/, '');
const isProtected = Boolean(!isAdmin && cleanNew && isProtectedVfsFile(cleanNew));
const displayError =
error || (isProtected ? 'Nur Administratoren dürfen .SKILL- und .applet.js-Dateien umbenennen.' : '');
return ( return (
<Dialog <Dialog
@@ -81,8 +105,8 @@ export default class VfsRenameDialog extends PureComponent {
label="" label=""
value={targetPath} value={targetPath}
onChange={(nextVal) => this.setState({ targetPath: nextVal, error: '' })} onChange={(nextVal) => this.setState({ targetPath: nextVal, error: '' })}
error={Boolean(error)} error={Boolean(displayError)}
helperText={error} helperText={displayError}
disabled={isRenaming} disabled={isRenaming}
/> />
</DialogContent> </DialogContent>
@@ -94,7 +118,7 @@ export default class VfsRenameDialog extends PureComponent {
<Button <Button
type="submit" type="submit"
variant="contained" variant="contained"
disabled={isRenaming || !targetPath.trim()} disabled={isRenaming || !cleanNew || isProtected}
sx={{ borderRadius: 2 }} sx={{ borderRadius: 2 }}
> >
{isRenaming ? 'Speichere…' : 'Umbenennen'} {isRenaming ? 'Speichere…' : 'Umbenennen'}

View File

@@ -16,8 +16,9 @@ import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineR
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded'; import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded'; import WrapTextRoundedIcon from '@mui/icons-material/WrapTextRounded';
import companyAutomat from '../../automats/companyAutomat';
import VfsFileIcon from './VfsFileIcon'; import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize } from './vfsUtils'; import { formatVfsSize, isProtectedVfsFile } from './vfsUtils';
const CodeMirrorCore = React.lazy(() => import('./CodeMirrorCore')); const CodeMirrorCore = React.lazy(() => import('./CodeMirrorCore'));
@@ -72,6 +73,7 @@ export default class VfsTextEditor extends PureComponent {
super(props); super(props);
this.editorView = null; this.editorView = null;
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
wrapLines: true, wrapLines: true,
cursorLine: 1, cursorLine: 1,
cursorCol: 1, cursorCol: 1,
@@ -79,9 +81,16 @@ export default class VfsTextEditor extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
this.focusEditor(); this.focusEditor();
} }
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.fileName !== this.props.fileName) { if (prevProps.fileName !== this.props.fileName) {
this.focusEditor(); this.focusEditor();
@@ -145,6 +154,9 @@ export default class VfsTextEditor extends PureComponent {
}; };
handleSaveShortcut = () => { handleSaveShortcut = () => {
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const isProtectedFile = isProtectedVfsFile(this.props.fileName);
if (this.props.readOnly || (!isAdmin && isProtectedFile)) return;
if (this.props.isDirty && !this.props.isSaving) { if (this.props.isDirty && !this.props.isSaving) {
this.props.onSave?.(); this.props.onSave?.();
} }
@@ -168,6 +180,9 @@ export default class VfsTextEditor extends PureComponent {
} = this.props; } = this.props;
const { wrapLines, cursorLine, cursorCol } = this.state; const { wrapLines, cursorLine, cursorCol } = this.state;
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const isProtectedFile = isProtectedVfsFile(fileName);
const isReadOnly = Boolean(this.props.readOnly || (!isAdmin && isProtectedFile));
const text = content || ''; const text = content || '';
const lineCount = text.split('\n').length; const lineCount = text.split('\n').length;
const charCount = text.length; const charCount = text.length;
@@ -292,17 +307,17 @@ export default class VfsTextEditor extends PureComponent {
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
color={isDirty ? 'success' : 'inherit'} color={!isReadOnly && isDirty ? 'success' : 'inherit'}
startIcon={isSaving ? <CircularProgress size={14} color="inherit" /> : <SaveRoundedIcon />} startIcon={isSaving ? <CircularProgress size={14} color="inherit" /> : <SaveRoundedIcon />}
onClick={onSave} onClick={onSave}
disabled={!isDirty || isSaving} disabled={isReadOnly || !isDirty || isSaving}
sx={{ sx={{
borderRadius: 2, borderRadius: 2,
px: 1.5, px: 1.5,
py: 0.4, py: 0.4,
fontSize: '0.78rem', fontSize: '0.78rem',
fontWeight: 600, fontWeight: 600,
...(isDirty && { ...(!isReadOnly && isDirty && {
bgcolor: 'success.main', bgcolor: 'success.main',
color: '#fff', color: '#fff',
'&:hover': { bgcolor: 'success.dark' }, '&:hover': { bgcolor: 'success.dark' },
@@ -396,7 +411,7 @@ export default class VfsTextEditor extends PureComponent {
fileName={fileName} fileName={fileName}
mime={file?.mime} mime={file?.mime}
wrapLines={wrapLines} wrapLines={wrapLines}
readOnly={this.props.readOnly} readOnly={isReadOnly}
onSaveShortcut={this.handleSaveShortcut} onSaveShortcut={this.handleSaveShortcut}
onCreateEditor={this.handleEditorCreate} onCreateEditor={this.handleEditorCreate}
onUpdate={this.handleViewUpdate} onUpdate={this.handleViewUpdate}

View File

@@ -87,10 +87,30 @@ export function getVfsCategory(filePath, mime = '') {
* @returns {boolean} * @returns {boolean}
*/ */
export function isAppletFile(filePath) { export function isAppletFile(filePath) {
const p = String(filePath || '').toLowerCase(); const p = String(filePath || '').trim().toLowerCase();
return p.endsWith('.applet.js'); return p.endsWith('.applet.js');
} }
/**
* Check whether a VFS path is a skill file (`*.skill`, case-insensitive).
* @param {string} filePath
* @returns {boolean}
*/
export function isSkillFile(filePath) {
const p = String(filePath || '').trim().toLowerCase();
return p.endsWith('.skill');
}
/**
* Check whether a VFS path is an admin-protected file (`*.skill` or `*.applet.js`, case-insensitive).
* @param {string} filePath
* @returns {boolean}
*/
export function isProtectedVfsFile(filePath) {
const p = String(filePath || '').trim().toLowerCase();
return p.endsWith('.skill') || p.endsWith('.applet.js');
}
/** /**
* Split a VFS file path into directory and basename. * Split a VFS file path into directory and basename.
* @param {string} path * @param {string} path

View File

@@ -37,6 +37,7 @@ class ArtikelLager extends PureComponent {
}; };
handleBlur = () => { handleBlur = () => {
if (this.props.disabled) return;
const item = this.props.warehouse ?? this.props.item; const item = this.props.warehouse ?? this.props.item;
if (!item) return; if (!item) return;
const draft = this.state.draft.trim(); const draft = this.state.draft.trim();
@@ -60,6 +61,7 @@ class ArtikelLager extends PureComponent {
} }
handleKeyDown = (event) => { handleKeyDown = (event) => {
if (this.props.disabled) return;
const item = this.props.warehouse ?? this.props.item; const item = this.props.warehouse ?? this.props.item;
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.preventDefault(); event.preventDefault();
@@ -73,6 +75,7 @@ class ArtikelLager extends PureComponent {
}; };
handleDelete = () => { handleDelete = () => {
if (this.props.disabled) return;
const item = this.props.warehouse ?? this.props.item; const item = this.props.warehouse ?? this.props.item;
if (!item) return; if (!item) return;
if (!window.confirm(`Lager „${item.name}" wirklich löschen?`)) return; if (!window.confirm(`Lager „${item.name}" wirklich löschen?`)) return;

View File

@@ -12,11 +12,10 @@ import CloseIcon from '@mui/icons-material/Close';
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'; import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SensorsIcon from '@mui/icons-material/Sensors'; import SensorsIcon from '@mui/icons-material/Sensors';
import sseAutomat, { getSseClientId } from '../automats/sseAutomat'; import sseAutomat from '../automats/sseAutomat';
const PANEL_WIDTH = 420; const PANEL_WIDTH = 420;
const PANEL_HEIGHT = 380; const PANEL_HEIGHT = 380;
const RAW_EVENT_LIMIT = 500;
const pulseAnimationA = keyframes` const pulseAnimationA = keyframes`
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); } 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); } 35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); }
@@ -43,15 +42,11 @@ class SseMonitor extends PureComponent {
this.state = { this.state = {
status: sseAutomat.state.status, status: sseAutomat.state.status,
companyId: sseAutomat.state.companyId, companyId: sseAutomat.state.companyId,
events: [], events: sseAutomat.state.events,
open: false, open: false,
position: null, position: null,
eventPulse: 0, eventPulse: 0,
}; };
this.nextRawId = 0;
this.rawAbort = null;
this.rawReconnectTimer = null;
this.rawBuffer = '';
this.panelRef = createRef(); this.panelRef = createRef();
this.handleToggle = this.handleToggle.bind(this); this.handleToggle = this.handleToggle.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
@@ -66,15 +61,14 @@ class SseMonitor extends PureComponent {
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({ this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
status: state.status, status: state.status,
companyId: state.companyId, companyId: state.companyId,
events: state.events,
})); }));
window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keydown', this.handleKeyDown);
this.startRawStream();
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubscribe?.(); this.unsubscribe?.();
window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keydown', this.handleKeyDown);
this.stopRawStream();
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
@@ -83,96 +77,6 @@ class SseMonitor extends PureComponent {
if (currentEventId != null && currentEventId !== previousEventId) { if (currentEventId != null && currentEventId !== previousEventId) {
this.setState((state) => ({ eventPulse: state.eventPulse + 1 })); this.setState((state) => ({ eventPulse: state.eventPulse + 1 }));
} }
if (prevState.companyId !== this.state.companyId) {
this.startRawStream();
}
}
pushRawEvent(type, data) {
const entry = {
id: ++this.nextRawId,
type: type || 'message',
data: data ?? '',
lastEventId: '',
time: Date.now(),
};
this.setState((state) => ({
events: [...state.events.slice(-(RAW_EVENT_LIMIT - 1)), entry],
}));
}
handleRawChunk(text) {
this.rawBuffer += text;
let boundary;
while ((boundary = this.rawBuffer.indexOf('\n\n')) !== -1) {
const frame = this.rawBuffer.slice(0, boundary);
this.rawBuffer = this.rawBuffer.slice(boundary + 2);
let type = '';
const dataLines = [];
let comment = '';
for (const line of frame.split('\n')) {
if (line.startsWith(':')) {
comment = line.slice(1).trim();
} else if (line.startsWith('event:')) {
type = line.slice(6).trim();
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
// No filtering: show every frame, including heartbeats and
// events no automat listens for (brand, modelconversation, ...).
if (!type && comment) {
this.pushRawEvent(comment, '');
} else if (type || dataLines.length > 0) {
this.pushRawEvent(type || 'message', dataLines.join('\n'));
}
}
}
stopRawStream() {
if (this.rawReconnectTimer) {
clearTimeout(this.rawReconnectTimer);
this.rawReconnectTimer = null;
}
if (this.rawAbort) {
try {
this.rawAbort.abort();
} catch {}
this.rawAbort = null;
}
}
async startRawStream() {
this.stopRawStream();
this.rawBuffer = '';
const controller = new AbortController();
this.rawAbort = controller;
try {
const url = new URL('/api/events', window.location.origin);
try {
const cid = getSseClientId();
if (cid) url.searchParams.set('cid', `${cid}-monitor`);
} catch {}
const res = await fetch(url.href, {
credentials: 'include',
signal: controller.signal,
headers: { Accept: 'text/event-stream' },
});
if (!res.ok || !res.body) throw new Error(`SSE monitor HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
this.handleRawChunk(decoder.decode(value, { stream: true }));
}
} catch (err) {
if (controller.signal.aborted) return;
this.pushRawEvent('error', String(err?.message ?? err));
}
if (!controller.signal.aborted && !this.rawReconnectTimer) {
this.rawReconnectTimer = setTimeout(() => this.startRawStream(), 3000);
}
} }
handleKeyDown(event) { handleKeyDown(event) {
@@ -190,7 +94,7 @@ class SseMonitor extends PureComponent {
} }
handleClear() { handleClear() {
this.setState({ events: [] }); sseAutomat.actions.clearEvents();
} }
handlePointerDown(event) { handlePointerDown(event) {

View File

@@ -25,6 +25,8 @@ import HistoryIcon from '@mui/icons-material/History';
import PublicIcon from '@mui/icons-material/Public'; import PublicIcon from '@mui/icons-material/Public';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import BarChartIcon from '@mui/icons-material/BarChart'; import BarChartIcon from '@mui/icons-material/BarChart';
import BugReportIcon from '@mui/icons-material/BugReport';
import BusinessIcon from '@mui/icons-material/Business';
import { isKategorienPath } from './components/category/categoryRoute'; import { isKategorienPath } from './components/category/categoryRoute';
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat'; import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
import { NAV_ITEM_ACTIVE_ACCENT } from './colors'; import { NAV_ITEM_ACTIVE_ACCENT } from './colors';
@@ -97,6 +99,16 @@ export const NAV_ITEMS = [
icon: InfoIcon, icon: InfoIcon,
help: 'Systemstatus, Serververbindung und Mandanteneinstellungen', help: 'Systemstatus, Serververbindung und Mandanteneinstellungen',
}, },
...(import.meta.env.DEV
? [
{
path: '/debug',
label: 'Debug',
icon: BugReportIcon,
help: 'Entwickler- und Diagnosewerkzeuge für Automats (nur im Dev-Modus)',
},
]
: []),
]; ];
class DeliveryNeuIcon extends PureComponent { class DeliveryNeuIcon extends PureComponent {
@@ -270,8 +282,8 @@ export const SUB_NAV = {
'/infos': [ '/infos': [
{ {
segment: '', segment: '',
label: 'ArticleHistory', label: 'Artikel-Historie',
title: 'ArticleHistory', title: 'Artikel-Historie',
icon: HistoryIcon, icon: HistoryIcon,
help: 'Historie aller Artikeländerungen und Ereignisse', help: 'Historie aller Artikeländerungen und Ereignisse',
}, },
@@ -297,6 +309,19 @@ export const SUB_NAV = {
help: 'Statistische Auswertungen und Kennzahlen', help: 'Statistische Auswertungen und Kennzahlen',
}, },
], ],
...(import.meta.env.DEV
? {
'/debug': [
{
segment: '',
label: 'Company',
title: 'Company Automat',
icon: BusinessIcon,
help: 'companyAutomat Eingänge, Ausgänge (Actions & Selectors) und internen State inspizieren & steuern',
},
],
}
: {}),
}; };
export function isNavItemActive(pathname, navPath) { export function isNavItemActive(pathname, navPath) {
@@ -359,6 +384,11 @@ export function resolveActiveSub(pathname, sectionPath) {
return ''; return '';
} }
// Treat /debug/company and /debug/companyautomat as segment ''
if (sectionPath === '/debug' && (rest === 'company' || rest === 'companyautomat')) {
return '';
}
const active = subNav.find((item) => { const active = subNav.find((item) => {
if (item.segment === 'kategorien') { if (item.segment === 'kategorien') {
return isKategorienPath(pathname); return isKategorienPath(pathname);

View File

@@ -11,17 +11,28 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Popover from '@mui/material/Popover';
import Badge from '@mui/material/Badge';
import Checkbox from '@mui/material/Checkbox';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Divider from '@mui/material/Divider';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListIcon from '@mui/icons-material/FilterList';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat'; import articleSearchAutomat from '../automats/articleSearchAutomat';
import articleIndexAutomat from '../automats/articleIndexAutomat';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import { formatPrice, getArticleGross } from '../utils/price'; import { formatPrice, getArticleGross } from '../utils/price';
import { thumb } from '../api';
import { getThumbHashDataUrl } from '../utils/thumbHashCache';
import { import {
buildSearchUrl, buildSearchUrl,
queryFromParams, queryFromParams,
@@ -48,6 +59,7 @@ import navigationAutomat, {
import categoryAutomat from '../automats/categoryAutomat'; import categoryAutomat from '../automats/categoryAutomat';
import companyAutomat from '../automats/companyAutomat'; import companyAutomat from '../automats/companyAutomat';
import { BrandLabel, BrandThumb } from '../components/brand'; import { BrandLabel, BrandThumb } from '../components/brand';
import { ArticleNameDialog } from '../components/article';
import { SCRIM_LABEL } from '../colors'; import { SCRIM_LABEL } from '../colors';
// #endregion // #endregion
@@ -110,6 +122,15 @@ class ResultRow extends Component {
const showChildBadge = isParent || childCount > 0; const showChildBadge = isParent || childCount > 0;
const price = getArticleGross(row, companyAutomat.state.current?.taxrate); const price = getArticleGross(row, companyAutomat.state.current?.taxrate);
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const fullHash = row.fullHash ?? firstPhoto?.fullHash ?? null;
const thumbHash = row.thumbHash ?? firstPhoto?.thumbHash ?? null;
const smallThumbBinary = row.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
const thumbSrc = smallThumbBinary
? `data:image/avif;base64,${smallThumbBinary}`
: (fullHash ? thumb.url(fullHash) : null);
const thumbHashUrl = thumbHash ? getThumbHashDataUrl(thumbHash) : null;
return ( return (
<ListItemButton <ListItemButton
type="button" type="button"
@@ -127,15 +148,28 @@ class ResultRow extends Component {
borderRadius: 0.5, borderRadius: 0.5,
overflow: 'hidden', overflow: 'hidden',
bgcolor: 'action.hover', bgcolor: 'action.hover',
backgroundImage: thumbHashUrl ? `url("${thumbHashUrl}")` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}} }}
> >
{row.smallThumbBinary && ( {thumbSrc ? (
<Box <Box
component="img" component="img"
alt="" alt=""
src={`data:image/avif;base64,${row.smallThumbBinary}`} src={thumbSrc}
loading="lazy"
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/> />
) : (
thumbHashUrl && (
<Box
component="img"
alt=""
src={thumbHashUrl}
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
)
)} )}
</Box> </Box>
<Typography <Typography
@@ -239,6 +273,28 @@ class ResultRow extends Component {
} }
// #endregion // #endregion
// #region Constants
const EMPTY_FILTER_OPTIONS = [
{ key: 'ean', label: 'EAN' },
{ key: 'photo', label: 'Foto' },
{ key: 'price', label: 'Preis' },
{ key: 'brand', label: 'Marke' },
{ key: 'supplier', label: 'Lieferant' },
{ key: 'seo', label: 'SEO' },
{ key: 'desc', label: 'Beschreibung' },
];
const EMPTY_FILTER_LABELS = {
ean: 'Ohne EAN',
photo: 'Ohne Foto',
price: 'Ohne Preis',
brand: 'Ohne Marke',
supplier: 'Ohne Lieferant',
seo: 'Ohne SEO',
desc: 'Ohne Beschreibung',
};
// #endregion
// #region Helpers // #region Helpers
function initialSearchState(searchParams) { function initialSearchState(searchParams) {
const query = queryFromParams(searchParams); const query = queryFromParams(searchParams);
@@ -258,6 +314,8 @@ function initialSearchState(searchParams) {
categoryFilterOpen: false, categoryFilterOpen: false,
brandFilterOpen: false, brandFilterOpen: false,
supplierFilterOpen: false, supplierFilterOpen: false,
moreFilterAnchorEl: null,
newArticleOpen: false,
error: automatResult.error, error: automatResult.error,
categoryFacets: automatResult.categoryFacets, categoryFacets: automatResult.categoryFacets,
brandFacets: automatResult.brandFacets, brandFacets: automatResult.brandFacets,
@@ -330,6 +388,109 @@ class ArtikelSearchPage extends Component {
})); }));
}; };
handleOpenMoreFilters = (event) => {
this.setState({ moreFilterAnchorEl: event.currentTarget });
};
handleCloseMoreFilters = () => {
this.setState({ moreFilterAnchorEl: null });
};
handleToggleEmptyFilter = (emptyKey) => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
const selectedEmpty = parseFilter(emptyParam);
const nextEmpty = selectedEmpty.includes(emptyKey)
? selectedEmpty.filter((k) => k !== emptyKey)
: [...selectedEmpty, emptyKey];
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
buildFilter(nextEmpty),
hierarchyParam,
);
};
handleHierarchyChange = (nextHierarchy) => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchy = nextHierarchy === 'all' ? '' : nextHierarchy;
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
emptyParam,
hierarchy,
);
};
handleClearAllMoreFilters = () => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
'',
'',
);
};
handleOpenNewArticle = () => {
this.setState({ newArticleOpen: true });
};
handleCloseNewArticle = () => {
this.setState({ newArticleOpen: false });
};
handleCreateArticle = async ({ names }) => {
const filterParam = this.props.searchParams.get('filter') ?? '';
const categories = parseFilter(filterParam);
const parentId =
categories[0] ||
categoryAutomat.state.rootCategoryId ||
companyAutomat.state.current?.rootCategoryId ||
companyAutomat.state.current?.rootcategory_id ||
1;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const created = await categoryAutomat.actions.createArticle({
parentId,
name: list[0] ?? '',
});
void articleIndexAutomat.actions.reload().catch(() => { });
if (created) {
this.props.navigate(artikelDetailPath(created));
}
return;
}
const createdList = await categoryAutomat.actions.createArticles({ parentId, names: list });
void articleIndexAutomat.actions.reload().catch(() => { });
if (createdList?.[0]) {
this.props.navigate(artikelDetailPath(createdList[0]));
}
};
handleOpenArticle = (row) => { handleOpenArticle = (row) => {
this.persistQuery(); this.persistQuery();
this.props.navigate(artikelDetailPath(row)); this.props.navigate(artikelDetailPath(row));
@@ -343,6 +504,8 @@ class ArtikelSearchPage extends Component {
filter: query.filter, filter: query.filter,
brand: query.brand, brand: query.brand,
supplier: query.supplier, supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
offset: query.offset, offset: query.offset,
}); });
}; };
@@ -360,16 +523,14 @@ class ArtikelSearchPage extends Component {
_suppliers: state.suppliers, _suppliers: state.suppliers,
})); }));
void companyAutomat.actions.loadBrands().catch(() => {});
void companyAutomat.actions.loadSuppliers().catch(() => {});
void companyAutomat.actions.loadAllSupplierArticles().catch(() => {});
// Initial search: kicks off index loading if idle and derives current items // Initial search: kicks off index loading if idle and derives current items
const { const {
filter: urlFilter, filter: urlFilter,
offset, offset,
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
} = queryFromParams(this.props.searchParams); } = queryFromParams(this.props.searchParams);
const hasFilterInUrl = this.props.searchParams.has('filter'); const hasFilterInUrl = this.props.searchParams.has('filter');
const navCategory = navigationAutomat.state.lastCategoryId; const navCategory = navigationAutomat.state.lastCategoryId;
@@ -380,6 +541,8 @@ class ArtikelSearchPage extends Component {
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, { const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
}); });
setLastSearchTerm(activeTarget, syncedUrl); setLastSearchTerm(activeTarget, syncedUrl);
this.props.navigate(syncedUrl, { replace: true }); this.props.navigate(syncedUrl, { replace: true });
@@ -394,6 +557,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(activeTarget, filter, offset, { buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
}), }),
); );
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
@@ -401,6 +566,8 @@ class ArtikelSearchPage extends Component {
filter, filter,
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
offset: 0, offset: 0,
}).catch(() => { }); }).catch(() => { });
} }
@@ -419,6 +586,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(target, query.filter, query.offset, { buildSearchUrl(target, query.filter, query.offset, {
brand: query.brand, brand: query.brand,
supplier: query.supplier, supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
}), }),
); );
} }
@@ -439,6 +608,8 @@ class ArtikelSearchPage extends Component {
searchParams.get('filter') !== prev.get('filter') searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('brand') !== prev.get('brand') || searchParams.get('brand') !== prev.get('brand')
|| searchParams.get('supplier') !== prev.get('supplier') || searchParams.get('supplier') !== prev.get('supplier')
|| searchParams.get('empty') !== prev.get('empty')
|| searchParams.get('hierarchy') !== prev.get('hierarchy')
|| searchParams.get('offset') !== prev.get('offset') || searchParams.get('offset') !== prev.get('offset')
) { ) {
this.fetchResults(); this.fetchResults();
@@ -453,17 +624,23 @@ class ArtikelSearchPage extends Component {
nextOffset = 0, nextOffset = 0,
nextBrand = this.props.searchParams.get('brand') ?? '', nextBrand = this.props.searchParams.get('brand') ?? '',
nextSupplier = this.props.searchParams.get('supplier') ?? '', nextSupplier = this.props.searchParams.get('supplier') ?? '',
nextEmpty = this.props.searchParams.get('empty') ?? '',
nextHierarchy = this.props.searchParams.get('hierarchy') ?? '',
) => { ) => {
writeArtikelQuery({ writeArtikelQuery({
target: nextTarget, target: nextTarget,
filter: nextFilter, filter: nextFilter,
brand: nextBrand, brand: nextBrand,
supplier: nextSupplier, supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
offset: nextOffset, offset: nextOffset,
}); });
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, { const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
brand: nextBrand, brand: nextBrand,
supplier: nextSupplier, supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
}); });
setLastSearchTerm(nextTarget, nextUrl); setLastSearchTerm(nextTarget, nextUrl);
@@ -488,16 +665,18 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
const { filter, brand, supplier } = queryFromParams(this.props.searchParams); const { filter, brand, supplier, empty, hierarchy } = queryFromParams(this.props.searchParams);
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier }); const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier, empty, hierarchy });
setLastSearchTerm(value, currentUrl); setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, brand, supplier, offset: 0 }); writeArtikelQuery({ target: value, filter, brand, supplier, empty, hierarchy, offset: 0 });
this.props.navigate(currentUrl, { replace: true }); this.props.navigate(currentUrl, { replace: true });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: value, target: value,
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset: 0, offset: 0,
}).catch(() => { }); }).catch(() => { });
}; };
@@ -507,15 +686,17 @@ class ArtikelSearchPage extends Component {
}; };
fetchResults = () => { fetchResults = () => {
const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams); const { target, filter, brand, supplier, empty, hierarchy, offset } = queryFromParams(this.props.searchParams);
writeArtikelQuery({ target, filter, brand, supplier, offset }); writeArtikelQuery({ target, filter, brand, supplier, empty, hierarchy, offset });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: target.trim(), target: target.trim(),
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset, offset,
}).catch(() => { }); }).catch(() => { });
}; };
@@ -528,6 +709,8 @@ class ArtikelSearchPage extends Component {
categoryFilterOpen, categoryFilterOpen,
brandFilterOpen, brandFilterOpen,
supplierFilterOpen, supplierFilterOpen,
moreFilterAnchorEl,
newArticleOpen,
error, error,
totalCount, totalCount,
categoryFacets, categoryFacets,
@@ -538,10 +721,15 @@ class ArtikelSearchPage extends Component {
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? ''; const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? ''; const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
const categories = parseFilter(filterParam); const categories = parseFilter(filterParam);
const selectedBrands = parseFilter(brandParam); const selectedBrands = parseFilter(brandParam);
const selectedSuppliers = parseFilter(supplierParam); const selectedSuppliers = parseFilter(supplierParam);
const selectedEmpty = parseFilter(emptyParam);
const activeMoreFilterCount = selectedEmpty.length + (hierarchyParam ? 1 : 0);
const isAnyMoreFilterActive = activeMoreFilterCount > 0;
const rows = recordset ?? []; const rows = recordset ?? [];
const categoriesMap = categoryAutomat.state?.categories; const categoriesMap = categoryAutomat.state?.categories;
@@ -676,6 +864,29 @@ class ArtikelSearchPage extends Component {
> >
Lieferanten Lieferanten
</Button> </Button>
<IconButton
onClick={this.handleOpenMoreFilters}
color={isAnyMoreFilterActive ? 'primary' : 'default'}
sx={{
height: 40,
width: 40,
border: '1px solid',
borderColor: isAnyMoreFilterActive ? 'primary.main' : 'rgba(0, 0, 0, 0.23)',
borderRadius: 1,
flexShrink: 0,
}}
>
<Badge badgeContent={activeMoreFilterCount} color="primary">
<FilterAltIcon fontSize="small" />
</Badge>
</IconButton>
<Button
variant="contained"
onClick={this.handleOpenNewArticle}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Neuer Artikel
</Button>
{categories.map((catId) => { {categories.map((catId) => {
const label = getCategoryBreadcrumbLabel( const label = getCategoryBreadcrumbLabel(
@@ -698,6 +909,8 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(selectedBrands), buildFilter(selectedBrands),
buildFilter(selectedSuppliers), buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -724,6 +937,8 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(next), buildFilter(next),
buildFilter(selectedSuppliers), buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -750,12 +965,78 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(selectedBrands), buildFilter(selectedBrands),
buildFilter(next), buildFilter(next),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/> />
); );
})} })}
{hierarchyParam === 'father' && (
<Chip
key="hierarchy-father"
label="Typ: Vater / Normal"
color="primary"
variant="outlined"
onDelete={() => {
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
emptyParam,
'',
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
)}
{hierarchyParam === 'child' && (
<Chip
key="hierarchy-child"
label="Typ: Kind / Normal"
color="primary"
variant="outlined"
onDelete={() => {
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
emptyParam,
'',
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
)}
{selectedEmpty.map((emptyKey) => (
<Chip
key={`empty-${emptyKey}`}
label={EMPTY_FILTER_LABELS[emptyKey] || emptyKey}
color="primary"
variant="outlined"
onDelete={() => {
const next = selectedEmpty.filter((k) => k !== emptyKey);
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
buildFilter(next),
hierarchyParam,
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
))}
</Box> </Box>
{error && ( {error && (
@@ -1027,6 +1308,97 @@ class ArtikelSearchPage extends Component {
</PanelTile> </PanelTile>
</Box> </Box>
</Box> </Box>
<ArticleNameDialog
open={newArticleOpen}
initialName={input}
onClose={this.handleCloseNewArticle}
onSubmit={this.handleCreateArticle}
/>
<Popover
open={Boolean(moreFilterAnchorEl)}
anchorEl={moreFilterAnchorEl}
onClose={this.handleCloseMoreFilters}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
width: 320,
p: 2,
borderRadius: 2,
boxShadow: 4,
},
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
{isAnyMoreFilterActive && (
<Button
size="small"
color="secondary"
onClick={this.handleClearAllMoreFilters}
sx={{ py: 0.25, px: 1, minWidth: 0, textTransform: 'none', fontSize: 13 }}
>
Zurücksetzen
</Button>
)}
</Box>
<RadioGroup
value={hierarchyParam || 'all'}
onChange={(e) => this.handleHierarchyChange(e.target.value)}
sx={{ mb: 1.5, mt: 0.5 }}
>
<FormControlLabel
value="all"
control={<Radio size="small" />}
label={<Typography variant="body2">Alle Artikel</Typography>}
/>
<FormControlLabel
value="father"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2">Vater- oder Normalartikel</Typography>
</Box>
}
/>
<FormControlLabel
value="child"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2">Kind- oder Normalartikel</Typography>
</Box>
}
/>
</RadioGroup>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Artikel ohne:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', mt: 0.5 }}>
{EMPTY_FILTER_OPTIONS.map(({ key, label }) => {
const checked = selectedEmpty.includes(key);
return (
<FormControlLabel
key={key}
control={
<Checkbox
size="small"
checked={checked}
onChange={() => this.handleToggleEmptyFilter(key)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
sx={{ my: -0.25 }}
/>
);
})}
</Box>
</Popover>
</> </>
); );
} }

View File

@@ -72,7 +72,7 @@ class ArtikelSkuPage extends Component {
await Promise.all([ await Promise.all([
artikelAutomat.actions.loadArticle(sku), artikelAutomat.actions.loadArticle(sku),
whenCategoryTreeReady(), whenCategoryTreeReady(),
companyAutomat.actions.load(), companyAutomat.state.current ? Promise.resolve(companyAutomat.state.current) : companyAutomat.actions.load(),
bootstrapAuthMe(), bootstrapAuthMe(),
]); ]);
if (this._mounted) this.setState({ ready: true }); if (this._mounted) this.setState({ ready: true });

View File

@@ -104,7 +104,6 @@ class ArtikelKategorienPage extends PureComponent {
companyId: s.current?.id ?? null, companyId: s.current?.id ?? null,
})); }));
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.ensurePosterminalsLoaded();
this.subscribeViewHandle(); this.subscribeViewHandle();
this.syncSortable(); this.syncSortable();
} }
@@ -556,7 +555,10 @@ class ArtikelKategorienPage extends PureComponent {
); );
})} })}
{!renameMode && articles.map((item, index) => ( {!renameMode &&
articles
.filter((item) => item.parentArticleId == null && !item.parentSku)
.map((item, index) => (
<ArticleTile <ArticleTile
key={item.id} key={item.id}
label={item.name != null ? item.name.trim() : undefined} label={item.name != null ? item.name.trim() : undefined}

View File

@@ -39,6 +39,7 @@ class ArtikelLagerSearchField extends PureComponent {
super(props); super(props);
const initial = companyAutomat.state.warehouseFilter || props.prefill || ''; const initial = companyAutomat.state.warehouseFilter || props.prefill || '';
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
input: initial, input: initial,
submitting: false, submitting: false,
}; };
@@ -50,6 +51,7 @@ class ArtikelLagerSearchField extends PureComponent {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
input: state.warehouseFilter, input: state.warehouseFilter,
})); }));
} }
@@ -64,6 +66,7 @@ class ArtikelLagerSearchField extends PureComponent {
} }
async handleSubmit() { async handleSubmit() {
if (!this.state.isAdmin) return;
const name = this.state.input.trim(); const name = this.state.input.trim();
if (!name || this.state.submitting) return; if (!name || this.state.submitting) return;
@@ -84,8 +87,8 @@ class ArtikelLagerSearchField extends PureComponent {
render() { render() {
const { inputRef, returnTo, navigate } = this.props; const { inputRef, returnTo, navigate } = this.props;
const { input, submitting } = this.state; const { isAdmin, input, submitting } = this.state;
const canSubmit = Boolean(input.trim()) && !submitting; const canCreate = isAdmin && Boolean(input.trim()) && !submitting;
return ( return (
<PanelTileGrid> <PanelTileGrid>
@@ -141,7 +144,7 @@ class ArtikelLagerSearchField extends PureComponent {
/> />
<Button <Button
variant="contained" variant="contained"
disabled={!canSubmit} disabled={!canCreate}
onClick={() => void this.handleSubmit()} onClick={() => void this.handleSubmit()}
> >
Hinzufügen Hinzufügen
@@ -159,6 +162,7 @@ class ArtikelLagerPage extends PureComponent {
const initialWarehouses = const initialWarehouses =
companyAutomat.state.filteredWarehouses ?? companyAutomat.state.warehouses ?? []; companyAutomat.state.filteredWarehouses ?? companyAutomat.state.warehouses ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.warehousesStatus === 'ready' ? 'ready' : 'loading', status: companyAutomat.state.warehousesStatus === 'ready' ? 'ready' : 'loading',
error: companyAutomat.state.warehousesError ? backendErrorFrom(companyAutomat.state.warehousesError) : null, error: companyAutomat.state.warehousesError ? backendErrorFrom(companyAutomat.state.warehousesError) : null,
warehouses: initialWarehouses, warehouses: initialWarehouses,
@@ -178,21 +182,21 @@ class ArtikelLagerPage extends PureComponent {
this._cancelled = false; this._cancelled = false;
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
warehouses: state.filteredWarehouses ?? state.warehouses, warehouses: state.filteredWarehouses ?? state.warehouses,
filter: state.warehouseFilter ?? '', filter: state.warehouseFilter ?? '',
status: state.warehousesStatus, status: state.warehousesStatus,
error: state.warehousesError ? backendErrorFrom(state.warehousesError) : null, error: state.warehousesError ? backendErrorFrom(state.warehousesError) : null,
})); }));
if (companyAutomat.state.warehousesStatus !== 'ready') { if (this.state.status === 'ready') {
void companyAutomat.actions.loadWarehouses().catch((err) => notifyError(err));
} else {
this._initSortable(); this._initSortable();
} }
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if ( if (
prevState.isAdmin !== this.state.isAdmin ||
prevState.status !== this.state.status || prevState.status !== this.state.status ||
prevState.warehouses !== this.state.warehouses || prevState.warehouses !== this.state.warehouses ||
prevState.filter !== this.state.filter prevState.filter !== this.state.filter
@@ -221,7 +225,7 @@ class ArtikelLagerPage extends PureComponent {
animation: 150, animation: 150,
ghostClass: 'sortable-ghost', ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen', chosenClass: 'sortable-chosen',
disabled: isFiltered, disabled: !this.state.isAdmin || isFiltered,
onEnd: (evt) => { onEnd: (evt) => {
const { oldIndex, newIndex } = evt; const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return; if (oldIndex === newIndex) return;
@@ -262,7 +266,7 @@ class ArtikelLagerPage extends PureComponent {
} }
render() { render() {
const { status, error, warehouses, filter } = this.state; const { isAdmin, status, error, warehouses, filter } = this.state;
const { prefill, returnTo } = getInitialParams(this.props); const { prefill, returnTo } = getInitialParams(this.props);
if (status === 'loading' || status === 'idle') { if (status === 'loading' || status === 'idle') {
@@ -301,6 +305,7 @@ class ArtikelLagerPage extends PureComponent {
<ArtikelLager <ArtikelLager
key={wh.id} key={wh.id}
warehouse={wh} warehouse={wh}
disabled={!isAdmin}
/> />
)) ))
)} )}

View File

@@ -9,20 +9,27 @@ import DialogTitle from '@mui/material/DialogTitle';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton'; import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import LabelIcon from '@mui/icons-material/Label'; import LabelIcon from '@mui/icons-material/Label';
import PrintIcon from '@mui/icons-material/Print';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats/navigationAutomat'; import { setLastArticleSku } from '../../automats/navigationAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { labeltemplate } from '../../api'; import { labeltemplate } from '../../api';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { import {
@@ -30,6 +37,36 @@ import {
SURFACE_BASE, SURFACE_BASE,
} from '../../colors'; } from '../../colors';
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;
}
const CanvasEditor = lazy(async () => { const CanvasEditor = lazy(async () => {
await import('react-canvas-label-editor/style.css'); await import('react-canvas-label-editor/style.css');
const mod = await import('react-canvas-label-editor'); const mod = await import('react-canvas-label-editor');
@@ -65,11 +102,15 @@ class ArtikelVorlagenPage extends PureComponent {
const initialDpi = DEFAULT_DPI; const initialDpi = DEFAULT_DPI;
const initialWidthClass = '2'; const initialWidthClass = '2';
const initialHeightMm = DEFAULT_HEIGHT_MM; const initialHeightMm = DEFAULT_HEIGHT_MM;
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.state = { this.state = {
status: 'loading', status: 'loading',
error: null, error: null,
templates: [], templates: [],
printers: initialPrinters,
printerMenuAnchor: null,
printing: false,
// Editor dialog state // Editor dialog state
editorOpen: false, editorOpen: false,
editorLoading: false, editorLoading: false,
@@ -97,16 +138,23 @@ class ArtikelVorlagenPage extends PureComponent {
this.handleDpiChange = this.handleDpiChange.bind(this); this.handleDpiChange = this.handleDpiChange.bind(this);
this.handleHeightMmChange = this.handleHeightMmChange.bind(this); this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this); this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
this.handlePrintClick = this.handlePrintClick.bind(this);
this.handleClosePrinterMenu = this.handleClosePrinterMenu.bind(this);
this.handleSelectPrinterForPrint = this.handleSelectPrinterForPrint.bind(this);
} }
componentDidMount() { componentDidMount() {
setLastArticleSku(null); setLastArticleSku(null);
this._cancelled = false; this._cancelled = false;
void this.loadTemplates(); void this.loadTemplates();
this.unsubPrinters = companyAutomat.subscribe(this, (state) => ({
printers: filterZplPrinters(state.agentprinters),
}));
} }
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubPrinters?.();
} }
async loadTemplates() { async loadTemplates() {
@@ -246,9 +294,66 @@ class ArtikelVorlagenPage extends PureComponent {
} }
handleEditorClose() { handleEditorClose() {
if (this.state.saving) return; if (this.state.saving || this.state.printing) return;
this._currentEditId = null; this._currentEditId = null;
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null }); this.setState({
editorOpen: false,
editorLoading: false,
editorTemplate: null,
printerMenuAnchor: null,
});
}
handlePrintClick(event) {
const { printers } = this.state;
if (!printers || printers.length === 0) {
enqueueSnackbar('Kein ZPL-Drucker gefunden', { variant: 'warning' });
return;
}
if (printers.length === 1) {
void this.executeTestPrint(printers[0]);
return;
}
this.setState({ printerMenuAnchor: event.currentTarget });
}
handleClosePrinterMenu() {
this.setState({ printerMenuAnchor: null });
}
handleSelectPrinterForPrint(targetPrinter) {
this.setState({ printerMenuAnchor: null });
void this.executeTestPrint(targetPrinter);
}
async executeTestPrint(targetPrinter) {
if (!targetPrinter?.id) return;
const {
editorTemplate, editorObjects, editorWidth, editorHeight, editorDpi, editorName,
} = this.state;
this.setState({ printing: true });
try {
const payload = {
agentprinterId: targetPrinter.id,
templateId: editorTemplate?.id,
name: editorName,
width: editorWidth,
height: editorHeight,
dpi: editorDpi,
objects: editorObjects,
};
await labeltemplate.testprint(payload);
enqueueSnackbar(`Testdruck an ${getPrinterLabel(targetPrinter)} gesendet.`, {
variant: 'success',
});
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) {
this.setState({ printing: false });
}
}
} }
async handleEditorSave() { async handleEditorSave() {
@@ -320,7 +425,7 @@ class ArtikelVorlagenPage extends PureComponent {
const { const {
editorOpen, editorLoading, editorTemplate, editorName, editorObjects, editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm, editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
editorClipboard, saving, editorClipboard, saving, printing, printers, printerMenuAnchor,
} = this.state; } = this.state;
const minHeightPx = getMinHeightPx(editorDpi); const minHeightPx = getMinHeightPx(editorDpi);
@@ -438,18 +543,66 @@ class ArtikelVorlagenPage extends PureComponent {
</> </>
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button onClick={this.handleEditorClose} disabled={saving}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip
title={
printers.length === 0
? 'Kein ZPL-Drucker verfügbar'
: printers.length === 1
? `Drucken auf ${getPrinterLabel(printers[0])}`
: 'Testdruck an Drucker senden'
}
>
<span>
<Button
variant="outlined"
color="primary"
startIcon={printing ? <CircularProgress size={18} /> : <PrintIcon />}
endIcon={printers.length > 1 ? <ArrowDropDownIcon /> : null}
onClick={this.handlePrintClick}
disabled={printing || saving || editorLoading || printers.length === 0}
>
{printing ? 'Druckt…' : 'Testdruck'}
</Button>
</span>
</Tooltip>
{printers.length > 1 && (
<Menu
anchorEl={printerMenuAnchor}
open={Boolean(printerMenuAnchor)}
onClose={this.handleClosePrinterMenu}
>
{printers.map((p) => (
<MenuItem
key={p.id}
onClick={() => this.handleSelectPrinterForPrint(p)}
>
<ListItemIcon>
<PrintIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={getPrinterLabel(p)}
secondary={p.serial ? `Seriennummer: ${p.serial}` : undefined}
/>
</MenuItem>
))}
</Menu>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button onClick={this.handleEditorClose} disabled={saving || printing}>
Abbrechen Abbrechen
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
onClick={this.handleEditorSave} onClick={this.handleEditorSave}
disabled={saving || editorLoading || !editorName.trim()} disabled={saving || printing || editorLoading || !editorName.trim()}
> >
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null} {saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
{saving ? 'Speichert…' : 'Speichern'} {saving ? 'Speichert…' : 'Speichern'}
</Button> </Button>
</Box>
</DialogActions> </DialogActions>
</AppDialog> </AppDialog>
); );

View File

@@ -21,6 +21,7 @@ class VersandartenSearchField extends PureComponent {
super(props); super(props);
const initial = companyAutomat.state.carrierFilter || ''; const initial = companyAutomat.state.carrierFilter || '';
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
input: initial, input: initial,
submitting: false, submitting: false,
}; };
@@ -29,6 +30,7 @@ class VersandartenSearchField extends PureComponent {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
input: state.carrierFilter, input: state.carrierFilter,
})); }));
} }
@@ -43,6 +45,7 @@ class VersandartenSearchField extends PureComponent {
} }
async handleSubmit() { async handleSubmit() {
if (!this.state.isAdmin) return;
const name = this.state.input.trim(); const name = this.state.input.trim();
if (!name || this.state.submitting) return; if (!name || this.state.submitting) return;
@@ -64,8 +67,8 @@ class VersandartenSearchField extends PureComponent {
render() { render() {
const { inputRef } = this.props; const { inputRef } = this.props;
const { input, submitting } = this.state; const { isAdmin, input, submitting } = this.state;
const canSubmit = Boolean(input.trim()) && !submitting; const canCreate = isAdmin && Boolean(input.trim()) && !submitting;
return ( return (
<PanelTileGrid> <PanelTileGrid>
@@ -102,7 +105,7 @@ class VersandartenSearchField extends PureComponent {
/> />
<Button <Button
variant="contained" variant="contained"
disabled={!canSubmit} disabled={!canCreate}
onClick={() => void this.handleSubmit()} onClick={() => void this.handleSubmit()}
> >
Hinzufügen Hinzufügen
@@ -120,6 +123,7 @@ class AuftragVersandartenPage extends PureComponent {
const initialCarriers = const initialCarriers =
companyAutomat.state.filteredCarriers ?? companyAutomat.state.carriers ?? []; companyAutomat.state.filteredCarriers ?? companyAutomat.state.carriers ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.carriersStatus === 'ready' ? 'ready' : 'loading', status: companyAutomat.state.carriersStatus === 'ready' ? 'ready' : 'loading',
error: companyAutomat.state.carriersError ? backendErrorFrom(companyAutomat.state.carriersError) : null, error: companyAutomat.state.carriersError ? backendErrorFrom(companyAutomat.state.carriersError) : null,
carriers: initialCarriers, carriers: initialCarriers,
@@ -138,21 +142,21 @@ class AuftragVersandartenPage extends PureComponent {
this._cancelled = false; this._cancelled = false;
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
carriers: state.filteredCarriers ?? state.carriers, carriers: state.filteredCarriers ?? state.carriers,
filter: state.carrierFilter ?? '', filter: state.carrierFilter ?? '',
status: state.carriersStatus, status: state.carriersStatus,
error: state.carriersError ? backendErrorFrom(state.carriersError) : null, error: state.carriersError ? backendErrorFrom(state.carriersError) : null,
})); }));
if (companyAutomat.state.carriersStatus !== 'ready') { if (this.state.status === 'ready') {
void companyAutomat.actions.loadCarriers().catch((err) => notifyError(err));
} else {
this._initSortable(); this._initSortable();
} }
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if ( if (
prevState.isAdmin !== this.state.isAdmin ||
prevState.status !== this.state.status || prevState.status !== this.state.status ||
prevState.carriers !== this.state.carriers || prevState.carriers !== this.state.carriers ||
prevState.filter !== this.state.filter prevState.filter !== this.state.filter
@@ -181,7 +185,7 @@ class AuftragVersandartenPage extends PureComponent {
animation: 150, animation: 150,
ghostClass: 'sortable-ghost', ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen', chosenClass: 'sortable-chosen',
disabled: isFiltered, disabled: !this.state.isAdmin || isFiltered,
onEnd: (evt) => { onEnd: (evt) => {
const { oldIndex, newIndex } = evt; const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return; if (oldIndex === newIndex) return;
@@ -222,7 +226,7 @@ class AuftragVersandartenPage extends PureComponent {
} }
render() { render() {
const { status, error, carriers, filter } = this.state; const { isAdmin, status, error, carriers, filter } = this.state;
if (status === 'loading' || status === 'idle') { if (status === 'loading' || status === 'idle') {
return ( return (
@@ -255,6 +259,7 @@ class AuftragVersandartenPage extends PureComponent {
<CarrierItem <CarrierItem
key={c.id} key={c.id}
carrier={c} carrier={c}
disabled={!isAdmin}
/> />
)) ))
)} )}

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ import CircularProgress from '@mui/material/CircularProgress';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
@@ -207,11 +206,9 @@ class GlobalHistoryRow extends PureComponent {
{/* 4. Meldung / Beschreibung */} {/* 4. Meldung / Beschreibung */}
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}> <Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}>
<Typography variant="body2" sx={ellipsisSx}> <Typography variant="body2" sx={ellipsisSx}>
{item.text || item.value || '–'} {item.text || item.value || '–'}
</Typography> </Typography>
</Tooltip>
</Box> </Box>
</ListItemButton> </ListItemButton>
); );

View File

@@ -22,13 +22,6 @@ export default class StatisticsPage extends PureComponent {
}} }}
> >
<BarChartIcon sx={{ fontSize: 48, opacity: 0.4 }} /> <BarChartIcon sx={{ fontSize: 48, opacity: 0.4 }} />
<Typography variant="h6" color="text.primary" fontWeight={600}>
Statistik & Auswertungen
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 420 }}>
Hier entstehen demnächst grafische Auswertungen, Umsatzstatistiken und
Bestandskennzahlen.
</Typography>
</Box> </Box>
</PanelTile> </PanelTile>
); );

View File

@@ -38,6 +38,7 @@ class KundeGruppenSearchField extends PureComponent {
super(props); super(props);
const initial = companyAutomat.state.customergroupFilter || props.prefill || ''; const initial = companyAutomat.state.customergroupFilter || props.prefill || '';
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
input: initial, input: initial,
submitting: false, submitting: false,
}; };
@@ -49,6 +50,7 @@ class KundeGruppenSearchField extends PureComponent {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
input: state.customergroupFilter, input: state.customergroupFilter,
})); }));
} }
@@ -63,6 +65,7 @@ class KundeGruppenSearchField extends PureComponent {
} }
async handleSubmit() { async handleSubmit() {
if (!this.state.isAdmin) return;
const name = this.state.input.trim(); const name = this.state.input.trim();
if (!name || this.state.submitting) return; if (!name || this.state.submitting) return;
@@ -83,8 +86,8 @@ class KundeGruppenSearchField extends PureComponent {
render() { render() {
const { inputRef, returnTo, navigate } = this.props; const { inputRef, returnTo, navigate } = this.props;
const { input, submitting } = this.state; const { isAdmin, input, submitting } = this.state;
const canSubmit = Boolean(input.trim()) && !submitting; const canCreate = isAdmin && Boolean(input.trim()) && !submitting;
return ( return (
<PanelTileGrid> <PanelTileGrid>
@@ -141,7 +144,7 @@ class KundeGruppenSearchField extends PureComponent {
/> />
<Button <Button
variant="contained" variant="contained"
disabled={!canSubmit} disabled={!canCreate}
onClick={() => void this.handleSubmit()} onClick={() => void this.handleSubmit()}
> >
Hinzufügen Hinzufügen
@@ -159,6 +162,7 @@ class KundeGruppenPage extends PureComponent {
const initialGroups = const initialGroups =
companyAutomat.state.filteredCustomergroups ?? companyAutomat.state.customergroups ?? []; companyAutomat.state.filteredCustomergroups ?? companyAutomat.state.customergroups ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.customergroupsStatus === 'ready' ? 'ready' : 'loading', status: companyAutomat.state.customergroupsStatus === 'ready' ? 'ready' : 'loading',
error: companyAutomat.state.customergroupsError ? backendErrorFrom(companyAutomat.state.customergroupsError) : null, error: companyAutomat.state.customergroupsError ? backendErrorFrom(companyAutomat.state.customergroupsError) : null,
groups: initialGroups, groups: initialGroups,
@@ -177,21 +181,21 @@ class KundeGruppenPage extends PureComponent {
this._cancelled = false; this._cancelled = false;
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
groups: state.filteredCustomergroups ?? state.customergroups, groups: state.filteredCustomergroups ?? state.customergroups,
filter: state.customergroupFilter ?? '', filter: state.customergroupFilter ?? '',
status: state.customergroupsStatus, status: state.customergroupsStatus,
error: state.customergroupsError ? backendErrorFrom(state.customergroupsError) : null, error: state.customergroupsError ? backendErrorFrom(state.customergroupsError) : null,
})); }));
if (companyAutomat.state.customergroupsStatus !== 'ready') { if (this.state.status === 'ready') {
void companyAutomat.actions.loadCustomergroups().catch((err) => notifyError(err));
} else {
this._initSortable(); this._initSortable();
} }
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
if ( if (
prevState.isAdmin !== this.state.isAdmin ||
prevState.status !== this.state.status || prevState.status !== this.state.status ||
prevState.groups !== this.state.groups || prevState.groups !== this.state.groups ||
prevState.filter !== this.state.filter prevState.filter !== this.state.filter
@@ -220,7 +224,7 @@ class KundeGruppenPage extends PureComponent {
animation: 150, animation: 150,
ghostClass: 'sortable-ghost', ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen', chosenClass: 'sortable-chosen',
disabled: isFiltered, disabled: !this.state.isAdmin || isFiltered,
onEnd: (evt) => { onEnd: (evt) => {
const { oldIndex, newIndex } = evt; const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return; if (oldIndex === newIndex) return;
@@ -261,7 +265,7 @@ class KundeGruppenPage extends PureComponent {
} }
render() { render() {
const { status, error, groups, filter } = this.state; const { isAdmin, status, error, groups, filter } = this.state;
const { prefill, returnTo } = getInitialParams(this.props); const { prefill, returnTo } = getInitialParams(this.props);
if (status === 'loading' || status === 'idle') { if (status === 'loading' || status === 'idle') {
@@ -300,6 +304,7 @@ class KundeGruppenPage extends PureComponent {
<KundeGruppe <KundeGruppe
key={group.id} key={group.id}
group={group} group={group}
disabled={!isAdmin}
/> />
)) ))
)} )}

View File

@@ -1,4 +1,6 @@
import { lazy } from 'react';
import { withManifestId } from '../../components/manifest'; import { withManifestId } from '../../components/manifest';
import LieferungNeuPage from './neu';
const LieferungNeuPage = lazy(() => import('./neu'));
export default withManifestId('deliveryId', LieferungNeuPage); export default withManifestId('deliveryId', LieferungNeuPage);

View File

@@ -32,8 +32,19 @@ function sortSuppliers(suppliers) {
); );
} }
function draftsFrom(suppliers) { function draftsFrom(suppliers, currentDrafts = {}, prevSuppliers = []) {
return Object.fromEntries(suppliers.map((row) => [row.id, row.name])); const prevNames = new Map((prevSuppliers || []).map((s) => [s.id, s.name]));
const drafts = {};
for (const row of suppliers) {
const draft = currentDrafts[row.id];
const prevName = prevNames.get(row.id);
if (draft !== undefined && prevName !== undefined && draft !== prevName) {
drafts[row.id] = draft;
} else {
drafts[row.id] = row.name;
}
}
return drafts;
} }
function getInitialParams(props) { function getInitialParams(props) {
@@ -56,18 +67,20 @@ class LieferungLieferantenPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const { prefill } = getInitialParams(props); const { prefill } = getInitialParams(props);
const initialSuppliers = sortSuppliers(companyAutomat.state.suppliers ?? []);
this.state = { this.state = {
status: 'loading', status: companyAutomat.state.suppliersStatus,
error: null, error: companyAutomat.state.suppliersError
suppliers: [], ? backendErrorFrom(companyAutomat.state.suppliersError)
drafts: {}, : null,
suppliers: initialSuppliers,
drafts: draftsFrom(initialSuppliers),
newName: prefill, newName: prefill,
busyKey: null, busyKey: null,
}; };
this._cancelled = false; this._cancelled = false;
this.handleCreate = this.handleCreate.bind(this); this.handleCreate = this.handleCreate.bind(this);
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.loadSuppliers = this.loadSuppliers.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -79,7 +92,24 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ newName: prefill }); this.setState({ newName: prefill });
} }
this._cancelled = false; this._cancelled = false;
void this.loadSuppliers();
if (companyAutomat.state.suppliersStatus === 'idle') {
void companyAutomat.actions.loadSuppliers().catch((err) => notifyError(err));
}
this.unsub = companyAutomat.subscribe(
this,
(state) => {
const sorted = sortSuppliers(state.suppliers ?? []);
return {
status: state.suppliersStatus,
error: state.suppliersError ? backendErrorFrom(state.suppliersError) : null,
suppliers: sorted,
drafts: draftsFrom(sorted, this.state?.drafts, this.state?.suppliers),
};
},
['suppliers', 'suppliersStatus', 'suppliersError'],
);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -91,41 +121,13 @@ class LieferungLieferantenPage extends PureComponent {
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
} this.unsub?.();
applySuppliers(suppliers) {
const sorted = sortSuppliers(suppliers);
this.setState({
status: 'ready',
error: null,
suppliers: sorted,
drafts: draftsFrom(sorted),
});
}
async loadSuppliers() {
this.setState({ status: 'loading', error: null });
try {
const list = await companyAutomat.actions.loadSuppliers();
if (this._cancelled) return;
this.applySuppliers(list);
} catch (err) {
if (this._cancelled) return;
this.setState({ status: 'failed', error: backendErrorFrom(err) });
}
} }
handleRetry() { handleRetry() {
void this.loadSuppliers(); void companyAutomat.actions.loadSuppliers().catch((err) => notifyError(err));
}
async refreshFromServer() {
const list = await companyAutomat.actions.reloadSuppliers();
if (this._cancelled) return;
this.applySuppliers(list);
} }
async handleCreate() { async handleCreate() {
@@ -142,7 +144,6 @@ class LieferungLieferantenPage extends PureComponent {
deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id); deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id);
deliveryDraftAutomat.actions.setUnmatchedSupplierName(null); deliveryDraftAutomat.actions.setUnmatchedSupplierName(null);
await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ newName: '', busyKey: null }); this.setState({ newName: '', busyKey: null });
enqueueSnackbar(`Lieferant „${newSupplier.name || name}“ angelegt.`, { variant: 'success' }); enqueueSnackbar(`Lieferant „${newSupplier.name || name}“ angelegt.`, { variant: 'success' });
@@ -157,7 +158,6 @@ class LieferungLieferantenPage extends PureComponent {
} }
} }
async handleRename(supplier) { async handleRename(supplier) {
const draft = String(this.state.drafts[supplier.id] ?? '').trim(); const draft = String(this.state.drafts[supplier.id] ?? '').trim();
if (!draft) { if (!draft) {
@@ -171,7 +171,6 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
await companyAutomat.actions.updateSupplier(supplier.id, { name: draft }); await companyAutomat.actions.updateSupplier(supplier.id, { name: draft });
await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
enqueueSnackbar('Lieferant gespeichert.', { variant: 'success' }); enqueueSnackbar('Lieferant gespeichert.', { variant: 'success' });
@@ -194,7 +193,6 @@ class LieferungLieferantenPage extends PureComponent {
this.setState({ busyKey: supplier.id }); this.setState({ busyKey: supplier.id });
try { try {
await companyAutomat.actions.removeSupplier(supplier.id); await companyAutomat.actions.removeSupplier(supplier.id);
await this.refreshFromServer();
if (this._cancelled) return; if (this._cancelled) return;
this.setState({ busyKey: null }); this.setState({ busyKey: null });
enqueueSnackbar('Lieferant gelöscht.', { variant: 'success' }); enqueueSnackbar('Lieferant gelöscht.', { variant: 'success' });
@@ -211,7 +209,7 @@ class LieferungLieferantenPage extends PureComponent {
const { returnTo } = getInitialParams(this.props); const { returnTo } = getInitialParams(this.props);
if (status === 'loading') { if (status === 'loading' || status === 'idle') {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress /> <CircularProgress />

View File

@@ -191,7 +191,6 @@ class LieferungNeuPage extends PureComponent {
warehouses: state.warehouses, warehouses: state.warehouses,
}; };
}); });
void companyAutomat.actions.loadWarehouses().catch(() => { });
this._cancelled = false; this._cancelled = false;
if (this.props.docId != null) { if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`); setLastDeliveryPath(`/lieferung/${this.props.docId}`);
@@ -577,6 +576,20 @@ class LieferungNeuPage extends PureComponent {
} }
try { try {
// If this line was previously mapped to a different article, unlink the old supplier article
const existingSupplierArticle =
(line?.key != null ? this.state.supplierArticlesByLineKey?.[line.key] : null) ||
(supplierSku ? (this.state.supplierArticlesMap?.[supplierSku.toLowerCase()] || this.state.supplierArticlesMap?.[supplierSku]) : null);
const oldInternalSku = existingSupplierArticle?.article?.sku;
const oldAssignmentId = existingSupplierArticle?.id;
if (oldInternalSku && oldAssignmentId != null && String(oldInternalSku).toLowerCase() !== String(internalSku).toLowerCase()) {
try {
await this.automat.actions.unlinkSupplierArticle(oldInternalSku, oldAssignmentId, line?.key);
} catch (unlinkErr) {
console.warn('Vorherige Lieferanten-Zuordnung konnte nicht gelöst werden:', unlinkErr);
}
}
await articleApi.addSupplier(internalSku, { await articleApi.addSupplier(internalSku, {
entityId: partyId, entityId: partyId,
sku: supplierSku || undefined, sku: supplierSku || undefined,
@@ -588,13 +601,16 @@ class LieferungNeuPage extends PureComponent {
if (supplierSku) { if (supplierSku) {
await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key); await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
} else {
await this.automat.actions.fetchSupplierArticleForSku(internalSku, line.key);
} }
await this.automat.actions.fetchSupplierArticles(partyId); await this.automat.actions.fetchSupplierArticles(partyId);
if (!line.name && article.name) { if (!line.name || (existingSupplierArticle?.article?.name && line.name === existingSupplierArticle.article.name)) {
this.automat.actions.updateLineitem({ this.automat.actions.updateLineitem({
...line, ...line,
name: article.name, name: article.name || line.name,
...(!line.sku ? { sku: internalSku } : {}),
}); });
} }

View File

@@ -22,5 +22,6 @@ export function normalizeCompany(raw, overrides = {}) {
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate, reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate, taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId, rootCategoryId: overrides.rootCategoryId !== undefined ? overrides.rootCategoryId : raw.rootCategoryId,
role: overrides.role !== undefined ? overrides.role : raw.role,
}; };
} }

View File

@@ -63,8 +63,10 @@ export function getNextDocumentNumber(kind) {
const prefixStyle = refSeq?.prefix ?? ''; const prefixStyle = refSeq?.prefix ?? '';
if (kind === 'retoure') { if (kind === 'retoure') {
prefix = 'RET-'; prefix = prefixStyle.endsWith('-') || !prefixStyle ? 'RET-' : 'RET';
pad = 5; startNum = 1;
} else if (kind === 'return') {
prefix = prefixStyle.endsWith('-') || !prefixStyle ? 'RTN-' : 'RTN';
startNum = 1; startNum = 1;
} else if (kind === 'delivery') { } else if (kind === 'delivery') {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-'); prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-');
@@ -160,7 +162,16 @@ export async function ensureDocumentNumber(kind) {
const seq = numbers.find((r) => r.type === kind); const seq = numbers.find((r) => r.type === kind);
let prefix = seq?.prefix; let prefix = seq?.prefix;
if (prefix == null) { if (prefix == null) {
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : (kind === 'retoure' ? 'RET-' : 'LS-')); prefix =
kind === 'quote'
? 'ANG-'
: kind === 'order'
? 'AUF-'
: kind === 'retoure'
? 'RET-'
: kind === 'return'
? 'RTN-'
: 'LS-';
} }
let maxFound = maxKnownNumberByKind.get(kind) ?? 0; let maxFound = maxKnownNumberByKind.get(kind) ?? 0;
@@ -215,7 +226,9 @@ export function getMaxDocumentNumber(kind = 'quote') {
let prefix = ''; let prefix = '';
if (kind === 'retoure') { if (kind === 'retoure') {
return `RET-${'9'.repeat(5)}`; prefix = prefixStyle.endsWith('-') || !prefixStyle ? 'RET-' : 'RET';
} else if (kind === 'return') {
prefix = prefixStyle.endsWith('-') || !prefixStyle ? 'RTN-' : 'RTN';
} else if (kind === 'delivery') { } else if (kind === 'delivery') {
prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-'); prefix = prefixStyle.startsWith('DEL-') ? 'DEL-' : (prefixStyle.startsWith('L-') ? 'L-' : 'LS-');
} else if ( } else if (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Some files were not shown because too many files have changed in this diff Show More