Compare commits

...

71 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
seb
96d0ae6742 u 2026-10-28 00:08:45 +01:00
seb
fab291f68e u 2026-10-27 08:39:27 +01:00
seb
481ea16a6a u 2026-10-27 03:59:56 +01:00
seb
75691e6afc u 2026-10-27 00:28:06 +01:00
seb
f52ab1be0c u 2026-10-26 19:22:53 +01:00
seb
13e1821355 u 2026-10-25 17:56:12 +01:00
seb
eeb4b92a79 u 2026-10-24 06:43:56 +02:00
seb
3801ac4e52 u 2026-10-24 06:20:51 +02:00
seb
31fdc30c5a u 2026-10-24 01:47:14 +02:00
seb
bf39f4d017 u 2026-10-24 01:39:31 +02:00
seb
2f946442c8 u 2026-10-24 01:24:11 +02:00
seb
35144c7187 u 2026-10-23 23:11:05 +02:00
seb
5dd45916b8 u 2026-10-23 22:40:28 +02:00
seb
b9bd0380cb u 2026-10-23 20:31:34 +02:00
202 changed files with 17840 additions and 8131 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 268 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`
@@ -257,8 +294,8 @@ _none_
- Params: `sku: str, body: { eans: str[] }` - Params: `sku: str, body: { eans: str[] }`
- Returns: `204 void` - Returns: `204 void`
#### Get article history by SKU — `GET /article/{sku}/history` #### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: str, query: limit?: int` - Params: `sku: str, query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { history: { id: int, date: str, text: str }[], truncated: bool }` - Returns: `200 { history: { id: int, date: str, text: str }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Add article image — `POST /article/{sku}/image` #### Add article image — `POST /article/{sku}/image`
- Params: `sku: str, image/avif` - Params: `sku: str, image/avif`
- Returns: `201 { image: { picId: int, sort: int, full: { w: int, h: int, size: int }, thumb: { w: int, h: int }, hash: str, thumbHash: str } }` - Returns: `201 { image: { picId: int, sort: int, full: { w: int, h: int, size: int }, thumb: { w: int, h: int }, hash: str, thumbHash: str } }`
@@ -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 }[] }`
@@ -460,6 +509,9 @@ _none_
#### Add category image — `POST /category/{id}/image` #### Add category image — `POST /category/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }` - Returns: `201 { category: { id: int, thumbW: int, thumbH: int, thumbHash: str } }`
#### Generate category mosaic image from contents — `POST /category/{id}/image/generate`
- Params: `id: int, body?: { size?: int }`
- Returns: `200 { category: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, size: int, grid: str, articleCount: int } }`
#### List category properties — `GET /category/{id}/property` #### List category properties — `GET /category/{id}/property`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }` - Returns: `200 { properties: { id: int, propertyId: int, name: str, exclusive: bool, value: str, sort: int, categoryId?: int }[] }`
@@ -474,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`
@@ -507,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
@@ -531,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 }`
@@ -552,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
@@ -606,6 +673,8 @@ _none_
- Returns: `200 SSE` - Returns: `200 SSE`
- Events: - Events:
- `user`: `{ selectedCompanyId }`
- `company`: `{ companyId }`
- `category`: `{}` - `category`: `{}`
- `articles`: `{}` - `articles`: `{}`
- `article-<sku>`: `{}` - `article-<sku>`: `{}`
@@ -613,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>`: `{}`
@@ -642,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
@@ -700,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 }[] }`
@@ -750,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`
@@ -759,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`
@@ -769,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[] }`
@@ -784,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
@@ -805,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[] }`
@@ -832,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 }`
@@ -868,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
@@ -880,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 }[] }`
@@ -891,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`
@@ -900,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`
@@ -909,11 +1029,59 @@ _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
#### List retoures — `GET /retoure`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, entityId?: int, seekMonths?: int`
- Returns: `200`
#### Create retoure — `POST /retoure`
- Params: `body: { date: str, number?: str, entityId?: int, externalNumber?: str, lines?: any[] }`
- Returns: `201`
#### Rebuild retoure summaries — `POST /retoure/rebuild-summaries`
_none_
- Returns: `200`
#### Delete retoure — `DELETE /retoure/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get retoure by ID — `GET /retoure/{id}`
- Params: `id: int`
- Returns: `200`
#### Update retoure — `PUT /retoure/{id}`
- Params: `id: int, body: {}`
- Returns: `200`
#### Create shipment for retoure — `POST /retoure/{id}/shipment`
- Params: `id: int`
- Returns: `201`
### Section: /return
#### List returns — `GET /return`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int`
- Returns: `200`
#### Create return — `POST /return`
- Params: `body: {}`
- Returns: `201`
#### Rebuild return summaries — `POST /return/rebuild-summaries`
_none_
- Returns: `200`
#### Delete return — `DELETE /return/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get return by ID — `GET /return/{id}`
- Params: `id: int`
- Returns: `200`
#### Update return — `PUT /return/{id}`
- Params: `id: int`
- Returns: `200`
#### Book return to ledger — `POST /return/{id}/book`
- Params: `id: int`
- Returns: `200`
### Section: /serper ### Section: /serper
@@ -952,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
@@ -967,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 }[] }`
@@ -988,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`
@@ -997,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
@@ -1045,18 +1213,21 @@ _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
#### JTL-POS category synchronization — `GET /v1/category` #### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: str, limit?: int` - Params: `query: lastChangedCategory?: str, limit?: int`
- Returns: `200 {}[]` - Returns: `200 {}[]`
#### POS category image delivery — `GET /v1/cimage`
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg`
#### POS discovery and pairing — `GET /v1/client` #### 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`
@@ -1074,7 +1245,7 @@ _none_
- Params: `query: mandantId?: str, body: { orders?: {}[] }` - Params: `query: mandantId?: str, body: { orders?: {}[] }`
- Returns: `200 { status: str, externalOrderId: str, message: str }[]` - Returns: `200 { status: str, externalOrderId: str, message: str }[]`
#### POS product image delivery — `GET /v1/pimage` #### POS product image delivery — `GET /v1/pimage`
- Params: `query: path: str, w?: int, h?: int, format?: str` - Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
- Returns: `200 jpeg` - Returns: `200 jpeg`
#### JTL-POS product catalog synchronization — `GET /v1/product` #### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: str, limit?: int` - Params: `query: lastChangedProduct?: str, limit?: int`
@@ -1136,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

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 260 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

@@ -58,45 +58,30 @@ const CLUSTERS = [
label: 'Session & Real-Time Hub', label: 'Session & Real-Time Hub',
automats: ['authAutomat', 'sseAutomat'], automats: ['authAutomat', 'sseAutomat'],
}, },
{
id: 'company_basedata',
label: 'Company & Consolidated Base Data',
automats: ['companyAutomat'],
},
{ {
id: 'catalog_products', id: 'catalog_products',
label: 'Catalog & Products', label: 'Catalog & Products',
automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat', 'brandAutomat', 'unitAutomat'], automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'],
}, },
{ {
id: 'search_indexing', id: 'search_indexing',
label: 'Search & Indexing', label: 'Search & Indexing',
automats: ['articleIndexAutomat', 'articleSearchAutomat'], automats: ['articleIndexAutomat', 'articleSearchAutomat'],
}, },
{
id: 'properties_attributes',
label: 'Properties & Attributes',
automats: ['propertyAutomat', 'attributeAutomat'],
},
{ {
id: 'purchasing_logistics', id: 'purchasing_logistics',
label: 'Suppliers & Deliveries', label: 'Deliveries & Inbound',
automats: ['supplierAutomat', 'deliveryAutomat', 'deliveryEditorAutomat'], automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
}, },
{ {
id: 'sales_crm', id: 'sales_crm',
label: 'Sales & CRM', label: 'Sales & CRM',
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat', 'customergroupAutomat'], automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
},
{
id: 'inventory_storage',
label: 'Inventory & Warehousing',
automats: ['warehouseAutomat'],
},
{
id: 'finance_config',
label: 'Company & Finance',
automats: ['companyAutomat', 'accountAutomat', 'numberAutomat'],
},
{
id: 'hardware_pos',
label: 'POS & Hardware',
automats: ['posterminalAutomat'],
}, },
{ {
id: 'ai_workspace', id: 'ai_workspace',
@@ -110,8 +95,8 @@ const CLUSTERS = [
}, },
{ {
id: 'system_ui', id: 'system_ui',
label: 'UI, Settings & System', label: 'UI, Navigation & System',
automats: ['navigationAutomat', 'userSettingsAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'], automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
}, },
]; ];
@@ -136,16 +121,18 @@ const AUTOMAT_SPECS = [
{ {
id: 'authAutomat', id: 'authAutomat',
title: 'authAutomat', title: 'authAutomat',
subtitle: 'Session & Multi-Tenant Gateway', subtitle: 'Always-On Session & Multi-Tenant Gateway',
entries: [ entries: [
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company' }, { type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'' }, { type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' }, { type: 'ACTION', name: '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: 'selectSelectedCompanyId()', desc: 'Tenant selector' }, { type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
{ type: 'HOOK', name: 'auth/sessionEstablished', desc: 'Session ready lifecycle', port: 'p_auth_status' }, { type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
{ type: 'HOOK', name: 'auth/companyChanged', desc: 'Company change signal', port: 'p_auth_user' },
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' }, { type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
], ],
}, },
@@ -166,7 +153,32 @@ const AUTOMAT_SPECS = [
], ],
}, },
// 2. Catalog & Products // 2. Company & Consolidated Base Data
{
id: 'companyAutomat',
title: 'companyAutomat',
subtitle: 'Consolidated Company & Base Data Hub',
entries: [
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' },
{ type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' },
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' },
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' },
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' },
{ type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' },
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' },
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' },
{ type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' },
{ type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' },
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' },
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' },
{ type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' },
{ type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' },
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
{ type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' },
],
},
// 3. Catalog & Products
{ {
id: 'categoryAutomat', id: 'categoryAutomat',
title: 'categoryAutomat', title: 'categoryAutomat',
@@ -225,35 +237,8 @@ const AUTOMAT_SPECS = [
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' }, { type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
], ],
}, },
{
id: 'brandAutomat',
title: 'brandAutomat',
subtitle: 'Catalog Brands & Makers',
entries: [
{ type: 'STATE', name: 'brands: Brand[]', desc: 'Decoupled brand catalog' },
{ type: 'STATE', name: 'filteredBrands: Brand[]', desc: 'Filtered search slice' },
{ type: 'ACTION', name: 'load(silent)', desc: 'Deduplicated list fetch' },
{ type: 'ACTION', name: 'create() / rename()', desc: 'Brand mutations' },
{ type: 'ACTION', name: 'addImage() / deleteImage()', desc: 'Brand logo AVIF image' },
{ type: 'API', name: 'GET /api/brand', desc: 'Paginated brand records' },
{ type: 'SSE', name: 'brand', desc: 'SSE signal with sender tab' },
],
},
{
id: 'unitAutomat',
title: 'unitAutomat',
subtitle: 'Units of Measure (UoM)',
entries: [
{ type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units' },
{ type: 'STATE', name: 'status: \'idle\'|\'ready\'', desc: 'Load lifecycle state' },
{ type: 'ACTION', name: 'load()', desc: 'Fetch localized units' },
{ type: 'ACTION', name: 'upsert(body)', desc: 'Create or update unit' },
{ type: 'ACTION', name: 'delete(id)', desc: 'Remove unused unit' },
{ type: 'API', name: 'GET /api/unit', desc: 'Company unit directory' },
],
},
// 3. Search & Discovery // 4. Search & Discovery
{ {
id: 'articleIndexAutomat', id: 'articleIndexAutomat',
title: 'articleIndexAutomat', title: 'articleIndexAutomat',
@@ -281,52 +266,11 @@ const AUTOMAT_SPECS = [
], ],
}, },
// 4. Properties & Attributes // 5. Deliveries & Inbound
{
id: 'propertyAutomat',
title: 'propertyAutomat',
subtitle: 'Custom Metadata Properties',
entries: [
{ type: 'STATE', name: 'properties: PropertyName[]', desc: 'Sorted company properties' },
{ type: 'ACTION', name: 'load()', desc: 'Fetch property definitions' },
{ type: 'ACTION', name: 'createProperty()', desc: 'Add new property definition' },
{ type: 'ACTION', name: 'updateProperty()', desc: 'Edit title & sort' },
{ type: 'API', name: 'GET /api/propertyname', desc: 'Company properties' },
{ type: 'SSE', name: 'propertyname', desc: 'Real-time property signal' },
],
},
{
id: 'attributeAutomat',
title: 'attributeAutomat',
subtitle: 'Product Attributes & Discrete Values',
entries: [
{ type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes with sorted values' },
{ type: 'ACTION', name: 'load()', desc: 'Fetch attribute definitions' },
{ type: 'ACTION', name: 'createAttribute()', desc: 'Create attribute group' },
{ type: 'ACTION', name: 'addValue() / deleteValue()', desc: 'Discrete value options' },
{ type: 'API', name: 'GET /api/attributename', desc: 'Attributes & values' },
{ type: 'SSE', name: 'attributename', desc: 'Real-time attribute signal' },
],
},
// 5. Suppliers & Deliveries
{
id: 'supplierAutomat',
title: 'supplierAutomat',
subtitle: 'Supplier Directory & Catalogs',
entries: [
{ type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Active vendor list' },
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Load lifecycle state' },
{ type: 'ACTION', name: 'load()', desc: 'Deduplicated fetch' },
{ type: 'ACTION', name: 'reload()', desc: 'Silent background sync' },
{ type: 'API', name: 'GET /api/supplier', desc: 'Vendor records' },
{ type: 'SSE', name: 'supplier', desc: 'Real-time supplier signal' },
],
},
{ {
id: 'deliveryAutomat', id: 'deliveryAutomat',
title: 'deliveryAutomat', title: 'deliveryAutomat',
subtitle: 'createListAutomat (Deliveries)', subtitle: 'Chunk-Paginated Deliveries',
entries: [ entries: [
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' }, { type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' }, { type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
@@ -355,7 +299,7 @@ const AUTOMAT_SPECS = [
{ {
id: 'quoteAutomat', id: 'quoteAutomat',
title: 'quoteAutomat', title: 'quoteAutomat',
subtitle: 'createListAutomat (Quotes)', subtitle: 'Chunk-Paginated Quotes',
entries: [ entries: [
{ type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' }, { type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' }, { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' },
@@ -381,7 +325,7 @@ const AUTOMAT_SPECS = [
{ {
id: 'entityAutomat', id: 'entityAutomat',
title: 'entityAutomat', title: 'entityAutomat',
subtitle: 'createListAutomat (Entities)', subtitle: 'Customer & Partner Directory',
entries: [ entries: [
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' }, { type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' }, { type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
@@ -389,83 +333,8 @@ const AUTOMAT_SPECS = [
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' }, { type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
], ],
}, },
{
id: 'customergroupAutomat',
title: 'customergroupAutomat',
subtitle: 'createListAutomat (Groups)',
entries: [
{ type: 'STATE', name: 'items: Customergroup[]', desc: 'Pricing discount tiers' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Discount group load' },
{ type: 'API', name: 'GET /api/customergroup', desc: 'Pricing tiers' },
],
},
// 7. Inventory & Storage // 7. AI Assistant & Workspace
{
id: 'warehouseAutomat',
title: 'warehouseAutomat',
subtitle: 'Warehouse Locations & Stock',
entries: [
{ type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage areas & bins' },
{ type: 'STATE', name: 'filteredWarehouses', desc: 'Filter search slice' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated sync' },
{ type: 'ACTION', name: 'setFilter(query)', desc: 'Local filter query' },
{ type: 'API', name: 'GET /api/warehouse', desc: 'Warehouse listings' },
{ type: 'SSE', name: 'warehouse', desc: 'Real-time stock movement' },
],
},
// 8. Company & Finance
{
id: 'companyAutomat',
title: 'companyAutomat',
subtitle: 'Active Company Configuration',
entries: [
{ type: 'STATE', name: 'current: Company', desc: 'Tax rates & ISO language', port: 'p_comp_curr' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Fetch tenant settings' },
{ type: 'SELECT', name: 'selectCompanyTaxrate()', desc: 'Standard tax rate' },
{ type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' },
{ type: 'SELECT', name: 'selectCompanyName()', desc: 'Company legal name' },
{ type: 'API', name: 'GET /api/company/:id', desc: 'Legal & tax profile' },
],
},
{
id: 'accountAutomat',
title: 'accountAutomat',
subtitle: 'createListAutomat (Accounts)',
entries: [
{ type: 'STATE', name: 'items: Account[]', desc: 'Bank & payment ledgers' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Payment accounts load' },
{ type: 'API', name: 'GET /api/account', desc: 'Chart of accounts' },
],
},
{
id: 'numberAutomat',
title: 'numberAutomat',
subtitle: 'createListAutomat (Sequences)',
entries: [
{ type: 'STATE', name: 'items: NumberSeq[]', desc: 'Invoice/receipt counters' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Number series sync' },
{ type: 'API', name: 'GET /api/number', desc: 'Document counters' },
],
},
// 9. POS & Hardware
{
id: 'posterminalAutomat',
title: 'posterminalAutomat',
subtitle: 'POS Terminals & Gating',
entries: [
{ type: 'STATE', name: 'terminals: Posterminal[]', desc: 'Hardware terminals', port: 'p_pos_term' },
{ type: 'ACTION', name: 'load()', desc: 'Fetch POS registers' },
{ type: 'ACTION', name: 'create() / delete()', desc: 'Terminal registrations' },
{ type: 'ACTION', name: 'toggleCategory()', desc: 'Gated category filters' },
{ type: 'SELECT', name: 'selectPosterminals()', desc: 'Active registers' },
{ type: 'API', name: 'GET /api/posterminal', desc: 'Terminal device configs' },
],
},
// 10. AI Assistant & Workspace
{ {
id: 'chatAutomat', id: 'chatAutomat',
title: 'chatAutomat', title: 'chatAutomat',
@@ -497,7 +366,7 @@ const AUTOMAT_SPECS = [
], ],
}, },
// 11. Audit & Virtual Grids // 8. Audit & Virtual Grids
{ {
id: 'articleHistoryGridAutomat', id: 'articleHistoryGridAutomat',
title: 'articleHistoryGridAutomat', title: 'articleHistoryGridAutomat',
@@ -532,7 +401,7 @@ const AUTOMAT_SPECS = [
], ],
}, },
// 12. UI, Settings & System // 9. UI, Navigation & System
{ {
id: 'navigationAutomat', id: 'navigationAutomat',
title: 'navigationAutomat', title: 'navigationAutomat',
@@ -547,18 +416,6 @@ const AUTOMAT_SPECS = [
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' }, { type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
], ],
}, },
{
id: 'userSettingsAutomat',
title: 'userSettingsAutomat',
subtitle: 'IndexedDB User Preferences',
entries: [
{ type: 'STATE', name: 'viewerPosition: \'top\'|\'right\'', desc: 'Editor docking location' },
{ type: 'STATE', name: 'chatDrawerWidth: 460', desc: 'AI drawer pixel width', port: 'p_usr_drawer' },
{ type: 'ACTION', name: 'setViewerPosition()', desc: 'Persist docking' },
{ type: 'ACTION', name: 'setChatDrawerWidth()', desc: 'Persist drawer width' },
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
],
},
{ {
id: 'helpAutomat', id: 'helpAutomat',
title: 'helpAutomat', title: 'helpAutomat',
@@ -595,23 +452,13 @@ const AUTOMAT_SPECS = [
*/ */
const EDGE_DEFS = [ const EDGE_DEFS = [
// Session & Auth lifecycle // Session & Auth lifecycle
{ from: 'authAutomat', to: 'sseAutomat', label: 'bind session & cid', port: 'p_auth_user' }, { from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' },
{ from: 'authAutomat', to: 'companyAutomat', label: 'load company profile', port: 'p_auth_switchCompany' }, { from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' },
{ from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' },
{ from: 'authAutomat', to: 'brandAutomat', label: 'sync brands on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'unitAutomat', label: 'sync units on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'attributeAutomat', label: 'sync attributes on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'propertyAutomat', label: 'sync properties on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'supplierAutomat', label: 'sync suppliers on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'warehouseAutomat', label: 'sync warehouses on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'posterminalAutomat', label: 'sync terminals on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'customergroupAutomat', label: 'sync customer groups', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'numberAutomat', label: 'sync number series', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'accountAutomat', label: 'sync payment accounts', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' }, { from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' }, { from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' },
@@ -624,16 +471,12 @@ const EDGE_DEFS = [
{ from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-<sku>\' sse reload', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'warehouseAutomat', label: '\'warehouse\' sse trigger', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'supplierAutomat', label: '\'supplier\' sse trigger', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'brandAutomat', label: '\'brand\' sse trigger', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'attributeAutomat', label: '\'attributename\' sse', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'propertyAutomat', label: '\'propertyname\' sse', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' },
{ from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' },
// Catalog, Indexing & Search pipeline // Catalog, Indexing & Search pipeline
{ from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId', port: 'p_comp_root' }, { from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' },
{ from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' }, { from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' },
{ from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' }, { from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' },
{ from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' }, { from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' },
@@ -643,18 +486,13 @@ const EDGE_DEFS = [
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' }, { from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
// Purchasing & Deliveries // Purchasing & Deliveries
{ from: 'deliveryEditorAutomat', to: 'supplierAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' }, { from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' },
{ from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' }, { from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' },
{ from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' }, { from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' },
// Sales & CRM // Sales & CRM
{ from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' }, { from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' },
{ from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' }, { from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' },
{ from: 'companyAutomat', to: 'customergroupAutomat', label: 'poscustomergroup', port: 'p_comp_curr' },
// POS & Hardware
{ from: 'posterminalAutomat', to: 'categoryAutomat', label: 'filter enabled roots', port: 'p_pos_term' },
{ from: 'companyAutomat', to: 'posterminalAutomat', label: 'company context', port: 'p_comp_curr' },
// AI Assistant & Workspace // AI Assistant & Workspace
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' }, { from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
@@ -665,7 +503,7 @@ const EDGE_DEFS = [
{ from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' }, { from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' },
{ from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' }, { from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' },
{ from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' }, { from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' },
{ from: 'userSettingsAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_usr_drawer' }, { from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' }, { from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
// Self-loops // Self-loops

1652
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",
@@ -33,8 +34,6 @@
"@mui/material": "^9.5.0", "@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1", "@shopify/draggable": "^1.2.1",
"@uiw/react-codemirror": "^4.25.12", "@uiw/react-codemirror": "^4.25.12",
"ag-grid-community": "^36.2.0",
"ag-grid-react": "^36.2.0",
"dashgui": "file:../dashgui", "dashgui": "file:../dashgui",
"flexsearch": "^0.8.212", "flexsearch": "^0.8.212",
"jsbarcode": "^3.12.3", "jsbarcode": "^3.12.3",
@@ -42,12 +41,11 @@
"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-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
"react-window": "^2.3.4", "react-window": "^2.3.4",
"skelehash-wasm": "^0.1.0", "skelehash-wasm": "^0.1.1",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
}, },
@@ -64,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,16 @@
import { PureComponent, Suspense } from 'react'; import { PureComponent, lazy, Suspense } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { RouterProvider } from 'react-router-dom';
import authAutomat from './automats/authAutomat'; import authAutomat from './automats/authAutomat';
import LoginPage from './components/LoginPage'; import LoginPage from './components/LoginPage';
import CompanySetupDialog from './components/CompanySetupDialog'; import AppRouter from './appRouter';
import { router } from './appRouter';
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor'))
: null;
function PageFallback() { function PageFallback() {
return ( return (
@@ -30,7 +35,13 @@ class App extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']); this.unsubscribe = authAutomat.subscribe(this, [
'status',
'user',
'hasAuth',
'hasCompany',
'retryNotification',
]);
} }
componentWillUnmount() { componentWillUnmount() {
@@ -38,25 +49,44 @@ class App extends PureComponent {
} }
render() { render() {
const { status, user } = this.state; const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
if (status === 'loading') { if (status === 'loading') {
return ( return (
<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>
); );
} }
if (status !== 'authed') { if (!isAuthed) {
if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') { if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search); const returnTo = encodeURIComponent(window.location.pathname + window.location.search);
window.history.replaceState(null, '', `/login?returnTo=${returnTo}`); window.history.replaceState(null, '', `/login?returnTo=${returnTo}`);
@@ -64,16 +94,28 @@ class App extends PureComponent {
return <LoginPage />; return <LoginPage />;
} }
const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize'; const isOAuthAuthorize =
typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
if (user?.selectedCompanyId == null && !isOAuthAuthorize) {
return <CompanySetupDialog />;
}
return ( return (
<>
<Suspense fallback={<PageFallback />}> <Suspense fallback={<PageFallback />}>
<RouterProvider router={router} /> {!isCompanySelected ? (
isOAuthAuthorize ? (
<OAuthAuthorizePage />
) : (
<CompanySetupDialog />
)
) : (
<AppRouter />
)}
</Suspense> </Suspense>
{SseMonitor ? (
<Suspense fallback={null}>
<SseMonitor />
</Suspense>
) : null}
</>
); );
} }
} }

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

@@ -50,11 +50,19 @@ export const article = {
// #endregion // #endregion
/** /**
* GET /api/article/:sku/history * GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
* @param {string} sku
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
*/ */
getBySkuHistory(sku, { limit } = {}) { getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit)); if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString(); const qs = params.toString();
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`); return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
}, },

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

View File

@@ -125,6 +125,21 @@ export const category = {
}, },
// #endregion // #endregion
/**
* POST /api/category/:id/image/generate
* Body (optional): { size?: 2 | 3 | 4 }
* → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } }
*/
// #region POST /api/category/:id/image/generate — generateImage
generateImage(categoryId, { size } = {}) {
return http(`/api/category/${categoryId}/image/generate`, {
method: 'POST',
body: size != null ? { size } : undefined,
});
},
// #endregion
/** /**
* GET /api/category/export.zip → application/zip (binary download) * GET /api/category/export.zip → application/zip (binary download)
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252, * `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,

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,8 +15,12 @@ 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 { setting } from './setting.js'; export { setting } from './setting.js';
export { serper } from './serper.js'; export { serper } from './serper.js';
export { supplier } from './supplier.js'; export { supplier } from './supplier.js';
@@ -30,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

@@ -11,8 +11,17 @@ export const payment = {
// #endregion // #endregion
// #region POST /api/order/:id/payment — create // #region POST /api/order/:id/payment — create
create(orderId, body) { create(orderIdOrBody, maybeBody) {
return http(`/api/order/${orderId}/payment`, { method: 'POST', body }); if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
return http('/api/payment', { method: 'POST', body: orderIdOrBody });
}
return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
},
// #endregion
// #region POST /api/payment — register
register(body) {
return http('/api/payment', { method: 'POST', body });
}, },
// #endregion // #endregion

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',
});
},
};

187
src/api/retoure.js Normal file
View File

@@ -0,0 +1,187 @@
import { http } from './http.js';
/**
* Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
* Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
* positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
*/
export function normalizeRetoureSummary(row) {
if (!row) return null;
const id = row.manifest_id ?? row.id;
const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
const entityId = row.entity_id ?? row.entityId ?? null;
const customerName = row.customer_name ?? row.customerName ?? '';
const positions = Number(row.positions) || 0;
const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
const isPaid = Boolean(row.is_paid ?? row.isPaid);
const isShipped = Boolean(row.is_shipped ?? row.isShipped);
const createdAt = row.created_at ?? row.createdAt ?? null;
return {
id,
manifest_id: id,
number: num,
retoure_number: num,
date: dt,
retoure_date: dt,
entityId,
entity_id: entityId,
customerName,
customer_name: customerName,
positions,
grossTotal,
gross_total: grossTotal,
paidTotal,
paid_total: paidTotal,
orderedQuantity,
ordered_quantity: orderedQuantity,
shippedQuantity,
shipped_quantity: shippedQuantity,
isPaid,
is_paid: isPaid,
isShipped,
is_shipped: isShipped,
createdAt,
created_at: createdAt,
lines: row.lines ?? [],
files: row.files ?? [],
shipments: row.shipments ?? [],
payments: row.payments ?? [],
};
}
/**
* Customer Returns / Retouren (manifest type = retoure).
*/
export const retoure = {
/**
* GET /api/retoure
* Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
* → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
*/
// #region GET /api/retoure — list
async list({
q,
search,
from,
to,
isPaid,
isShipped,
onlyUnpaid,
onlyUndelivered,
page,
pageSize,
offset,
limit,
entityId,
seekMonths,
} = {}) {
const params = new URLSearchParams();
const query = q || search;
if (query && String(query).trim()) {
params.set('q', String(query).trim());
params.set('search', String(query).trim());
}
if (from) params.set('from', String(from));
if (to) params.set('to', String(to));
if (isPaid !== undefined && isPaid !== null) {
params.set('isPaid', String(isPaid));
} else if (onlyUnpaid === true || onlyUnpaid === 'true') {
params.set('isPaid', 'false');
}
if (isShipped !== undefined && isShipped !== null) {
params.set('isShipped', String(isShipped));
} else if (onlyUndelivered === true || onlyUndelivered === 'true') {
params.set('isShipped', 'false');
}
if (page != null) params.set('page', String(page));
if (pageSize != null) params.set('pageSize', String(pageSize));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
const items = rawList.map(normalizeRetoureSummary);
const total = Number(res?.total ?? res?.count ?? items.length);
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
return {
retoures: items,
summaries: items,
items,
total,
unfilteredTotal,
raw: res,
};
},
// #endregion
/** GET /api/retoure/:id → { retoure: Retoure } */
// #region GET /api/retoure/:id — get
async get(id) {
const res = await http(`/api/retoure/${id}`);
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/**
* POST /api/retoure
* Body: { number?, date, entityId?, externalNumber?, lines }
* → 201 { retoure: Retoure }
*/
// #region POST /api/retoure — create
async create(body) {
const res = await http('/api/retoure', { method: 'POST', body });
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/**
* PUT /api/retoure/:id
* Body: { number?, date?, entityId?, externalNumber?, lines? }
* → 200 { retoure: Retoure }
*/
// #region PUT /api/retoure/:id — update
async update(id, body) {
const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/** DELETE /api/retoure/:id → 204 */
// #region DELETE /api/retoure/:id — delete
delete(id) {
return http(`/api/retoure/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/retoure/:id/shipment
* Creates return shipment.
* Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
* → 201 { shipment: Shipment }
*/
// #region POST /api/retoure/:id/shipment — createShipment
createShipment(id, body = {}) {
return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
},
// #endregion
};

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

@@ -1,40 +1,46 @@
import { lazy } from 'react'; import { lazy } from 'react';
import { Navigate, createBrowserRouter } from 'react-router-dom'; import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom';
import AppShell from './components/AppShell'; import AppShell from './components/AppShell';
import SectionLayout from './components/SectionLayout'; import SectionLayout from './components/SectionLayout';
import ArtikelUebersichtPage from './routes/artikel'; const ArtikelUebersichtPage = lazy(() => import('./routes/artikel'));
import ArtikelKategorienPage from './routes/artikel/kategorien'; const ArtikelKategorienPage = lazy(() => import('./routes/artikel/kategorien'));
import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorien'; const RedirectArtikelKategorien = lazy(() => import('./routes/artikel/RedirectArtikelKategorien'));
import ArtikelLagerPage from './routes/artikel/lager'; const ArtikelLagerPage = lazy(() => import('./routes/artikel/lager'));
import ArtikelMarkenPage from './routes/artikel/marken'; const ArtikelMarkenPage = lazy(() => import('./routes/artikel/marken'));
import ArtikelVorlagenPage from './routes/artikel/vorlagen'; const ArtikelVorlagenPage = lazy(() => import('./routes/artikel/vorlagen'));
import ArtikelMerkmalePage from './routes/artikel/merkmale'; const ArtikelMerkmalePage = lazy(() => import('./routes/artikel/merkmale'));
import ArtikelSkuPage from './routes/artikel/_sku'; const ArtikelSkuPage = lazy(() => import('./routes/artikel/_sku'));
import LieferungListePage from './routes/lieferung'; const LieferungListePage = lazy(() => import('./routes/lieferung'));
import LieferungNeuPage from './routes/lieferung/neu'; const LieferungNeuPage = lazy(() => import('./routes/lieferung/neu'));
import LieferungLieferantenPage from './routes/lieferung/lieferanten'; const LieferungLieferantenPage = lazy(() => import('./routes/lieferung/lieferanten'));
import LieferungEditPage from './routes/lieferung/_deliveryId'; const LieferungEditPage = lazy(() => import('./routes/lieferung/_deliveryId'));
import AuftragAngebotPage from './routes/auftrag'; const AuftragAngebotPage = lazy(() => import('./routes/auftrag'));
import AuftragAngebotNeuPage from './routes/auftrag/neuesangebot'; const AuftragAngebotNeuPage = lazy(() => import('./routes/auftrag/neuesangebot'));
import AuftragAngebotEditPage from './routes/auftrag/_quoteId'; const AuftragAngebotEditPage = lazy(() => import('./routes/auftrag/_quoteId'));
import AuftragBestellungPage from './routes/auftrag/bestellung'; const AuftragBestellungPage = lazy(() => import('./routes/auftrag/bestellung'));
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu'; const AuftragBestellungNeuPage = lazy(() => import('./routes/auftrag/bestellung/neu'));
import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId'; const AuftragBestellungEditPage = lazy(() => import('./routes/auftrag/bestellung/_orderId'));
import AuftragAusliefernPage from './routes/auftrag/ausliefern'; const AuftragAusliefernPage = lazy(() => import('./routes/auftrag/ausliefern'));
import AuftragZahlungenPage from './routes/auftrag/zahlungen'; const AuftragRetourePage = lazy(() => import('./routes/auftrag/retoure'));
import AuftragVersandartenPage from './routes/auftrag/versandarten'; const AuftragRetoureNeuPage = lazy(() => import('./routes/auftrag/retoure/neu'));
const AuftragRetoureEditPage = lazy(() => import('./routes/auftrag/retoure/_retoureId'));
const AuftragZahlungenPage = lazy(() => import('./routes/auftrag/zahlungen'));
const AuftragVersandartenPage = lazy(() => import('./routes/auftrag/versandarten'));
import KundeListePage from './routes/kunde'; const KundeListePage = lazy(() => import('./routes/kunde'));
import KundeNeuPage from './routes/kunde/neu'; const KundeNeuPage = lazy(() => import('./routes/kunde/neu'));
import KundeGruppenPage from './routes/kunde/gruppen'; const KundeGruppenPage = lazy(() => import('./routes/kunde/gruppen'));
import KundeEditPage from './routes/kunde/_entityId'; const KundeEditPage = lazy(() => import('./routes/kunde/_entityId'));
import StatisticsPage from './routes/infos/StatisticsPage'; const StatisticsPage = lazy(() => import('./routes/infos/StatisticsPage'));
import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage'; 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);
@@ -109,11 +115,13 @@ const routes = [
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> }, { path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> }, { path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
{ path: 'ausliefern', element: <AuftragAusliefernPage /> }, { path: 'ausliefern', element: <AuftragAusliefernPage /> },
{ path: 'retoure', element: <AuftragRetourePage /> },
{ path: 'retoure/neu', element: <AuftragRetoureNeuPage /> },
{ path: 'retoure/:retoureId', element: <AuftragRetoureEditPage /> },
{ path: 'zahlungen', element: <AuftragZahlungenPage /> }, { path: 'zahlungen', element: <AuftragZahlungenPage /> },
{ path: 'versandarten', element: <AuftragVersandartenPage /> }, { path: 'versandarten', element: <AuftragVersandartenPage /> },
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> }, { path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
{ path: ':quoteId', element: <AuftragAngebotEditPage /> }, { path: ':quoteId', element: <AuftragAngebotEditPage /> },
], ],
}, },
{ {
@@ -126,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 /> },
], ],
}, },
@@ -138,3 +159,7 @@ export const router =
if (import.meta.hot) { if (import.meta.hot) {
import.meta.hot.data.router = router; import.meta.hot.data.router = router;
} }
export default function AppRouter() {
return <RouterProvider router={router} />;
}

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

@@ -1,21 +0,0 @@
/**
* @file accountAutomat.js
* @description Manages cached payment accounts (`GET /api/account`) for the selected company.
*/
import { createListAutomat } from './createListAutomat';
import { account as accountApi } from '../api';
export const accountAutomat = createListAutomat({
name: 'account',
fetch: async () => {
const res = await accountApi.list();
return Array.isArray(res?.accounts) ? res.accounts : [];
},
sort: (items) =>
[...items].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')
),
});
export default accountAutomat;

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';
@@ -34,26 +34,73 @@ import { requireArray, requireObject } from '../utils/apiShape';
* @property {number|null} taxRate Tax rate in percent. * @property {number|null} taxRate Tax rate in percent.
*/ */
import { createChunkListAutomat } from './createChunkListAutomat';
import authAutomat from './authAutomat';
/** /**
* Per-SKU article change history (`GET /api/article/{sku}/history`). * Registry of SKU-specific chunk-list automats for article history.
* * @type {Map<string, import('react-automat').Automat>}
* The backend returns the full list; pagination happens client-side in
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
*
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
*/ */
export const articleHistoryAutomat = new KeyedAutomat( const skuHistoryAutomats = new Map();
null,
{}, /**
{ * Gets or creates the chunk-list automat for a specific article's history.
name: 'articleHistory', *
loader: async (sku) => { * @param {string|number} sku Article SKU.
const res = await articleApi.getBySkuHistory(String(sku)); * @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
requireObject(res, 'GET /api/article/:sku/history'); */
return requireArray(res.history, 'history'); export function getArticleHistoryAutomat(sku) {
const rawKey = String(sku);
const key = rawKey.trim().toLowerCase();
let inst = skuHistoryAutomats.get(key);
if (!inst) {
inst = createChunkListAutomat({
name: `articleHistory:${rawKey}`,
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
});
skuHistoryAutomats.set(key, inst);
}
return inst;
}
/**
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
*
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
*/
export const articleHistoryAutomat = {
get: getArticleHistoryAutomat,
key: getArticleHistoryAutomat,
has(sku) {
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
}, },
async load(sku) {
const inst = getArticleHistoryAutomat(sku);
return inst.getItems();
}, },
); async reload(sku) {
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
if (inst) {
await inst.actions.reload();
}
},
reset() {
for (const inst of skuHistoryAutomats.values()) {
inst.actions.reset();
}
skuHistoryAutomats.clear();
},
};
// Reset on logout or company change
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
articleHistoryAutomat.reset();
}
});
/** /**
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`). * Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
@@ -68,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;
@@ -286,11 +350,90 @@ categoryAutomat.subscribe((state) => {
scheduleRebuild(); scheduleRebuild();
} }
}); });
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
scheduleRebuild();
}
authAutomat.subscribe((auth) => { 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

@@ -14,8 +14,7 @@ import { Automat } from 'react-automat';
import FlexSearch from 'flexsearch'; import FlexSearch from 'flexsearch';
import articleIndexAutomat from './articleIndexAutomat'; import articleIndexAutomat from './articleIndexAutomat';
import categoryAutomat from './categoryAutomat'; import categoryAutomat from './categoryAutomat';
import brandAutomat from './brandAutomat'; import companyAutomat from './companyAutomat';
import supplierAutomat from './supplierAutomat';
import { parseFilter } from '../components/article/searchFormat'; import { parseFilter } from '../components/article/searchFormat';
// #region Types & Initial State // #region Types & Initial State
@@ -68,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,
@@ -344,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,
@@ -368,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) {
@@ -379,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) {
@@ -585,7 +640,7 @@ function buildCategoryFacets(matchedArticles) {
*/ */
function buildBrandFacets(matchedArticles) { function buildBrandFacets(matchedArticles) {
const facets = {}; const facets = {};
const allBrands = brandAutomat.state?.brands ?? []; const allBrands = companyAutomat.state?.brands ?? [];
const brandById = new Map(); const brandById = new Map();
const brandByName = new Map(); const brandByName = new Map();
for (const b of allBrands) { for (const b of allBrands) {
@@ -633,8 +688,8 @@ function buildBrandFacets(matchedArticles) {
*/ */
function buildSupplierFacets(matchedArticles) { function buildSupplierFacets(matchedArticles) {
const facets = {}; const facets = {};
const suppliers = supplierAutomat.state?.suppliers ?? []; const suppliers = companyAutomat.state?.suppliers ?? [];
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {}; const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const supplierArticleSets = new Map(); const supplierArticleSets = new Map();
for (const s of suppliers) { for (const s of suppliers) {
@@ -670,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
@@ -690,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;
@@ -738,7 +809,7 @@ const actions = {
// Apply supplier filter if set // Apply supplier filter if set
const selectedSuppliers = parseFilter(supplier); const selectedSuppliers = parseFilter(supplier);
if (selectedSuppliers.length > 0) { if (selectedSuppliers.length > 0) {
const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {}; const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const allowedSkus = new Set(); const allowedSkus = new Set();
for (const sId of selectedSuppliers) { for (const sId of selectedSuppliers) {
const list = articlesBySupplier[String(sId)] ?? []; const list = articlesBySupplier[String(sId)] ?? [];
@@ -753,6 +824,64 @@ const actions = {
}); });
} }
// Apply hierarchy filter:
// - "father": "father or normal article" -> brand editable, exclude child variants
// - "child": "child or normal article" -> price editable, exclude parents
if (hierarchy === 'father') {
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
} else if (hierarchy === 'child') {
filteredArticles = filteredArticles.filter(
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
);
}
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
filteredArticles = filteredArticles.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
const sortedArticles = sortArticlesByRelevance(filteredArticles, query); const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
const totalCount = sortedArticles.length; const totalCount = sortedArticles.length;
@@ -782,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',
@@ -898,15 +1029,10 @@ categoryAutomat.subscribe((catState) => {
} }
}); });
// Update facets when brands are loaded or updated. // Update facets when brands, suppliers, or supplier articles are loaded or updated.
brandAutomat.subscribe(() => { companyAutomat.subscribe(() => {
actions.runSearch(); actions.runSearch();
}); }, ['brands', 'suppliers', 'articlesBySupplier']);
// Update facets when suppliers or supplier articles are loaded or updated.
supplierAutomat.subscribe(() => {
actions.runSearch();
});
// #endregion // #endregion
// #region Selectors & Exports // #region Selectors & Exports

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

@@ -1,225 +0,0 @@
/**
* @file attributeAutomat.js
* @description Manages catalog product attributes and their discrete values
* (e.g. Color, Size) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event.
* - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`,
* `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`).
*/
import { Automat } from 'react-automat';
import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} AttributeValue
* @property {number} id
* @property {number} attributeId
* @property {string} value
* @property {number} sort
*/
/**
* @typedef {Object} AttributeName
* @property {number} id
* @property {string} name
* @property {number} sort
* @property {boolean} exclusive
* @property {AttributeValue[]} values
*/
/**
* @typedef {Object} AttributeState
* @property {AttributeName[]} attributes
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {AttributeState} */
const initialState = {
attributes: [],
status: 'idle',
error: null,
};
function sortValues(values) {
if (!Array.isArray(values)) return [];
return [...values].sort((a, b) => {
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
if (sDiff !== 0) return sDiff;
return (a.id ?? 0) - (b.id ?? 0);
});
}
function normalizeAttributes(attributes) {
if (!Array.isArray(attributes)) return [];
return [...attributes]
.map((attr) => ({
...attr,
exclusive: Boolean(attr.exclusive),
values: sortValues(attr.values),
}))
.sort((a, b) => {
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
if (sDiff !== 0) return sDiff;
return (a.id ?? 0) - (b.id ?? 0);
});
}
let attributeAutomat;
const actions = {
/** Load all attribute names and their values for the selected company. */
async load() {
attributeAutomat.setState({ status: 'loading', error: null });
try {
const res = await attributenameApi.list();
const attributes = normalizeAttributes(res.attributenames);
attributeAutomat.setState({ attributes, status: 'succeeded' });
return attributes;
} catch (err) {
attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create an attribute name.
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
*/
async createAttribute(body) {
const res = await attributenameApi.create(body);
await actions.load();
return res.attributename;
},
/**
* Update an attribute name.
* @param {number} id
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
*/
async updateAttribute(id, body) {
const res = await attributenameApi.update(id, body);
await actions.load();
return res.attributename;
},
/**
* Delete an attribute name.
* @param {number} id
*/
async deleteAttribute(id) {
await attributenameApi.delete(id);
await actions.load();
},
/**
* Delete multiple attribute names.
* @param {number[]} ids
*/
async deleteAttributes(ids) {
for (const id of ids) {
await attributenameApi.delete(id);
}
await actions.load();
},
/**
* Reorder attribute names.
* @param {number[]} attributenameIds
*/
async reorderAttributes(attributenameIds) {
await attributenameApi.order(attributenameIds);
await actions.load();
},
/**
* Create a value for an attribute.
* @param {number} attributeId
* @param {{ value: string, sort?: number }} body
*/
async createValue(attributeId, body) {
const res = await attributevalueApi.create(attributeId, body);
await actions.load();
return res.attributevalue;
},
/**
* Update an attribute value.
* @param {number} id
* @param {{ value?: string, sort?: number }} body
*/
async updateValue(id, body) {
const res = await attributevalueApi.update(id, body);
await actions.load();
return res.attributevalue;
},
/**
* Delete an attribute value.
* @param {number} id
*/
async deleteValue(id) {
await attributevalueApi.delete(id);
await actions.load();
},
/**
* Delete multiple attribute values.
* @param {number[]} ids
*/
async deleteValues(ids) {
for (const id of ids) {
await attributevalueApi.delete(id);
}
await actions.load();
},
/**
* Reorder values of an attribute.
* @param {number} attributeId
* @param {number[]} attributevalueIds
*/
async reorderValues(attributeId, attributevalueIds) {
await attributevalueApi.order(attributeId, attributevalueIds);
await actions.load();
},
/** Reset to idle state (called on logout / company switch). */
reset() {
attributeAutomat.setState(initialState);
},
};
/**
* Global attribute state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<AttributeState>}
*/
attributeAutomat = new Automat(initialState, actions, { name: 'attribute' });
// Selectors
export function selectAttributes() {
return attributeAutomat.state.attributes;
}
export function selectAttributeStatus() {
return attributeAutomat.state.status;
}
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void attributeAutomat.actions.load();
} else if (auth.status === 'anon') {
attributeAutomat.actions.reset();
}
});
export default attributeAutomat;

View File

@@ -1,30 +1,24 @@
/** /**
* @file authAutomat.js * @file authAutomat.js
* @description Central authentication and multi-tenant session manager. * @description Central authentication and multi-tenant session manager.
* Tracks current user, Google OAuth client ID, and active company selection. * Tracks current user, Google OAuth client ID, active company selection, and
* the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`).
* *
* Upstream Triggers: * Upstream Triggers:
* - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load. * - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load.
* - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`. * - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`.
* - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`. * - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
* `createCompany(payload)`, or `logout()`.
* *
* Downstream Triggers: * Downstream Triggers:
* Subscribed to by almost all company-scoped automats, orchestrating their loading * - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`.
* when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`): * - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet).
* - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat` * - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat`
* - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat` * are mounted and company APIs are triggered.
* - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company
* - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations
* - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from
* the category views once the session is established
* - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`,
* `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`)
* - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`)
* - `artikelAutomat`: Clears cached articles on logout
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import { auth, company } from '../api'; import { auth, company, number as numberApi } from '../api';
/** /**
* @typedef {Object} AuthUser * @typedef {Object} AuthUser
@@ -36,7 +30,13 @@ import { auth, company } 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} 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. */
@@ -50,11 +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,
hasCompany: false,
retryNotification: null,
}; };
let emit = () => { }; let emit = () => { };
// #region helpers
/**
* Detects whether an error represents an HTTP 502 Bad Gateway response.
*
* @param {any} err
* @returns {boolean}
*/
export function is502BadGateway(err) {
if (!err) return false;
if (Number(err.status) === 502) return true;
const msg = String(err.message || '');
return /502|bad\s*gateway/i.test(msg);
}
/** /**
* Registers the application-level dispatcher used for authentication events. * Registers the application-level dispatcher used for authentication events.
* *
@@ -65,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;
@@ -93,43 +112,132 @@ function mergeUser(current, incoming) {
return merged; return merged;
} }
/**
* Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`).
*
* @param {AuthUser|null} user
* @param {'loading'|'anon'|'authed'} status
* @param {object} [extra]
* @returns {Partial<AuthState>}
*/
function buildAuthState(user, status, extra = {}) {
const hasAuth = status === 'authed' && Boolean(user);
const hasCompany = hasAuth && user?.selectedCompanyId != null;
return {
user: hasAuth ? user : null,
status,
hasAuth,
hasCompany,
vhost: authAutomat?.state?.vhost ?? false,
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
retryNotification: null,
...extra,
};
}
function syncSse(state) {
const sseAutomat = Automat.get('sse');
if (sseAutomat?.actions?.syncWithAuth) {
sseAutomat.actions.syncWithAuth(state);
}
}
function applySession(nextSession) {
const googleClientId =
nextSession.googleClientId ?? authAutomat.state.googleClientId;
const schemaVersion =
nextSession.schemaVersion !== undefined
? nextSession.schemaVersion
: (authAutomat.state.schemaVersion ?? null);
const vhost =
nextSession.vhost !== undefined
? Boolean(nextSession.vhost)
: (authAutomat.state.vhost ?? false);
const vhostSettings =
nextSession.vhostSettings !== undefined
? nextSession.vhostSettings
: (authAutomat.state.vhostSettings ?? null);
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
} else {
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession;
}
// #endregion
let authAutomat; let authAutomat;
let inFlightBootstrap = null;
const actions = { const actions = {
// #region bootstrapSession
// MARK: calls auth.me from ../api
/** /**
* Restores an existing session or requests it from the authentication 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] * @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
* Optional preloaded session. * Optional preloaded session.
* @returns {Promise<Object>} Resolved session response. * @returns {Promise<Object>} Resolved session response.
*/ */
async bootstrapSession(session) { async bootstrapSession(session) {
if (session) {
return applySession(session);
}
if (inFlightBootstrap) {
return inFlightBootstrap;
}
inFlightBootstrap = (async () => {
let attempt = 0;
while (true) {
try { try {
const nextSession = session ?? await auth.me(); const nextSession = await auth.me();
const googleClientId = return applySession(nextSession);
nextSession.googleClientId ?? authAutomat.state.googleClientId;
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
} else {
authAutomat.setState({ googleClientId });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
authAutomat.setState({ user, googleClientId, status: 'authed' });
emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession;
} catch (error) { } 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') {
authAutomat.setState({ user: null, status: 'anon' }); const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { retryNotification: null })
);
syncSse(nextState);
} }
throw error; throw error;
} }
}, }
})().finally(() => {
inFlightBootstrap = null;
});
return inFlightBootstrap;
},
// #endregion
// #region loginWithGoogle
// MARK: calls auth.loginWithGoogle from ../api
/** /**
* Exchanges a Google credential for an authenticated application session. * Exchanges a Google credential for an authenticated application session.
* *
@@ -143,19 +251,25 @@ const actions = {
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)'); throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
} }
const user = mergeUser(authAutomat.state.user, session.user); const user = mergeUser(authAutomat.state.user, session.user);
authAutomat.setState({ const nextState = authAutomat.setState(
user, buildAuthState(user, 'authed', {
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
status: 'authed', schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
}); })
);
syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED }); emit({ type: AUTH_SESSION_ESTABLISHED });
return session; return session;
} catch (error) { } catch (error) {
authAutomat.setState({ user: null, status: 'anon' }); const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
syncSse(nextState);
throw error; throw error;
} }
}, },
// #endregion
// #region selectCompany
// MARK: calls company.select from ../api
/** /**
* Selects the company used by the current session. * Selects the company used by the current session.
* *
@@ -168,11 +282,15 @@ const actions = {
authAutomat.state.user, authAutomat.state.user,
data.user ?? { selectedCompanyId: companyId }, data.user ?? { selectedCompanyId: companyId },
); );
authAutomat.setState({ user, status: 'authed' }); const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
syncSse(nextState);
emit({ type: AUTH_COMPANY_CHANGED }); emit({ type: AUTH_COMPANY_CHANGED });
return user; return user;
}, },
// #endregion
// #region createCompany
// MARK: calls company.create and company.select from ../api
/** /**
* Creates a company and selects it for the current user. * Creates a company and selects it for the current user.
* *
@@ -180,7 +298,8 @@ const actions = {
* @returns {Promise<AuthUser>} Updated user. * @returns {Promise<AuthUser>} Updated user.
*/ */
async createCompany(payload) { async createCompany(payload) {
const data = await company.create(payload); const { numbers, ...companyPayload } = payload || {};
const data = await company.create(companyPayload);
let incoming = data.user; let incoming = data.user;
if (!incoming && data.company?.id) { if (!incoming && data.company?.id) {
const selected = await company.select(data.company.id); const selected = await company.select(data.company.id);
@@ -190,12 +309,34 @@ const actions = {
throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.'); throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.');
} }
if (numbers) {
try {
const listRes = await numberApi.list();
const serverNumbers = listRes?.numbers ?? [];
for (const serverRow of serverNumbers) {
const userConfig = numbers[serverRow.type];
if (userConfig) {
const prefix = String(userConfig.prefix ?? '');
const padding = Math.max(0, Number(userConfig.padding) || 0);
const next = Math.max(1, Number(userConfig.next) || 1);
await numberApi.update(serverRow.id, { prefix, padding, next });
}
}
} catch (numErr) {
console.warn('Initial number sequences could not be saved:', numErr);
}
}
const user = mergeUser(authAutomat.state.user, incoming); const user = mergeUser(authAutomat.state.user, incoming);
authAutomat.setState({ user, status: 'authed' }); const nextState = authAutomat.setState(buildAuthState(user, 'authed'));
syncSse(nextState);
emit({ type: AUTH_COMPANY_CHANGED }); emit({ type: AUTH_COMPANY_CHANGED });
return user; return user;
}, },
// #endregion
// #region logout
// MARK: calls auth.logout from ../api
/** /**
* Ends the server session, clears the current user, and returns to `/`. * Ends the server session, clears the current user, and returns to `/`.
* *
@@ -204,9 +345,11 @@ const actions = {
async logout() { async logout() {
await auth.logout(); await auth.logout();
window.history.replaceState(null, '', '/'); window.history.replaceState(null, '', '/');
authAutomat.setState({ user: null, status: 'anon' }); const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
syncSse(nextState);
emit({ type: AUTH_LOGGED_OUT }); emit({ type: AUTH_LOGGED_OUT });
}, },
// #endregion
}; };
/** /**
@@ -216,16 +359,27 @@ const actions = {
*/ */
authAutomat = new Automat(initialState, actions, { name: 'auth' }); authAutomat = new Automat(initialState, actions, { name: 'auth' });
// #region selectors
export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
export const selectSelectedCompanyId = () =>
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
export const selectRetryNotification = () => authAutomat.state.retryNotification;
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
export const selectVhost = () => Boolean(authAutomat.state.vhost);
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
/** /**
* Re-reads `GET /api/auth/me` from the server. * Re-reads `GET /api/auth/me` from the server.
* *
* 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
export default authAutomat; export default authAutomat;

View File

@@ -1,160 +0,0 @@
/**
* @file brandAutomat.js
* @description Manages catalog brands and manufacturers for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
* `actions.load(true)`, which silently re-fetches the list. Subscribed views
* (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then
* re-render from the updated state. Events sent by this tab (matching `sender`)
* are ignored.
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
*/
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';
import sseAutomat, { getSseClientId } from './sseAutomat';
/**
* @typedef {Object} BrandState
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
* @property {Array<object>} filteredBrands Filtered brands according to active filter query.
* @property {string} filter Active search/filter string.
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {BrandState} */
const initialState = {
brands: [],
filteredBrands: [],
filter: '',
status: 'idle',
error: null,
};
const sortBrands = (brands) =>
[...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
const applyFilter = (brands, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands;
};
let brandAutomat;
let inflightLoad = null;
const FETCH_PAGE_SIZE = 100;
// #region fetch
async function fetchAllBrands() {
let offset = 0, total = Infinity;
const collected = [];
while (offset === 0 || collected.length < total) {
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
const page = Array.isArray(res.brands) ? res.brands : [];
collected.push(...page);
offset += page.length;
if (page.length === 0) break;
}
return { brands: sortBrands(collected) };
}
const updateStateWithBrands = (brands, extra = {}) => {
const filteredBrands = applyFilter(brands, brandAutomat.state.filter);
brandAutomat.setState({ brands, filteredBrands, ...extra });
return brands;
};
const actions = {
/** Sets the active brand search filter and updates filteredBrands. */
filter(query) {
const filter = String(query ?? '');
brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) });
},
/**
* Load all brands for the selected company (pages through the API).
* @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'.
*/
async load(silent = false) {
if (inflightLoad) return inflightLoad;
if (!silent) {
brandAutomat.setState({ status: 'loading', error: null });
}
return (inflightLoad = fetchAllBrands()
.then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null }))
.catch((err) => {
const error = err?.message ?? String(err);
brandAutomat.setState(silent ? { error } : { status: 'failed', error });
throw err;
})
.finally(() => { inflightLoad = null; }));
},
// #region add
async create(body) {
const res = await brandApi.create(body);
const row = res.brand;
updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row]));
return row;
},
// #region rename
async rename(id, body) {
const res = await brandApi.rename(id, body);
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b))));
return res;
},
// #region del
async remove(id) {
await brandApi.delete(id);
updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id));
},
async removeBrands(ids) {
const idSet = new Set(ids);
for (const id of ids) {
await brandApi.delete(id);
}
updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id)));
await actions.load(true);
},
// #region addPic
async addImage(id, file) {
const res = await brandApi.addImage(id, file);
await brandAutomat.actions.load(true);
return res.brand;
},
// #region delPic
async deleteImage(id) {
const res = await brandApi.deleteImage(id);
const updated = res.brand ?? { pics: [] };
updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b)));
return res.brand;
}
};
/**
* Global brand state container.
* @type {Automat<BrandState>}
*/
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) void brandAutomat.actions.load();
else if (auth.status === 'anon') brandAutomat.setState(initialState);
});
// #region onSse
sseAutomat.actions.on('brand', (event) => {
try {
const payload = JSON.parse(event?.data ?? '{}');
if (payload?.sender && payload.sender === getSseClientId()) return;
} catch { }
void brandAutomat.actions.load(true).catch(() => { });
});
export default brandAutomat;

View File

@@ -1,251 +0,0 @@
/**
* @file carrierAutomat.js
* @description Manages cached shipping carriers / Versandarten (`GET /api/carrier`) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event.
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions.
*/
import { Automat } from 'react-automat';
import { carrier as carrierApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} Carrier
* @property {number} id Carrier identifier.
* @property {string} name Carrier name.
* @property {number} sort Sort order.
* @property {boolean} hasPackages Whether carrier uses package tracking.
* @property {boolean} isReverse Whether carrier is used for returns.
*/
/**
* @typedef {Object} CarrierState
* @property {Array<Carrier>} carriers Company-scoped carriers.
* @property {Array<Carrier>} filteredCarriers Filtered carriers according to active filter query.
* @property {string} filter Active search/filter string.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|null} error Last load error.
*/
/** @type {CarrierState} */
const initialState = {
carriers: [],
filteredCarriers: [],
filter: '',
status: 'idle',
error: null,
};
let carrierAutomat;
const applyFilter = (carriers, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers;
};
const updateStateWithCarriers = (carriers, extra = {}) => {
const list = Array.isArray(carriers) ? carriers : [];
const filteredCarriers = applyFilter(list, carrierAutomat.state.filter);
carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra });
return list;
};
/**
* Fetches the company-scoped carrier list.
*
* @returns {Promise<Array<Carrier>>} Carrier list.
*/
async function fetchCarriers() {
const data = await carrierApi.list();
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.carriers)) return data.carriers;
return [];
}
/** In-flight `load()` promise so concurrent callers share one request. */
let inflightLoad = null;
/**
* Loads the carrier list once, deduplicating concurrent callers.
*
* @returns {Promise<Array<Carrier>>} Loaded carriers.
*/
function loadCarriers() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
carrierAutomat.setState({ status: 'loading', error: null });
try {
const carriers = await fetchCarriers();
updateStateWithCarriers(carriers, { status: 'ready', error: null });
return carriers;
} catch (error) {
carrierAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/** Sets the active carrier search filter and updates filteredCarriers. */
filter(query) {
const filter = String(query ?? '');
carrierAutomat.setState({
filter,
filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter),
});
},
/**
* Loads the carrier list, exposing a loading/failed lifecycle.
*
* @returns {Promise<Array<Carrier>>} Loaded carriers.
*/
load() {
return loadCarriers();
},
/**
* Silently re-fetches the carrier list while keeping the current state.
*
* @returns {Promise<Array<Carrier>>} Fresh carriers.
*/
async reload() {
try {
const carriers = await fetchCarriers();
updateStateWithCarriers(carriers, { status: 'ready', error: null });
return carriers;
} catch (error) {
carrierAutomat.setState({ error });
throw error;
}
},
/**
* Creates a carrier and refreshes the cached list.
*
* @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload.
* @returns {Promise<Carrier>} Created carrier.
*/
async create(body) {
const data = await carrierApi.create(body);
const created = data?.carrier || data;
await carrierAutomat.actions.reload();
return created;
},
/**
* Updates a carrier and refreshes the cached list.
*
* @param {string|number} id Carrier identifier.
* @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload.
* @returns {Promise<Carrier>} Updated carrier.
*/
async update(id, body) {
const data = await carrierApi.update(id, body);
const updated = data?.carrier || data;
await carrierAutomat.actions.reload();
return updated;
},
/**
* Deletes a carrier and refreshes the cached list.
*
* @param {string|number} id Carrier identifier.
* @returns {Promise<void>}
*/
async remove(id) {
await carrierApi.delete(id);
await carrierAutomat.actions.reload();
},
/**
* Persists a new sort order for all carriers.
*
* @param {Array<string|number>} carrierIds IDs in the desired order.
* @returns {Promise<void>}
*/
async reorder(carrierIds) {
const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c]));
const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean);
if (reordered.length === carrierAutomat.state.carriers.length) {
updateStateWithCarriers(reordered);
}
await carrierApi.order({ carrierIds });
await carrierAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Carrier>} carriers Carriers to cache.
* @returns {void}
*/
setCarriers(carriers) {
updateStateWithCarriers(carriers, {
status: 'ready',
error: null,
});
},
/**
* Forgets the cached list and returns to the initial state.
*
* @returns {void}
*/
clear() {
carrierAutomat.setState({ ...initialState });
},
};
/**
* Company-scoped carrier cache. Loads automatically once an authenticated
* session with a selected company exists and is cleared when auth is gone.
*
* @type {Automat<CarrierState>}
*/
carrierAutomat = new Automat(initialState, actions, { name: 'carrier' });
/**
* Derives the cache key from the auth state: the selected company while
* authenticated, otherwise `null`.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {string|null} Cache key.
*/
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
/**
* Loads the carrier list when auth/company becomes available and forgets it
* when the session ends.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {void}
*/
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
carrierAutomat.actions.clear();
} else {
void carrierAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
export default carrierAutomat;

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';
@@ -401,6 +403,28 @@ const actions = {
return res.category; return res.category;
}, },
/**
* Generate a mosaic image for a category from its articles and subcategories.
* @param {{ categoryId: number|string, size?: number }} param0
*/
async generateCategoryImage({ categoryId, size } = {}) {
const res = await categoryApi.generateImage(categoryId, { size });
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
const nextCategories = {
...categories,
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
};
categoryAutomat.setState({
categories: nextCategories,
});
refreshCategoryViewChildren(nextCategories);
return res.category;
},
// ─── Local state patches ─────────────────────────────────────────────────── // ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from all live category view lists (used after article deletion). */ /** Remove an article from all live category view lists (used after article deletion). */
@@ -430,6 +454,11 @@ const actions = {
refreshCategoryViewChildren(nextCategories); refreshCategoryViewChildren(nextCategories);
}, },
/** Invalidate all loaded category views. */
invalidateAllCategoryViews() {
invalidateAllCategoryViews();
},
/** Reset state to idle (logout / company switch). */ /** Reset state to idle (logout / company switch). */
reset() { reset() {
disposeAllCategoryViewAutomats(); disposeAllCategoryViewAutomats();
@@ -457,9 +486,9 @@ categoryAutomat.views = categoryViews;
// Load tree whenever the company data changes (or first becomes available) // Load tree whenever the company data changes (or first becomes available)
let _lastCompanyId = null; let _lastCompanyId = null;
companyAutomat.subscribe((company) => { function syncCategoryWithCompany(company) {
const id = company.current?.id ?? null; const id = company?.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null; const rootCategoryId = company?.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) { if (id && rootCategoryId && id !== _lastCompanyId) {
const isCompanySwitch = _lastCompanyId !== null; const isCompanySwitch = _lastCompanyId !== null;
_lastCompanyId = id; _lastCompanyId = id;
@@ -472,7 +501,10 @@ companyAutomat.subscribe((company) => {
_lastCompanyId = null; _lastCompanyId = null;
categoryAutomat.actions.reset(); categoryAutomat.actions.reset();
} }
}); }
companyAutomat.subscribe(syncCategoryWithCompany);
syncCategoryWithCompany(companyAutomat.state);
// Reset tree on logout // Reset tree on logout
authAutomat.subscribe((auth) => { authAutomat.subscribe((auth) => {
@@ -482,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,
@@ -502,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.
* *

File diff suppressed because it is too large Load Diff

View File

@@ -1,165 +0,0 @@
/**
* @file createListAutomat.js
* @description Factory function for company-scoped list automats with
* deduplicated `load()`, silent `reload()`, and automatic session synchronization.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes each created list automat to auth state — calls
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
*
* Used by:
* - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`,
* `entityAutomat`, `customergroupAutomat`, `numberAutomat`.
*
* NOT used by `articleIndexAutomat`: that store is derived from the loaded
* category views rather than fetched from a whole-company endpoint.
*/
import { Automat } from 'react-automat';
import authAutomat from './authAutomat';
/**
* @typedef {Object} ListAutomatState
* @property {Array<object>} items Loaded records (empty until the first success).
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error.
*/
/**
* Creates a company-scoped list store following the brand/unit/warehouse
* pattern: `load()` deduplicates concurrent callers, `reload()` refreshes
* silently, and `reset()` clears on logout.
*
* This replaces the stale-while-revalidate list caches that previously lived
* in `StorageManager` (articleIndex, delivery, quote, order, entity,
* customergroup, number).
*
* @template T
* @param {object} options Factory options.
* @param {string} options.name Automat registry name (also used for logging).
* @param {() => Promise<Array<T>>} options.fetch Loads the full record list.
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
* @returns {Automat<ListAutomatState>} Configured list automat instance.
*/
export function createListAutomat({ name, fetch: fetchItems, sort }) {
const initialState = { items: [], status: 'idle', error: null };
let automat;
/** In-flight `load()` promise so concurrent consumers share one request. */
let inflightLoad = null;
/**
* Fetches and normalizes the record list.
*
* @returns {Promise<Array<T>>} Records from the backend.
*/
const fetchRecords = async () => {
const items = await fetchItems();
return sort ? sort(items) : items;
};
const actions = {
/**
* Loads the list, deduplicating concurrent callers.
*
* @returns {Promise<Array<T>>} Loaded records.
*/
load() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
automat.setState({ status: 'loading', error: null });
try {
const items = await fetchRecords();
automat.setState({ items, status: 'ready', error: null });
return items;
} catch (error) {
automat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
/**
* Silently re-fetches the list while keeping the current state, so
* subscribers do not flash a loading state.
*
* @returns {Promise<Array<T>>} Fresh records.
*/
async reload() {
try {
const items = await fetchRecords();
automat.setState({ items, status: 'ready', error: null });
return items;
} catch (error) {
automat.setState({ error });
throw error;
}
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<T>} items Records to cache.
* @returns {Array<T>} Cached records.
*/
replace(items) {
automat.setState({ items, status: 'ready', error: null });
return items;
},
/**
* Re-fetches the list once the selected company is known.
*
* @returns {Promise<Array<T>>} Loaded records.
*/
ensureLoaded() {
if (automat.state.status === 'ready') {
return Promise.resolve(automat.state.items);
}
return actions.load();
},
/** Reset to idle state (called on logout). */
reset() {
inflightLoad = null;
automat.setState(initialState);
},
};
automat = new Automat(initialState, actions, { name });
// Load on session/company establishment, reset on logout.
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {});
} else if (auth.status === 'anon') {
actions.reset();
}
});
return automat;
}
/**
* Selects the record list of a list automat.
*
* @param {Automat<ListAutomatState>} automat List automat.
* @returns {Array<object>} Current records.
*/
export function selectListItems(automat) {
return automat.state.items;
}
/**
* Selects the load status of a list automat.
*
* @param {Automat<ListAutomatState>} automat List automat.
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
*/
export function selectListStatus(automat) {
return automat.state.status;
}

View File

@@ -1,197 +0,0 @@
/**
* @file customergroupAutomat.js
* @description Manages cached customer groups (`GET /api/customergroup`) for
* the active company. Follows the brand/warehouse automat pattern with
* filtering, deduplicated loads, silent reloads, and CRUD actions.
*
* Upstream Triggers:
* - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`,
* and `actions.reset()` on `'anon'`.
* - UI / Routes: Mutating actions (`create`, `update`, `remove`, `reorder`, `filter`).
*/
import { Automat } from 'react-automat';
import { customergroup as customergroupApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
/**
* @typedef {Object} CustomerGroup
* @property {string|number} id Customer group id.
* @property {string} name Group name.
* @property {number} sort Sort index.
* @property {boolean} editable False for reserved POS / Lieferanten groups.
*/
/**
* @typedef {Object} CustomerGroupState
* @property {Array<CustomerGroup>} items Loaded records.
* @property {Array<CustomerGroup>} filteredItems Records matching active search filter.
* @property {string} filter Active search/filter string.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|string|null} error Last load error.
*/
/** @type {CustomerGroupState} */
const initialState = {
items: [],
filteredItems: [],
filter: '',
status: 'idle',
error: null,
};
const applyFilter = (groups, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
return query
? groups.filter((g) => String(g.name ?? '').toLowerCase().includes(query))
: groups;
};
const sortGroups = (items) =>
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
let customergroupAutomat;
const updateStateWithGroups = (groups, extra = {}) => {
const list = sortGroups(Array.isArray(groups) ? groups : []);
const filteredItems = applyFilter(list, customergroupAutomat.state.filter);
customergroupAutomat.setState({ items: list, filteredItems, ...extra });
return list;
};
async function fetchGroups() {
const data = await customergroupApi.list();
requireObject(data, 'GET /api/customergroup');
return requireArray(data.customergroups, 'customergroups');
}
let inflightLoad = null;
function loadGroups() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
customergroupAutomat.setState({ status: 'loading', error: null });
try {
const groups = await fetchGroups();
updateStateWithGroups(groups, { status: 'ready', error: null });
return groups;
} catch (error) {
customergroupAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/** Sets the active search filter query and updates filteredItems. */
filter(query) {
const filter = String(query ?? '');
customergroupAutomat.setState({
filter,
filteredItems: applyFilter(customergroupAutomat.state.items, filter),
});
},
/** Loads the list, deduplicating concurrent callers. */
load() {
return loadGroups();
},
/** Silently refreshes the customer groups without flashing loading state. */
async reload() {
try {
const groups = await fetchGroups();
updateStateWithGroups(groups, { status: 'ready', error: null });
return groups;
} catch (error) {
customergroupAutomat.setState({ error });
throw error;
}
},
/** Replaces the cached list without an API call. */
replace(items) {
return updateStateWithGroups(items, { status: 'ready', error: null });
},
/** Returns cached items if ready, otherwise loads. */
ensureLoaded() {
if (customergroupAutomat.state.status === 'ready') {
return Promise.resolve(customergroupAutomat.state.items);
}
return actions.load();
},
/** Resets state to initial idle values. */
reset() {
inflightLoad = null;
customergroupAutomat.setState(initialState);
},
/** Creates a customer group and refreshes the cache. */
async create(body) {
const data = await customergroupApi.create(body);
requireObject(data, 'POST /api/customergroup');
requireObject(data.customergroup, 'customergroup');
requireDefined(data.customergroup.id, 'customergroup.id');
await customergroupAutomat.actions.reload();
return data.customergroup;
},
/** Updates a customer group name and refreshes the cache. */
async update(id, body) {
const data = await customergroupApi.update(id, body);
requireObject(data, 'PATCH /api/customergroup/:id');
requireObject(data.customergroup, 'customergroup');
requireDefined(data.customergroup.id, 'customergroup.id');
await customergroupAutomat.actions.reload();
return data.customergroup;
},
rename(id, body) {
return actions.update(id, body);
},
/** Deletes a customer group and refreshes the cache. */
async remove(id) {
await customergroupApi.delete(id);
await customergroupAutomat.actions.reload();
},
/** Reorders customer groups and refreshes the cache. */
async reorder(customergroupIds) {
const data = await customergroupApi.order({ customergroupIds });
requireObject(data, 'PUT /api/customergroup/order');
await customergroupAutomat.actions.reload();
},
};
/**
* Company customer groups (`GET /api/customergroup`).
*
* Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must
* not be renamed or deleted by the UI.
*
* @type {Automat<CustomerGroupState>}
*/
customergroupAutomat = new Automat(initialState, actions, {
name: 'customergroup',
});
export { customergroupAutomat };
// Load on session/company establishment, reset on logout.
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {});
} else if (auth.status === 'anon') {
actions.reset();
}
});
export default customergroupAutomat;

View File

@@ -14,7 +14,7 @@ import { Automat } from 'react-automat';
import { entity as entityApi } from '../api'; import { entity as entityApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import customergroupAutomat from './customergroupAutomat'; import companyAutomat from './companyAutomat';
/** /**
* @typedef {Object} Entity * @typedef {Object} Entity
@@ -48,7 +48,7 @@ const initialState = {
const applyFilter = (entities, filter) => { const applyFilter = (entities, filter) => {
const query = String(filter ?? '').trim().toLowerCase(); const query = String(filter ?? '').trim().toLowerCase();
if (!query) return entities; if (!query) return entities;
const groups = customergroupAutomat?.state?.items ?? []; const groups = companyAutomat?.state?.customergroups ?? [];
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g])); const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
return entities.filter((e) => { return entities.filter((e) => {
@@ -200,13 +200,15 @@ entityAutomat = new Automat(initialState, actions, {
export { entityAutomat }; export { entityAutomat };
// Load on session/company establishment, reset on logout. // Load on session/company establishment, reset on logout.
authAutomat.subscribe((auth) => { const syncEntityWithAuth = (auth) => {
const companyId = auth.user?.selectedCompanyId; const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) { if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {}); void actions.load().catch(() => {});
} else if (auth.status === 'anon') { } else if (auth.status === 'anon') {
actions.reset(); actions.reset();
} }
}); };
authAutomat.subscribe(syncEntityWithAuth);
syncEntityWithAuth(authAutomat.state);
export default entityAutomat; export default entityAutomat;

View File

@@ -1,6 +1,6 @@
/** /**
* @file index.js * @file index.js
* @description Central barrel exporting all domain, configuration, and list * @description Central barrel exporting domain, configuration, and list
* automats, along with their action creators and reactive selectors. * automats, along with their action creators and reactive selectors.
*/ */
@@ -22,9 +22,6 @@ export {
clearNavigation, clearNavigation,
} from './navigationAutomat'; } from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as helpAutomat } from './helpAutomat'; export { default as helpAutomat } from './helpAutomat';
export { export {
toggleHelp, toggleHelp,
@@ -44,10 +41,6 @@ export {
applyPwaUpdate, applyPwaUpdate,
} from './pwaAutomat'; } from './pwaAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat';
export { default as carrierAutomat } from './carrierAutomat';
/** Article search over the derived index (replaces StorageManager.doSearch). */ /** Article search over the derived index (replaces StorageManager.doSearch). */
export { export {
default as articleSearchAutomat, default as articleSearchAutomat,
@@ -63,6 +56,7 @@ export {
default as articleIndexAutomat, default as articleIndexAutomat,
setArticleIndexName, setArticleIndexName,
setArticleIndexStock, setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex, buildArticleIndex,
selectArticleIndexItems, selectArticleIndexItems,
} from './articleIndexAutomat'; } from './articleIndexAutomat';
@@ -98,9 +92,20 @@ export {
subscribeChunkStats, subscribeChunkStats,
} from './orderAutomat'; } from './orderAutomat';
export { default as entityAutomat } from './entityAutomat'; export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat'; export {
export { default as numberAutomat } from './numberAutomat'; default as retoureAutomat,
export { default as accountAutomat } from './accountAutomat'; selectRetoures,
selectRetoureTotal,
selectRetoureUnfilteredTotal,
isRetourePaid,
isRetoureShipped,
isRetoureDelivered,
getRetoureDate,
getItems as getRetoureItems,
requireChunk as requireRetoureChunk,
getChunkStats as getRetoureChunkStats,
subscribeChunkStats as subscribeRetoureChunkStats,
} from './retoureAutomat';
export { default as backupAutomat } from './backupAutomat'; export { default as backupAutomat } from './backupAutomat';
export { export {
default as shipmentAutomat, default as shipmentAutomat,
@@ -113,11 +118,9 @@ export {
getChunkStats as getShipmentChunkStats, getChunkStats as getShipmentChunkStats,
subscribeChunkStats as subscribeShipmentChunkStats, subscribeChunkStats as subscribeShipmentChunkStats,
} from './shipmentAutomat'; } from './shipmentAutomat';
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
/** Per-SKU article detail views keyed by SKU. */ /** Per-SKU article detail views keyed by SKU. */
export { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat'; export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
/** Single shared GET /api/events connection. */ /** Single shared GET /api/events connection. */
export { default as sseAutomat, invalidateArticle } from './sseAutomat'; export { default as sseAutomat, invalidateArticle } from './sseAutomat';
@@ -132,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,
@@ -142,19 +146,45 @@ export {
selectCompanyReducedTaxrate, selectCompanyReducedTaxrate,
selectCompanyTaxfreerate, selectCompanyTaxfreerate,
selectCompanyRootCategoryId, selectCompanyRootCategoryId,
} from './companyAutomat'; selectNumbers,
selectNumbersStatus,
export { default as brandAutomat } from './brandAutomat';
export { default as unitAutomat } from './unitAutomat';
export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat';
export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat';
export { default as posterminalAutomat } from './posterminalAutomat';
export {
selectPosterminals, selectPosterminals,
selectPosterminalStatus, selectPosterminalStatus,
selectHasPosterminals, selectHasPosterminals,
} from './posterminalAutomat'; selectPrinterAgents,
selectPrinterAgentStatus,
selectHasPrinterAgents,
selectAgentPrinters,
selectAgentPrinterStatus,
selectLivePrinters,
selectSuppliers,
selectSuppliersStatus,
selectUnits,
selectUnitsStatus,
setViewerPosition,
setChatDrawerWidth,
selectChatDrawerWidth,
selectWarehouses,
selectWarehousesStatus,
selectProperties,
selectPropertyStatus,
selectCustomergroups,
selectCustomergroupsStatus,
selectCarriers,
selectCarriersStatus,
selectBrands,
selectBrandsStatus,
selectAttributes,
selectAttributeStatus,
selectAccounts,
selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat'; export { default as categoryAutomat } from './categoryAutomat';
export { export {
@@ -218,84 +248,3 @@ export {
closeExecution, closeExecution,
normalizeRoute, normalizeRoute,
} from './pinnedAppletAutomat'; } from './pinnedAppletAutomat';
/**
* ============================================================================
* Overview of Automats in Dashfront
* ============================================================================
*
* Automats are lightweight, reactive, out-of-tree state containers powered by
* `react-automat`. They manage all client-side state without React context,
* hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent`
* via constructor reads and fine-grained subscriptions.
*
* 1. Multi-Tenant Session & Security
* ----------------------------------------------------------------------------
* - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`,
* `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`).
* Coordinates session bootstrap, company switching, and acts as the root
* lifecycle trigger that loads or resets all company-scoped automats.
*
* 2. Real-Time SSE Hub
* ----------------------------------------------------------------------------
* - `sseAutomat`: Manages the single persistent Server-Sent Events connection
* (`GET /api/events`). Automatically reconnects when the active company changes
* and terminates on logout. Dispatches incoming server mutation signals into
* targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`,
* `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`,
* `propertyAutomat`).
*
* 3. Domain & Entity Caches
* ----------------------------------------------------------------------------
* - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`)
* with inflight request deduplication, optimistic renames, and reactive selectors.
* - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`)
* providing per-SKU supplier catalog bindings and change history.
* - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts
* and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR).
*
* 4. Company Configuration & Catalogs
* ----------------------------------------------------------------------------
* - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID).
* - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create,
* rename, move, reorder, delete), and breadcrumb resolution.
* - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child
* categories per slug path, auto-preloaded when category tree finishes loading.
* - `brandAutomat`: Catalog manufacturer and brand records.
* - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.).
* - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters.
* - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color).
* - `propertyAutomat`: Custom product property and metadata names.
* - `supplierAutomat`: Directory of vendor entities and supplier article mappings.
* - `warehouseAutomat`: Warehouse locations and stock storage areas.
*
* 5. Company-Scoped List Automats (`createListAutomat`)
* ----------------------------------------------------------------------------
* Built with the shared list factory with deduplicated `load()`, silent `reload()`,
* and automatic synchronization with `authAutomat`:
* - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`).
* - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`).
* - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`).
* - `entityAutomat`: Customers and business partners (`GET /api/entity`).
* - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`).
* - `numberAutomat`: Document numbering sequences (`GET /api/number`).
*
* 6. Search & Infinite-Scroll Grid Automats
* ----------------------------------------------------------------------------
* - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded
* category views (`GET /api/category/:id/articles`) — no dedicated index endpoint.
* - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`.
* - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history.
* - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs.
*
* 7. UI State & Utilities
* ----------------------------------------------------------------------------
* - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens.
* - `navigationAutomat`: Persists last visited delivery document path and article SKU.
* - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB).
* - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB).
* ============================================================================
*/

View File

@@ -224,14 +224,21 @@ export const orderDraftAutomat = createManifestDraftAutomat({
name: 'order-draft', name: 'order-draft',
}); });
/** Shared singleton automat for retoure draft. */
export const retoureDraftAutomat = createManifestDraftAutomat({
kind: 'retoure',
name: 'retoure-draft',
});
/** /**
* Returns the draft automat for a given manifest kind. * Returns the draft automat for a given manifest kind.
* @param {'quote'|'order'} kind * @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null} * @returns {Automat|null}
*/ */
export function getManifestDraftAutomat(kind) { export function getManifestDraftAutomat(kind) {
if (kind === 'quote') return quoteDraftAutomat; if (kind === 'quote') return quoteDraftAutomat;
if (kind === 'order') return orderDraftAutomat; if (kind === 'order') return orderDraftAutomat;
if (kind === 'retoure') return retoureDraftAutomat;
return null; return null;
} }
@@ -423,6 +430,7 @@ export function createManifestEditAutomat({ kind, name }) {
const actions = { const actions = {
loadDoc(id, doc) { loadDoc(id, doc) {
const date = dateInputValue(doc.date); const date = dateInputValue(doc.date);
const createdAt = doc.createdAt ?? doc.created_at ?? null;
const partyId = doc.partyId ?? null; const partyId = doc.partyId ?? null;
const number = doc.number ?? ''; const number = doc.number ?? '';
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : ''; const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
@@ -431,6 +439,7 @@ export function createManifestEditAutomat({ kind, name }) {
const baseline = { const baseline = {
date, date,
createdAt,
partyId, partyId,
number, number,
externalNumber, externalNumber,
@@ -443,6 +452,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit', mode: 'edit',
id, id,
date, date,
createdAt,
partyId, partyId,
number, number,
externalNumber, externalNumber,
@@ -451,10 +461,10 @@ export function createManifestEditAutomat({ kind, name }) {
shipments: doc.shipments || [], shipments: doc.shipments || [],
payments: doc.payments || [], payments: doc.payments || [],
shippings: doc.shippings || [], shippings: doc.shippings || [],
orderTotal: doc.orderTotal ?? null, orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null,
paidTotal: doc.paidTotal ?? null, paidTotal: doc.paidTotal ?? doc.paid_total ?? null,
paid: Boolean(doc.paid), paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
shipped: Boolean(doc.shipped), shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
delivered: Boolean(doc.delivered), delivered: Boolean(doc.delivered),
baseline, baseline,
isDirty: false, isDirty: false,
@@ -508,6 +518,7 @@ export function createManifestEditAutomat({ kind, name }) {
commitSaved(savedDoc) { commitSaved(savedDoc) {
const date = dateInputValue(savedDoc.date); const date = dateInputValue(savedDoc.date);
const createdAt = savedDoc.createdAt ?? savedDoc.created_at ?? automat.state.createdAt ?? null;
const partyId = savedDoc.partyId ?? null; const partyId = savedDoc.partyId ?? null;
const number = savedDoc.number ?? automat.state.number; const number = savedDoc.number ?? automat.state.number;
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : ''; const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
@@ -516,6 +527,7 @@ export function createManifestEditAutomat({ kind, name }) {
const baseline = { const baseline = {
date, date,
createdAt,
partyId, partyId,
number, number,
externalNumber, externalNumber,
@@ -525,6 +537,7 @@ export function createManifestEditAutomat({ kind, name }) {
automat.setState({ automat.setState({
date, date,
createdAt,
partyId, partyId,
number, number,
externalNumber, externalNumber,
@@ -545,6 +558,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit', mode: 'edit',
id: null, id: null,
date: '', date: '',
createdAt: null,
partyId: null, partyId: null,
number: '', number: '',
externalNumber: '', externalNumber: '',
@@ -566,6 +580,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit', mode: 'edit',
id: null, id: null,
date: '', date: '',
createdAt: null,
partyId: null, partyId: null,
number: '', number: '',
externalNumber: '', externalNumber: '',
@@ -597,13 +612,20 @@ export const orderEditAutomat = createManifestEditAutomat({
name: 'order-edit', name: 'order-edit',
}); });
/** Shared singleton automat for the single last edited retoure. */
export const retoureEditAutomat = createManifestEditAutomat({
kind: 'retoure',
name: 'retoure-edit',
});
/** /**
* Returns the edit automat for a given manifest kind. * Returns the edit automat for a given manifest kind.
* @param {'quote'|'order'} kind * @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null} * @returns {Automat|null}
*/ */
export function getManifestEditAutomat(kind) { export function getManifestEditAutomat(kind) {
if (kind === 'quote') return quoteEditAutomat; if (kind === 'quote') return quoteEditAutomat;
if (kind === 'order') return orderEditAutomat; if (kind === 'order') return orderEditAutomat;
if (kind === 'retoure') return retoureEditAutomat;
return null; return null;
} }

View File

@@ -1,41 +0,0 @@
/**
* @file numberAutomat.js
* @description Manages cached document numbering sequences (`GET /api/number`)
* (e.g. delivery, entity) for the active company. Built via `createListAutomat`.
*
* Upstream Triggers:
* - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with
* a `selectedCompanyId`, and `reset()` on `'anon'`.
* - UI / Routes: Manual calls to `load()` or `reload()`.
*/
import { number as numberApi } from '../api';
import { createListAutomat } from './createListAutomat';
/**
* @typedef {Object} NumberSequence
* @property {string|number} id Sequence id.
* @property {string} type Document type key (e.g. `delivery`, `entity`).
* @property {string} prefix Prefix rendered before the counter.
* @property {number} padding Zero-padding width.
* @property {number} next Next counter value.
*/
/**
* Company document number sequences (`GET /api/number`).
*
* @type {import('react-automat').Automat<import('./createListAutomat').ListAutomatState>}
*/
export const numberAutomat = createListAutomat({
name: 'number',
fetch: async () => {
const data = await numberApi.list();
return data.numbers ?? [];
},
sort: (items) =>
[...items].sort((a, b) =>
String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'),
),
});
export default numberAutomat;

View File

@@ -1,170 +0,0 @@
/**
* @file posterminalAutomat.js
* @description Manages registered Point-of-Sale (POS) hardware terminals and
* their enabled category mappings for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading.
* - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`).
*/
import { Automat } from 'react-automat';
import { posterminal as posterminalApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} PosterminalState
* @property {Array<object>} terminals
* @property {number|string|null} companyId
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PosterminalState} */
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
let posterminalAutomat;
const actions = {
/** Load all posterminals for the selected company. */
async load() {
const companyId = authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
posterminalAutomat.setState({ status: 'loading', error: null });
try {
const res = await posterminalApi.list();
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
return terminals;
} catch (err) {
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create a new posterminal.
* @param {{ name: string }} param0
*/
async create({ name }) {
const res = await posterminalApi.create({ name });
const terminal = res.posterminal;
posterminalAutomat.setState({
terminals: [...posterminalAutomat.state.terminals, terminal],
});
return terminal;
},
/**
* Update a posterminal.
* @param {number} terminalId
* @param {object} patch
*/
async update(terminalId, patch) {
const res = await posterminalApi.update(terminalId, patch);
const updated = res.posterminal;
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, ...updated } : t
),
});
return updated;
},
/**
* Delete a posterminal.
* @param {number} terminalId
*/
async delete(terminalId) {
await posterminalApi.delete(terminalId);
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
});
},
/**
* Enable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async enableAll({ terminalId }) {
const res = await posterminalApi.enableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async disableAll({ terminalId }) {
const res = await posterminalApi.disableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Enable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async enableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.enableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async disableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.disableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
posterminalAutomat.setState(initialState);
},
};
function patchTerminalCategories(terminalId, categoryIds) {
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, categoryIds } : t,
),
});
}
/**
* Global posterminal state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<PosterminalState>}
*/
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void posterminalAutomat.actions.load();
} else if (auth.status === 'anon') {
posterminalAutomat.actions.reset();
}
});
// Selectors
export const selectPosterminals = () => posterminalAutomat.state.terminals;
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
export default posterminalAutomat;

View File

@@ -1,153 +0,0 @@
/**
* @file propertyAutomat.js
* @description Manages catalog product property names (custom metadata definitions)
* for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event.
* - UI / Settings: Mutating actions (`createProperty`, `updateProperty`,
* `deleteProperty`, `reorderProperties`).
*/
import { Automat } from 'react-automat';
import { propertyname as propertynameApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} PropertyName
* @property {number} id
* @property {string} name
* @property {number} sort
*/
/**
* @typedef {Object} PropertyState
* @property {PropertyName[]} properties
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PropertyState} */
const initialState = {
properties: [],
status: 'idle',
error: null,
};
function normalizeProperties(properties) {
if (!Array.isArray(properties)) return [];
return [...properties]
.map((p) => ({
...p,
exclusive: Boolean(p.exclusive),
}))
.sort((a, b) => {
const sDiff = (a.sort ?? 0) - (b.sort ?? 0);
if (sDiff !== 0) return sDiff;
return (a.id ?? 0) - (b.id ?? 0);
});
}
let propertyAutomat;
const actions = {
/** Load all property names for the selected company. */
async load() {
propertyAutomat.setState({ status: 'loading', error: null });
try {
const res = await propertynameApi.list();
const properties = normalizeProperties(res.propertynames);
propertyAutomat.setState({ properties, status: 'succeeded' });
return properties;
} catch (err) {
propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create a property name.
* @param {{ name: string, sort?: number, exclusive?: boolean }} body
*/
async createProperty(body) {
const res = await propertynameApi.create(body);
await actions.load();
return res.propertyname;
},
/**
* Update a property name.
* @param {number} id
* @param {{ name?: string, sort?: number, exclusive?: boolean }} body
*/
async updateProperty(id, body) {
const res = await propertynameApi.update(id, body);
await actions.load();
return res.propertyname;
},
/**
* Delete a property name.
* @param {number} id
*/
async deleteProperty(id) {
await propertynameApi.delete(id);
await actions.load();
},
/**
* Delete multiple property names.
* @param {number[]} ids
*/
async deleteProperties(ids) {
for (const id of ids) {
await propertynameApi.delete(id);
}
await actions.load();
},
/**
* Reorder property names.
* @param {number[]} propertynameIds
*/
async reorderProperties(propertynameIds) {
await propertynameApi.order(propertynameIds);
await actions.load();
},
/** Reset to idle state (called on logout / company switch). */
reset() {
propertyAutomat.setState(initialState);
},
};
/**
* Global property state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<PropertyState>}
*/
propertyAutomat = new Automat(initialState, actions, { name: 'property' });
// Selectors
export function selectProperties() {
return propertyAutomat.state.properties;
}
export function selectPropertyStatus() {
return propertyAutomat.state.status;
}
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void propertyAutomat.actions.load();
} else if (auth.status === 'anon') {
propertyAutomat.actions.reset();
}
});
export default propertyAutomat;

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,27 @@
/** /**
* @file sseAutomat.js * @file sseAutomat.js
* @description Central Server-Sent Events hub managing the single persistent * @description Central Server-Sent Events hub managing the single persistent
* `GET /api/events` connection and fanning mutation signals into domain automats. * `GET /api/events` connection and fanning mutation signals into active automats.
* *
* Upstream Triggers: * Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — reconnects stream when * - `authAutomat`: Connects stream when `hasAuth` is true (and reconnects when
* `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout). * `selectedCompanyId` changes); disconnects when `hasAuth` is false (logout).
* *
* Downstream Triggers: * Downstream Triggers:
* Dispatches real-time SSE mutation events into specific automats: * Dispatches real-time SSE mutation events into active automats without eagerly
* - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-<sku>` signals. * importing or instantiating unmounted stores:
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`): * - `companyAutomat`: Refreshes warehouses, carriers, attributes, properties,
* Reloads SKU-keyed views on `article-<sku>` signals. * suppliers, and brands when `companyAutomat` is active.
* - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()` * - `categoryAutomat`: Refreshes category tree and open category views on `'category'`.
* on `'category'` SSE events. * - `articleIndexAutomat`: Refreshes derived article index on `'articles'` / `'warehouse'`.
* - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events; * - `artikelAutomat` & `articleDetailAutomat`: Refreshes watched SKU on `article-<sku>`.
* the index is derived from the reloaded category views, so no extra request. * - `orderAutomat`, `quoteAutomat`, `shipmentAutomat`, `deliveryAutomat`, `retoureAutomat`:
* - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events. * Reloads when those automats are active.
* - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events.
* - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events.
* - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events.
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import authAutomat from './authAutomat'; import authAutomat from './authAutomat';
import artikelAutomat from './artikelAutomat';
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat'; import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
import { articleIndexAutomat } from './articleIndexAutomat';
import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat';
import warehouseAutomat from './warehouseAutomat';
import attributeAutomat from './attributeAutomat';
import propertyAutomat from './propertyAutomat';
import supplierAutomat from './supplierAutomat';
import deliveryAutomat from './deliveryAutomat';
import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
import shipmentAutomat from './shipmentAutomat';
import carrierAutomat from './carrierAutomat';
/** /**
* @typedef {Object} SseState * @typedef {Object} SseState
@@ -45,10 +29,10 @@ import carrierAutomat from './carrierAutomat';
* @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;
const SSE_CLIENT_ID_KEY = 'sse.cid'; const SSE_CLIENT_ID_KEY = 'sse.cid';
function createClientId() { function createClientId() {
@@ -62,8 +46,6 @@ function createClientId() {
/** /**
* Returns the tab-scoped SSE client id, creating and persisting it on first use. * Returns the tab-scoped SSE client id, creating and persisting it on first use.
* Created when the automat module is evaluated so every `EventSource` in this
* tab shares the same `cid`.
* *
* @returns {string} Client id. * @returns {string} Client id.
*/ */
@@ -80,7 +62,6 @@ export function getSseClientId() {
} }
} }
// Created once per tab at automat creation time and stored to sessionStorage.
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null; const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
/** @type {SseState} */ /** @type {SseState} */
@@ -89,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. */
@@ -97,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();
@@ -120,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 {
@@ -146,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) {
@@ -168,144 +302,178 @@ function attachHandlers(es) {
} }
} }
/**
* Binds the single `article-<sku>` listener for a watched SKU.
*
* The listener resolves the handler set at call time, so a SKU that lost all
* watchers turns into a cheap no-op instead of needing removal. Listeners are
* reclaimed whenever the stream is rebuilt on a company switch.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
function attachArticleListener(sku) { 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);
}
}); });
} }
function connect() { async function startFetchStream(url, companyId) {
const companyId = authAutomat.state.user?.selectedCompanyId; if (streamAbortController) {
if (companyId == null) { streamAbortController.abort();
disconnect(); 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; return;
} }
throw new Error(`SSE HTTP ${res.status}`);
}
const url = new URL('/api/events', window.location.origin); if (!res.body) {
if (sseClientId) url.searchParams.set('cid', sseClientId); throw new Error('Response body is null');
console.log(`[sse] connecting`); }
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
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 }); const es = new EventSource(url.href, { withCredentials: true });
eventSource = es;
// Category tree changed: refresh the tree and every open category view.
addTrackedListener(es, 'category', () => {
void categoryAutomat.actions.loadTree().catch((err) => {
console.error('[sse] category tree refresh failed', err);
});
invalidateAllCategoryViews();
});
// Article index changed: refresh the index and open category views.
addTrackedListener(es, 'articles', () => {
invalidateAllCategoryViews();
void articleIndexAutomat.actions.reload().catch((err) => {
console.error('[sse] article index refresh failed', err);
});
});
addTrackedListener(es, 'warehouse', () => {
void warehouseAutomat.actions.reload().catch((err) => {
console.error('[sse] warehouse refresh failed', err);
});
invalidateAllCategoryViews();
void articleIndexAutomat.actions.reload().catch((err) => {
console.error('[sse] article index refresh on warehouse event failed', err);
});
});
addTrackedListener(es, 'carrier', () => {
void carrierAutomat.actions.reload().catch((err) => {
console.error('[sse] carrier refresh failed', err);
});
});
addTrackedListener(es, 'attributename', () => {
void attributeAutomat.actions.load().catch((err) => {
console.error('[sse] attributename refresh failed', err);
});
});
addTrackedListener(es, 'propertyname', () => {
void propertyAutomat.actions.load().catch((err) => {
console.error('[sse] propertyname refresh failed', err);
});
});
addTrackedListener(es, 'supplier', () => {
void supplierAutomat.actions.reload().catch((err) => {
console.error('[sse] supplier refresh failed', err);
});
});
addTrackedListener(es, 'order', () => {
void orderAutomat.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
void shipmentAutomat.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
});
addTrackedListener(es, 'quote', () => {
void quoteAutomat.actions.reload().catch((err) => {
console.error('[sse] quote refresh failed', err);
});
});
addTrackedListener(es, 'shipment', () => {
void shipmentAutomat.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
void orderAutomat.actions.reload().catch((err) => {
console.error('[sse] order refresh failed', err);
});
});
addTrackedListener(es, 'delivery', () => {
void deliveryAutomat.actions.reload().catch((err) => {
console.error('[sse] delivery refresh failed', err);
});
});
addTrackedListener(es, 'open', () => { addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' }); sseAutomat.setState({ status: 'connected', companyId });
}); });
addTrackedListener(es, 'message', () => {}); addTrackedListener(es, 'user', (event) => {
dispatchSseEvent(event);
});
addTrackedListener(es, 'message', (event) => {
dispatchSseEvent(event);
});
// EventSource reconnects on its own; only surface the state.
es.onerror = (e) => { es.onerror = (e) => {
console.error('[sse] event stream error', e); console.error('[sse] event stream error', e);
recordEvent(e || { type: 'error', data: '', lastEventId: '' }); recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' }); sseAutomat.setState({ status: 'error' });
}; };
eventSource = es;
attachHandlers(es); attachHandlers(es);
} }
// #region connect
// MARK: opens GET /api/events stream when hasAuth is true
function connect() {
clearReconnectTimer();
const auth = authAutomat.state;
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
if (!hasAuth) {
disconnect();
return;
}
const companyId =
auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null;
const url = new URL('/api/events', window.location.origin);
if (sseClientId) url.searchParams.set('cid', sseClientId);
console.log('[sse] connecting');
sseAutomat.setState({ status: 'connecting', companyId });
if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
void startFetchStream(url, companyId);
} else {
startEventSource(url, companyId);
}
}
// #endregion
let wasAuthed = false;
function handleAuthSync(auth) {
const isAuthed = Boolean(auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user));
const companyId =
isAuthed && auth?.user?.selectedCompanyId != null
? String(auth.user.selectedCompanyId)
: null;
const relogin = isAuthed && !wasAuthed;
wasAuthed = isAuthed;
if (!isAuthed) {
disconnect();
sseAutomat.setState({ companyId: null });
return;
}
if (!relogin && (streamAbortController || eventSource) && companyId === sseAutomat.state.companyId) {
return;
}
disconnect();
sseAutomat.setState({ companyId });
connect();
}
const actions = { const actions = {
/** /**
* Closes and reopens the event stream. Required after a company switch, * Closes and reopens the event stream.
* because the stream is scoped to the selected company at connect time.
* *
* @returns {void} * @returns {void}
*/ */
@@ -314,18 +482,25 @@ const actions = {
connect(); connect();
}, },
/**
* Synchronizes the SSE connection with `authAutomat` state.
*
* @param {import('./authAutomat').AuthState} authState
*/
syncWithAuth(authState) {
handleAuthSync(authState ?? authAutomat.state);
},
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 `*`.
* *
* Registration order does not matter: handlers added while disconnected are * @param {string} event Event name or pattern.
* attached on the next connect. * @param {Function} handler Signal handler.
*
* @param {string} event Event name (e.g. `category`, `articles`).
* @param {(event: MessageEvent) => void} handler Signal handler.
* @returns {() => void} Unsubscribe callback. * @returns {() => void} Unsubscribe callback.
*/ */
on(event, handler) { on(event, handler) {
@@ -338,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?.();
@@ -348,40 +525,61 @@ const actions = {
}, },
/** /**
* Subscribes to `article-<sku>` signals for one SKU. * Subscribes to any article mutation event (`article-<sku>`).
* *
* The backend emits this event whenever that article changes. Exactly one * @param {(sku: string, event: object) => void} handler Called with changed SKU.
* listener is attached per SKU while at least one caller is subscribed. * @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,
});
},
}; };
/** /**
* Global `GET /api/events` connection. * Global `GET /api/events` connection.
* *
* Owns the single `EventSource` for the tab and dispatches signals into the
* domain automats. Reconnects whenever the selected company changes.
*
* @type {Automat<SseState>} * @type {Automat<SseState>}
*/ */
sseAutomat = new Automat(initialState, actions, { name: 'sse' }); sseAutomat = new Automat(initialState, actions, { name: 'sse' });
@@ -389,48 +587,23 @@ sseAutomat = new Automat(initialState, actions, { name: 'sse' });
/** /**
* Refreshes the cached article plus any open history/supplier view. * Refreshes the cached article plus any open history/supplier view.
* *
* Background refreshes stay silent: an article that is not on screen has no
* visible subscriber to notify, so failures are swallowed.
*
* @param {string} sku Article SKU. * @param {string} sku Article SKU.
* @returns {void} * @returns {void}
*/ */
export function invalidateArticle(sku) { export function invalidateArticle(sku) {
const key = String(sku); const key = String(sku);
void artikelAutomat.actions.reloadArticle(key).catch(() => {}); const art = Automat.get('artikel');
if (art?.actions?.reloadArticle) {
void art.actions.reloadArticle(key).catch(() => {});
}
if (articleHistoryAutomat.has(key)) { if (articleHistoryAutomat.has(key)) {
void articleHistoryAutomat.reload(key).catch(() => {}); void articleHistoryAutomat.reload(key).catch(() => {});
} }
if (articleSupplierAutomat.has(key)) { // Reload assigned suppliers and sync with companyAutomat
void articleSupplierAutomat.reload(key).catch(() => {}); void articleSupplierAutomat.reload(key).catch(() => {});
} }
}
// Reconnect when the selected company changes; disconnect on logout. authAutomat.subscribe(handleAuthSync);
// On logout the stream must close; on relogin (anon -> authed with a company) handleAuthSync(authAutomat.state);
// the stream is re-established even if the company id matches the previous one.
let wasAuthed = false;
authAutomat.subscribe((auth) => {
const companyId =
auth.status === 'authed' && auth.user?.selectedCompanyId != null
? String(auth.user.selectedCompanyId)
: null;
const isAuthed = companyId != null;
const relogin = isAuthed && !wasAuthed;
wasAuthed = isAuthed;
if (!relogin && companyId === sseAutomat.state.companyId) return;
if (companyId == null) {
disconnect();
sseAutomat.setState({ companyId: null });
return;
}
// A different company (or a fresh login) means a different stream scope.
disconnect();
sseAutomat.setState({ companyId });
connect();
});
export default sseAutomat; export default sseAutomat;

View File

@@ -1,268 +0,0 @@
/**
* @file supplierAutomat.js
* @description Manages cached supplier list (`GET /api/supplier`) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event.
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`.
*/
import { Automat } from 'react-automat';
import { supplier as supplierApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
/**
* @typedef {Object} Supplier
* @property {string|number} id Supplier identifier.
* @property {string} name Supplier name.
*/
/**
* @typedef {Object} SupplierState
* @property {Array<Supplier>} suppliers Company-scoped suppliers.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|null} error Last load error.
*/
/** @type {SupplierState} */
const initialState = {
suppliers: [],
articlesBySupplier: {},
articlesLoading: false,
status: 'idle',
error: null,
};
let supplierAutomat;
/**
* Fetches the company-scoped supplier list.
*
* @returns {Promise<Array<Supplier>>} Supplier list.
*/
async function fetchSuppliers() {
const data = await supplierApi.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
/** In-flight `load()` promise so concurrent consumers share one request. */
let inflightLoad = null;
/**
* Loads the supplier list once, deduplicating concurrent callers.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
function loadSuppliers() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
supplierAutomat.setState({ status: 'loading', error: null });
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
void actions.loadAllSupplierArticles().catch(() => {});
return suppliers;
} catch (error) {
supplierAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/**
* Loads the supplier list, exposing a loading/failed lifecycle.
*
* @returns {Promise<Array<Supplier>>} Loaded suppliers.
*/
load() {
return loadSuppliers();
},
/**
* Silently re-fetches the supplier list while keeping the current state.
*
* @returns {Promise<Array<Supplier>>} Fresh suppliers.
*/
async reload() {
try {
const suppliers = await fetchSuppliers();
supplierAutomat.setState({ suppliers, status: 'ready', error: null });
void actions.loadAllSupplierArticles().catch(() => {});
return suppliers;
} catch (error) {
supplierAutomat.setState({ error });
throw error;
}
},
/**
* Loads articles for a single supplier, caching them in state.articlesBySupplier.
*
* @param {string|number} supplierId
* @returns {Promise<Array<object>>}
*/
async loadArticlesForSupplier(supplierId) {
const sId = String(supplierId);
if (supplierAutomat.state.articlesBySupplier?.[sId]) {
return supplierAutomat.state.articlesBySupplier[sId];
}
const res = await supplierApi.getArticles(supplierId);
const articles = Array.isArray(res?.articles) ? res.articles : [];
supplierAutomat.setState({
articlesBySupplier: {
...(supplierAutomat.state.articlesBySupplier || {}),
[sId]: articles,
},
});
return articles;
},
/**
* Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier.
*
* @returns {Promise<Record<string, Array<object>>>}
*/
async loadAllSupplierArticles() {
const suppliers = supplierAutomat.state.suppliers;
if (!suppliers.length) return {};
supplierAutomat.setState({ articlesLoading: true });
try {
const results = await Promise.allSettled(
suppliers.map(async (s) => {
const res = await supplierApi.getArticles(s.id);
return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] };
}),
);
const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) };
for (const r of results) {
if (r.status === 'fulfilled') {
nextMap[r.value.id] = r.value.articles;
}
}
supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false });
return nextMap;
} catch {
supplierAutomat.setState({ articlesLoading: false });
return supplierAutomat.state.articlesBySupplier || {};
}
},
/**
* Creates a supplier and refreshes the cached list.
*
* @param {{name: string}} body Creation payload.
* @returns {Promise<Supplier>} Created supplier.
*/
async create(body) {
const data = await supplierApi.create(body);
requireObject(data, 'POST /api/supplier');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Renames a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @param {{name: string}} body Update payload.
* @returns {Promise<Supplier>} Updated supplier.
*/
async update(id, body) {
const data = await supplierApi.update(id, body);
requireObject(data, 'PATCH /api/supplier/:id');
requireObject(data.supplier, 'supplier');
requireDefined(data.supplier.id, 'supplier.id');
await supplierAutomat.actions.reload();
return data.supplier;
},
/**
* Deletes a supplier and refreshes the cached list.
*
* @param {string|number} id Supplier identifier.
* @returns {Promise<void>}
*/
async remove(id) {
await supplierApi.delete(id);
await supplierAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Supplier>} suppliers Suppliers to cache.
* @returns {void}
*/
setSuppliers(suppliers) {
supplierAutomat.setState({
suppliers: Array.isArray(suppliers) ? suppliers : [],
status: 'ready',
error: null,
});
},
/**
* Forgets the cached list and returns to the initial state.
*
* @returns {void}
*/
clear() {
supplierAutomat.setState({ ...initialState });
},
};
/**
* Company-scoped supplier cache. Loads automatically once an authenticated
* session with a selected company exists and is cleared when auth is gone.
*
* @type {Automat<SupplierState>}
*/
supplierAutomat = new Automat(initialState, actions, { name: 'supplier' });
/**
* Derives the cache key from the auth state: the selected company while
* authenticated, otherwise `null`.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {string|null} Cache key.
*/
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
/**
* Loads the supplier list when auth/company becomes available and forgets it
* when the session ends.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {void}
*/
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
supplierAutomat.actions.clear();
} else {
void supplierAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
export default supplierAutomat;

View File

@@ -1,105 +0,0 @@
/**
* @file unitAutomat.js
* @description Manages allowable units of measure (Stück, kg, Liter, etc.)
* for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
* - UI / Settings: Mutating actions (`upsert`, `delete`).
*/
import { Automat } from 'react-automat';
import { unit as unitApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} UnitState
* @property {Array<object>} units
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {UnitState} */
const initialState = {
units: [],
status: 'idle',
error: null,
};
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
let unitAutomat;
const actions = {
/** Load all units for the selected company. */
async load() {
unitAutomat.setState({ status: 'loading', error: null });
try {
const res = await unitApi.list();
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
unitAutomat.setState({ units, status: 'succeeded' });
return units;
} catch (err) {
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert (create or update) a unit.
* @param {object} body
*/
async upsert(body) {
const res = await unitApi.upsert(body);
const row = res.unit;
const units = sortUnits([
...unitAutomat.state.units.filter((u) => u.si !== row.si),
row,
]);
unitAutomat.setState({ units });
return units;
},
/**
* Delete a unit by SI symbol.
* @param {string} si
*/
async delete(si) {
await unitApi.delete(si);
unitAutomat.setState({
units: unitAutomat.state.units.filter((u) => u.si !== si),
});
},
/** Reset to idle state (called on logout / company switch). */
reset() {
unitAutomat.setState(initialState);
},
};
/**
* Global unit state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<UnitState>}
*/
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void unitAutomat.actions.load();
} else if (auth.status === 'anon') {
unitAutomat.actions.reset();
}
});
export default unitAutomat;

View File

@@ -1,75 +0,0 @@
/**
* @file userSettingsAutomat.js
* @description Manages client-side user UI layout preferences (attachment viewer
* position, chat drawer width). Persisted in IndexedDB.
*
* Upstream Triggers:
* - UI / User layout preferences: Calls to `setViewerPosition(position)` and
* `setChatDrawerWidth(width)`.
*/
import { Automat } from 'react-automat';
/**
* @typedef {'top'|'right'|'below'} ViewerPosition
*/
/**
* @typedef {Object} UserSettingsState
* @property {ViewerPosition} viewerPosition Preferred docking position of the
* attachment viewer (text editor / PDF preview) relative to the article list.
* @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels.
*/
/** @type {UserSettingsState} */
const initialState = {
viewerPosition: 'top',
chatDrawerWidth: 460,
};
/**
* Persisted per-user UI preferences. Unlike the other automats this one is
* backed by IndexedDB (`persist: true`) so settings survive reloads and
* sessions.
*
* @type {Automat<UserSettingsState>}
*/
const userSettingsAutomat = new Automat(
initialState,
{
/**
* Set the docking position of the attachment viewer.
*
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
* @returns {void}
*/
setViewerPosition(position) {
if (position !== 'top' && position !== 'right' && position !== 'below') {
return;
}
userSettingsAutomat.setState({ viewerPosition: position });
},
/**
* Set the width of the chat drawer in pixels.
*
* @param {number} width
* @returns {void}
*/
setChatDrawerWidth(width) {
const num = Number(width);
if (Number.isNaN(num) || num < 280) {
return;
}
userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) });
},
},
{
name: 'userSettings',
persist: true,
}
);
export default userSettingsAutomat;
export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions;
export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460;

View File

@@ -1,256 +0,0 @@
/**
* @file warehouseAutomat.js
* @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers
* `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event.
* - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`.
*/
import { Automat } from 'react-automat';
import { warehouse as warehouseApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
/**
* @typedef {Object} Warehouse
* @property {string|number} id Warehouse identifier.
* @property {string} name Warehouse name.
*/
/**
* @typedef {Object} WarehouseState
* @property {Array<Warehouse>} warehouses Company-scoped warehouses.
* @property {Array<Warehouse>} filteredWarehouses Filtered warehouses according to active filter query.
* @property {string} filter Active search/filter string.
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
* @property {Error|null} error Last load error.
*/
/** @type {WarehouseState} */
const initialState = {
warehouses: [],
filteredWarehouses: [],
filter: '',
status: 'idle',
error: null,
};
let warehouseAutomat;
const applyFilter = (warehouses, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses;
};
const updateStateWithWarehouses = (warehouses, extra = {}) => {
const list = Array.isArray(warehouses) ? warehouses : [];
const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter);
warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra });
return list;
};
/**
* Fetches the company-scoped warehouse list.
*
* @returns {Promise<Array<Warehouse>>} Warehouse list.
*/
async function fetchWarehouses() {
const data = await warehouseApi.list();
requireObject(data, 'GET /api/warehouse');
return requireArray(data.warehouses, 'warehouses');
}
/** In-flight `load()` promise so concurrent callers share one request. */
let inflightLoad = null;
/**
* Loads the warehouse list once, deduplicating concurrent callers.
*
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
*/
function loadWarehouses() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
warehouseAutomat.setState({ status: 'loading', error: null });
try {
const warehouses = await fetchWarehouses();
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
return warehouses;
} catch (error) {
warehouseAutomat.setState({ status: 'failed', error });
throw error;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
}
const actions = {
/** Sets the active warehouse search filter and updates filteredWarehouses. */
filter(query) {
const filter = String(query ?? '');
warehouseAutomat.setState({
filter,
filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter),
});
},
/**
* Loads the warehouse list, exposing a loading/failed lifecycle.
*
* @returns {Promise<Array<Warehouse>>} Loaded warehouses.
*/
load() {
return loadWarehouses();
},
/**
* Silently re-fetches the warehouse list while keeping the current state.
*
* @returns {Promise<Array<Warehouse>>} Fresh warehouses.
*/
async reload() {
try {
const warehouses = await fetchWarehouses();
updateStateWithWarehouses(warehouses, { status: 'ready', error: null });
return warehouses;
} catch (error) {
warehouseAutomat.setState({ error });
throw error;
}
},
/**
* Creates a warehouse and refreshes the cached list.
*
* @param {{name: string}} body Creation payload.
* @returns {Promise<Warehouse>} Created warehouse.
*/
async create(body) {
const data = await warehouseApi.create(body);
requireObject(data, 'POST /api/warehouse');
requireObject(data.warehouse, 'warehouse');
requireDefined(data.warehouse.id, 'warehouse.id');
await warehouseAutomat.actions.reload();
return data.warehouse;
},
/**
* Renames a warehouse and refreshes the cached list.
*
* @param {string|number} id Warehouse identifier.
* @param {{name: string}} body Update payload.
* @returns {Promise<Warehouse>} Updated warehouse.
*/
async update(id, body) {
const data = await warehouseApi.update(id, body);
requireObject(data, 'PATCH /api/warehouse/:id');
requireObject(data.warehouse, 'warehouse');
requireDefined(data.warehouse.id, 'warehouse.id');
await warehouseAutomat.actions.reload();
return data.warehouse;
},
rename(id, body) {
return actions.update(id, body);
},
/**
* Deletes a warehouse and refreshes the cached list.
*
* @param {string|number} id Warehouse identifier.
* @returns {Promise<void>}
*/
async remove(id) {
await warehouseApi.delete(id);
await warehouseAutomat.actions.reload();
},
/**
* Persists a new sort order for all warehouses.
*
* @param {Array<string|number>} warehouseIds IDs in the desired order.
* @returns {Promise<void>}
*/
async reorder(warehouseIds) {
const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w]));
const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean);
if (reordered.length === warehouseAutomat.state.warehouses.length) {
updateStateWithWarehouses(reordered);
}
await warehouseApi.order({ warehouseIds });
await warehouseAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Warehouse>} warehouses Warehouses to cache.
* @returns {void}
*/
setWarehouses(warehouses) {
updateStateWithWarehouses(warehouses, {
status: 'ready',
error: null,
});
},
/**
* Forgets the cached list and returns to the initial state.
*
* @returns {void}
*/
clear() {
warehouseAutomat.setState({ ...initialState });
},
};
/**
* Company-scoped warehouse cache. Loads automatically once an authenticated
* session with a selected company exists and is cleared when auth is gone.
*
* @type {Automat<WarehouseState>}
*/
warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' });
/**
* Derives the cache key from the auth state: the selected company while
* authenticated, otherwise `null`.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {string|null} Cache key.
*/
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
/**
* Loads the warehouse list when auth/company becomes available and forgets it
* when the session ends.
*
* @param {import('./authAutomat').AuthState} authState Auth automat state.
* @returns {void}
*/
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
warehouseAutomat.actions.clear();
} else {
void warehouseAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
export default warehouseAutomat;

View File

@@ -8,11 +8,15 @@ 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';
import { defaultCategoryPath } from './category/categoryRoute'; import { defaultCategoryPath } from './category/categoryRoute';
import { navigationAutomat } from '../automats'; import companyAutomat from '../automats/companyAutomat';
import navigationAutomat from '../automats/navigationAutomat';
import { initPwa } from '../automats/pwaAutomat';
import PwaUpdateDialog from './PwaUpdateDialog';
import { import {
NAV_ITEM_ACTIVE_ACCENT, NAV_ITEM_ACTIVE_ACCENT,
NAV_SUB_ACTIVE, NAV_SUB_ACTIVE,
@@ -22,9 +26,16 @@ import {
import { ChatFab, ChatDrawer } from './chat'; import { ChatFab, ChatDrawer } from './chat';
import { PinnedAppletFabs, PinnedAppletDialog } from './applet'; import { PinnedAppletFabs, PinnedAppletDialog } from './applet';
// Ensure companyAutomat is retained as part of the 1st-level navigation shell
void companyAutomat;
initPwa();
const BreakpointOverlay = 0 && import.meta.env.DEV const BreakpointOverlay = 0 && import.meta.env.DEV
? lazy(() => import('./BreakpointOverlay')) ? lazy(() => import('./BreakpointOverlay'))
: null; : null;
const ChunkCacheMonitor = import.meta.env.DEV
? lazy(() => import('../devtools/ChunkCacheMonitor'))
: null;
const SIDEBAR_WIDTH = 144; const SIDEBAR_WIDTH = 144;
@@ -188,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>
); );
@@ -403,13 +426,20 @@ export default function AppShell() {
minWidth: 0, minWidth: 0,
})} })}
> >
<Suspense fallback={null}>
<Outlet /> <Outlet />
</Suspense>
</Box> </Box>
</Box> </Box>
<ChatFab />
<ChatDrawer /> <ChatDrawer />
<PinnedAppletFabs pathname={pathname} /> <PinnedAppletFabs pathname={pathname} />
<PinnedAppletDialog /> <PinnedAppletDialog />
<PwaUpdateDialog />
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
{BreakpointOverlay && ( {BreakpointOverlay && (
<Suspense fallback={null}> <Suspense fallback={null}>
<BreakpointOverlay /> <BreakpointOverlay />

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,11 +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, number as numberApi } from '../api'; import authAutomat from '../automats/authAutomat';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import companyAutomat from '../automats/companyAutomat'; import companyAutomat from '../automats/companyAutomat';
import numberAutomat from '../automats/numberAutomat';
import { import {
defaultIso639, defaultIso639,
ISO639_OPTIONS, ISO639_OPTIONS,
@@ -53,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 },
@@ -67,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 },
}, },
}, },
{ {
@@ -80,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 },
@@ -93,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 },
@@ -106,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 },
}, },
}, },
]; ];
@@ -117,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: '',
@@ -141,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,30 +227,8 @@ class CompanySetupDialog extends Component {
iso639, iso639,
taxrate: parsedTaxrate, taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate, reducedtaxrate: parsedReducedtaxrate,
numbers,
}); });
// Apply initial number sequences if set
if (numbers) {
try {
const listRes = await numberApi.list();
const serverNumbers = listRes?.numbers ?? [];
for (const serverRow of serverNumbers) {
const userConfig = numbers[serverRow.type];
if (userConfig) {
const prefix = String(userConfig.prefix ?? '');
const padding = Math.max(0, Number(userConfig.padding) || 0);
const next = Math.max(1, Number(userConfig.next) || 1);
await numberApi.update(serverRow.id, { prefix, padding, next });
}
}
} catch (numErr) {
console.warn('Initial number sequences could not be saved:', numErr);
}
}
await bootstrapAuthMe();
await companyAutomat.actions.load();
await numberAutomat.actions.reload().catch(() => { });
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -247,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) });
} }
@@ -258,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

@@ -7,7 +7,7 @@ import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper'; import Popper from '@mui/material/Popper';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { helpAutomat } from '../automats'; import helpAutomat from '../automats/helpAutomat';
import { import {
RED, RED,
RED_HOVER, RED_HOVER,

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

@@ -22,7 +22,7 @@ import { enqueueSnackbar } from 'notistack';
import AppDialog from './AppDialog'; import AppDialog from './AppDialog';
import { thumb, article as articleApi } from '../api'; import { thumb, article as articleApi } from '../api';
import artikelAutomat from '../automats/artikelAutomat'; import artikelAutomat from '../automats/artikelAutomat';
import brandAutomat from '../automats/brandAutomat'; import companyAutomat from '../automats/companyAutomat';
import categoryAutomat from '../automats/categoryAutomat'; import categoryAutomat from '../automats/categoryAutomat';
import chatAutomat from '../automats/chatAutomat'; import chatAutomat from '../automats/chatAutomat';
import { requireArray, requireObject } from '../utils/apiShape'; import { requireArray, requireObject } from '../utils/apiShape';
@@ -238,7 +238,7 @@ export default class PicturesDialog extends PureComponent {
if (onUpload) { if (onUpload) {
await onUpload(file); await onUpload(file);
} else if (brand?.id) { } else if (brand?.id) {
await brandAutomat.actions.addImage(brand.id, file); await companyAutomat.actions.addBrandImage(brand.id, file);
enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' }); enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' });
} else if (article?.sku) { } else if (article?.sku) {
const uploadStarted = performance.now(); const uploadStarted = performance.now();
@@ -318,7 +318,7 @@ export default class PicturesDialog extends PureComponent {
if (onDelete) { if (onDelete) {
await onDelete(pic, 0); await onDelete(pic, 0);
} else if (brand?.id) { } else if (brand?.id) {
await brandAutomat.actions.deleteImage(brand.id); await companyAutomat.actions.deleteBrandImage(brand.id);
enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' }); enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' });
} else if (category?.id) { } else if (category?.id) {
await categoryAutomat.actions.deleteCategoryImage(category.id); await categoryAutomat.actions.deleteCategoryImage(category.id);

View File

@@ -14,7 +14,8 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt';
import Switch from '@mui/material/Switch'; import Switch from '@mui/material/Switch';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats'; import helpAutomat, { toggleHelp } from '../automats/helpAutomat';
import pwaAutomat, { openPwaDialog } from '../automats/pwaAutomat';
import SettingsDialog from './settings/SettingsDialog'; import SettingsDialog from './settings/SettingsDialog';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
@@ -24,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,
@@ -36,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() {
@@ -46,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() {
@@ -73,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) {
@@ -97,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 = (() => {
@@ -200,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

@@ -31,7 +31,7 @@ import { stock } from '../../api';
import { formatStock } from './searchFormat'; import { formatStock } from './searchFormat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import artikelAutomat from '../../automats/artikelAutomat'; import artikelAutomat from '../../automats/artikelAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { import {
BORDER, BORDER,
GREEN_BG, GREEN_BG,
@@ -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);
@@ -64,7 +56,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
reason: '', reason: '',
submitting: false, submitting: false,
error: null, error: null,
warehouses: warehouseAutomat.state.warehouses || [], warehouses: companyAutomat.state.warehouses || [],
trackStock: isTrackStock, trackStock: isTrackStock,
initialTrackStock: isTrackStock, initialTrackStock: isTrackStock,
}; };
@@ -79,14 +71,13 @@ export default class ArticleStockAdjustDialog extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubWarehouse = warehouseAutomat.subscribe((state) => { this.unsubWarehouse = companyAutomat.subscribe((state) => {
const warehouses = state.warehouses || []; const warehouses = state.warehouses || [];
this.setState((prev) => ({ this.setState((prev) => ({
warehouses, warehouses,
warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''), warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''),
})); }));
}); }, ['warehouses']);
void warehouseAutomat.actions.load().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 warehouseAutomat.actions.load().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;
@@ -107,7 +97,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
reason: '', reason: '',
submitting: false, submitting: false,
error: null, error: null,
warehouses: warehouseAutomat.state.warehouses || [], warehouses: companyAutomat.state.warehouses || [],
trackStock: isTrackStock, trackStock: isTrackStock,
initialTrackStock: isTrackStock, initialTrackStock: isTrackStock,
}); });
@@ -121,7 +111,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
if (articleWarehouses.length > 0) { if (articleWarehouses.length > 0) {
return articleWarehouses[0].id; return articleWarehouses[0].id;
} }
const allWarehouses = warehouseAutomat.state.warehouses || []; const allWarehouses = companyAutomat.state.warehouses || [];
return allWarehouses.length > 0 ? allWarehouses[0].id : ''; return allWarehouses.length > 0 ? allWarehouses[0].id : '';
} }
@@ -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

@@ -25,7 +25,7 @@ import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
import AppDialog from '../AppDialog'; import AppDialog from '../AppDialog';
import ArticlePhoto from './ArticlePhoto'; import ArticlePhoto from './ArticlePhoto';
import { article as articleApi } from '../../api'; import { article as articleApi } from '../../api';
import brandAutomat from '../../automats/brandAutomat'; import companyAutomat from '../../automats/companyAutomat';
import categoryAutomat from '../../automats/categoryAutomat'; import categoryAutomat from '../../automats/categoryAutomat';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
@@ -195,7 +195,7 @@ export default class ArticleUnifyDialog extends Component {
resolveBrandName(article) { resolveBrandName(article) {
if (!article) return '–'; if (!article) return '–';
const brands = brandAutomat.state.brands || []; const brands = companyAutomat.state.brands || [];
if (article.brandId != null) { if (article.brandId != null) {
const b = brands.find((x) => x.id === article.brandId); const b = brands.find((x) => x.id === article.brandId);
if (b?.name) return b.name; if (b?.name) return b.name;

View File

@@ -15,7 +15,7 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import TextInput from '../TextInput'; import TextInput from '../TextInput';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import unitAutomat from '../../automats/unitAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { unit as unitApi } from '../../api'; import { unit as unitApi } from '../../api';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -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 unitAutomat.actions.load();
this.setState({ units, unitsLoading: false });
} catch (err) {
this.setState({ unitsLoading: false });
notifyError(err);
} }
} }
@@ -116,23 +115,22 @@ export default class ArticleUnitEditorDialog extends Component {
if (!formValid(form)) return; 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 unitAutomat.actions.load();
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 unitAutomat.actions.load(); 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

@@ -4,7 +4,7 @@ import TextInput from '../../TextInput';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { EditablePanelTile, PanelTile } from 'dashgui/panels'; import { EditablePanelTile, PanelTile } from 'dashgui/panels';
import brandAutomat from '../../../automats/brandAutomat'; import companyAutomat from '../../../automats/companyAutomat';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { requireObject } from '../../../utils/apiShape'; import { requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
@@ -119,16 +119,16 @@ export default class ArticleViewBrand extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
brands: brandAutomat.state.brands ?? [], brands: companyAutomat.state.brands ?? [],
}; };
this._cancelled = false; this._cancelled = false;
} }
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsub = brandAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
brands: state.brands ?? [], brands: state.brands ?? [],
})); }), ['brands']);
const parentSku = this.props.article?.parentSku || this.props.article?.parent_sku; const parentSku = this.props.article?.parentSku || this.props.article?.parent_sku;
if (parentSku && !getArticle(parentSku)) { if (parentSku && !getArticle(parentSku)) {
void artikelAutomat.actions.loadArticle(parentSku).catch(() => {}); void artikelAutomat.actions.loadArticle(parentSku).catch(() => {});
@@ -168,8 +168,8 @@ export default class ArticleViewBrand extends PureComponent {
if (existing) { if (existing) {
brandId = existing.id; brandId = existing.id;
} else { } else {
await brandAutomat.actions.create({ name }); await companyAutomat.actions.createBrand({ name });
const nextBrands = await brandAutomat.actions.load(); const nextBrands = await companyAutomat.actions.loadBrands();
this.setState({ brands: nextBrands }); this.setState({ brands: nextBrands });
brandId = nextBrands.find( brandId = nextBrands.find(
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(), (row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),

View File

@@ -32,9 +32,8 @@ import { PanelTile } from 'dashgui/panels';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { artikelDetailPath } from '../articleRoute'; import { artikelDetailPath } from '../articleRoute';
import { article as articleApi } from '../../../api'; import { article as articleApi } from '../../../api';
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import companyAutomat from '../../../automats/companyAutomat'; import companyAutomat, { selectAttributes } from '../../../automats/companyAutomat';
import { formatStock } from '../searchFormat'; import { formatStock } from '../searchFormat';
import { formatPrice, getArticleGross } from '../../../utils/price'; import { formatPrice, getArticleGross } from '../../../utils/price';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
@@ -46,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)
@@ -57,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))) {
@@ -66,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 ||
@@ -77,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)) {
@@ -104,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans: eanCandidates, allEans: eanCandidates,
trackStock,
}; };
} }
@@ -124,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,
@@ -159,10 +176,7 @@ export default class ArticleViewLists extends PureComponent {
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ this.unsubCompany = companyAutomat.subscribe((state) => {
company: state.current,
}));
this.unsubAttr = attributeAutomat.subscribe((state) => {
if (this._cancelled) return; if (this._cancelled) return;
this.setState((prev) => { this.setState((prev) => {
const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId)); const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId));
@@ -172,19 +186,21 @@ export default class ArticleViewLists extends PureComponent {
nextSelectedId = selectable[0].id; nextSelectedId = selectable[0].id;
} }
return { return {
company: state.current,
attributes: state.attributes, attributes: state.attributes,
attributesStatus: state.attributesStatus,
selectedAttributeId: nextSelectedId, selectedAttributeId: nextSelectedId,
}; };
}); });
}); }, ['current', 'attributes', 'attributesStatus']);
this.unsubArtikel = artikelAutomat.subscribe(() => { this.unsubArtikel = artikelAutomat.subscribe(() => {
if (this._cancelled) return; if (this._cancelled) return;
this.forceUpdate(); this.forceUpdate();
}); });
if (attributeAutomat.state.status === 'idle') { if (companyAutomat.state.attributesStatus === 'idle') {
void attributeAutomat.actions.load(); void companyAutomat.actions.loadAttributes();
} }
if (this.props.article?.sku) { if (this.props.article?.sku) {
@@ -262,7 +278,6 @@ export default class ArticleViewLists extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubCompany?.(); this.unsubCompany?.();
this.unsubAttr?.();
this.unsubArtikel?.(); this.unsubArtikel?.();
} }
@@ -395,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;
@@ -1350,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
@@ -1467,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',
}} }}
> >
@@ -1586,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',
}} }}
> >
@@ -1606,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' ? (
@@ -1628,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>
@@ -1881,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;
@@ -1895,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
@@ -2037,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',
}} }}
> >
@@ -2050,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',
@@ -2071,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 })}
@@ -2116,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
@@ -2142,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

@@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography';
import { EditablePanelTile, PanelTile } from 'dashgui/panels'; import { EditablePanelTile, PanelTile } from 'dashgui/panels';
import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog';
import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog';
import unitAutomat from '../../../automats/unitAutomat';
import companyAutomat from '../../../automats/companyAutomat'; import companyAutomat from '../../../automats/companyAutomat';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
@@ -333,7 +332,7 @@ export default class ArticleViewPrice extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const company = companyFromStore(); const company = companyFromStore();
const units = unitAutomat.state.units; const units = companyAutomat.state.units;
this.state = { this.state = {
tiersDialogOpen: false, tiersDialogOpen: false,
unitEditorOpen: false, unitEditorOpen: false,
@@ -345,12 +344,10 @@ export default class ArticleViewPrice extends PureComponent {
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubUnits = unitAutomat.subscribe(this, (state) => ({
units: state.units ?? [],
}));
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
units: state.units ?? [],
company: state.current ?? null, company: state.current ?? null,
})); }), ['units', 'current']);
this.unsubArtikel = artikelAutomat.subscribe(this, () => ({})); this.unsubArtikel = artikelAutomat.subscribe(this, () => ({}));
if (this.props.article?.isParent) { if (this.props.article?.isParent) {
this.ensureChildren(this.props.article.children); this.ensureChildren(this.props.article.children);
@@ -369,8 +366,6 @@ export default class ArticleViewPrice extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubUnits?.();
this.unsubUnits = null;
this.unsubCompany?.(); this.unsubCompany?.();
this.unsubCompany = null; this.unsubCompany = null;
this.unsubArtikel?.(); this.unsubArtikel?.();
@@ -462,7 +457,7 @@ export default class ArticleViewPrice extends PureComponent {
} }
refreshUnits = async () => { refreshUnits = async () => {
const units = await unitAutomat.actions.load(); const units = await companyAutomat.actions.loadUnits();
this.setState({ units }); this.setState({ units });
}; };

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';
@@ -10,8 +12,7 @@ import Tooltip from '@mui/material/Tooltip';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; import companyAutomat, { selectAttributes, selectProperties } from '../../../automats/companyAutomat';
import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { article as articleApi } from '../../../api'; import { article as articleApi } from '../../../api';
@@ -81,26 +82,27 @@ export default class ArticleAttributesTab extends PureComponent {
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubAttr = attributeAutomat.subscribe((state) => { this.unsubCompany = companyAutomat.subscribe((state) => {
if (this._cancelled) return; if (this._cancelled) return;
this.setState((prev) => { this.setState((prev) => {
let nextKey = prev.selectedMerkmalKey; let nextKey = prev.selectedMerkmalKey;
let nextItemId = prev.selectedItemId; let nextItemId = prev.selectedItemId;
const selectableAttrs = state.attributes; const selectableAttrs = state.attributes;
const globalProps = state.properties;
const isCurrentKeyValid = const isCurrentKeyValid =
nextKey && nextKey &&
(selectableAttrs.some((a) => `attr-${a.id}` === nextKey) || (selectableAttrs.some((a) => `attr-${a.id}` === nextKey) ||
prev.globalProperties.some((p) => `prop-${p.id}` === nextKey)); globalProps.some((p) => `prop-${p.id}` === nextKey));
if (!isCurrentKeyValid) { if (!isCurrentKeyValid) {
const first = selectableAttrs[0]; const first = selectableAttrs[0];
if (first) { if (first) {
nextKey = `attr-${first.id}`; nextKey = `attr-${first.id}`;
nextItemId = first.values?.[0]?.id ?? ''; nextItemId = first.values?.[0]?.id ?? '';
} else if (prev.globalProperties[0]) { } else if (globalProps[0]) {
nextKey = `prop-${prev.globalProperties[0].id}`; nextKey = `prop-${globalProps[0].id}`;
nextItemId = ''; nextItemId = '';
} else { } else {
nextKey = ''; nextKey = '';
@@ -108,38 +110,24 @@ export default class ArticleAttributesTab extends PureComponent {
} }
} }
return { return {
globalAttributes: state.attributes, globalAttributes: selectableAttrs,
globalProperties: globalProps,
selectedMerkmalKey: nextKey, selectedMerkmalKey: nextKey,
selectedItemId: nextItemId, selectedItemId: nextItemId,
}; };
}); });
}); }, ['attributes', 'properties']);
this.unsubProp = propertyAutomat.subscribe((state) => {
if (this._cancelled) return;
this.setState((prev) => {
const selectableAttrs = prev.globalAttributes;
let nextKey = prev.selectedMerkmalKey;
if (!nextKey && !selectableAttrs.length && state.properties[0]) {
nextKey = `prop-${state.properties[0].id}`;
}
return {
globalProperties: state.properties,
selectedMerkmalKey: nextKey,
};
});
});
this.unsubArtikel = artikelAutomat.subscribe(() => { this.unsubArtikel = artikelAutomat.subscribe(() => {
if (this._cancelled) return; if (this._cancelled) return;
this.forceUpdate(); this.forceUpdate();
}); });
if (attributeAutomat.state.status === 'idle') { if (companyAutomat.state.attributesStatus === 'idle') {
void attributeAutomat.actions.load(); void companyAutomat.actions.loadAttributes();
} }
if (propertyAutomat.state.status === 'idle') { if (companyAutomat.state.propertiesStatus === 'idle') {
void propertyAutomat.actions.load(); void companyAutomat.actions.loadProperties();
} }
void this.loadArticleData(this.props.sku); void this.loadArticleData(this.props.sku);
@@ -153,8 +141,7 @@ export default class ArticleAttributesTab extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
this.unsubAttr?.(); this.unsubCompany?.();
this.unsubProp?.();
this.unsubArtikel?.(); this.unsubArtikel?.();
} }
@@ -577,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 }}>

File diff suppressed because it is too large Load Diff

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

@@ -9,7 +9,7 @@ import GoogleIcon from '@mui/icons-material/Google';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { article as articleApi } from '../../../api'; import { article as articleApi } from '../../../api';
import { artikelAutomat, getArticle } from '../../../automats'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { import {
articleEans, articleEans,

View File

@@ -23,7 +23,6 @@ import { enqueueSnackbar } from 'notistack';
import { article as articleApi } from '../../../api'; import { article as articleApi } from '../../../api';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import { formatPrice } from '../../../utils/price'; import { formatPrice } from '../../../utils/price';
import supplierAutomat from '../../../automats/supplierAutomat';
import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat'; import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat';
import sseAutomat from '../../../automats/sseAutomat'; import sseAutomat from '../../../automats/sseAutomat';
import companyAutomat from '../../../automats/companyAutomat'; import companyAutomat from '../../../automats/companyAutomat';
@@ -39,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,
@@ -48,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();
} }
@@ -90,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 supplierAutomat.actions.load();
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

@@ -9,7 +9,7 @@ import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import brandAutomat from '../../automats/brandAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import BrandThumb from './BrandThumb'; import BrandThumb from './BrandThumb';
import BrandImageSearchDialog from './BrandImageSearchDialog'; import BrandImageSearchDialog from './BrandImageSearchDialog';
@@ -56,7 +56,7 @@ class ArtikelMarke extends PureComponent {
async renameBrand(brand, name) { async renameBrand(brand, name) {
try { try {
await brandAutomat.actions.rename(brand.id, { name }); await companyAutomat.actions.renameBrand(brand.id, { name });
enqueueSnackbar('Marke gespeichert.', { variant: 'success' }); enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
} catch (err) { } catch (err) {
this.setState({ draft: brand?.name ?? '' }); this.setState({ draft: brand?.name ?? '' });
@@ -99,7 +99,7 @@ class ArtikelMarke extends PureComponent {
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return; if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
void (async () => { void (async () => {
try { try {
await brandAutomat.actions.remove(brand.id); await companyAutomat.actions.removeBrand(brand.id);
enqueueSnackbar('Marke gelöscht.', { variant: 'success' }); enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

View File

@@ -3,7 +3,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import brandAutomat from '../../automats/brandAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
// Own component so typing a new brand name never rerenders ArtikelMarkenPage // Own component so typing a new brand name never rerenders ArtikelMarkenPage
@@ -19,7 +19,7 @@ class ArtikelMarkeNew extends PureComponent {
const name = this.state.newName.trim(); const name = this.state.newName.trim();
if (!name) return; if (!name) return;
try { try {
const row = await brandAutomat.actions.create({ name }); const row = await companyAutomat.actions.createBrand({ name });
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' }); enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
this.setState({ newName: '' }); this.setState({ newName: '' });
} catch (err) { } catch (err) {

View File

@@ -25,7 +25,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { serper } from '../../api'; import { serper } from '../../api';
import brandAutomat from '../../automats/brandAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo'; import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo';
import { import {
CHECKERBOARD, CHECKERBOARD,
@@ -211,8 +211,8 @@ export default class BrandImageSearchDialog extends PureComponent {
this.setState({ processingStep: 'Logo wird gespeichert...' }); this.setState({ processingStep: 'Logo wird gespeichert...' });
// 2. Upload as brandpic and update brandAutomat // 2. Upload as brandpic and update companyAutomat
await brandAutomat.actions.addImage(brand.id, avifFile); await companyAutomat.actions.addBrandImage(brand.id, avifFile);
enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, { enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, {
variant: 'success', variant: 'success',

View File

@@ -9,7 +9,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import carrierAutomat from '../../automats/carrierAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
class CarrierItem extends PureComponent { class CarrierItem extends PureComponent {
@@ -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();
@@ -54,7 +55,7 @@ class CarrierItem extends PureComponent {
async updateCarrier(item, patch) { async updateCarrier(item, patch) {
try { try {
await carrierAutomat.actions.update(item.id, patch); await companyAutomat.actions.updateCarrier(item.id, patch);
enqueueSnackbar('Versandart gespeichert.', { variant: 'success' }); enqueueSnackbar('Versandart gespeichert.', { variant: 'success' });
} catch (err) { } catch (err) {
this.setState({ draft: item.name ?? '' }); this.setState({ draft: item.name ?? '' });
@@ -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,12 +94,13 @@ 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;
void (async () => { void (async () => {
try { try {
await carrierAutomat.actions.remove(item.id); await companyAutomat.actions.removeCarrier(item.id);
enqueueSnackbar('Versandart gelöscht.', { variant: 'success' }); enqueueSnackbar('Versandart gelöscht.', { variant: 'success' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

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

@@ -40,7 +40,7 @@ import { setting as settingApi } from '../../api';
import chatAutomat, { import chatAutomat, {
selectPendingAskUser, selectPendingAskUser,
} from '../../automats/chatAutomat'; } from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat'; import companyAutomat from '../../automats/companyAutomat';
import vfsAutomat from '../../automats/vfsAutomat'; import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel, VfsFileIcon } from '../vfs'; import { VfsEditorPanel, VfsFileIcon } from '../vfs';
import ChatFlowList from './ChatFlowList'; import ChatFlowList from './ChatFlowList';
@@ -130,7 +130,7 @@ export default class ChatDrawer extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = chatAutomat.state; const s = chatAutomat.state;
const settings = userSettingsAutomat.state; const settings = companyAutomat.state;
const vfs = vfsAutomat.state; const vfs = vfsAutomat.state;
this.state = { this.state = {
isOpen: s.isOpen, isOpen: s.isOpen,
@@ -293,12 +293,12 @@ export default class ChatDrawer extends PureComponent {
return patch; return patch;
}); });
this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => { this.unsubSettings = companyAutomat.subscribe(this, (state) => {
if (!this.state.isResizing) { if (!this.state.isResizing) {
return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH }; return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH };
} }
return null; return null;
}); }, ['chatDrawerWidth']);
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({ this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
isVfsOpen: state.isOpen, isVfsOpen: state.isOpen,
@@ -465,7 +465,7 @@ export default class ChatDrawer extends PureComponent {
this.setState({ isResizing: false }); this.setState({ isResizing: false });
vfsAutomat.actions.setVfsWidth(currentVfsWidth); vfsAutomat.actions.setVfsWidth(currentVfsWidth);
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); companyAutomat.actions.setChatDrawerWidth(currentChatWidth);
}; };
window.addEventListener('pointermove', onMove, { passive: false }); window.addEventListener('pointermove', onMove, { passive: false });
@@ -531,7 +531,7 @@ export default class ChatDrawer extends PureComponent {
if (isVfs) { if (isVfs) {
vfsAutomat.actions.setVfsWidth(currentVfsWidth); vfsAutomat.actions.setVfsWidth(currentVfsWidth);
} else { } else {
userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); companyAutomat.actions.setChatDrawerWidth(currentChatWidth);
} }
}; };
@@ -546,7 +546,7 @@ export default class ChatDrawer extends PureComponent {
handleResizeDoubleClick = () => { handleResizeDoubleClick = () => {
this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH }); this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH });
userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH); companyAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH);
}; };
cleanupResizeListeners = () => { cleanupResizeListeners = () => {

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,7 +30,9 @@ 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 { deliveryEditorAutomat, setLastDeliveryPath } from '../../automats'; import { router } from '../../appRouter';
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats/navigationAutomat';
import HealingMarkdown from './HealingMarkdown'; import HealingMarkdown from './HealingMarkdown';
import { import {
VfsAttachmentLink, VfsAttachmentLink,
@@ -577,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

@@ -6,7 +6,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import customergroupAutomat from '../../automats/customergroupAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
class KundeGruppe extends PureComponent { class KundeGruppe extends PureComponent {
@@ -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;
@@ -54,7 +55,7 @@ class KundeGruppe extends PureComponent {
async renameGroup(item, name) { async renameGroup(item, name) {
try { try {
await customergroupAutomat.actions.update(item.id, { name }); await companyAutomat.actions.updateCustomergroup(item.id, { name });
enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' }); enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' });
} catch (err) { } catch (err) {
this.setState({ draft: item.name ?? '' }); this.setState({ draft: item.name ?? '' });
@@ -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;
@@ -87,7 +89,7 @@ class KundeGruppe extends PureComponent {
if (!window.confirm(`Kundengruppe „${item.name}“ wirklich löschen?`)) return; if (!window.confirm(`Kundengruppe „${item.name}“ wirklich löschen?`)) return;
void (async () => { void (async () => {
try { try {
await customergroupAutomat.actions.remove(item.id); await companyAutomat.actions.removeCustomergroup(item.id);
enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' }); enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

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

@@ -37,7 +37,7 @@ import {
} from '../../utils/deliveryLedger'; } from '../../utils/deliveryLedger';
import { formatStock } from '../article/searchFormat'; import { formatStock } from '../article/searchFormat';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import warehouseAutomat from '../../automats/warehouseAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { supplier as supplierApi } from '../../api'; import { supplier as supplierApi } from '../../api';
import deliveryEditorAutomat, { buildSupplierArticlesMap } from '../../automats/deliveryEditorAutomat'; import deliveryEditorAutomat, { buildSupplierArticlesMap } from '../../automats/deliveryEditorAutomat';
import { import {
@@ -65,7 +65,7 @@ export default class BookDeliveryDialog extends PureComponent {
} }
} }
const initialWarehouses = warehouseAutomat.state.warehouses || []; const initialWarehouses = companyAutomat.state.warehouses || [];
const initialSupplierMap = const initialSupplierMap =
props.supplierArticlesMap || props.supplierArticlesMap ||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? {}); (deliveryEditorAutomat?.state?.supplierArticlesMap ?? {});
@@ -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(() => { });
@@ -107,7 +106,7 @@ export default class BookDeliveryDialog extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubWarehouse = warehouseAutomat.subscribe((state) => { this.unsubWarehouse = companyAutomat.subscribe((state) => {
const warehouses = state.warehouses || []; const warehouses = state.warehouses || [];
this.setState((prev) => { this.setState((prev) => {
const selectedId = const selectedId =
@@ -118,11 +117,10 @@ export default class BookDeliveryDialog extends PureComponent {
selectedWarehouseId: selectedId, selectedWarehouseId: selectedId,
}; };
}); });
}); }, ['warehouses']);
this.unsubArticle = artikelAutomat.subscribe(() => { this.unsubArticle = artikelAutomat.subscribe(() => {
this.forceUpdate(); this.forceUpdate();
}); });
void warehouseAutomat.actions.load().catch(() => { });
this.loadSupplierArticlesIfNeeded(); this.loadSupplierArticlesIfNeeded();
if (this.props.open) { if (this.props.open) {
@@ -205,7 +203,7 @@ export default class BookDeliveryDialog extends PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) { if (this.props.open && !prevProps.open) {
const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || []; const currentWarehouses = this.state.warehouses || companyAutomat.state.warehouses || [];
const currentSupplierMap = const currentSupplierMap =
this.props.supplierArticlesMap || this.props.supplierArticlesMap ||
(deliveryEditorAutomat?.state?.supplierArticlesMap ?? this.state.supplierArticlesMap ?? {}); (deliveryEditorAutomat?.state?.supplierArticlesMap ?? this.state.supplierArticlesMap ?? {});
@@ -225,7 +223,6 @@ export default class BookDeliveryDialog extends PureComponent {
supplierArticlesMap: currentSupplierMap, supplierArticlesMap: currentSupplierMap,
}, },
() => { () => {
void warehouseAutomat.actions.load().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

@@ -23,7 +23,7 @@ import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import deliveryAutomat, { CHUNK_SIZE } from '../../automats/deliveryAutomat'; import deliveryAutomat, { CHUNK_SIZE } from '../../automats/deliveryAutomat';
import numberAutomat from '../../automats/numberAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { delivery as deliveryApi } from '../../api'; import { delivery as deliveryApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -693,11 +693,15 @@ export class DeliveryList extends PureComponent {
supplierId: deliveryAutomat.state.supplierId ?? null, supplierId: deliveryAutomat.state.supplierId ?? null,
}); });
} }
this.unsubNumber = numberAutomat.subscribe(this, () => { this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.headerItem = createDummyTestDelivery(); this.headerItem = createDummyTestDelivery();
this.forceUpdate(); this.forceUpdate();
}); },
void numberAutomat.actions.ensureLoaded().catch(() => { }); ['numbers'],
);
void companyAutomat.actions.ensureNumbersLoaded().catch(() => { });
void this.loadSuppliers(); void this.loadSuppliers();
} }

View File

@@ -17,10 +17,12 @@ import OrderDocumentsTiles from './OrderDocumentsTiles';
import OrderShipmentDialog from './OrderShipmentDialog'; import OrderShipmentDialog from './OrderShipmentDialog';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import entityAutomat from '../../automats/entityAutomat'; import entityAutomat from '../../automats/entityAutomat';
import customergroupAutomat from '../../automats/customergroupAutomat';
import supplierAutomat from '../../automats/supplierAutomat';
import orderAutomat from '../../automats/orderAutomat'; import orderAutomat from '../../automats/orderAutomat';
import retoureAutomat from '../../automats/retoureAutomat';
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
import sseAutomat from '../../automats/sseAutomat';
import OrderLedgerPanel from './OrderLedgerPanel';
import { fetchOrderLedgerEntries } from '../../utils/orderLedger';
import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price'; import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price';
import { resolveSalesPrice } from '../../utils/articleDetail'; import { resolveSalesPrice } from '../../utils/articleDetail';
import { import {
@@ -42,19 +44,35 @@ import {
getManifestEditAutomat, getManifestEditAutomat,
} from '../../automats/manifestDraftAutomat'; } from '../../automats/manifestDraftAutomat';
export function computeDocGrossTotal(lines) {
if (!Array.isArray(lines)) return 0;
return lines.reduce((acc, line) => {
const amount = Number(line.amount ?? line.count) || 0;
if (line.gross != null && line.gross !== '') {
const g = Number(line.gross) || 0;
return acc + amount * g;
}
const net = Number(line.net ?? line.netprice ?? line.netPrice) || 0;
const tax = Number(line.tax ?? line.taxrate ?? line.taxRate) || 0;
const rebate = Number(line.rebate) || 0;
const lineNet = amount * net * (1 - rebate / 100);
return acc + lineNet * (1 + tax / 100);
}, 0);
}
class ManifestForm extends PureComponent { class ManifestForm extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.cfg = manifestConfig(props.kind); this.cfg = manifestConfig(props.kind);
const groups = customergroupAutomat.state.items ?? []; const groups = companyAutomat.state.customergroups ?? [];
const initialParties = props.kind === 'delivery' const initialParties = props.kind === 'delivery'
? (supplierAutomat.state.status === 'ready' ? (supplierAutomat.state.suppliers ?? []) : []) ? (companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : [])
: (entityAutomat.state.status === 'ready' : (entityAutomat.state.status === 'ready'
? filterNonSupplierEntities(entityAutomat.state.items ?? [], groups) ? filterNonSupplierEntities(entityAutomat.state.items ?? [], groups)
: []); : []);
const partiesReady = props.kind === 'delivery' const partiesReady = props.kind === 'delivery'
? supplierAutomat.state.status === 'ready' ? companyAutomat.state.suppliersStatus === 'ready'
: (entityAutomat.state.status === 'ready' && customergroupAutomat.state.status === 'ready'); : (entityAutomat.state.status === 'ready' && companyAutomat.state.customergroupsStatus === 'ready');
this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null; this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null;
this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null; this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null;
@@ -90,6 +108,7 @@ class ManifestForm extends PureComponent {
parties: initialParties, parties: initialParties,
partiesLoading: !partiesReady, partiesLoading: !partiesReady,
date: initialDate, date: initialDate,
createdAt: initialDoc?.createdAt || initialEdit?.createdAt || null,
partyId: initialDoc?.partyId ?? null, partyId: initialDoc?.partyId ?? null,
number: initialDoc?.number || '', number: initialDoc?.number || '',
externalNumber: initialDoc?.externalNumber || '', externalNumber: initialDoc?.externalNumber || '',
@@ -102,6 +121,7 @@ class ManifestForm extends PureComponent {
shipments: initialEdit?.shipments || [], shipments: initialEdit?.shipments || [],
payments: initialEdit?.payments || [], payments: initialEdit?.payments || [],
shippings: initialEdit?.shippings || [], shippings: initialEdit?.shippings || [],
ledgerEntries: [],
orderTotal: initialEdit?.orderTotal ?? null, orderTotal: initialEdit?.orderTotal ?? null,
paidTotal: initialEdit?.paidTotal ?? null, paidTotal: initialEdit?.paidTotal ?? null,
paid: Boolean(initialEdit?.paid), paid: Boolean(initialEdit?.paid),
@@ -161,37 +181,39 @@ class ManifestForm extends PureComponent {
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
const syncPartiesFromState = () => {
if (this.props.kind === 'delivery') { if (this.props.kind === 'delivery') {
this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ this.setState({
parties: state.status === 'ready' ? (state.suppliers ?? []) : [], parties: companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : [],
partiesLoading: state.status === 'loading', partiesLoading: companyAutomat.state.suppliersStatus === 'loading',
})); });
} else { } else {
const updateParties = () => {
const entities = entityAutomat.state.items ?? []; const entities = entityAutomat.state.items ?? [];
const groups = customergroupAutomat.state.items ?? []; const groups = companyAutomat.state.customergroups ?? [];
this.setState({ this.setState({
parties: filterNonSupplierEntities(entities, groups), parties: filterNonSupplierEntities(entities, groups),
partiesLoading: partiesLoading:
entityAutomat.state.status === 'loading' || entityAutomat.state.status === 'loading' ||
customergroupAutomat.state.status === 'loading', companyAutomat.state.customergroupsStatus === 'loading',
}); });
}
}; };
if (this.props.kind !== 'delivery') {
this.unsubParties = entityAutomat.subscribe(this, () => { this.unsubParties = entityAutomat.subscribe(this, () => {
updateParties(); syncPartiesFromState();
return null; return null;
}); });
this.unsubGroups = customergroupAutomat.subscribe(this, () => { void companyAutomat.actions.ensureCustomergroupsLoaded();
updateParties();
return null;
});
void customergroupAutomat.actions.ensureLoaded();
} }
if (companyAutomat.state.status === 'idle') { if (companyAutomat.state.status === 'idle') {
void companyAutomat.actions.load(); void companyAutomat.actions.load();
} }
this.unsubCompany = companyAutomat.subscribe(this, (state) => { this.unsubCompany = companyAutomat.subscribe(
this,
(state) => {
syncPartiesFromState();
const taxrate = state.current?.taxrate; const taxrate = state.current?.taxrate;
if (taxrate != null) { if (taxrate != null) {
this.setState((prev) => { this.setState((prev) => {
@@ -215,10 +237,21 @@ class ManifestForm extends PureComponent {
return null; return null;
}); });
} }
}); return null;
},
['current', 'suppliers', 'suppliersStatus', 'customergroups', 'customergroupsStatus'],
);
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') {
this.unsubSseLedger = sseAutomat?.actions?.on?.('ledger', () => {
void this.reloadLedger();
});
this.unsubSseStock = sseAutomat?.actions?.on?.('stock', () => {
void this.reloadLedger();
});
}
} else { } else {
void this.loadPartiesOnly(); void this.loadPartiesOnly();
void ensureDocumentNumber(this.props.kind).catch(() => {}); void ensureDocumentNumber(this.props.kind).catch(() => {});
@@ -239,6 +272,7 @@ class ManifestForm extends PureComponent {
if (String(state.id) === String(this.props.docId)) { if (String(state.id) === String(this.props.docId)) {
return { return {
date: state.date || '', date: state.date || '',
createdAt: state.createdAt ?? null,
partyId: state.partyId, partyId: state.partyId,
externalNumber: state.externalNumber || '', externalNumber: state.externalNumber || '',
lines: state.lines || [], lines: state.lines || [],
@@ -268,32 +302,29 @@ class ManifestForm extends PureComponent {
if (prevProps.kind !== this.props.kind) { if (prevProps.kind !== this.props.kind) {
this.cfg = manifestConfig(this.props.kind); this.cfg = manifestConfig(this.props.kind);
this.unsubParties?.(); this.unsubParties?.();
this.unsubGroups?.(); this.unsubParties = null;
if (this.props.kind === 'delivery') { if (this.props.kind === 'delivery') {
this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ this.setState({
parties: state.status === 'ready' ? (state.suppliers ?? []) : [], parties: companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : [],
partiesLoading: state.status === 'loading', partiesLoading: companyAutomat.state.suppliersStatus === 'loading',
})); });
} else { } else {
const updateParties = () => { const updateParties = () => {
const entities = entityAutomat.state.items ?? []; const entities = entityAutomat.state.items ?? [];
const groups = customergroupAutomat.state.items ?? []; const groups = companyAutomat.state.customergroups ?? [];
this.setState({ this.setState({
parties: filterNonSupplierEntities(entities, groups), parties: filterNonSupplierEntities(entities, groups),
partiesLoading: partiesLoading:
entityAutomat.state.status === 'loading' || entityAutomat.state.status === 'loading' ||
customergroupAutomat.state.status === 'loading', companyAutomat.state.customergroupsStatus === 'loading',
}); });
}; };
this.unsubParties = entityAutomat.subscribe(this, () => { this.unsubParties = entityAutomat.subscribe(this, () => {
updateParties(); updateParties();
return null; return null;
}); });
this.unsubGroups = customergroupAutomat.subscribe(this, () => {
updateParties(); updateParties();
return null; void companyAutomat.actions.ensureCustomergroupsLoaded();
});
void customergroupAutomat.actions.ensureLoaded();
} }
} }
@@ -359,12 +390,29 @@ class ManifestForm extends PureComponent {
this.unsubParties?.(); this.unsubParties?.();
this.unsubGroups?.(); this.unsubGroups?.();
this.unsubCompany?.(); this.unsubCompany?.();
this.unsubSseLedger?.();
this.unsubSseStock?.();
if (this._parseAbortController) { if (this._parseAbortController) {
this._parseAbortController.abort(); this._parseAbortController.abort();
this._parseAbortController = null; this._parseAbortController = null;
} }
} }
async reloadLedger() {
if ((this.props.kind !== 'order' && this.props.kind !== 'retoure') || this.props.mode !== 'edit' || !this.props.docId) return;
try {
const ledgerEntries = await fetchOrderLedgerEntries(this.props.docId, {
shipments: this.state.shipments,
lines: this.state.lines,
docNumber: this.state.number,
});
if (this._cancelled) return;
this.setState({ ledgerEntries });
} catch (err) {
console.warn('[ManifestForm] Failed to reload ledger entries:', err);
}
}
async loadPartiesOnly() { async loadPartiesOnly() {
if (this.state.parties.length === 0) { if (this.state.parties.length === 0) {
this.setState({ status: 'loadingParties', error: null, partiesLoading: true }); this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
@@ -416,6 +464,7 @@ class ManifestForm extends PureComponent {
dirtyLineFields: this.editAutomat.state.dirtyLineFields, dirtyLineFields: this.editAutomat.state.dirtyLineFields,
dirtySummary: this.editAutomat.state.dirtySummary, dirtySummary: this.editAutomat.state.dirtySummary,
date: this.editAutomat.state.date, date: this.editAutomat.state.date,
createdAt: this.editAutomat.state.createdAt ?? null,
partyId: this.editAutomat.state.partyId, partyId: this.editAutomat.state.partyId,
number: this.editAutomat.state.number, number: this.editAutomat.state.number,
externalNumber: this.editAutomat.state.externalNumber, externalNumber: this.editAutomat.state.externalNumber,
@@ -445,7 +494,7 @@ class ManifestForm extends PureComponent {
let shipments = doc.shipments; let shipments = doc.shipments;
let payments = doc.payments; let payments = doc.payments;
if (kind === 'order') { if (kind === 'order' || kind === 'retoure') {
if (!shipments) { if (!shipments) {
try { try {
const shRes = await shipmentApi.listByOrder(docId); const shRes = await shipmentApi.listByOrder(docId);
@@ -464,8 +513,26 @@ class ManifestForm extends PureComponent {
} }
} }
let ledgerEntries = [];
if (kind === 'order' || kind === 'retoure') {
try {
ledgerEntries = await fetchOrderLedgerEntries(docId, {
shipments,
lines: doc.lines,
docNumber: doc.number,
});
} catch (lErr) {
console.warn('[ManifestForm] Failed to load ledger entries:', lErr);
}
}
const linesGross = computeDocGrossTotal(lines);
const computedOrderTotal = doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? (linesGross !== 0 ? linesGross : null);
const computedPaidTotal = doc.paidTotal ?? doc.paid_total ?? (Array.isArray(payments) && payments.length > 0 ? payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0) : null);
const loadedDoc = { const loadedDoc = {
date: dateInputValue(doc.date), date: dateInputValue(doc.date),
createdAt: doc.createdAt ?? doc.created_at ?? null,
partyId: doc[cfg.partyField], partyId: doc[cfg.partyField],
number: doc.number ?? '', number: doc.number ?? '',
externalNumber: externalNumber:
@@ -475,11 +542,11 @@ class ManifestForm extends PureComponent {
shipments: shipments || [], shipments: shipments || [],
payments: payments || [], payments: payments || [],
shippings: doc.shippings || [], shippings: doc.shippings || [],
orderTotal: doc.orderTotal ?? null, orderTotal: computedOrderTotal,
paidTotal: doc.paidTotal ?? null, paidTotal: computedPaidTotal,
paid: Boolean(doc.paid), paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
shipped: Boolean(doc.shipped), shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
isShipped: Boolean(doc.isShipped ?? doc.shipped), isShipped: Boolean(doc.isShipped ?? doc.shipped ?? doc.is_shipped),
delivered: Boolean(doc.delivered), delivered: Boolean(doc.delivered),
}; };
@@ -492,6 +559,7 @@ class ManifestForm extends PureComponent {
parties, parties,
partiesLoading: false, partiesLoading: false,
...loadedDoc, ...loadedDoc,
ledgerEntries,
isDirty: false, isDirty: false,
dirtyFields: null, dirtyFields: null,
dirtyLineKeys: [], dirtyLineKeys: [],
@@ -867,7 +935,7 @@ class ManifestForm extends PureComponent {
handleRemoveLine(key) { handleRemoveLine(key) {
this.setState((prev) => { this.setState((prev) => {
const lineToRemove = prev.lines.find((line) => line.key === key); const lineToRemove = prev.lines.find((line) => line.key === key);
if (lineToRemove && this.props.kind === 'order') { if (lineToRemove && (this.props.kind === 'order' || this.props.kind === 'retoure')) {
const rawSku = lineToRemove.sku != null ? String(lineToRemove.sku).trim().toLowerCase() : ''; const rawSku = lineToRemove.sku != null ? String(lineToRemove.sku).trim().toLowerCase() : '';
const orderLineId = lineToRemove.id; const orderLineId = lineToRemove.id;
const lineName = lineToRemove.name != null ? String(lineToRemove.name).trim().toLowerCase() : ''; const lineName = lineToRemove.name != null ? String(lineToRemove.name).trim().toLowerCase() : '';
@@ -906,7 +974,7 @@ class ManifestForm extends PureComponent {
handleRemoveAllLines() { handleRemoveAllLines() {
this.setState((prev) => { this.setState((prev) => {
let nextLines = []; let nextLines = [];
if (this.props.kind === 'order') { if (this.props.kind === 'order' || this.props.kind === 'retoure') {
const shippedSkus = new Set(); const shippedSkus = new Set();
for (const sh of prev.shipments || []) { for (const sh of prev.shipments || []) {
for (const sl of sh.lines || []) { for (const sl of sh.lines || []) {
@@ -1161,8 +1229,12 @@ class ManifestForm extends PureComponent {
async handleShipmentCreated() { async handleShipmentCreated() {
await this.loadEdit(); await this.loadEdit();
if (this.props.kind === 'retoure') {
void retoureAutomat.actions.reload();
} else {
void orderAutomat.actions.reload(); void orderAutomat.actions.reload();
} }
}
async handleAttachmentDelete(attachment) { async handleAttachmentDelete(attachment) {
if (!attachment) return; if (!attachment) return;
@@ -1256,6 +1328,7 @@ class ManifestForm extends PureComponent {
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`); const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
const savedDoc = { const savedDoc = {
date: dateInputValue(doc.date), date: dateInputValue(doc.date),
createdAt: doc.createdAt ?? doc.created_at ?? this.state.createdAt ?? null,
partyId: doc[cfg.partyField], partyId: doc[cfg.partyField],
number: doc.number ?? '', number: doc.number ?? '',
externalNumber: externalNumber:
@@ -1264,12 +1337,21 @@ class ManifestForm extends PureComponent {
attachments: this.state.attachments, attachments: this.state.attachments,
}; };
const linesGross = computeDocGrossTotal(savedDoc.lines);
const savedDocWithTotals = {
...savedDoc,
orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? this.state.orderTotal ?? (linesGross !== 0 ? linesGross : null),
paidTotal: doc.paidTotal ?? doc.paid_total ?? this.state.paidTotal ?? null,
shipments: this.state.shipments,
payments: this.state.payments,
};
if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) { if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) {
this.editAutomat.actions.commitSaved(savedDoc); this.editAutomat.actions.commitSaved(savedDocWithTotals);
} }
this.setState({ this.setState({
...savedDoc, ...savedDocWithTotals,
isDirty: false, isDirty: false,
dirtyFields: null, dirtyFields: null,
dirtyLineKeys: [], dirtyLineKeys: [],
@@ -1361,6 +1443,7 @@ class ManifestForm extends PureComponent {
partyId={partyId} partyId={partyId}
partyError={this.state.partyError} partyError={this.state.partyError}
date={date} date={date}
createdAt={this.state.createdAt}
number={number} number={number}
externalNumber={externalNumber} externalNumber={externalNumber}
saving={saving} saving={saving}
@@ -1378,8 +1461,8 @@ class ManifestForm extends PureComponent {
onPdfDelete={this.handleAttachmentDelete} onPdfDelete={this.handleAttachmentDelete}
onPdfCreate={kind === 'quote' || kind === 'order' ? this.handleCreatePdf : undefined} onPdfCreate={kind === 'quote' || kind === 'order' ? this.handleCreatePdf : undefined}
onCreateOrder={kind === 'quote' && this.props.mode === 'edit' ? this.handleCreateOrder : undefined} onCreateOrder={kind === 'quote' && this.props.mode === 'edit' ? this.handleCreateOrder : undefined}
onOpenCreateShipment={kind === 'order' && this.props.mode === 'edit' ? this.handleOpenCreateShipment : undefined} onOpenCreateShipment={(kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? this.handleOpenCreateShipment : undefined}
deliverDisabled={kind === 'order' && this.isFullyDelivered()} deliverDisabled={(kind === 'order' || kind === 'retoure') && this.isFullyDelivered()}
onSave={this.handleSave} onSave={this.handleSave}
onPartyChange={this.handlePartyChange} onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange} onDateChange={this.handleDateChange}
@@ -1387,13 +1470,24 @@ class ManifestForm extends PureComponent {
onExternalNumberChange={this.handleExternalNumberChange} onExternalNumberChange={this.handleExternalNumberChange}
/> />
{status === 'ready' ? this.renderArticles() : null} {status === 'ready' ? this.renderArticles() : null}
{status === 'ready' && kind === 'order' && this.props.mode === 'edit' ? ( {status === 'ready' && (kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? (
<OrderDocumentsTiles <OrderDocumentsTiles
kind={kind}
orderId={this.props.docId} orderId={this.props.docId}
orderNumber={number} orderNumber={number}
externalNumber={externalNumber} externalNumber={externalNumber}
orderTotal={this.state.orderTotal} orderTotal={
paidTotal={this.state.paidTotal} this.state.orderTotal != null && Number(this.state.orderTotal) !== 0
? this.state.orderTotal
: computeDocGrossTotal(this.state.lines)
}
paidTotal={
this.state.paidTotal != null
? this.state.paidTotal
: (Array.isArray(this.state.payments) && this.state.payments.length > 0
? this.state.payments.reduce((acc, p) => acc + (Number(p.amount) || 0), 0)
: 0)
}
paid={this.state.paid} paid={this.state.paid}
shipped={this.state.shipped} shipped={this.state.shipped}
delivered={this.state.delivered} delivered={this.state.delivered}
@@ -1409,6 +1503,9 @@ class ManifestForm extends PureComponent {
navigate={this.props.navigate} navigate={this.props.navigate}
/> />
) : null} ) : null}
{status === 'ready' && (kind === 'order' || kind === 'retoure') && this.props.mode === 'edit' ? (
<OrderLedgerPanel entries={this.state.ledgerEntries} />
) : null}
</PanelTileGrid> </PanelTileGrid>
<ManifestPdfDialog <ManifestPdfDialog
open={pdfDialogOpen} open={pdfDialogOpen}
@@ -1427,6 +1524,7 @@ class ManifestForm extends PureComponent {
<OrderShipmentDialog <OrderShipmentDialog
open={this.state.shipmentDialogOpen} open={this.state.shipmentDialogOpen}
onClose={this.handleCloseShipmentDialog} onClose={this.handleCloseShipmentDialog}
kind={kind}
orderId={this.props.docId} orderId={this.props.docId}
orderNumber={number} orderNumber={number}
externalNumber={externalNumber} externalNumber={externalNumber}

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

@@ -21,12 +21,11 @@ import {
manifestConfig, manifestConfig,
partyDisplayName, partyDisplayName,
getLowestSortNonSupplierCustomerGroup, getLowestSortNonSupplierCustomerGroup,
formatDateTime,
} from './manifestConfig'; } from './manifestConfig';
import { notifyError, notifyErrorMessage } from '../../utils/notify'; import { notifyError, notifyErrorMessage } from '../../utils/notify';
import entityAutomat from '../../automats/entityAutomat'; import entityAutomat from '../../automats/entityAutomat';
import customergroupAutomat from '../../automats/customergroupAutomat'; import companyAutomat from '../../automats/companyAutomat';
import supplierAutomat from '../../automats/supplierAutomat';
import numberAutomat from '../../automats/numberAutomat';
import quoteAutomat from '../../automats/quoteAutomat'; import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat'; import orderAutomat from '../../automats/orderAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat'; import deliveryAutomat from '../../automats/deliveryAutomat';
@@ -41,7 +40,7 @@ class ManifestHeader extends PureComponent {
const selected = const selected =
(props.parties || []).find((row) => row.id === props.partyId) ?? (props.parties || []).find((row) => row.id === props.partyId) ??
(props.kind === 'delivery' (props.kind === 'delivery'
? (supplierAutomat.state.suppliers || []).find((row) => row.id === props.partyId) ? (companyAutomat.state.suppliers || []).find((row) => row.id === props.partyId)
: (entityAutomat.state.items || []).find((row) => row.id === props.partyId)) ?? : (entityAutomat.state.items || []).find((row) => row.id === props.partyId)) ??
null; null;
this.state = { this.state = {
@@ -67,7 +66,7 @@ class ManifestHeader extends PureComponent {
(this.props.parties || []).find((row) => row.id === this.props.partyId) ?? (this.props.parties || []).find((row) => row.id === this.props.partyId) ??
(this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null) ?? (this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null) ??
(this.props.kind === 'delivery' (this.props.kind === 'delivery'
? (supplierAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId) ? (companyAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId)
: (entityAutomat.state.items || []).find((row) => row.id === this.props.partyId)); : (entityAutomat.state.items || []).find((row) => row.id === this.props.partyId));
if (selected) { if (selected) {
this.setState({ partyInput: partyDisplayName(this.props.kind, selected) }); this.setState({ partyInput: partyDisplayName(this.props.kind, selected) });
@@ -146,14 +145,14 @@ class ManifestHeader extends PureComponent {
try { try {
const { kind, onPartyChange } = this.props; const { kind, onPartyChange } = this.props;
if (kind === 'delivery') { if (kind === 'delivery') {
const row = await supplierAutomat.actions.create({ name: raw }); const row = await companyAutomat.actions.createSupplier({ name: raw });
const displayName = row?.name || raw; const displayName = row?.name || raw;
enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' }); enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' });
this.setState({ creatingParty: false, createdParty: row, partyInput: displayName }); this.setState({ creatingParty: false, createdParty: row, partyInput: displayName });
onPartyChange?.(row.id); onPartyChange?.(row.id);
} else { } else {
await customergroupAutomat.actions.ensureLoaded(); await companyAutomat.actions.ensureCustomergroupsLoaded();
const groups = customergroupAutomat.state.items ?? []; const groups = companyAutomat.state.customergroups ?? [];
const targetGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0]; const targetGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0];
if (!targetGroup) { if (!targetGroup) {
throw new Error('Keine Kundengruppe vorhanden.'); throw new Error('Keine Kundengruppe vorhanden.');
@@ -256,11 +255,15 @@ class ManifestHeader extends PureComponent {
componentDidMount() { componentDidMount() {
this._cancelled = false; this._cancelled = false;
this.unsubNumber = numberAutomat.subscribe(this, () => { this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.forceUpdate(); this.forceUpdate();
}); },
['numbers'],
);
this.subscribeListAutomat(this.props.kind); this.subscribeListAutomat(this.props.kind);
void numberAutomat.actions.ensureLoaded().catch(() => {}); void companyAutomat.actions.ensureNumbersLoaded().catch(() => {});
if (!this.props.number && this.props.docId == null) { if (!this.props.number && this.props.docId == null) {
void ensureDocumentNumber(this.props.kind) void ensureDocumentNumber(this.props.kind)
.then(() => { .then(() => {
@@ -303,6 +306,7 @@ class ManifestHeader extends PureComponent {
partiesLoading, partiesLoading,
partyId, partyId,
date, date,
createdAt,
number, number,
externalNumber, externalNumber,
saving, saving,
@@ -512,6 +516,24 @@ class ManifestHeader extends PureComponent {
}} }}
/> />
) : null} ) : null}
{isEdit && createdAt ? (
<SingleLineTextEdit
size="small"
label="Erstellt am"
value={formatDateTime(createdAt)}
disabled
slotProps={{
inputLabel: { shrink: true },
}}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 150px' },
minWidth: { sm: 130 },
'& .MuiInputBase-input': {
fontVariantNumeric: 'tabular-nums',
},
}}
/>
) : null}
</Box> </Box>
<AttachmentList <AttachmentList
kind={kind} kind={kind}

View File

@@ -259,7 +259,7 @@ class ManifestLines extends PureComponent {
</Button> </Button>
); );
})()} })()}
{(totalNet > 0 || totalGross > 0) && ( {(totalNet !== 0 || totalGross !== 0) && (
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 2, pr: 0.5 }}> <Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 2, pr: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 600, fontSize: '0.8rem' }}> <Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 600, fontSize: '0.8rem' }}>
Netto:{' '} Netto:{' '}

View File

@@ -11,10 +11,19 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined'; import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels'; import { PanelTile } from 'dashgui/panels';
import { formatDate } from './manifestConfig'; import AppDialog from '../AppDialog';
import { payment as paymentApi } from '../../api';
import { notifyError } from '../../utils/notify';
import { formatDate, formatDateTime, todayInputValue } from './manifestConfig';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { typeLabel, typeIcon } from './OrderPaymentList'; import { typeLabel, typeIcon, PAYMENT_TYPES } from './OrderPaymentList';
import ShipmentDetailDialog from './ShipmentDetailDialog'; import ShipmentDetailDialog from './ShipmentDetailDialog';
import { import {
GREEN_PANEL, GREEN_PANEL,
@@ -23,19 +32,81 @@ import {
} from '../../colors'; } from '../../colors';
/** /**
* Renders 1-wide associated document tiles for an order in edit mode: * Renders 1-wide associated document tiles for an order or retoure in edit mode:
* 1. Lieferscheine / Auslieferungen (Shipments) * 1. Lieferscheine / Auslieferungen (Shipments)
* 2. Zahlungen (Payments) * 2. Zahlungen / Erstattungen (Payments)
*/ */
export class OrderDocumentsTiles extends PureComponent { export class OrderDocumentsTiles extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
selectedShipment: null, selectedShipment: null,
paymentDialogOpen: false,
paymentAmount: '',
paymentType: 'cash',
paymentDate: todayInputValue(),
paymentSaving: false,
}; };
this.handleOpenShipment = this.handleOpenShipment.bind(this); this.handleOpenShipment = this.handleOpenShipment.bind(this);
this.handleCloseShipment = this.handleCloseShipment.bind(this); this.handleCloseShipment = this.handleCloseShipment.bind(this);
this.handleOpenPayments = this.handleOpenPayments.bind(this); this.handleOpenPayments = this.handleOpenPayments.bind(this);
this.handleOpenPaymentDialog = this.handleOpenPaymentDialog.bind(this);
this.handleClosePaymentDialog = this.handleClosePaymentDialog.bind(this);
this.handleSubmitPayment = this.handleSubmitPayment.bind(this);
}
handleOpenPaymentDialog() {
const { orderTotal, paidTotal, kind } = this.props;
const tot = Number(orderTotal) || 0;
const pd = Number(paidTotal) || 0;
const absTot = Math.abs(tot);
const absPd = Math.abs(pd);
const isPaid = Boolean(this.props.paid || (absTot > 0 && absPd >= absTot - 0.005));
const remaining = isPaid ? 0 : Math.max(0, absTot - absPd);
const defaultAmount = remaining > 0
? (kind === 'retoure' || tot < 0 ? -remaining : remaining)
: '';
this.setState({
paymentDialogOpen: true,
paymentAmount: defaultAmount !== '' ? String(Math.round(defaultAmount * 100) / 100) : '',
paymentType: 'cash',
paymentDate: todayInputValue(),
});
}
handleClosePaymentDialog() {
this.setState({ paymentDialogOpen: false, paymentSaving: false });
}
async handleSubmitPayment(e) {
e?.preventDefault?.();
const { orderId, onShipmentChanged } = this.props;
const { paymentAmount, paymentType, paymentDate, paymentSaving } = this.state;
if (paymentSaving || !orderId) return;
const amt = Number(String(paymentAmount).replace(',', '.'));
if (Number.isNaN(amt) || amt === 0) {
enqueueSnackbar('Bitte einen gültigen Betrag ungleich 0 eingeben.', { variant: 'warning' });
return;
}
this.setState({ paymentSaving: true });
try {
await paymentApi.create({
manifestId: orderId,
amount: amt,
type: paymentType,
date: paymentDate || undefined,
});
enqueueSnackbar(amt < 0 ? 'Erstattung erfolgreich gebucht!' : 'Zahlung erfolgreich erfasst!', {
variant: 'success',
});
this.handleClosePaymentDialog();
onShipmentChanged?.();
} catch (err) {
notifyError(err);
this.setState({ paymentSaving: false });
}
} }
handleOpenShipment(sh) { handleOpenShipment(sh) {
@@ -91,6 +162,7 @@ export class OrderDocumentsTiles extends PureComponent {
const carrier = sh.shippings?.[0]?.carrier; const carrier = sh.shippings?.[0]?.carrier;
const tracking = sh.shippings?.[0]?.trackingCode; const tracking = sh.shippings?.[0]?.trackingCode;
const posCount = sh.lineCount ?? sh.lines?.length ?? 0; const posCount = sh.lineCount ?? sh.lines?.length ?? 0;
const shDate = sh.date || sh.createdAt || sh.created_at;
return ( return (
<Card <Card
@@ -114,7 +186,7 @@ export class OrderDocumentsTiles extends PureComponent {
</Typography> </Typography>
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}> <Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{sh.date ? formatDate(sh.date) : '–'} {shDate ? formatDateTime(shDate) : '–'}
</Typography> </Typography>
{navigate && ( {navigate && (
<IconButton <IconButton
@@ -162,32 +234,54 @@ export class OrderDocumentsTiles extends PureComponent {
} }
renderPaymentsTile() { renderPaymentsTile() {
const { payments = [], paid, paidTotal, orderTotal } = this.props; const { payments = [], paid, paidTotal, orderTotal, kind } = this.props;
const isRetoure = kind === 'retoure';
const tot = Number(orderTotal) || 0; const tot = Number(orderTotal) || 0;
const pd = Number(paidTotal) || 0; const pd = Number(paidTotal) || 0;
const openAmount = Math.max(0, tot - pd); const absTot = Math.abs(tot);
const absPd = Math.abs(pd);
const isPaid = Boolean(paid || (absTot > 0 && absPd >= absTot - 0.005));
const isPaid = Boolean(paid || tot <= 0 || (tot > 0 && pd >= tot)); let openAmount = 0;
const statusLabel = isPaid ? 'Bezahlt' : pd > 0 ? 'Teilzahlung' : 'Offen'; if (!isPaid) {
const statusColor = isPaid ? 'success' : pd > 0 ? 'warning' : 'error'; const remaining = Math.max(0, absTot - absPd);
openAmount = tot < 0 ? -remaining : remaining;
}
const statusLabel = isPaid
? (isRetoure ? 'Erstattet' : 'Bezahlt')
: Math.abs(pd) > 0
? 'Teilzahlung'
: 'Offen';
const statusColor = isPaid ? 'success' : Math.abs(pd) > 0 ? 'warning' : 'error';
return ( return (
<PanelTile wide={1} minHeight={220} color={YELLOW_PANEL} title="Zahlungen"> <PanelTile wide={1} minHeight={220} color={YELLOW_PANEL} title={isRetoure ? 'Erstattungen / Zahlungen' : 'Zahlungen'}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}> <Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
<PaymentsOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} /> <PaymentsOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} /> <Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} />
</Stack> </Stack>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Button
size="small"
variant="contained"
onClick={this.handleOpenPaymentDialog}
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
>
{isRetoure ? '+ Erstattung' : '+ Zahlung'}
</Button>
<Button <Button
size="small" size="small"
variant="outlined" variant="outlined"
onClick={this.handleOpenPayments} onClick={this.handleOpenPayments}
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }} sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
> >
Zahlungen Journal
</Button> </Button>
</Box> </Box>
</Box>
{/* Financial KPI Card */} {/* Financial KPI Card */}
<Box <Box
@@ -202,7 +296,7 @@ export class OrderDocumentsTiles extends PureComponent {
> >
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Summe: {isRetoure ? 'Gutschrift / Summe:' : 'Summe:'}
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 700 }}> <Typography variant="caption" sx={{ fontWeight: 700 }}>
{formatPrice(tot)} {formatPrice(tot)}
@@ -210,7 +304,7 @@ export class OrderDocumentsTiles extends PureComponent {
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.25 }}>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Bezahlt: {isRetoure ? 'Erstattet:' : 'Bezahlt:'}
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: 'success.main' }}> <Typography variant="caption" sx={{ fontWeight: 700, color: 'success.main' }}>
{formatPrice(pd)} {formatPrice(pd)}
@@ -221,7 +315,7 @@ export class OrderDocumentsTiles extends PureComponent {
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Offen: Offen:
</Typography> </Typography>
<Typography variant="caption" sx={{ fontWeight: 700, color: openAmount > 0 ? 'error.main' : 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 700, color: openAmount !== 0 ? 'error.main' : 'text.secondary' }}>
{formatPrice(openAmount)} {formatPrice(openAmount)}
</Typography> </Typography>
</Box> </Box>
@@ -231,6 +325,9 @@ export class OrderDocumentsTiles extends PureComponent {
<Stack spacing={1}> <Stack spacing={1}>
{payments.map((pay) => { {payments.map((pay) => {
const pType = pay.type || pay.paymentType || pay.payment_type; const pType = pay.type || pay.paymentType || pay.payment_type;
const payDate = pay.date || pay.createdAt || pay.created_at || pay.timestamp;
const pAmt = Number(pay.amount) || 0;
const isRefund = pAmt < 0;
return ( return (
<Card <Card
key={pay.id} key={pay.id}
@@ -257,20 +354,32 @@ export class OrderDocumentsTiles extends PureComponent {
</Typography> </Typography>
</Stack> </Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexShrink: 0, ml: 1 }}>
{pay.date ? (
<Typography variant="caption" color="text.secondary">
{formatDate(pay.date)}
</Typography>
) : null}
<Typography <Typography
variant="subtitle2" variant="subtitle2"
sx={{ fontWeight: 700, color: 'success.dark', fontVariantNumeric: 'tabular-nums' }} sx={{
fontWeight: 700,
color: isRefund ? 'error.main' : 'success.dark',
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
ml: 1,
}}
> >
+{formatPrice(pay.amount)} {isRefund ? formatPrice(pAmt) : `+${formatPrice(pAmt)}`}
</Typography> </Typography>
</Stack>
</Box> </Box>
{payDate ? (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 0.5 }}>
{pay.id != null ? (
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
#{pay.id}
</Typography>
) : <span />}
<Typography variant="caption" color="text.secondary">
{formatDateTime(payDate)}
</Typography>
</Box>
) : null}
</Card> </Card>
); );
})} })}
@@ -281,6 +390,9 @@ export class OrderDocumentsTiles extends PureComponent {
} }
render() { render() {
const { kind } = this.props;
const isRetoure = kind === 'retoure';
return ( return (
<> <>
{this.renderShipmentsTile()} {this.renderShipmentsTile()}
@@ -292,6 +404,56 @@ export class OrderDocumentsTiles extends PureComponent {
onNavigate={this.props.navigate} onNavigate={this.props.navigate}
onChanged={this.props.onShipmentChanged} onChanged={this.props.onShipmentChanged}
/> />
<AppDialog
open={this.state.paymentDialogOpen}
onClose={this.handleClosePaymentDialog}
title={isRetoure ? 'Erstattung buchen' : 'Zahlung erfassen'}
>
<Box component="form" onSubmit={this.handleSubmitPayment} sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Betrag (€)"
type="text"
autoFocus
value={this.state.paymentAmount}
onChange={(e) => this.setState({ paymentAmount: e.target.value })}
helperText={isRetoure ? 'Negative Beträge für Gutschrift/Erstattung (z.B. -50.00)' : 'Zahlungsbetrag'}
fullWidth
required
/>
<TextField
select
label="Zahlungsart"
value={this.state.paymentType}
onChange={(e) => this.setState({ paymentType: e.target.value })}
fullWidth
>
{PAYMENT_TYPES.map((t) => (
<MenuItem key={t.value} value={t.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{typeIcon(t.value, { fontSize: 18 })}
<span>{t.label}</span>
</Box>
</MenuItem>
))}
</TextField>
<TextField
label="Datum"
type="date"
value={this.state.paymentDate}
onChange={(e) => this.setState({ paymentDate: e.target.value })}
slotProps={{ inputLabel: { shrink: true } }}
fullWidth
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={this.handleClosePaymentDialog} disabled={this.state.paymentSaving}>
Abbrechen
</Button>
<Button type="submit" variant="contained" disabled={this.state.paymentSaving}>
{this.state.paymentSaving ? 'Bucht…' : 'Buchen'}
</Button>
</Box>
</Box>
</AppDialog>
</> </>
); );
} }

View File

@@ -0,0 +1,96 @@
import { PureComponent } from 'react';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import { PanelTile } from 'dashgui/panels';
import { SURFACE_BASE, SURFACE_RAISED } from '../../colors';
export default class OrderLedgerPanel extends PureComponent {
render() {
const { entries = [] } = this.props;
if (!Array.isArray(entries) || entries.length === 0) {
return null;
}
return (
<PanelTile
wide={4}
color={SURFACE_BASE}
sx={{
gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' },
}}
>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
<TableRow>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Zeitpunkt</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>ArtNr</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lieferschein / Beleg</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Lager</TableCell>
<TableCell align="right" sx={{ fontWeight: 600, py: 0.5, whiteSpace: 'nowrap' }}>Menge verbucht</TableCell>
<TableCell sx={{ fontWeight: 600, py: 0.5 }}>Referenz</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.map((entry) => {
const isNegative = entry.rawAmount != null && entry.rawAmount < 0;
return (
<TableRow key={entry.id} hover>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.created_at ? new Date(entry.created_at).toLocaleString('de-DE') : '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
{entry.sku}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.name}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.documentNumber || '–'}
</Typography>
</TableCell>
<TableCell sx={{ py: 0.5, whiteSpace: 'nowrap' }}>
<Typography variant="caption">
{entry.warehouseName}
</Typography>
</TableCell>
<TableCell
align="right"
sx={{
py: 0.5,
fontWeight: 700,
color: isNegative ? 'error.main' : 'success.main',
whiteSpace: 'nowrap',
}}
>
{entry.mengeVerbucht}
</TableCell>
<TableCell sx={{ py: 0.5 }}>
<Typography variant="caption">
{entry.transaction_text || '–'}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</PanelTile>
);
}
}

View File

@@ -26,7 +26,7 @@ import orderAutomat, {
isOrderPaid, isOrderPaid,
isOrderDelivered, isOrderDelivered,
} from '../../automats/orderAutomat'; } from '../../automats/orderAutomat';
import numberAutomat from '../../automats/numberAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { order as orderApi } from '../../api'; import { order as orderApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -595,11 +595,15 @@ export class OrderList extends PureComponent {
filterKey: orderAutomat.getFilterKey(state), filterKey: orderAutomat.getFilterKey(state),
entityId: state.entityId ?? null, entityId: state.entityId ?? null,
})); }));
this.unsubNumber = numberAutomat.subscribe(this, () => { this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.headerItem = createDummyTestOrder(); this.headerItem = createDummyTestOrder();
this.forceUpdate(); this.forceUpdate();
}); },
void numberAutomat.actions.ensureLoaded().catch(() => {}); ['numbers'],
);
void companyAutomat.actions.ensureNumbersLoaded().catch(() => {});
void this.loadParties(); void this.loadParties();
} }

View File

@@ -17,7 +17,7 @@ import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import orderAutomat from '../../automats/orderAutomat'; import orderAutomat from '../../automats/orderAutomat';
import accountAutomat from '../../automats/accountAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { payment as paymentApi } from '../../api'; import { payment as paymentApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -184,14 +184,18 @@ export class OrderPaymentList extends PureComponent {
status: state.status, status: state.status,
error: state.error ? backendErrorFrom(state.error) : null, error: state.error ? backendErrorFrom(state.error) : null,
})); }));
this.unsubAccount = accountAutomat.subscribe(this, (state) => ({ this.unsubAccount = companyAutomat.subscribe(
accountsById: Object.fromEntries((state.items || []).map((a) => [a.id, a.name])), this,
})); (state) => ({
accountsById: Object.fromEntries((state.accounts || []).map((a) => [a.id, a.name])),
}),
['accounts'],
);
if (orderAutomat.state.status === 'idle') { if (orderAutomat.state.status === 'idle') {
void orderAutomat.actions.load().catch(() => {}); void orderAutomat.actions.load().catch(() => {});
} }
void accountAutomat.actions.ensureLoaded().catch(() => {}); void companyAutomat.actions.ensureAccountsLoaded().catch(() => {});
void this.loadPartiesData(); void this.loadPartiesData();
} }

View File

@@ -23,7 +23,7 @@ import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import AppDialog from '../AppDialog'; import AppDialog from '../AppDialog';
import { order as orderApi } from '../../api'; import { order as orderApi, retoure as retoureApi } from '../../api';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { formatStock } from '../article/searchFormat'; import { formatStock } from '../article/searchFormat';
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
@@ -364,7 +364,9 @@ export default class OrderShipmentDialog extends PureComponent {
lines: linesToShip, lines: linesToShip,
}; };
const res = await orderApi.createShipment(orderId, payload); const res = this.props.kind === 'retoure'
? await retoureApi.createShipment(orderId, payload)
: await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment; const shipment = res?.shipment;
const shNum = shipment?.number || ''; const shNum = shipment?.number || '';
@@ -380,7 +382,7 @@ export default class OrderShipmentDialog extends PureComponent {
this.setState({ saving: false, error: err?.message || String(err) }); this.setState({ saving: false, error: err?.message || String(err) });
} }
} }
// // MARK: calls orderApi.createShipment to ship all remaining lines // // MARK: calls orderApi/retoureApi.createShipment to ship all remaining lines
// #region POST /api/order/:id/shipment — ship all remaining // #region POST /api/order/:id/shipment — ship all remaining
async handleShipAll() { async handleShipAll() {
const { orderId, onClose, onSuccess } = this.props; const { orderId, onClose, onSuccess } = this.props;
@@ -394,7 +396,9 @@ export default class OrderShipmentDialog extends PureComponent {
externalNumber: externalNumber.trim() || undefined, externalNumber: externalNumber.trim() || undefined,
}; };
const res = await orderApi.createShipment(orderId, payload); const res = this.props.kind === 'retoure'
? await retoureApi.createShipment(orderId, payload)
: await orderApi.createShipment(orderId, payload);
const shipment = res?.shipment; const shipment = res?.shipment;
const shNum = shipment?.number || ''; const shNum = shipment?.number || '';

View File

@@ -18,7 +18,7 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import SkeleHashImage from '../settings/SkeleHashImage'; import SkeleHashImage from '../settings/SkeleHashImage';
import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat'; import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat';
import numberAutomat from '../../automats/numberAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { quote as quoteApi } from '../../api'; import { quote as quoteApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
@@ -593,11 +593,15 @@ export class QuoteList extends PureComponent {
filterKey: quoteAutomat.getFilterKey(state), filterKey: quoteAutomat.getFilterKey(state),
entityId: state.entityId ?? null, entityId: state.entityId ?? null,
})); }));
this.unsubNumber = numberAutomat.subscribe(this, () => { this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.headerItem = createDummyTestQuote(); this.headerItem = createDummyTestQuote();
this.forceUpdate(); this.forceUpdate();
}); },
void numberAutomat.actions.ensureLoaded().catch(() => { }); ['numbers'],
);
void companyAutomat.actions.ensureNumbersLoaded().catch(() => { });
void this.loadParties(); void this.loadParties();
} }

View File

@@ -0,0 +1,856 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
import Switch from '@mui/material/Switch';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import retoureAutomat, {
CHUNK_SIZE,
isRetourePaid,
isRetoureShipped,
} from '../../automats/retoureAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { retoure as retoureApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import { getMaxDocumentNumber } from '../../utils/documentNumber';
import PartyFilterPanel, { PartyFilterButton, PartyFilterChip } from './PartyFilterPanel';
import withNavigate from '../withNavigate';
import {
formatDate,
loadParties,
manifestConfig,
numberDisplay,
partyDisplayName,
} from './manifestConfig';
// MARK: pure helpers
function retoureGrossTotal(lines) {
if (!Array.isArray(lines)) return 0;
return lines.reduce((acc, line) => {
const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
}, 0);
}
const cfg = manifestConfig('retoure');
// Dummy retoure generator using the active sequence scheme with max digits for layout testing
export function createDummyTestRetoure() {
const maxNumber = getMaxDocumentNumber('retoure');
return {
id: 999999,
number: maxNumber,
externalNumber: 'REF-999999',
date: '2030-10-30T12:00:00Z',
customerName: 'Kunde',
positions: 999,
grossTotal: -30000.90,
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns: 'max-content max-content 1fr max-content max-content max-content max-content auto',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 1.5,
alignItems: 'center',
width: '100%',
boxSizing: 'border-box',
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestRetoure();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>{d.number}</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>{d.externalNumber || 'Nr. / Ref.'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.date ? formatDate(d.date) : '–'}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body1">{d.customerName}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Checkbox size="small" sx={{ p: 0.5 }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Checkbox size="small" sx={{ p: 0.5 }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<Chip size="small" label={d.positions} sx={{ height: 24, '& .MuiChip-label': { px: 0.5 } }} />
</Box>
<Box aria-hidden sx={{ gridColumn: 7, gridRow: 1, whiteSpace: 'nowrap', display: 'flex', justifyContent: 'flex-end', ...measuringCellSx }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>
{formatPrice(d.grossTotal)}
</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 8, gridRow: 1, display: 'flex', justifyContent: 'center', ...measuringCellSx }}>
<IconButton sx={{ width: 40, height: 40 }} />
</Box>
</>
);
}
/**
* Ambient context for `RetoureRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and the party map
* here on every render.
*/
const rowContext = {
partiesById: {},
busyId: null,
onOpen: null,
onDelete: null,
onTogglePaid: null,
onToggleShipped: null,
headerItem: null,
};
// MARK: <RetoureRow/>
class RetoureRow extends PureComponent {
partyName(partyId, fallbackName) {
if (partyId == null) return fallbackName || '–';
const party = rowContext.partiesById[partyId];
return partyDisplayName('retoure', party) || fallbackName || `Kunde #${partyId}`;
}
render() {
const { item: retoure, disabled } = this.props;
const dummy = this.props.headerItem || rowContext.headerItem;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!retoure) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}><Skeleton variant="text" width="90%" height={20} /></Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}><Skeleton variant="text" width="60%" height={20} /></Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="circular" width={20} height={20} /></Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="circular" width={20} height={20} /></Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center' }}><Skeleton variant="rounded" width={28} height={20} /></Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}><Skeleton variant="text" width="80%" height={20} /></Box>
<Box sx={{ gridColumn: 8, gridRow: 2 }} />
</ListItemButton>
);
}
const { busyId, onOpen, onDelete, onTogglePaid, onToggleShipped } = rowContext;
const lines = retoure.lines ?? [];
const positions = retoure.positions ?? lines.length;
const gross = retoure.gross_total ?? retoure.grossTotal ?? (lines.length > 0 ? retoureGrossTotal(lines) : 0);
const paid = isRetourePaid(retoure);
const shipped = isRetoureShipped(retoure);
const num = retoure.number ?? retoure.retoure_number ?? `RET-${retoure.id}`;
const dt = retoure.date ?? retoure.retoure_date;
const entityId = retoure.entityId ?? retoure.entity_id;
const customer = this.partyName(entityId, retoure.customerName ?? retoure.customer_name);
return (
<ListItemButton
divider
sx={{ ...rowGridSx, height: 64, minHeight: 64, maxHeight: 64, px: 2, py: 0 }}
onClick={disabled ? undefined : () => onOpen?.(retoure)}
disabled={disabled}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 600, fontFamily: 'monospace', ...ellipsisSx }}>
{num}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', ...ellipsisSx }}>
{numberDisplay(retoure.externalNumber)}
</Typography>
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{dt ? formatDate(dt) : '–'}
</Typography>
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{customer}
</Typography>
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={paid}
color="success"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onTogglePaid?.(retoure, e.target.checked)}
slotProps={{ input: { 'aria-label': `Retoure ${num} bezahlt` } }}
sx={{ p: 0.5 }}
/>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Checkbox
size="small"
checked={shipped}
color="primary"
onClick={(e) => e.stopPropagation()}
onChange={(e) => onToggleShipped?.(retoure, e.target.checked)}
slotProps={{ input: { 'aria-label': `Retoure ${num} geliefert` } }}
sx={{ p: 0.5 }}
/>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<Chip
size="small"
label={positions}
title={`${positions} Positionen`}
sx={{
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
bgcolor: 'action.hover',
color: positions > 0 ? 'text.primary' : 'text.disabled',
'& .MuiChip-label': { px: 0.5 },
}}
/>
</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, display: 'flex', justifyContent: 'flex-end' }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums', textAlign: 'right', whiteSpace: 'nowrap' }}
>
{gross !== 0 ? formatPrice(gross) : '—'}
</Typography>
</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, display: 'flex', justifyContent: 'center' }}>
<IconButton
aria-label={`„${num}“ löschen`}
disabled={busyId != null || disabled}
onClick={(e) => {
e.stopPropagation();
onDelete?.(retoure);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</Box>
</ListItemButton>
);
}
}
// MARK: <RetoureSearchField/>
class RetoureSearchField extends PureComponent {
constructor(props) {
super(props);
const s = retoureAutomat.state;
const stats = retoureAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
onlyUnpaid: s.onlyUnpaid ?? false,
onlyUndelivered: s.onlyUndelivered ?? false,
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
negativeSeekMonths: s.negativeSeekMonths ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
filterCachesCount: stats?.filterCaches?.length || 1,
};
}
componentDidMount() {
this.unsub = retoureAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
onlyUnpaid: state.onlyUnpaid ?? false,
onlyUndelivered: state.onlyUndelivered ?? false,
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = retoureAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
filterCachesCount: stats.filterCaches?.length || 1,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const {
inputRef,
onCreate,
customerFilterOpen,
onToggleCustomerFilter,
selectedCustomer,
onClearCustomerFilter,
} = this.props;
const { input, onlyUnpaid, onlyUndelivered, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field, customer filter button, active chip & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
<SingleLineTextEdit
sx={{ flex: 1, minWidth: { xs: '100%', sm: 220 } }}
size="small"
search
clear
value={input}
onChange={(next) => retoureAutomat.actions.filter(next)}
/>
<PartyFilterButton
title="Kunden"
open={customerFilterOpen}
onClick={onToggleCustomerFilter}
/>
{selectedCustomer && (
<PartyFilterChip
label={partyDisplayName('retoure', selectedCustomer)}
onDelete={onClearCustomerFilter}
/>
)}
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
{/* Filter Bar: "Nur ungezahlte", "Nur ungelieferte", Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Left: Unpaid & Undelivered toggles */}
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 1.5,
px: 1.5,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<FormControlLabel
control={
<Switch
size="small"
color="warning"
checked={onlyUnpaid}
onChange={(e) => retoureAutomat.actions.setOnlyUnpaid(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUnpaid ? 600 : 400, userSelect: 'none' }}>
Nur unerstattete
</Typography>
}
sx={{ m: 0 }}
/>
<Divider orientation="vertical" flexItem sx={{ my: 0.5 }} />
<FormControlLabel
control={
<Switch
size="small"
color="primary"
checked={onlyUndelivered}
onChange={(e) => retoureAutomat.actions.setOnlyUndelivered(e.target.checked)}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: onlyUndelivered ? 600 : 400, userSelect: 'none' }}>
Nur offene
</Typography>
}
sx={{ m: 0 }}
/>
</Box>
{/* Right: Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => retoureAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => retoureAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => retoureAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Tooltip title={`Chunk-Cache-Monitor öffnen (Alt+Shift+C) — ${this.state.lruChunksCount || 0}/${this.state.maxLruChunks || 50} Chunks im RAM über ${this.state.filterCachesCount || 1} Filter`}>
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'}
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
</Tooltip>
)}
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <RetoureList/>
export class RetoureList extends PureComponent {
constructor(props) {
super(props);
this.headerItem = createDummyTestRetoure();
// getItems() returns current items or null and triggers loading of the first chunk
this.state = {
items: retoureAutomat.getItems(),
unfilteredTotal: retoureAutomat.state.unfilteredTotal ?? 0,
filterKey: retoureAutomat.getFilterKey?.() ?? '',
entityId: retoureAutomat.state.entityId ?? null,
customerFilterOpen: false,
partiesById: {},
partiesList: [],
busyId: null,
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handleTogglePaid = this.handleTogglePaid.bind(this);
this.handleToggleShipped = this.handleToggleShipped.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
this.handleToggleCustomerFilter = this.handleToggleCustomerFilter.bind(this);
this.handleSelectCustomer = this.handleSelectCustomer.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = retoureAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: retoureAutomat.getFilterKey(state),
entityId: state.entityId ?? null,
}));
this.unsubNumber = companyAutomat.subscribe(
this,
() => {
this.headerItem = createDummyTestRetoure();
this.forceUpdate();
},
['numbers'],
);
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
this.unsubNumber?.();
}
handleToggleCustomerFilter() {
this.setState((prev) => ({ customerFilterOpen: !prev.customerFilterOpen }));
}
handleSelectCustomer(entityId) {
retoureAutomat.actions.setEntityId(entityId);
}
async loadParties() {
try {
const parties = await loadParties('retoure');
if (!this._cancelled) {
this.setState({
partiesById: cfg.partiesByIdFromList(parties),
partiesList: parties || [],
});
}
} catch {
// Non-fatal: names fall back to ids.
}
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
retoureAutomat.requireChunk(startChunk, endChunk);
}
handleRetry() {
void retoureAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(retoure) {
this.props.navigate(cfg.editPath(retoure.id));
}
async handleDelete(retoure) {
if (this.state.busyId != null) return;
const num = retoure.number ?? retoure.retoure_number ?? `RET-${retoure.id}`;
if (!window.confirm(cfg.confirmDelete(num))) return;
this.setState({ busyId: retoure.id });
try {
await retoureApi.delete(retoure.id);
await retoureAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
async handleTogglePaid(retoure, checked) {
if (this.state.busyId != null) return;
const id = retoure.id ?? retoure.manifest_id;
const patch = { isPaid: checked, is_paid: checked, paid: checked };
retoureAutomat.actions.patchRetoure(id, patch);
try {
await retoureApi.update(id, { isPaid: checked });
enqueueSnackbar(checked ? 'Retoure als erstattet/bezahlt markiert' : 'Retoure als offen markiert', {
variant: 'info',
});
} catch (err) {
console.warn('[RetoureList] update paid on backend failed:', err);
}
}
async handleToggleShipped(retoure, checked) {
if (this.state.busyId != null) return;
const id = retoure.id ?? retoure.manifest_id;
const patch = { isShipped: checked, is_shipped: checked, delivered: checked };
retoureAutomat.actions.patchRetoure(id, patch);
try {
await retoureApi.update(id, { isShipped: checked });
enqueueSnackbar(checked ? 'Retoure als geliefert/eingegangen markiert' : 'Retoure als offen markiert', {
variant: 'info',
});
} catch (err) {
console.warn('[RetoureList] update shipped on backend failed:', err);
}
}
render() {
const {
items,
unfilteredTotal,
filterKey,
partiesById,
partiesList,
entityId,
customerFilterOpen,
busyId,
} = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
rowContext.onTogglePaid = this.handleTogglePaid;
rowContext.onToggleShipped = this.handleToggleShipped;
rowContext.headerItem = this.headerItem;
const customerCounts = {};
if (Array.isArray(items)) {
for (const ret of items) {
const eId = ret?.entityId ?? ret?.entity_id;
if (eId) {
customerCounts[eId] = (customerCounts[eId] || 0) + 1;
}
}
}
const selectedCustomer = entityId ? (partiesById[entityId] || { id: entityId, name: `Kunde #${entityId}` }) : null;
const searchField = (
<RetoureSearchField
inputRef={this.searchRef}
onCreate={this.handleCreate}
customerFilterOpen={customerFilterOpen}
onToggleCustomerFilter={this.handleToggleCustomerFilter}
selectedCustomer={selectedCustomer}
onClearCustomerFilter={() => this.handleSelectCustomer(null)}
/>
);
if (items === null && retoureAutomat.state.status === 'failed') {
return (
<>
{searchField}
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(retoureAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
{searchField}
<Box sx={{ display: 'flex', gap: 2.5, alignItems: 'stretch' }}>
{customerFilterOpen && (
<PartyFilterPanel
title="Kunden"
parties={partiesList}
selectedId={entityId}
onSelect={this.handleSelectCustomer}
onClose={this.handleToggleCustomerFilter}
counts={customerCounts}
searchPlaceholder="Kunde suchen…"
/>
)}
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
headerItem={this.headerItem}
renderItem={RetoureRow}
virtualScrollbar
header={
<Box
sx={{
...rowGridSx,
height: 36,
minHeight: 36,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
{renderInvisibleDummyRow(this.headerItem)}
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>Nr. / Ref.</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Kunde</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Bezahlt">
<PaymentsOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, display: 'flex', justifyContent: 'center', alignItems: 'center', ...headerCellSx }}>
<Tooltip title="Geliefert">
<LocalShippingOutlinedIcon sx={{ fontSize: 18 }} />
</Tooltip>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, display: 'flex', justifyContent: 'center', textAlign: 'center', ...headerCellSx }}>Pos.</Box>
<Box sx={{ gridColumn: 7, gridRow: 2, textAlign: 'right', ...headerCellSx }}>Gesamt</Box>
<Box sx={{ gridColumn: 8, gridRow: 2, ...headerCellSx }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</Box>
</Box>
</>
);
}
}
export default withNavigate(RetoureList);

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