Compare commits

...

38 Commits

Author SHA1 Message Date
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
109 changed files with 5008 additions and 1776 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 284 API endpoints across all modules. Overview of all 314 API endpoints across all modules.
### Notation ### Notation
@@ -140,44 +140,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types ### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T18[], externalNumber?: str|null, createdAt?: str, shipping?: T20|null, shippings?: T20[], payment?: T29|null, payments?: T29[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }` - `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?: T10[], 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?: T10[] }` - `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: T10[], 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, 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[] }` - `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }` - `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] }, thumb?: str|null }` - `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }` - `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: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }` - Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T28|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
#### Update article by SKU — `PATCH /article/{sku}` #### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }` - Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void` - Returns: `204 void`
@@ -277,11 +314,11 @@ _none_
#### Toggle parent mode for an article — `POST /article/{sku}/parent` #### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `204 void` - Returns: `204 void`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices` #### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }` - Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
- Returns: `204 void` - Returns: `204 void`
#### Print article label — `POST /article/{sku}/print` #### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }` - Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
- Returns: `200 { success: bool }` - Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property` #### List article properties — `GET /article/{sku}/property`
- Params: `sku: str` - Params: `sku: str`
@@ -318,19 +355,19 @@ _none_
#### List attribute names — `GET /attributename` #### List attribute names — `GET /attributename`
_none_ _none_
- Returns: `200 { attributenames: T15[] }` - Returns: `200 { attributenames: T17[] }`
#### Create attribute name — `POST /attributename` #### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T15 }` - Returns: `201 { attributename: T17 }`
#### Reorder attribute names — `PUT /attributename/order` #### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }` - Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value` #### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int` - Params: `attributeId: int`
- Returns: `200 { values: T27[] }` - Returns: `200 { values: T30[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value` #### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }` - Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T27 }` - Returns: `201 { attributevalue: T30 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -339,7 +376,7 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}` #### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T15 }` - Returns: `200 { attributename: T17 }`
### Section: /attributevalue ### Section: /attributevalue
@@ -348,28 +385,40 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}` #### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }` - Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T27 }` - Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image` #### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }` - Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image` #### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }` - Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /auth ### Section: /auth
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T32 }` - Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### Current user — `GET /auth/me` #### Current user — `GET /auth/me`
_none_ _none_
- Returns: `200 { user: T32, googleClientId: str }` - Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
#### Set selected company — `PUT /auth/selected-company` #### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T32 }` - Returns: `200 { user: T36 }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /backup ### Section: /backup
@@ -390,7 +439,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` - Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T33[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -399,19 +448,19 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }` - Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }` - Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace brand image — `POST /brand/{id}/image` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }` - Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /carrier ### Section: /carrier
#### List carriers — `GET /carrier` #### List carriers — `GET /carrier`
_none_ _none_
- Returns: `200 { carriers: T30[] }` - Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier` #### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T30 }` - Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order` #### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }` - Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }` - Returns: `200 { carrierIds: int[] }`
@@ -420,10 +469,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}` #### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { carrier: T30 }` - Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}` #### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T30 }` - Returns: `200 { carrier: T34 }`
### Section: /category ### Section: /category
@@ -446,8 +495,8 @@ _none_
- Params: `id: int, body: { name?: str, parentId?: int }` - Params: `id: int, body: { name?: str, parentId?: int }`
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - Params: `id: int, query: includeChildren?: bool`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T25|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }` - Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T28|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }` - Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -477,10 +526,10 @@ _none_
#### List companies — `GET /company` #### List companies — `GET /company`
_none_ _none_
- Returns: `200 { companies: T17[] }` - Returns: `200 { companies: T19[] }`
#### Create company — `POST /company` #### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T17, user: T32 }` - Returns: `201 { company: T19, user: T36 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export` #### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str` - Params: `query: stream?: str`
- Returns: `200 SSE` - Returns: `200 SSE`
@@ -510,7 +559,22 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update company — `PUT /company/{id}` #### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T17 }` - Returns: `200 { company: T19 }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: T31[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: T31 }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: T31 }`
### Section: /customergroup ### Section: /customergroup
@@ -534,7 +598,7 @@ _none_
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int` - Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T36 }[], total: int, unfilteredTotal: int, offset: int, limit: int }` - Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create delivery — `POST /delivery` #### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }` - Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 { delivery: T4 }` - Returns: `201 { delivery: T4 }`
@@ -561,46 +625,46 @@ _none_
#### 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
@@ -618,8 +682,13 @@ _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`: `{}` - `supplier`: `{}`
- `vfs`: `{}` - `vfs`: `{}`
@@ -648,8 +717,11 @@ _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: T9 }` - 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`
@@ -657,26 +729,26 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 { labeltemplate: T9 }` - 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: T9 }` - 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: T9 }` - 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
@@ -706,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 }[] }`
@@ -756,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`
@@ -765,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`
@@ -775,10 +847,10 @@ _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: T10, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
@@ -790,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
@@ -838,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 }`
@@ -874,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
@@ -886,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 }[] }`
@@ -897,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`
@@ -906,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`
@@ -915,7 +1029,7 @@ _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`
@@ -1009,10 +1123,10 @@ _none_
- Returns: `201 { attachment: T10, 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
@@ -1021,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 }[] }`
@@ -1042,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`
@@ -1051,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
@@ -1099,10 +1213,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### List units — `GET /unit` #### List units — `GET /unit`
_none_ _none_
- Returns: `200 { units: T26[] }` - Returns: `200 { units: T29[] }`
#### Upsert unit — `PUT /unit` #### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T26 }` - Returns: `200 { unit: T29 }`
### Section: /v1 ### Section: /v1
@@ -1193,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 }`

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',
{ {

1081
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

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

24
src/api/autoinvite.js Normal file
View File

@@ -0,0 +1,24 @@
import { http } from './http.js';
/**
* Pending invites for emails that are not registered yet (admin only).
*/
export const autoinvite = {
// #region GET /api/autoinvite — list pending invites
list() {
return http('/api/autoinvite');
},
// #endregion
// #region POST /api/autoinvite — create an invite
create(body) {
return http('/api/autoinvite', { method: 'POST', body });
},
// #endregion
// #region DELETE /api/autoinvite/:id — delete an invite
delete(id) {
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
},
// #endregion
};

30
src/api/companyuser.js Normal file
View File

@@ -0,0 +1,30 @@
import { http } from './http.js';
/**
* Company members (admin only).
*/
export const companyuser = {
// #region GET /api/companyuser — list members
list() {
return http('/api/companyuser');
},
// #endregion
// #region POST /api/companyuser — add an existing user by email
create(body) {
return http('/api/companyuser', { method: 'POST', body });
},
// #endregion
// #region PATCH /api/companyuser/:id — change role
setRole(id, role) {
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
},
// #endregion
// #region DELETE /api/companyuser/:id — remove a member
delete(id) {
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -15,6 +15,9 @@ export { number } from './number.js';
export { order } from './order.js'; export { order } from './order.js';
export { posterminal } from './posterminal.js'; export { posterminal } from './posterminal.js';
export { printer } from './printer.js'; export { printer } from './printer.js';
export { printeragent } from './printeragent.js';
export { agentprinter } from './agentprinter.js';
export { printerjob } from './printerjob.js';
export { propertyname } from './propertyname.js'; export { propertyname } from './propertyname.js';
export { quote } from './quote.js'; export { quote } from './quote.js';
export { retoure } from './retoure.js'; export { retoure } from './retoure.js';
@@ -31,6 +34,8 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js'; export { shipping } from './shipping.js';
export { payment } from './payment.js'; export { payment } from './payment.js';
export { account } from './account.js'; export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js'; export { mcp } from './mcp.js';
export { backup } from './backup.js'; export { backup } from './backup.js';
export { carrier } from './carrier.js'; export { carrier } from './carrier.js';

View File

@@ -5,10 +5,11 @@ import { http } from './http.js';
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb } * Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
*/ */
export const labeltemplate = { export const labeltemplate = {
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */ /** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
// #region GET /api/labeltemplate — list // #region GET /api/labeltemplate — list
list() { list(params) {
return http('/api/labeltemplate'); const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
return http(`/api/labeltemplate${search}`);
}, },
// #endregion // #endregion
@@ -48,5 +49,23 @@ export const labeltemplate = {
}, },
// #endregion // #endregion
/**
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
* @param {number|object} idOrBody
* @param {object} [maybeBody]
* → 200 { success: true, printerjob?: PrinterJob }
*/
// #region POST /api/labeltemplate/testprint — testprint
testprint(idOrBody, maybeBody) {
if (typeof idOrBody === 'object' && idOrBody !== null) {
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
}
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
method: 'POST',
body: maybeBody || {},
});
},
// #endregion
// thumb is returned inline as a base64 string in list/get responses // thumb is returned inline as a base64 string in list/get responses
}; };

View File

@@ -51,4 +51,29 @@ export const printer = {
return http('/api/printer/order', { method: 'PUT', body: { printerIds } }); return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
}, },
// #endregion // #endregion
/**
* GET /api/printer/state
* Query: ?serial=, ?agentId=
* → { printers: LivePrinter[] }
*/
// #region GET /api/printer/state — live state
state({ serial, agentId } = {}) {
const params = new URLSearchParams();
if (serial) params.set('serial', serial);
if (agentId != null) params.set('agentId', agentId);
const query = params.toString();
return http(`/api/printer/state${query ? `?${query}` : ''}`);
},
// #endregion
/**
* GET /api/printer/:id/state
* → { printer: LivePrinter }
*/
// #region GET /api/printer/:id/state — single printer state
printerState(id) {
return http(`/api/printer/${id}/state`);
},
// #endregion
}; };

58
src/api/printeragent.js Normal file
View File

@@ -0,0 +1,58 @@
import { http } from './http.js';
export const printeragent = {
/**
* GET /api/printeragent → { printeragents: Printeragent[] }
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
*/
list() {
return http('/api/printeragent');
},
/**
* PUT /api/printeragent
* Body: { name: string, sort?: number }
* Creates a new printer agent (auto-generates 6-digit pairingpin).
* → 200 { printeragent: Printeragent }
*/
create({ name, sort } = {}) {
const body = { name };
if (sort !== undefined && sort !== null && sort !== '') {
body.sort = Number(sort);
}
return http('/api/printeragent', {
method: 'PUT',
body,
});
},
/**
* PATCH /api/printeragent/:id
* Body: { name?: string, sort?: number, enabled?: boolean }
* Updates name, sort, enabled, or any combination.
* → 200 { printeragent: Printeragent }
*/
update(id, patch = {}) {
const body = {};
if (patch.name !== undefined) body.name = patch.name;
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
body.sort = Number(patch.sort);
}
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
return http(`/api/printeragent/${id}`, {
method: 'PATCH',
body,
});
},
/**
* DELETE /api/printeragent/:id → 204 No Content
* Permanently deletes agent.
*/
delete(id) {
return http(`/api/printeragent/${id}`, {
method: 'DELETE',
});
},
};

142
src/api/printerjob.js Normal file
View File

@@ -0,0 +1,142 @@
import { http } from './http.js';
export const printerjob = {
/**
* POST /api/zpljob
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
* → { printerjob: PrinterJob }
*/
createZplJob({ agentprinterId, data, copiescount = 1 }) {
return http('/api/zpljob', {
method: 'POST',
body: {
agentprinterId: Number(agentprinterId),
data: String(data),
copiescount: Number(copiescount) || 1,
},
});
},
/**
* Sends a minimal ZPL test label to a specific agent printer.
* @param {number} agentprinterId
* @param {{ model?: string }} [options]
* → { printerjob: PrinterJob }
*/
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
const timeStr = new Date().toLocaleTimeString('de-DE');
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
const zpl = [
'^XA',
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
'^XZ',
].join('\n');
return this.createZplJob({
agentprinterId,
data: zpl,
copiescount: 1,
});
},
/**
* GET /api/printerjob
* @param {{ agentprinterId?: number, state?: string }} [query]
* → { printerjobs: PrinterJob[] }
*/
list(query = {}) {
const params = new URLSearchParams();
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
if (query.state != null) params.set('state', String(query.state));
const qs = params.toString();
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
},
/**
* GET /api/printerjob/:id
* → { printerjob: PrinterJob }
*/
get(id) {
return http(`/api/printerjob/${id}`);
},
/**
* POST /api/printerjob
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
* → { printerjob: PrinterJob }
*/
create({ agentprinterId, type = 'zpl', data }) {
return http('/api/printerjob', {
method: 'POST',
body: {
agentprinterId: Number(agentprinterId),
type,
data,
},
});
},
/**
* POST /api/printerjob/:id/cancel
* → { success: true, printerjob: PrinterJob }
*/
cancel(id) {
return http(`/api/printerjob/${id}/cancel`, {
method: 'POST',
});
},
/**
* POST /api/printerjob/cancel-all
* Sends ~JA to clear printer buffer and cancels all queued jobs.
* @param {{ agentprinterId?: number }} [payload]
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
*/
async cancelAll({ agentprinterId } = {}) {
if (agentprinterId != null) {
try {
await this.createZplJob({
agentprinterId: Number(agentprinterId),
data: '~JA',
copiescount: 1,
});
} catch (e) {
console.warn('Failed to send ~JA to printer:', e);
}
}
try {
return await http('/api/printerjob/cancel-all', {
method: 'POST',
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
});
} catch (err) {
if (err?.status === 404) {
const { printerjobs = [] } = await this.list({ agentprinterId });
const cancellable = printerjobs.filter(
(j) => j.state === 'pending' || j.state === 'processing',
);
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
return {
success: true,
cancelledCount: results.length,
jobs: results.map((r) => r.printerjob),
};
}
throw err;
}
},
/**
* DELETE /api/printerjob/:id
* → { success: true, deletedJob: PrinterJob }
*/
delete(id) {
return http(`/api/printerjob/${id}`, {
method: 'DELETE',
});
},
};

View File

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

View File

@@ -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

@@ -92,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.
@@ -126,11 +122,15 @@ function toIndexRecord(article, categoryId) {
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null; const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []); 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,
@@ -143,6 +143,8 @@ function toIndexRecord(article, categoryId) {
categories, 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, fullHash,
thumbHash, thumbHash,
photos, photos,
@@ -200,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;
}
} }
} }

View File

@@ -67,6 +67,8 @@ const initialState = {
filter: '', filter: '',
brand: '', brand: '',
supplier: '', supplier: '',
empty: '',
hierarchy: '',
offset: 0, offset: 0,
pageSize: null, pageSize: null,
loading: false, loading: false,
@@ -343,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
extraArticles = [], extraArticles = [],
allowParent = false, allowParent = false,
categories = [], categories = [],
hierarchy = '',
empty = '',
offset = 0, offset = 0,
pageSize = 10, pageSize = 10,
limit, limit,
@@ -367,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase())); const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
let pool = rawMatches; let pool = rawMatches;
if (!allowParent) { if (!allowParent && !hierarchy) {
pool = pool.filter((a) => !a.isParent && !a.is_parent); pool = pool.filter((a) => !a.isParent && !a.is_parent);
} }
if (excludedSet.size > 0) { if (excludedSet.size > 0) {
@@ -378,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase())); pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
} }
if (hierarchy === 'father') {
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
} else if (hierarchy === 'child') {
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
}
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
pool = pool.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
let matchesList = pool; let matchesList = pool;
if (categories && categories.length > 0) { if (categories && categories.length > 0) {
@@ -669,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
return facets; return facets;
} }
function getSkusWithSupplier() {
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const set = new Set();
for (const list of Object.values(articlesBySupplier)) {
if (Array.isArray(list)) {
for (const item of list) {
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
if (item.sku) set.add(String(item.sku).toLowerCase());
}
}
}
return set;
}
// #endregion // #endregion
// #endregion // #endregion
@@ -689,6 +759,8 @@ const actions = {
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? ''); const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? ''); const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize; const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
@@ -752,6 +824,64 @@ const actions = {
}); });
} }
// Apply hierarchy filter:
// - "father": "father or normal article" -> brand editable, exclude child variants
// - "child": "child or normal article" -> price editable, exclude parents
if (hierarchy === 'father') {
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
} else if (hierarchy === 'child') {
filteredArticles = filteredArticles.filter(
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
);
}
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
filteredArticles = filteredArticles.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
const sortedArticles = sortArticlesByRelevance(filteredArticles, query); const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
const totalCount = sortedArticles.length; const totalCount = sortedArticles.length;
@@ -781,6 +911,8 @@ const actions = {
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset, offset,
pageSize: safePageSize, pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading', loading: articleIndexAutomat.state.status === 'loading',

View File

@@ -31,6 +31,8 @@ import { auth, company, number as numberApi } from '../api';
* @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 {number|null} schemaVersion Database schema version returned by the backend.
* @property {boolean} vhost Whether vhost mode is enabled.
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state. * @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
* @property {boolean} hasAuth True when the session has a valid authenticated user. * @property {boolean} hasAuth True when the session has a valid authenticated user.
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected. * @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
@@ -49,6 +51,8 @@ const initialState = {
user: null, user: null,
googleClientId: null, googleClientId: null,
schemaVersion: null, schemaVersion: null,
vhost: false,
vhostSettings: null,
status: 'loading', status: 'loading',
hasAuth: false, hasAuth: false,
hasCompany: false, hasCompany: false,
@@ -80,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;
@@ -124,6 +128,8 @@ function buildAuthState(user, status, extra = {}) {
status, status,
hasAuth, hasAuth,
hasCompany, hasCompany,
vhost: authAutomat?.state?.vhost ?? false,
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
retryNotification: null, retryNotification: null,
...extra, ...extra,
}; };
@@ -143,22 +149,30 @@ function applySession(nextSession) {
nextSession.schemaVersion !== undefined nextSession.schemaVersion !== undefined
? nextSession.schemaVersion ? nextSession.schemaVersion
: (authAutomat.state.schemaVersion ?? null); : (authAutomat.state.schemaVersion ?? null);
const vhost =
nextSession.vhost !== undefined
? Boolean(nextSession.vhost)
: (authAutomat.state.vhost ?? false);
const vhostSettings =
nextSession.vhostSettings !== undefined
? nextSession.vhostSettings
: (authAutomat.state.vhostSettings ?? null);
if (!nextSession.user) { if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') { if (authAutomat.state.status !== 'authed') {
const nextState = authAutomat.setState( const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { googleClientId, schemaVersion, retryNotification: null }) buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
); );
syncSse(nextState); syncSse(nextState);
} else { } else {
authAutomat.setState({ googleClientId, schemaVersion, retryNotification: null }); authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
} }
return nextSession; return nextSession;
} }
const user = mergeUser(authAutomat.state.user, nextSession.user); const user = mergeUser(authAutomat.state.user, nextSession.user);
const nextState = authAutomat.setState( const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { googleClientId, schemaVersion, retryNotification: null }) buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
); );
syncSse(nextState); syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED }); emit({ type: AUTH_SESSION_ESTABLISHED });
@@ -352,6 +366,8 @@ export const selectSelectedCompanyId = () =>
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null; authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
export const selectRetryNotification = () => authAutomat.state.retryNotification; export const selectRetryNotification = () => authAutomat.state.retryNotification;
export const selectSchemaVersion = () => authAutomat.state.schemaVersion; 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.

View File

@@ -15,6 +15,7 @@
* - Brands (`brands`, `filteredBrands`, `brandFilter`) * - Brands (`brands`, `filteredBrands`, `brandFilter`)
* - Product attributes & values (`attributes`) * - Product attributes & values (`attributes`)
* - Payment accounts (`accounts`) * - Payment accounts (`accounts`)
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
* *
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when * Lives with the 1st-level navigation shell (`AppShell`) and triggers when
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
@@ -25,6 +26,10 @@ import {
company as companyApi, company as companyApi,
number as numberApi, number as numberApi,
posterminal as posterminalApi, posterminal as posterminalApi,
printer as printerApi,
printeragent as printeragentApi,
agentprinter as agentprinterApi,
printerjob as printerjobApi,
supplier as supplierApi, supplier as supplierApi,
unit as unitApi, unit as unitApi,
warehouse as warehouseApi, warehouse as warehouseApi,
@@ -35,6 +40,8 @@ import {
attributename as attributenameApi, attributename as attributenameApi,
attributevalue as attributevalueApi, attributevalue as attributevalueApi,
account as accountApi, account as accountApi,
companyuser as companyuserApi,
autoinvite as autoinviteApi,
} from '../api'; } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { normalizeCompany } from '../utils/companyForm'; import { normalizeCompany } from '../utils/companyForm';
@@ -168,6 +175,24 @@ const sortTerminals = (terminals) => {
}); });
}; };
const sortPrinterAgents = (agents) => {
if (!Array.isArray(agents)) return [];
return [...agents].sort((a, b) => {
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
if (sDiff !== 0) return sDiff;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
};
const sortAgentPrinters = (printers) => {
if (!Array.isArray(printers)) return [];
return [...printers].sort((a, b) => {
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
if (sDiff !== 0) return sDiff;
return (Number(a.id) || 0) - (Number(b.id) || 0);
});
};
const sortAccounts = (items) => const sortAccounts = (items) =>
[...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); [...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
@@ -237,6 +262,19 @@ const initialState = {
terminalsStatus: 'idle', terminalsStatus: 'idle',
terminalsError: null, terminalsError: null,
// Printer agents & agent printers
printeragents: [],
printeragentsCompanyId: null,
printeragentsStatus: 'idle',
printeragentsError: null,
agentprinters: [],
agentprintersCompanyId: null,
agentprintersStatus: 'idle',
agentprintersError: null,
livePrinters: [], // LivePrinter[] from /api/printer/state or SSE printer/printerstate
// 4. Suppliers // 4. Suppliers
suppliers: [], suppliers: [],
articlesBySupplier: {}, articlesBySupplier: {},
@@ -295,6 +333,14 @@ const initialState = {
accounts: [], accounts: [],
accountsStatus: 'idle', accountsStatus: 'idle',
accountsError: null, accountsError: null,
// 14. Members & invites (admin only; never part of loadAll)
members: [],
membersStatus: 'idle',
membersError: null,
invites: [],
invitesStatus: 'idle',
invitesError: null,
}; };
let companyAutomat; let companyAutomat;
@@ -303,6 +349,9 @@ let companyAutomat;
let inflightCompany = null; let inflightCompany = null;
let inflightNumbers = null; let inflightNumbers = null;
let inflightTerminals = null; let inflightTerminals = null;
let inflightPrinterAgents = null;
let inflightAgentPrinters = null;
let inflightLivePrinters = null;
let inflightSuppliers = null; let inflightSuppliers = null;
let inflightUnits = null; let inflightUnits = null;
let inflightWarehouses = null; let inflightWarehouses = null;
@@ -312,6 +361,8 @@ let inflightCarriers = null;
let inflightBrands = null; let inflightBrands = null;
let inflightAttributes = null; let inflightAttributes = null;
let inflightAccounts = null; let inflightAccounts = null;
let inflightMembers = null;
let inflightInvites = null;
// #endregion // #endregion
// #region internal state updaters & fetchers // #region internal state updaters & fetchers
@@ -496,6 +547,9 @@ const actions = {
actions.load(), actions.load(),
actions.loadNumbers(), actions.loadNumbers(),
actions.loadTerminals(), actions.loadTerminals(),
actions.loadPrinterAgents(),
actions.loadAgentPrinters(),
actions.loadLivePrinters(),
actions.loadSuppliers(), actions.loadSuppliers(),
actions.loadUnits(), actions.loadUnits(),
actions.loadWarehouses(), actions.loadWarehouses(),
@@ -512,6 +566,9 @@ const actions = {
inflightCompany = null; inflightCompany = null;
inflightNumbers = null; inflightNumbers = null;
inflightTerminals = null; inflightTerminals = null;
inflightPrinterAgents = null;
inflightAgentPrinters = null;
inflightLivePrinters = null;
inflightSuppliers = null; inflightSuppliers = null;
inflightUnits = null; inflightUnits = null;
inflightWarehouses = null; inflightWarehouses = null;
@@ -521,6 +578,8 @@ const actions = {
inflightBrands = null; inflightBrands = null;
inflightAttributes = null; inflightAttributes = null;
inflightAccounts = null; inflightAccounts = null;
inflightMembers = null;
inflightInvites = null;
companyAutomat.setState({ companyAutomat.setState({
...initialState, ...initialState,
viewerPosition: companyAutomat.state.viewerPosition, viewerPosition: companyAutomat.state.viewerPosition,
@@ -698,6 +757,180 @@ const actions = {
}, },
// #endregion // #endregion
// #region Printer agents (printeragent)
// MARK: calls printeragent.list from ../api/index.js
async loadPrinterAgents() {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightPrinterAgents) return inflightPrinterAgents;
companyAutomat.setState({ printeragentsStatus: 'loading', printeragentsError: null });
inflightPrinterAgents = (async () => {
try {
const res = await printeragentApi.list();
const printeragents = sortPrinterAgents(Array.isArray(res.printeragents) ? res.printeragents : []);
companyAutomat.setState({
printeragents,
printeragentsCompanyId: companyId,
printeragentsStatus: 'succeeded',
printeragentsError: null,
});
return printeragents;
} catch (err) {
companyAutomat.setState({
printeragentsStatus: 'failed',
printeragentsError: err?.message ?? String(err),
});
throw err;
} finally {
inflightPrinterAgents = null;
}
})();
return inflightPrinterAgents;
},
reloadPrinterAgents() {
return actions.loadPrinterAgents();
},
async createPrinterAgent(payload) {
const res = await printeragentApi.create(payload);
const agent = res.printeragent;
companyAutomat.setState({
printeragents: sortPrinterAgents([...companyAutomat.state.printeragents, agent]),
});
return agent;
},
async updatePrinterAgent(agentId, patch) {
const res = await printeragentApi.update(agentId, patch);
const updated = res.printeragent;
companyAutomat.setState({
printeragents: sortPrinterAgents(
companyAutomat.state.printeragents.map((a) =>
a.id === agentId ? { ...a, ...updated } : a,
),
),
});
return updated;
},
async deletePrinterAgent(agentId) {
await printeragentApi.delete(agentId);
companyAutomat.setState({
printeragents: companyAutomat.state.printeragents.filter((a) => a.id !== agentId),
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.printeragentId !== agentId),
});
},
// #endregion
// #region Agent printers (agentprinter) & live telemetry
// MARK: calls agentprinter.list from ../api/index.js
async loadAgentPrinters(printeragentId) {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightAgentPrinters) return inflightAgentPrinters;
companyAutomat.setState({ agentprintersStatus: 'loading', agentprintersError: null });
inflightAgentPrinters = (async () => {
try {
const res = await agentprinterApi.list(printeragentId);
const agentprinters = sortAgentPrinters(Array.isArray(res.agentprinters) ? res.agentprinters : []);
companyAutomat.setState({
agentprinters,
agentprintersCompanyId: companyId,
agentprintersStatus: 'succeeded',
agentprintersError: null,
});
return agentprinters;
} catch (err) {
companyAutomat.setState({
agentprintersStatus: 'failed',
agentprintersError: err?.message ?? String(err),
});
throw err;
} finally {
inflightAgentPrinters = null;
}
})();
return inflightAgentPrinters;
},
reloadAgentPrinters(printeragentId) {
return actions.loadAgentPrinters(printeragentId);
},
async updateAgentPrinter(id, patch) {
const res = await agentprinterApi.update(id, patch);
const updated = res.agentprinter;
companyAutomat.setState({
agentprinters: sortAgentPrinters(
companyAutomat.state.agentprinters.map((p) =>
p.id === id ? { ...p, ...updated } : p,
),
),
});
return updated;
},
async reorderAgentPrinters({ printeragentId, agentprinterIds }) {
await agentprinterApi.order({ printeragentId, agentprinterIds });
const idOrder = new Map(agentprinterIds.map((id, index) => [id, index]));
companyAutomat.setState({
agentprinters: sortAgentPrinters(
companyAutomat.state.agentprinters.map((p) =>
idOrder.has(p.id) ? { ...p, sort: idOrder.get(p.id) } : p,
),
),
});
},
async deleteAgentPrinter(id) {
await agentprinterApi.delete(id);
companyAutomat.setState({
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.id !== id),
});
},
async sendTestPrint(agentprinterId, options = {}) {
return printerjobApi.sendTestZpl(agentprinterId, options);
},
async cancelAllPrinterJobs(agentprinterId) {
return printerjobApi.cancelAll({ agentprinterId });
},
async createZplJob(payload) {
return printerjobApi.createZplJob(payload);
},
async loadLivePrinters() {
const companyId =
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
if (!companyId) return [];
if (inflightLivePrinters) return inflightLivePrinters;
inflightLivePrinters = (async () => {
try {
const res = await printerApi.state();
const livePrinters = Array.isArray(res.printers) ? res.printers : [];
companyAutomat.setState({ livePrinters });
return livePrinters;
} catch (err) {
return [];
} finally {
inflightLivePrinters = null;
}
})();
return inflightLivePrinters;
},
setLivePrinters(printers) {
companyAutomat.setState({
livePrinters: Array.isArray(printers) ? printers : [],
});
},
// #endregion
// #region 4. Suppliers (supplier) // #region 4. Suppliers (supplier)
// MARK: calls supplier.list from ../api/index.js // MARK: calls supplier.list from ../api/index.js
loadSuppliers() { loadSuppliers() {
@@ -1491,6 +1724,122 @@ const actions = {
return accounts; return accounts;
}, },
// #endregion // #endregion
// #region 14. Members (companyuser) & invites (autoinvite) — admin only
// MARK: calls companyuser.* / autoinvite.* from ../api/index.js
loadMembers() {
if (inflightMembers) return inflightMembers;
inflightMembers = (async () => {
companyAutomat.setState({ membersStatus: 'loading', membersError: null });
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersStatus: 'failed', membersError: error });
throw error;
} finally {
inflightMembers = null;
}
})();
return inflightMembers;
},
async reloadMembers() {
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersError: error });
throw error;
}
},
ensureMembersLoaded() {
if (companyAutomat.state.membersStatus === 'ready') {
return Promise.resolve(companyAutomat.state.members);
}
return actions.loadMembers();
},
/** Adds an already registered user by email. Rejects with 404/409 from the backend. */
async addMember(email, role = 'user') {
const res = await companyuserApi.create({ email, role });
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({ members: [...companyAutomat.state.members, member] });
return member;
},
/** Rejects with 409 when demoting the last admin. */
async setMemberRole(id, role) {
const res = await companyuserApi.setRole(id, role);
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({
members: companyAutomat.state.members.map((m) => (m.id === member.id ? member : m)),
});
return member;
},
/** Rejects with 409 when removing the last admin. */
async removeMember(id) {
await companyuserApi.delete(id);
companyAutomat.setState({ members: companyAutomat.state.members.filter((m) => m.id !== id) });
},
loadInvites() {
if (inflightInvites) return inflightInvites;
inflightInvites = (async () => {
companyAutomat.setState({ invitesStatus: 'loading', invitesError: null });
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesStatus: 'failed', invitesError: error });
throw error;
} finally {
inflightInvites = null;
}
})();
return inflightInvites;
},
async reloadInvites() {
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesError: error });
throw error;
}
},
ensureInvitesLoaded() {
if (companyAutomat.state.invitesStatus === 'ready') {
return Promise.resolve(companyAutomat.state.invites);
}
return actions.loadInvites();
},
/** Rejects with 409 when the email is already registered (use addMember then). */
async createInvite(email, role = 'user') {
const res = await autoinviteApi.create({ email, role });
const invite = requireObject(res?.autoinvite, 'autoinvite');
companyAutomat.setState({ invites: [...companyAutomat.state.invites, invite] });
return invite;
},
async deleteInvite(id) {
await autoinviteApi.delete(id);
companyAutomat.setState({ invites: companyAutomat.state.invites.filter((i) => i.id !== id) });
},
// #endregion
}; };
/** /**
@@ -1553,6 +1902,30 @@ sseAutomat.actions.on('carrier', () => {
sseAutomat.actions.on('posterminal', () => { sseAutomat.actions.on('posterminal', () => {
void companyAutomat.actions.loadTerminals().catch(() => {}); void companyAutomat.actions.loadTerminals().catch(() => {});
}); });
sseAutomat.actions.on('printeragent', () => {
void companyAutomat.actions.loadPrinterAgents().catch(() => {});
});
sseAutomat.actions.on('agentprinter', () => {
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
});
sseAutomat.actions.on('printerstate', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
return;
}
} catch {}
void companyAutomat.actions.loadLivePrinters().catch(() => {});
});
sseAutomat.actions.on('printer', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
}
} catch {}
});
sseAutomat.actions.on('customergroup', () => { sseAutomat.actions.on('customergroup', () => {
void companyAutomat.actions.reloadCustomergroups().catch(() => {}); void companyAutomat.actions.reloadCustomergroups().catch(() => {});
}); });
@@ -1565,6 +1938,15 @@ sseAutomat.actions.on('attributename', () => {
sseAutomat.actions.on('account', () => { sseAutomat.actions.on('account', () => {
void companyAutomat.actions.reloadAccounts().catch(() => {}); void companyAutomat.actions.reloadAccounts().catch(() => {});
}); });
// Admin-only lists: only refresh once an admin view has loaded them.
sseAutomat.actions.on('companyuser', () => {
if (companyAutomat.state.membersStatus !== 'ready') return;
void companyAutomat.actions.reloadMembers().catch(() => {});
});
sseAutomat.actions.on('autoinvite', () => {
if (companyAutomat.state.invitesStatus !== 'ready') return;
void companyAutomat.actions.reloadInvites().catch(() => {});
});
sseAutomat.actions.on('company', () => { sseAutomat.actions.on('company', () => {
void companyAutomat.actions.reload().catch(() => {}); void companyAutomat.actions.reload().catch(() => {});
}); });
@@ -1593,6 +1975,16 @@ export const selectPosterminals = () => companyAutomat.state.terminals;
export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus; export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus;
export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0; export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0;
// Printer agents selectors
export const selectPrinterAgents = () => companyAutomat.state.printeragents;
export const selectPrinterAgentStatus = () => companyAutomat.state.printeragentsStatus;
export const selectHasPrinterAgents = () => companyAutomat.state.printeragents.length > 0;
// Agent printers selectors
export const selectAgentPrinters = () => companyAutomat.state.agentprinters;
export const selectAgentPrinterStatus = () => companyAutomat.state.agentprintersStatus;
export const selectLivePrinters = () => companyAutomat.state.livePrinters;
// Suppliers selectors // Suppliers selectors
export const selectSuppliers = () => companyAutomat.state.suppliers; export const selectSuppliers = () => companyAutomat.state.suppliers;
export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus; export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus;
@@ -1633,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus
// Accounts selectors // Accounts selectors
export const selectAccounts = () => companyAutomat.state.accounts; export const selectAccounts = () => companyAutomat.state.accounts;
export const selectAccountsStatus = () => companyAutomat.state.accountsStatus; export const selectAccountsStatus = () => companyAutomat.state.accountsStatus;
// Own role in the selected company ('admin' | 'user' | undefined)
export const selectCompanyRole = () => companyAutomat.state.current?.role;
export const selectIsCompanyAdmin = () => companyAutomat.state.current?.role === 'admin';
// Members & invites selectors (admin only)
export const selectMembers = () => companyAutomat.state.members;
export const selectMembersStatus = () => companyAutomat.state.membersStatus;
export const selectInvites = () => companyAutomat.state.invites;
export const selectInvitesStatus = () => companyAutomat.state.invitesStatus;
// #endregion // #endregion
export default companyAutomat; export default companyAutomat;

View File

@@ -151,6 +151,12 @@ export {
selectPosterminals, selectPosterminals,
selectPosterminalStatus, selectPosterminalStatus,
selectHasPosterminals, selectHasPosterminals,
selectPrinterAgents,
selectPrinterAgentStatus,
selectHasPrinterAgents,
selectAgentPrinters,
selectAgentPrinterStatus,
selectLivePrinters,
selectSuppliers, selectSuppliers,
selectSuppliersStatus, selectSuppliersStatus,
selectUnits, selectUnits,
@@ -172,6 +178,12 @@ export {
selectAttributeStatus, selectAttributeStatus,
selectAccounts, selectAccounts,
selectAccountsStatus, selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat'; } from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat'; export { default as categoryAutomat } from './categoryAutomat';

View File

@@ -8,6 +8,7 @@ import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import OnlinePrinterIcons from './OnlinePrinterIcons';
import HelpBadge from './HelpBadge'; import HelpBadge from './HelpBadge';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery'; import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
@@ -198,8 +199,20 @@ class AppSidebar extends PureComponent {
})} })}
</List> </List>
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}> <Box
sx={{
ml: { xs: 'auto', lg: 0 },
flexShrink: 0,
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'flex-start' },
width: { xs: 'auto', lg: '100%' },
gap: { xs: 0.5, lg: 0 },
}}
>
<OnlinePrinterIcons />
<UserMenu /> <UserMenu />
<ChatFab />
</Box> </Box>
</Box> </Box>
); );
@@ -418,7 +431,6 @@ export default function AppShell() {
</Suspense> </Suspense>
</Box> </Box>
</Box> </Box>
<ChatFab />
<ChatDrawer /> <ChatDrawer />
<PinnedAppletFabs pathname={pathname} /> <PinnedAppletFabs pathname={pathname} />
<PinnedAppletDialog /> <PinnedAppletDialog />

View File

@@ -0,0 +1,318 @@
import { PureComponent } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import { enqueueSnackbar } from 'notistack';
import companyAutomat from '../automats/companyAutomat';
import { notifyError } from '../utils/notify';
import { getPrinterImage } from '../assets/printers';
export default class OnlinePrinterIcons extends PureComponent {
constructor(props) {
super(props);
this.state = {
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
? companyAutomat.state.agentprinters
: [],
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
menuAnchor: null,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
this.handleTestPrint = this.handleTestPrint.bind(this);
this.handleCancelAllJobs = this.handleCancelAllJobs.bind(this);
this.handleOpenPrinterSettings = this.handleOpenPrinterSettings.bind(this);
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
}));
}
componentWillUnmount() {
this.unsub?.();
}
getOnlinePrinters() {
const { agentprinters = [], livePrinters = [] } = this.state;
const liveBySerial = new Map();
for (const lp of livePrinters) {
if (lp.serial) liveBySerial.set(String(lp.serial).toLowerCase(), lp);
}
if (agentprinters.length > 0) {
return agentprinters
.filter((p) => {
if (p.online === true) return true;
const live =
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
livePrinters.find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === p.id) ||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
);
if (!live) return false;
return live.status !== 'offline' && !live.unreachable && !live.errors?.unreachable;
})
.map((p) => {
const live =
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
livePrinters.find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === p.id) ||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
);
return { ...p, live };
});
}
return livePrinters
.filter((lp) => lp.status !== 'offline' && !lp.unreachable && !lp.errors?.unreachable)
.map((lp) => ({
id: lp.agentprinterId || lp.id || lp.serial,
model: lp.model,
serial: lp.serial,
type: lp.type,
online: true,
open: Boolean(lp.errors?.headOpen || lp.headOpen),
pause: Boolean(lp.errors?.pause || lp.pause),
error: Boolean(lp.errors?.other || lp.error),
labelLength: lp.labelLength,
live: lp,
}));
}
handleOpenMenu(event, printer) {
this.setState({
menuAnchor: {
el: event.currentTarget,
printer,
},
});
}
handleCloseMenu() {
this.setState({ menuAnchor: null });
}
handleTestPrint(printer) {
if (!printer?.id) return;
this.handleCloseMenu();
companyAutomat.actions
.sendTestPrint(printer.id, {
model: printer.model || printer.type || 'Zebra',
})
.then(() => {
enqueueSnackbar(
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Warteschlange gesendet.`,
{ variant: 'success' },
);
})
.catch(notifyError);
}
handleCancelAllJobs(printer) {
if (!printer?.id) return;
this.handleCloseMenu();
companyAutomat.actions
.cancelAllPrinterJobs(printer.id)
.then(() => {
enqueueSnackbar(`Druckaufträge für ${printer.model || 'Drucker'} abgebrochen.`, {
variant: 'info',
});
})
.catch(notifyError);
}
handleOpenPrinterSettings() {
this.handleCloseMenu();
window.dispatchEvent(
new CustomEvent('open-settings', { detail: { tab: 'printeragent' } }),
);
}
render() {
const { menuAnchor } = this.state;
const onlinePrinters = this.getOnlinePrinters();
if (onlinePrinters.length === 0) {
return null;
}
const activePrinter = menuAnchor?.printer;
const activeHasError = Boolean(
activePrinter?.error ||
activePrinter?.open ||
activePrinter?.live?.errors?.other ||
activePrinter?.live?.errors?.headOpen,
);
const activeIsPaused = Boolean(activePrinter?.pause || activePrinter?.live?.errors?.pause);
const activeStatusText = activeHasError
? activePrinter?.open || activePrinter?.live?.errors?.headOpen
? 'Druckkopf offen'
: 'Fehler'
: activeIsPaused
? 'Pausiert'
: 'Bereit';
const anchorEl = menuAnchor?.el;
const isTop = Boolean(
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
);
return (
<>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'flex-start' },
alignSelf: { xs: 'center', lg: 'flex-start' },
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 0.5 },
gap: { xs: 0.5, lg: 1 },
}}
>
{onlinePrinters.map((p, index) => {
const hasError = Boolean(
p.error || p.open || p.live?.errors?.other || p.live?.errors?.headOpen,
);
const isPaused = Boolean(p.pause || p.live?.errors?.pause);
const label = p.model || 'Drucker';
const statusText = hasError
? p.open || p.live?.errors?.headOpen
? 'Druckkopf offen'
: 'Fehler'
: isPaused
? 'Pausiert'
: 'Bereit';
const tooltipTitle = `${label}${p.serial ? ` (${p.serial})` : ''} • ${statusText}`;
return (
<IconButton
key={p.id || p.serial || index}
onClick={(e) => this.handleOpenMenu(e, p)}
title={tooltipTitle}
sx={(theme) => ({
p: 0,
width: 32,
height: 32,
borderRadius: '50%',
color: theme.nav?.item || 'inherit',
'&:hover': {
color: theme.nav?.itemHoverText || 'inherit',
bgcolor: theme.nav?.itemHover || 'action.hover',
},
})}
aria-label={`Drucker ${label}`}
>
<Badge
variant="dot"
color={hasError ? 'error' : isPaused ? 'warning' : 'success'}
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<PrintOutlinedIcon sx={{ fontSize: 20 }} />
</Badge>
</IconButton>
);
})}
</Box>
<Menu
anchorEl={anchorEl}
open={Boolean(menuAnchor)}
onClose={this.handleCloseMenu}
anchorOrigin={{
vertical: isTop ? 'bottom' : 'top',
horizontal: isTop ? 'right' : 'left',
}}
transformOrigin={{
vertical: isTop ? 'top' : 'bottom',
horizontal: isTop ? 'right' : 'left',
}}
>
{activePrinter && (
<Box sx={{ px: 2, py: 1, minWidth: 210 }}>
{getPrinterImage(activePrinter.model) ? (
<Box
component="img"
src={getPrinterImage(activePrinter.model)}
alt={activePrinter.model}
sx={{ display: 'block', mx: 'auto', mb: 1, maxWidth: 80, height: 'auto' }}
/>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" fontWeight={600} noWrap>
{activePrinter.model || 'Drucker'}
</Typography>
<Chip
label={activeStatusText}
size="small"
color={activeHasError ? 'error' : activeIsPaused ? 'warning' : 'success'}
variant="outlined"
sx={{ fontSize: '0.65rem', height: 18, fontWeight: 600 }}
/>
</Box>
{activePrinter.serial ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', display: 'block' }}
>
S/N: {activePrinter.serial}
</Typography>
) : null}
{activePrinter.labelLength ? (
<Typography variant="caption" color="text.secondary" display="block">
Etikett: {activePrinter.labelLength} px
</Typography>
) : null}
</Box>
)}
<Divider />
<MenuItem onClick={() => this.handleTestPrint(activePrinter)}>
<ListItemIcon>
<PrintOutlinedIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Testdruck"
/>
</MenuItem>
<MenuItem onClick={() => this.handleCancelAllJobs(activePrinter)}>
<ListItemIcon>
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText
primary="Aufträge abbrechen"
/>
</MenuItem>
<MenuItem onClick={this.handleOpenPrinterSettings}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Einstellungen" />
</MenuItem>
</Menu>
</>
);
}
}

View File

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

View File

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

View File

@@ -9,14 +9,17 @@ import {
class ArticlePhoto extends Component { class ArticlePhoto extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { thumbLoaded: false }; this.state = { thumbLoaded: false, smallThumbError: false };
this.handleThumbLoad = this.handleThumbLoad.bind(this); this.handleThumbLoad = this.handleThumbLoad.bind(this);
this.handleThumbError = this.handleThumbError.bind(this); this.handleThumbError = this.handleThumbError.bind(this);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) { if (
this.setState({ thumbLoaded: false }); prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
prevProps.useSmallThumb !== this.props.useSmallThumb
) {
this.setState({ thumbLoaded: false, smallThumbError: false });
} }
} }
@@ -25,7 +28,11 @@ class ArticlePhoto extends Component {
} }
handleThumbError() { handleThumbError() {
this.setState({ thumbLoaded: false }); if (this.props.useSmallThumb && !this.state.smallThumbError) {
this.setState({ smallThumbError: true, thumbLoaded: false });
} else {
this.setState({ thumbLoaded: false });
}
} }
render() { render() {
@@ -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

@@ -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: s.categories,
categories: this.props.categories ?? s.categories, rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
}));
}); });
} });
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
} else if (this.state.rootCategoryId != null) { } else if (this.state.rootCategoryId != null) {
nextSelectedId = this.state.rootCategoryId; nextSelectedId = this.state.rootCategoryId;
} }
this.setState({ selectedId: nextSelectedId }); this.setState({
} selectedId: nextSelectedId,
categories: categoryAutomat.state.categories ?? this.props.categories,
if ( rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
});
} else if (
this.props.categories !== prevProps.categories || this.props.categories !== prevProps.categories ||
this.props.rootCategoryId !== prevProps.rootCategoryId this.props.rootCategoryId !== prevProps.rootCategoryId
) { ) {

View File

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

View File

@@ -45,7 +45,7 @@ import {
alpha, alpha,
} from '../../../colors'; } from '../../../colors';
function getVariantDetails(child, allArticles, defaultTaxRate) { function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
const cached = child?.sku ? getArticle(child.sku) : null; const cached = child?.sku ? getArticle(child.sku) : null;
const fromIndex = !cached && child?.sku const fromIndex = !cached && child?.sku
? (allArticles || []).find((a) => a.sku === child.sku) ? (allArticles || []).find((a) => a.sku === child.sku)
@@ -56,14 +56,30 @@ 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 (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) { if (trackStock) {
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0); if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) { stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
stockVal = Number(merged.stock); } else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) { stockVal = Number(merged.stock);
stockVal = Number(child.stock); } else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
stockVal = Number(child.stock);
}
} }
const rawUnit = const rawUnit =
@@ -76,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase()) rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
? ` ${rawUnit}` ? ` ${rawUnit}`
: ''; : '';
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null; const stockDisplay = trackStock && stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
// 3. EAN(s) // 4. EAN(s)
const eanCandidates = []; const eanCandidates = [];
const rawEans = merged.eans ?? child?.eans; const rawEans = merged.eans ?? child?.eans;
if (Array.isArray(rawEans)) { if (Array.isArray(rawEans)) {
@@ -103,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans: eanCandidates, allEans: eanCandidates,
trackStock,
}; };
} }
@@ -123,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
this.state = { this.state = {
attributes: initialAttributes, attributes: initialAttributes,
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
assignedAttributes: [], assignedAttributes: [],
allArticles: [], allArticles: [],
busy: false, busy: false,
@@ -170,10 +188,11 @@ export default class ArticleViewLists extends PureComponent {
return { return {
company: state.current, company: state.current,
attributes: state.attributes, attributes: state.attributes,
attributesStatus: state.attributesStatus,
selectedAttributeId: nextSelectedId, selectedAttributeId: nextSelectedId,
}; };
}); });
}, ['current', 'attributes']); }, ['current', 'attributes', 'attributesStatus']);
this.unsubArtikel = artikelAutomat.subscribe(() => { this.unsubArtikel = artikelAutomat.subscribe(() => {
if (this._cancelled) return; if (this._cancelled) return;
@@ -197,7 +216,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) { if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children); this.ensureChildren(parent.children);
} }
}).catch(() => {}); }).catch(() => { });
} }
void this.loadAllArticles(); void this.loadAllArticles();
@@ -218,7 +237,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) { if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children); this.ensureChildren(parent.children);
} }
}).catch(() => {}); }).catch(() => { });
} else { } else {
const parent = getArticle(this.props.article.parentSku); const parent = getArticle(this.props.article.parentSku);
if (parent?.children) { if (parent?.children) {
@@ -266,7 +285,7 @@ export default class ArticleViewLists extends PureComponent {
if (!Array.isArray(children)) return; if (!Array.isArray(children)) return;
for (const child of children) { for (const child of children) {
if (child?.sku && !getArticle(child.sku)) { if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {}); void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
} }
} }
} }
@@ -391,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state; const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
if (busy || !article?.sku) return; if (busy || !article?.sku) return;
const val = newChildText.trim();
const payload = {}; const payload = {};
if (isCustomChildValue) {
const val = newChildText.trim(); if (val && (isCustomChildValue || !selectedChildValueId)) {
if (!val) return; 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;
@@ -489,17 +519,17 @@ export default class ArticleViewLists extends PureComponent {
const nextItems = const nextItems =
existingIndex >= 0 existingIndex >= 0
? currentItems.map((item, idx) => ? currentItems.map((item, idx) =>
idx === existingIndex ? { ...item, count: item.count + count } : item idx === existingIndex ? { ...item, count: item.count + count } : item
) )
: [ : [
...currentItems, ...currentItems,
{ {
articleId: stuecklisteTargetArticle.id, articleId: stuecklisteTargetArticle.id,
sku: stuecklisteTargetArticle.sku, sku: stuecklisteTargetArticle.sku,
count, count,
sort: currentItems.length, sort: currentItems.length,
}, },
]; ];
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
@@ -613,20 +643,20 @@ export default class ArticleViewLists extends PureComponent {
const nextOptions = const nextOptions =
existingIndex >= 0 existingIndex >= 0
? currentOptions.map((opt, idx) => ? currentOptions.map((opt, idx) =>
idx === existingIndex idx === existingIndex
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) } ? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
: opt : opt
) )
: [ : [
...currentOptions, ...currentOptions,
{ {
articleId: optionTargetArticle.id, articleId: optionTargetArticle.id,
sku: optionTargetArticle.sku, sku: optionTargetArticle.sku,
count, count,
sort: currentOptions.length, sort: currentOptions.length,
mandated: Boolean(optionMandated), mandated: Boolean(optionMandated),
}, },
]; ];
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
@@ -832,70 +862,70 @@ export default class ArticleViewLists extends PureComponent {
}} }}
> >
{stuecklisteItems.map((item) => ( {stuecklisteItems.map((item) => (
<Box <Box
key={item.id || item.sku} key={item.id || item.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${item.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{ sx={{
display: 'flex', p: 0.25,
alignItems: 'center', color: 'text.secondary',
justifyContent: 'space-between', '&:hover': { color: 'error.main' },
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}} }}
> >
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}> <DeleteOutlinedIcon sx={{ fontSize: 16 }} />
<Chip </IconButton>
label={`${item.count}x`} </Box>
size="small" ))
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
} }
</Box> </Box>
</Box> </Box>
@@ -1042,81 +1072,81 @@ export default class ArticleViewLists extends PureComponent {
}} }}
> >
{optionsItems.map((opt) => ( {optionsItems.map((opt) => (
<Box <Box
key={opt.id || opt.sku} key={opt.id || opt.sku}
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
px: 1, px: 1,
py: 0.4, py: 0.4,
borderRadius: 1, borderRadius: 1,
bgcolor: 'background.paper', bgcolor: 'background.paper',
border: '1px solid', border: '1px solid',
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}> <Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip <Chip
label={`${opt.count}x`} label={`${opt.count}x`}
size="small" size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }} sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/> />
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}> <Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography <Typography
variant="body2" variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{opt.name || opt.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Link
component={RouterLink}
to={artikelDetailPath(opt.sku)}
sx={{ sx={{
fontWeight: 600, fontSize: '0.72rem',
fontSize: '0.82rem', fontFamily: 'monospace',
lineHeight: 1.2, color: 'text.secondary',
overflow: 'hidden', textDecoration: 'none',
textOverflow: 'ellipsis', '&:hover': { textDecoration: 'underline' },
whiteSpace: 'nowrap',
}} }}
> >
{opt.name || opt.sku} {opt.sku}
</Typography> </Link>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}> {opt.mandated ? (
<Link <Chip
component={RouterLink} label="Fest"
to={artikelDetailPath(opt.sku)} size="small"
sx={{ color="warning"
fontSize: '0.72rem', variant="outlined"
fontFamily: 'monospace', sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
color: 'text.secondary', />
textDecoration: 'none', ) : null}
'&:hover': { textDecoration: 'underline' },
}}
>
{opt.sku}
</Link>
{opt.mandated ? (
<Chip
label="Fest"
size="small"
color="warning"
variant="outlined"
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
/>
) : null}
</Box>
</Box> </Box>
</Box> </Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box> </Box>
))
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
} }
</Box> </Box>
</Box> </Box>
@@ -1209,14 +1239,14 @@ export default class ArticleViewLists extends PureComponent {
const siblings = hasSelf const siblings = hasSelf
? childrenList ? childrenList
: [ : [
...childrenList, ...childrenList,
{ {
id: articleData.id, id: articleData.id,
sku: articleData.sku, sku: articleData.sku,
name: articleData.name, name: articleData.name,
attributeValue: variationValue, attributeValue: variationValue,
}, },
]; ];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
@@ -1346,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans, allEans,
} = getVariantDetails(sibling, allArticles, defaultTaxRate); trackStock,
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
return ( return (
<Box <Box
@@ -1463,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main', color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
@@ -1582,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main', color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
@@ -1602,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
} }
renderStandardView(selectableAttributes, assignedAttributeIds) { renderStandardView(selectableAttributes, assignedAttributeIds) {
const { busy, activeCreatorMode, selectedAttributeId } = this.state; const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{activeCreatorMode === 'parent' ? ( {activeCreatorMode === 'parent' ? (
@@ -1624,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
</Button> </Button>
</Box> </Box>
{selectableAttributes.length === 0 ? ( {attributesStatus === 'loading' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={14} />
<Typography variant="caption" color="text.secondary">
Merkmale werden geladen…
</Typography>
</Box>
) : !attributes || attributes.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
) : selectableAttributes.length === 0 ? (
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen. Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
</Typography> </Typography>
@@ -1877,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}> <Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Varianten: Varianten:
</Typography> </Typography>
{children.length === 0 ? ( {children.length === 0 ? null : (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => { {children.map((child) => {
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19; const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
@@ -1891,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay, stockDisplay,
primaryEan, primaryEan,
allEans, allEans,
} = getVariantDetails(child, allArticles, defaultTaxRate); trackStock,
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
return ( return (
<Box <Box
@@ -2033,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{ sx={{
fontSize: '0.74rem', fontSize: '0.74rem',
fontWeight: 600, fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main', color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
@@ -2046,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
size="small" size="small"
disabled={busy} disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)} onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{ sx={{
p: 0.35, p: 0.35,
color: 'text.secondary', color: 'text.secondary',
@@ -2067,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
{isCustomChildValue || availableParentValues.length === 0 ? ( {isCustomChildValue || availableParentValues.length === 0 ? (
<TextInput <TextInput
label="Neuer Wert" label="Neuer Wert"
placeholder="Neuer Wert..."
value={newChildText} value={newChildText}
disabled={busy} disabled={busy}
onChange={(e) => this.setState({ newChildText: e.target.value })} onChange={(e) => this.setState({ newChildText: e.target.value })}
@@ -2112,34 +2167,31 @@ export default class ArticleViewLists extends PureComponent {
</TextInput> </TextInput>
)} )}
<Tooltip title="Variante hinzufügen"> <IconButton
<span> size="small"
<IconButton color="primary"
size="small" aria-label="Variante hinzufügen"
color="primary" disabled={
disabled={ busy ||
busy || (isCustomChildValue || availableParentValues.length === 0
(isCustomChildValue || availableParentValues.length === 0 ? !newChildText.trim()
? !newChildText.trim() : !selectedChildValueId)
: !selectedChildValueId) }
} onClick={this.handleAddChild}
onClick={this.handleAddChild} sx={{
sx={{ bgcolor: 'primary.main',
bgcolor: 'primary.main', color: 'primary.contrastText',
color: 'primary.contrastText', '&:hover': { bgcolor: 'primary.dark' },
'&:hover': { bgcolor: 'primary.dark' }, '&.Mui-disabled': {
'&.Mui-disabled': { bgcolor: 'action.disabledBackground',
bgcolor: 'action.disabledBackground', color: 'action.disabled',
color: 'action.disabled', },
}, p: 0.6,
p: 0.6, flexShrink: 0,
flexShrink: 0, }}
}} >
> {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 */}
@@ -2174,16 +2226,16 @@ export default class ArticleViewLists extends PureComponent {
)} )}
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}> <Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
{isStueckliste || {isStueckliste ||
hasOptions || hasOptions ||
activeCreatorMode === 'stueckliste' || activeCreatorMode === 'stueckliste' ||
activeCreatorMode === 'options' activeCreatorMode === 'options'
? this.renderCompoundManager( ? this.renderCompoundManager(
isStueckliste, isStueckliste,
hasOptions, hasOptions,
stuecklisteItems, stuecklisteItems,
optionsItems, optionsItems,
selectableOtherArticles selectableOtherArticles
) )
: this.renderStandardView(selectableAttributes, assignedAttributeIds)} : this.renderStandardView(selectableAttributes, assignedAttributeIds)}
</Box> </Box>
</Box> </Box>
@@ -2262,11 +2314,11 @@ export default class ArticleViewLists extends PureComponent {
<DialogContentText sx={{ fontSize: '0.88rem' }}> <DialogContentText sx={{ fontSize: '0.88rem' }}>
{confirmClearType === 'stueckliste' {confirmClearType === 'stueckliste'
? (hasOptions ? (hasOptions
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.' ? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?') : 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
: (isStueckliste : (isStueckliste
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.' ? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')} : 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
</DialogContentText> </DialogContentText>
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 2, pb: 2 }}> <DialogActions sx={{ px: 2, pb: 2 }}>

View File

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

View File

@@ -1,4 +1,6 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -562,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
</Tooltip> </Tooltip>
</Box> </Box>
{merkmalOptions.length === 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
)}
{/* Unified Assigned Merkmale List */} {/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && ( {assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>

View File

@@ -9,20 +9,70 @@ import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import PrintIcon from '@mui/icons-material/Print'; import PrintIcon from '@mui/icons-material/Print';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { labeltemplate, printer, article } from '../../../api'; import { labeltemplate, article } from '../../../api';
import { companyAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import BackendError from '../../BackendError'; import BackendError from '../../BackendError';
function filterZplPrinters(agentprinters) {
return (Array.isArray(agentprinters) ? agentprinters : []).filter(
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
);
}
function getPrinterLabel(p) {
let label;
if (p.model && p.serial) {
label = `${p.model} (${p.serial})`;
} else if (p.model) {
label = p.model;
} else if (p.serial) {
label = `Drucker (${p.serial})`;
} else if (p.desc && p.address) {
label = `${p.desc} (${p.address})`;
} else if (p.address) {
label = p.address;
} else if (p.desc) {
label = p.desc;
} else {
label = `Drucker #${p.id}`;
}
if (p.online === false) {
return `${label} (offline)`;
}
return label;
}
function getPrinterLabelLength(printer, livePrinters = []) {
if (!printer) return null;
const live = (livePrinters || []).find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === printer.id) ||
(lp.id && lp.id === printer.id) ||
(printer.serial && lp.serial && String(printer.serial).toLowerCase() === String(lp.serial).toLowerCase()),
);
const raw = printer.labelLength ?? live?.labelLength;
if (raw == null) return null;
const num = Number(raw);
return Number.isFinite(num) && num > 0 ? num : null;
}
export default class ArticleLabelTab extends PureComponent { export default class ArticleLabelTab extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.state = { this.state = {
templates: null, templates: null,
printers: null, printers: initialPrinters,
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
selectedTemplateId: '', selectedTemplateId: '',
selectedPrinterId: '', selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
quantity: 1, quantity: 1,
loading: true, loading: true,
error: null, error: null,
@@ -30,27 +80,61 @@ export default class ArticleLabelTab extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => {
const zplPrinters = filterZplPrinters(state.agentprinters);
this.setState((prev) => ({
printers: zplPrinters,
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
selectedPrinterId:
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
? prev.selectedPrinterId
: (zplPrinters[0]?.id ?? ''),
}));
});
this.loadData(); this.loadData();
} }
componentDidUpdate(prevProps) {
if (
prevProps.article?.sku !== this.props.article?.sku ||
prevProps.article?.name !== this.props.article?.name ||
prevProps.article?.prices !== this.props.article?.prices ||
prevProps.article?.eans !== this.props.article?.eans
) {
this.loadData();
}
}
componentWillUnmount() {
this.unsub?.();
}
async loadData() { async loadData() {
this.setState({ loading: true, error: null }); this.setState({ loading: true, error: null });
const sku = this.props.article?.sku;
try { try {
const [resTemplates, resPrinters] = await Promise.all([ const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
labeltemplate.list(),
printer.list(),
]);
const templates = resTemplates.labeltemplates || []; const templates = resTemplates.labeltemplates || [];
const zplPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl'); const zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.setState({ this.setState((prev) => ({
templates, templates,
printers: zplPrinters, printers: zplPrinters,
selectedTemplateId: templates.length > 0 ? templates[0].id : '', livePrinters: Array.isArray(companyAutomat.state.livePrinters)
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '', ? companyAutomat.state.livePrinters
: prev.livePrinters,
selectedTemplateId:
templates.length > 0
? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId)
? prev.selectedTemplateId
: templates[0].id
: '',
selectedPrinterId:
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
? prev.selectedPrinterId
: (zplPrinters[0]?.id ?? ''),
loading: false, loading: false,
}); }));
} catch (error) { } catch (error) {
this.setState({ error, loading: false }); this.setState({ error, loading: false });
} }
@@ -68,7 +152,7 @@ export default class ArticleLabelTab extends PureComponent {
try { try {
await article.printLabel(articleData.sku, { await article.printLabel(articleData.sku, {
templateId: selectedTemplateId, templateId: selectedTemplateId,
printerId: selectedPrinterId, agentprinterId: selectedPrinterId,
quantity, quantity,
}); });
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' }); enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
@@ -77,10 +161,24 @@ export default class ArticleLabelTab extends PureComponent {
} }
}; };
handleCancelAllJobs = async () => {
const { selectedPrinterId, printers } = this.state;
const p = (printers || []).find((item) => item.id === selectedPrinterId);
const printerName = p ? getPrinterLabel(p) : 'Drucker';
try {
await companyAutomat.actions.cancelAllPrinterJobs(selectedPrinterId || undefined);
enqueueSnackbar(`Druckaufträge für ${printerName} abgebrochen.`, { variant: 'info' });
} catch (err) {
notifyError(err);
}
};
render() { render() {
const { const {
templates, templates,
printers, printers,
livePrinters,
selectedTemplateId, selectedTemplateId,
selectedPrinterId, selectedPrinterId,
quantity, quantity,
@@ -88,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
error, error,
} = this.state; } = this.state;
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
if (loading) { if (loading) {
return ( return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}> <Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
@@ -130,8 +231,18 @@ export default class ArticleLabelTab extends PureComponent {
<Grid container spacing={2}> <Grid container spacing={2}>
{templates.map((t) => { {templates.map((t) => {
const isSelected = t.id === selectedTemplateId; const isSelected = t.id === selectedTemplateId;
const dpi = t.dpi === 300 ? 300 : 203;
const tmplHeight = t.height || 200;
const tmplWidth = t.width || 448;
const hasPrinterLength = printerLabelLength != null && printerLabelLength > 0;
const totalHeight = hasPrinterLength ? Math.max(tmplHeight, printerLabelLength) : tmplHeight;
const isCutoff = hasPrinterLength && printerLabelLength < tmplHeight;
const outlineColor = isCutoff ? '#d32f2f' : '#1976d2';
const tmplHeightPercent = (tmplHeight / totalHeight) * 100;
const printerHeightPercent = hasPrinterLength ? (printerLabelLength / totalHeight) * 100 : 100;
return ( return (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
<Paper <Paper
variant="outlined" variant="outlined"
onClick={() => this.setState({ selectedTemplateId: t.id })} onClick={() => this.setState({ selectedTemplateId: t.id })}
@@ -175,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
bgcolor: 'background.default', bgcolor: 'background.default',
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}} }}
> >
{t.thumb ? ( {t.thumb ? (
<img <Box
src={`data:image/png;base64,${t.thumb}`} sx={{
alt={t.name} position: 'relative',
style={{ display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
objectFit: 'contain', aspectRatio: `${tmplWidth} / ${totalHeight}`,
boxSizing: 'border-box',
bgcolor: '#fff',
borderRadius: 0.5,
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}} }}
/> >
{/* Template preview image */}
<Box
sx={{
width: '100%',
height: `${tmplHeightPercent}%`,
position: 'relative',
display: 'flex',
overflow: 'hidden',
borderRadius: 0.5,
}}
>
<img
src={`data:image/png;base64,${t.thumb}`}
alt={t.name}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
{/* If template exceeds printer label length, shade the cutoff overflow */}
{isCutoff && (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
top: `${(printerLabelLength / tmplHeight) * 100}%`,
bottom: 0,
bgcolor: 'rgba(211, 47, 47, 0.22)',
zIndex: 1,
pointerEvents: 'none',
}}
/>
)}
</Box>
{/* Dotted outline for printer label length */}
{hasPrinterLength && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${printerHeightPercent}%`,
border: `2px dotted ${outlineColor}`,
borderRadius: 0.5,
pointerEvents: 'none',
boxSizing: 'border-box',
zIndex: 2,
}}
/>
)}
</Box>
) : ( ) : (
<Typography variant="caption" color="text.disabled"> <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) => {
<MenuItem key={p.id} value={p.id}> const len = getPrinterLabelLength(p, livePrinters);
{p.desc ? `${p.desc} (${p.address})` : p.address} return (
</MenuItem> <MenuItem key={p.id} value={p.id}>
))} {getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
</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

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

View File

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

View File

@@ -1,10 +1,9 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab'; import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded'; import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
@@ -70,144 +69,161 @@ 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%',
bgcolor: CHAT_BUSY,
opacity: 0.5,
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabAskPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
},
}}
/>
)}
{/* Animated pulsating halo when model is busy thinking/working */}
{!hasPendingAsk && isBusy && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -6,
left: -6,
right: -6,
bottom: -6,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={66}
thickness={2.5}
sx={{
position: 'absolute',
top: -6,
left: -6,
color: CHAT_BUSY,
pointerEvents: 'none',
}}
/>
)}
<Badge
badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
variant={hasPendingAsk ? 'standard' : 'dot'}
invisible={!hasPendingAsk && !isBusy}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
'& .MuiBadge-badge': hasPendingAsk
? {
bgcolor: RED,
color: WHITE,
fontWeight: 900,
fontSize: '0.85rem',
minWidth: 22,
height: 22,
borderRadius: '50%', borderRadius: '50%',
boxShadow: `0 2px 10px ${RED_GLOW}`, bgcolor: CHAT_BUSY,
animation: 'badgePop 1.5s ease infinite', opacity: 0.5,
'@keyframes badgePop': { animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'0%, 100%': { transform: 'scale(1)' }, '@keyframes fabAskPulse': {
'50%': { transform: 'scale(1.2)' }, '0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
}, },
} }}
: undefined, />
}} )}
>
<Fab {/* Animated pulsating halo when model is busy thinking/working */}
color="primary" {!hasPendingAsk && isBusy && !isOpen && (
aria-label={ariaLabel} <Box
sx={{
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={38}
thickness={3}
sx={{
position: 'absolute',
top: -3,
left: -3,
color: CHAT_BUSY,
pointerEvents: 'none',
}}
/>
)}
<IconButton
onClick={this.handleClick} onClick={this.handleClick}
sx={(theme) => ({ aria-label={ariaLabel}
width: 54, sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
height: 54,
boxShadow: hasPendingAsk
? CHAT_FAB_SHADOW_PENDING
: isBusy
? CHAT_FAB_SHADOW_BUSY
: CHAT_FAB_SHADOW_IDLE,
background: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
})}
> >
{isOpen ? ( <Badge
<CloseRoundedIcon sx={{ fontSize: 24 }} /> badgeContent={hasPendingAsk ? '?' : undefined}
) : hasPendingAsk ? ( color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
<HelpOutlineRoundedIcon variant={hasPendingAsk ? 'standard' : 'dot'}
sx={{ invisible={!hasPendingAsk && !isBusy}
fontSize: 26, overlap="circular"
animation: 'fabWiggle 2s ease infinite', anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
'@keyframes fabWiggle': { sx={{
'0%, 100%': { transform: 'rotate(0deg)' }, '& .MuiBadge-badge': hasPendingAsk
'15%': { transform: 'rotate(-12deg)' }, ? {
'30%': { transform: 'rotate(12deg)' }, bgcolor: RED,
'45%': { transform: 'rotate(-8deg)' }, color: WHITE,
'60%': { transform: 'rotate(0deg)' }, fontWeight: 900,
}, fontSize: '0.75rem',
}} minWidth: 18,
/> height: 18,
) : isBusy ? ( borderRadius: '50%',
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} /> boxShadow: `0 2px 8px ${RED_GLOW}`,
) : ( animation: 'badgePop 1.5s ease infinite',
<ChatRoundedIcon sx={{ fontSize: 24 }} /> '@keyframes badgePop': {
)} '0%, 100%': { transform: 'scale(1)' },
</Fab> '50%': { transform: 'scale(1.2)' },
</Badge> },
}
: undefined,
}}
>
<Avatar
sx={(theme) => ({
width: 32,
height: 32,
bgcolor: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
background: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
color: WHITE,
transition: 'all 0.2s ease',
boxShadow: hasPendingAsk
? CHAT_FAB_SHADOW_PENDING
: isBusy
? CHAT_FAB_SHADOW_BUSY
: isOpen
? `0 2px 8px rgba(0,0,0,0.25)`
: 'none',
})}
>
{isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 18 }} />
) : hasPendingAsk ? (
<HelpOutlineRoundedIcon
sx={{
fontSize: 18,
animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' },
'15%': { transform: 'rotate(-12deg)' },
'30%': { transform: 'rotate(12deg)' },
'45%': { transform: 'rotate(-8deg)' },
'60%': { transform: 'rotate(0deg)' },
},
}}
/>
) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
) : (
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
)}
</Avatar>
</Badge>
</IconButton>
</Box>
</Box> </Box>
</Box> );
);
} }
} }

View File

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

View File

@@ -19,17 +19,18 @@ export default class ArticleSearchDialog extends PureComponent {
this.state = { this.state = {
assigningSku: null, assigningSku: null,
error: null, error: null,
showAllArticles: false,
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) { if (!prevProps.open && this.props.open) {
this.setState({ assigningSku: null, error: null }); this.setState({ assigningSku: null, error: null, showAllArticles: false });
} }
} }
handleClose = () => { handleClose = () => {
this.setState({ assigningSku: null, error: null }); this.setState({ assigningSku: null, error: null, showAllArticles: false });
this.props.onClose?.(); this.props.onClose?.();
}; };
@@ -61,9 +62,9 @@ export default class ArticleSearchDialog extends PureComponent {
render() { render() {
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props; const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props;
const { assigningSku, error } = this.state; const { assigningSku, error, showAllArticles } = this.state;
const isDelivery = kind === 'delivery'; const isDelivery = kind === 'delivery';
const onlySupplierArticles = isDelivery && !line; const onlySupplierArticles = isDelivery && !line && !showAllArticles;
const rawSku = line?.sku != null ? String(line.sku).trim() : ''; const rawSku = line?.sku != null ? String(line.sku).trim() : '';
const supplierArticlesList = onlySupplierArticles const supplierArticlesList = onlySupplierArticles
@@ -122,7 +123,7 @@ export default class ArticleSearchDialog extends PureComponent {
<Box> <Box>
<Typography variant="h6" component="div" fontWeight={600}> <Typography variant="h6" component="div" fontWeight={600}>
{line {line
? 'Existierenden Artikel zuordnen' ? (isDelivery ? 'Artikel neu zuordnen' : 'Existierenden Artikel zuordnen')
: isDelivery : isDelivery
? 'Lieferanten-Artikel auswählen' ? 'Lieferanten-Artikel auswählen'
: 'Artikel auswählen'} : 'Artikel auswählen'}
@@ -134,7 +135,21 @@ export default class ArticleSearchDialog extends PureComponent {
{line?.name ? ` · ${line.name}` : ''} {line?.name ? ` · ${line.name}` : ''}
</> </>
) : isDelivery ? ( ) : isDelivery ? (
'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel' <Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
<span>
{showAllArticles
? 'Alle Artikel im Katalog durchsuchen'
: 'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'}
</span>
<Button
size="small"
variant="text"
onClick={() => this.setState({ showAllArticles: !showAllArticles })}
sx={{ fontSize: '0.72rem', py: 0, px: 0.75, textTransform: 'none', minWidth: 'auto', fontWeight: 600 }}
>
{showAllArticles ? 'Nur Lieferanten-Artikel' : 'Alle Artikel durchsuchen'}
</Button>
</Box>
) : ( ) : (
'Artikel für Beleg auswählen' 'Artikel für Beleg auswählen'
)} )}

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ export default class NumberSettingsPanel extends PureComponent {
: []; : [];
const numbersStatus = companyAutomat.state.numbersStatus; const numbersStatus = companyAutomat.state.numbersStatus;
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: status:
numbersStatus === 'ready' numbersStatus === 'ready'
? 'ready' ? 'ready'
@@ -81,6 +82,7 @@ export default class NumberSettingsPanel extends PureComponent {
this.unsub = companyAutomat.subscribe(this, (state) => { this.unsub = companyAutomat.subscribe(this, (state) => {
const numbers = Array.isArray(state.numbers) ? state.numbers : []; const numbers = Array.isArray(state.numbers) ? state.numbers : [];
return { return {
isAdmin: state.current?.role === 'admin',
status: status:
state.numbersStatus === 'ready' state.numbersStatus === 'ready'
? 'ready' ? 'ready'
@@ -121,6 +123,7 @@ export default class NumberSettingsPanel extends PureComponent {
} }
async handleSave(id) { async handleSave(id) {
if (!this.state.isAdmin) return;
const draft = this.state.drafts[id]; const draft = this.state.drafts[id];
if (!draft || draft.busy) return; if (!draft || draft.busy) return;
const row = this.state.numbers.find((r) => r.id === id); const row = this.state.numbers.find((r) => r.id === id);
@@ -211,7 +214,7 @@ export default class NumberSettingsPanel extends PureComponent {
} }
render() { render() {
const { status, error, numbers, drafts } = this.state; const { isAdmin, status, error, numbers, drafts } = this.state;
if (status === 'loading') { if (status === 'loading') {
return ( return (
@@ -249,7 +252,7 @@ export default class NumberSettingsPanel extends PureComponent {
<TextInput <TextInput
size="small" size="small"
value={draft.prefix} value={draft.prefix}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)} onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
/> />
@@ -259,7 +262,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small" size="small"
type="number" type="number"
value={draft.padding} value={draft.padding}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 1, step: 1 } }} slotProps={{ htmlInput: { min: 1, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)} onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
@@ -270,7 +273,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small" size="small"
type="number" type="number"
value={draft.next} value={draft.next}
disabled={draft.busy} disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 0, step: 1 } }} slotProps={{ htmlInput: { min: 0, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)} onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }} sx={{ input: { fontFamily: 'monospace' } }}
@@ -295,7 +298,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small" size="small"
aria-label="Speichern" aria-label="Speichern"
color="primary" color="primary"
disabled={draft.busy} disabled={!isAdmin || draft.busy}
onClick={() => void this.handleSave(row.id)} onClick={() => void this.handleSave(row.id)}
> >
<SaveOutlinedIcon fontSize="small" /> <SaveOutlinedIcon fontSize="small" />

View File

@@ -19,12 +19,29 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError'; import BackendError from '../BackendError';
import CategoryPickerDialog from '../article/CategoryPickerDialog'; import CategoryPickerDialog from '../article/CategoryPickerDialog';
import authAutomat from '../../automats/authAutomat';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat'; import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
import { categoryDisplayName } from '../../utils/categoryUtils'; import { categoryDisplayName } from '../../utils/categoryUtils';
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
function parseVhostSettings(str) {
if (!str || typeof str !== 'string') return {};
const result = {};
for (const part of str.split(';')) {
const trimmed = part.trim();
if (!trimmed) continue;
const colonIdx = trimmed.indexOf(':');
if (colonIdx !== -1) {
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
const val = trimmed.slice(colonIdx + 1).trim();
result[key] = val;
}
}
return result;
}
function formatDateTime(val) { function formatDateTime(val) {
if (!val) return null; if (!val) return null;
try { try {
@@ -47,11 +64,13 @@ export default class PosTerminalSettingsPanel extends Component {
super(props); super(props);
const terminals = companyAutomat.state.terminals ?? []; const terminals = companyAutomat.state.terminals ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading', status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
error: null, error: null,
terminals, terminals,
categories: categoryAutomat.state.categories, categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId, rootCategoryId: categoryAutomat.state.rootCategoryId,
vhostSettings: authAutomat.state.vhostSettings,
newTerminalName: '', newTerminalName: '',
creatingTerminal: false, creatingTerminal: false,
enableAllForNew: true, enableAllForNew: true,
@@ -75,6 +94,7 @@ export default class PosTerminalSettingsPanel extends Component {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
terminals: Array.isArray(state.terminals) ? state.terminals : [], terminals: Array.isArray(state.terminals) ? state.terminals : [],
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status, status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
})); }));
@@ -82,6 +102,9 @@ export default class PosTerminalSettingsPanel extends Component {
categories: state.categories, categories: state.categories,
rootCategoryId: state.rootCategoryId, rootCategoryId: state.rootCategoryId,
})); }));
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
vhostSettings: state.vhostSettings,
}));
if (categoryAutomat.state.treeStatus === 'idle') { if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree(); void categoryAutomat.actions.loadTree();
} }
@@ -90,6 +113,7 @@ export default class PosTerminalSettingsPanel extends Component {
componentWillUnmount() { componentWillUnmount() {
this.unsub?.(); this.unsub?.();
this.unsubCategory?.(); this.unsubCategory?.();
this.unsubAuth?.();
} }
async loadTerminals() { async loadTerminals() {
@@ -111,6 +135,7 @@ export default class PosTerminalSettingsPanel extends Component {
} }
async handleCreateTerminal() { async handleCreateTerminal() {
if (!this.state.isAdmin) return;
const { newTerminalName, enableAllForNew, terminals } = this.state; const { newTerminalName, enableAllForNew, terminals } = this.state;
const trimmed = newTerminalName.trim(); const trimmed = newTerminalName.trim();
if (!trimmed) return; if (!trimmed) return;
@@ -272,9 +297,11 @@ export default class PosTerminalSettingsPanel extends Component {
render() { render() {
const { const {
isAdmin,
status, status,
error, error,
terminals, terminals,
vhostSettings,
newTerminalName, newTerminalName,
creatingTerminal, creatingTerminal,
enableAllForNew, enableAllForNew,
@@ -284,6 +311,11 @@ export default class PosTerminalSettingsPanel extends Component {
rootCategoryId, rootCategoryId,
} = this.state; } = this.state;
const parsedVhost = parseVhostSettings(vhostSettings);
const posIp = parsedVhost.posip || parsedVhost.ip || null;
const posPort = parsedVhost.posport || parsedVhost.port || null;
const hasVhostSettings = Boolean(posIp || posPort || vhostSettings);
if (status === 'loading' && terminals.length === 0) { if (status === 'loading' && terminals.length === 0) {
return ( return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -298,13 +330,43 @@ export default class PosTerminalSettingsPanel extends Component {
return ( return (
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}> <Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
{hasVhostSettings && (
<Box
sx={{
mb: 2,
p: 1.5,
px: 2,
borderRadius: 2,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
gap: 4,
flexWrap: 'wrap',
}}
>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Pos Sync IP:{' '}
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
{posIp ?? '—'}
</Box>
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Pos Sync Port:{' '}
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
{posPort ?? '—'}
</Box>
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<TextField <TextField
size="small" size="small"
placeholder="Neues Terminal..." placeholder="Neues Terminal..."
value={newTerminalName} value={newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })} onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
autoComplete="off" autoComplete="off"
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
@@ -329,7 +391,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
checked={enableAllForNew} checked={enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })} onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
/> />
</Box> </Box>
</Tooltip> </Tooltip>
@@ -338,7 +400,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />} startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />}
onClick={this.handleCreateTerminal} onClick={this.handleCreateTerminal}
disabled={creatingTerminal || !newTerminalName.trim()} disabled={!isAdmin || creatingTerminal || !newTerminalName.trim()}
> >
Hinzufügen Hinzufügen
</Button> </Button>
@@ -434,7 +496,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch <Switch
size="small" size="small"
checked={isEnabled} checked={isEnabled}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)} onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
color="success" color="success"
/> />
@@ -458,7 +520,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
aria-label={`Terminal ${terminal.name} löschen`} aria-label={`Terminal ${terminal.name} löschen`}
onClick={() => void this.handleDeleteTerminal(terminal)} onClick={() => void this.handleDeleteTerminal(terminal)}
disabled={isDeleting || creatingTerminal || isPatching} disabled={!isAdmin || isDeleting || creatingTerminal || isPatching}
color="error" color="error"
> >
{isDeleting ? ( {isDeleting ? (
@@ -488,13 +550,14 @@ export default class PosTerminalSettingsPanel extends Component {
<Chip <Chip
icon={<FolderIcon />} icon={<FolderIcon />}
label={this.getCategoryLabel(terminal)} label={this.getCategoryLabel(terminal)}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={isAdmin ? () => this.setState({ pickerTerminal: terminal }) : undefined}
onDelete={ onDelete={
terminal.rootCategoryId != null isAdmin && terminal.rootCategoryId != null
? () => void this.handleSetRootCategory(terminal, null) ? () => void this.handleSetRootCategory(terminal, null)
: undefined : undefined
} }
clickable clickable={isAdmin}
disabled={!isAdmin || isPatching || isDeleting}
variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'} variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'}
color={terminal.rootCategoryId != null ? 'primary' : 'default'} color={terminal.rootCategoryId != null ? 'primary' : 'default'}
size="small" size="small"
@@ -502,6 +565,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Button <Button
size="small" size="small"
variant="text" variant="text"
disabled={!isAdmin || isPatching || isDeleting}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={() => this.setState({ pickerTerminal: terminal })}
sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }} sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }}
> >
@@ -518,7 +582,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === 0 || isPatching || isDeleting} disabled={!isAdmin || index === 0 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'up')} onClick={() => void this.handleMoveTerminal(terminal, 'up')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -530,7 +594,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === terminals.length - 1 || isPatching || isDeleting} disabled={!isAdmin || index === terminals.length - 1 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'down')} onClick={() => void this.handleMoveTerminal(terminal, 'down')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -541,7 +605,7 @@ export default class PosTerminalSettingsPanel extends Component {
<TextField <TextField
type="number" type="number"
size="small" size="small"
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0} value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)} onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
onBlur={() => void this.handleSortSubmit(terminal)} onBlur={() => void this.handleSortSubmit(terminal)}
@@ -566,7 +630,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch <Switch
size="small" size="small"
checked={Boolean(terminal.syncRootArticles)} checked={Boolean(terminal.syncRootArticles)}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)} onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
/> />
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -18,15 +18,20 @@ import authAutomat from '../../automats/authAutomat';
import CompanySettingsPanel from './CompanySettingsPanel'; import CompanySettingsPanel from './CompanySettingsPanel';
import NumberSettingsPanel from './NumberSettingsPanel'; import NumberSettingsPanel from './NumberSettingsPanel';
import PrinterSettingsPanel from './PrinterSettingsPanel'; import PrinterSettingsPanel from './PrinterSettingsPanel';
import PrinterAgentSettingsPanel from './PrinterAgentSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel'; import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel'; import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import MembersSettingsPanel from './MembersSettingsPanel';
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
import companyAutomat from '../../automats/companyAutomat';
import BackupSettingsPanel from './BackupSettingsPanel'; import BackupSettingsPanel from './BackupSettingsPanel';
import HealthSettingsPanel from './HealthSettingsPanel'; import HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'; import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
import SecurityIcon from '@mui/icons-material/Security'; import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined'; import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import LocalPrintshopOutlinedIcon from '@mui/icons-material/LocalPrintshopOutlined';
import { SURFACE_BASE } from '../../colors'; import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = { const TAB_KEYS = {
@@ -35,10 +40,12 @@ const TAB_KEYS = {
keys: 'keys', keys: 'keys',
numbers: 'numbers', numbers: 'numbers',
printers: 'printers', printers: 'printers',
printeragent: 'printeragent',
importExport: 'importExport', importExport: 'importExport',
backup: 'backup', backup: 'backup',
posterminal: 'posterminal', posterminal: 'posterminal',
security: 'security', security: 'security',
members: 'members',
}; };
const TABS = [ const TABS = [
@@ -47,38 +54,82 @@ const TABS = [
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel }, { id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel }, { id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel }, { id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ id: TAB_KEYS.printeragent, label: 'Drucker-Agent', icon: <LocalPrintshopOutlinedIcon />, Panel: PrinterAgentSettingsPanel },
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel }, { id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
{ id: TAB_KEYS.members, label: 'Mitglieder', icon: <GroupOutlinedIcon />, Panel: MembersSettingsPanel },
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel }, { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
]; ];
function getDefaultTab(vhost) {
return vhost ? TAB_KEYS.company : TAB_KEYS.health;
}
function getVisibleTabs(vhost, isAdmin) {
const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
if (!vhost) return tabs;
return tabs.filter(
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers
);
}
export default class SettingsDialog extends Component { export default class SettingsDialog extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
const vhost = Boolean(authAutomat.state.vhost);
this.state = { this.state = {
tab: TAB_KEYS.health, tab: getDefaultTab(vhost),
schemaVersion: authAutomat.state.schemaVersion, schemaVersion: authAutomat.state.schemaVersion,
vhost,
isAdmin: companyAutomat.state.current?.role === 'admin',
}; };
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
} }
componentDidMount() { componentDidMount() {
this.unsubAuth = authAutomat.subscribe(this, ['schemaVersion']); this.unsubAuth = authAutomat.subscribe(this, (state) => ({
schemaVersion: state.schemaVersion,
vhost: Boolean(state.vhost),
}));
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubAuth?.(); this.unsubAuth?.();
this.unsubCompany?.();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { open } = this.props; const { open } = this.props;
const { vhost } = this.state;
if (!open && prevProps.open) { if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.health }); this.setState({ tab: getDefaultTab(vhost) });
} }
if (open && !prevProps.open) { if (open && !prevProps.open) {
const currentVhost = Boolean(authAutomat.state.vhost);
const updates = {};
if (this.props.initialTab) {
updates.tab = this.props.initialTab;
}
if (this.state.schemaVersion !== authAutomat.state.schemaVersion) { if (this.state.schemaVersion !== authAutomat.state.schemaVersion) {
this.setState({ schemaVersion: authAutomat.state.schemaVersion }); updates.schemaVersion = authAutomat.state.schemaVersion;
}
if (this.state.vhost !== currentVhost) {
updates.vhost = currentVhost;
}
if (
currentVhost &&
(this.state.tab === TAB_KEYS.health ||
this.state.tab === TAB_KEYS.backup ||
this.state.tab === TAB_KEYS.printers)
) {
updates.tab = getDefaultTab(currentVhost);
}
if (Object.keys(updates).length > 0) {
this.setState(updates);
} }
} }
} }
@@ -89,7 +140,9 @@ export default class SettingsDialog extends Component {
render() { render() {
const { open, onClose } = this.props; const { open, onClose } = this.props;
const { tab, schemaVersion } = this.state; const { tab, schemaVersion, vhost, isAdmin } = this.state;
const visibleTabs = getVisibleTabs(vhost, isAdmin);
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
const paperSx = { const paperSx = {
height: '85vh', height: '85vh',
@@ -146,7 +199,7 @@ export default class SettingsDialog extends Component {
> >
<Tabs <Tabs
orientation="vertical" orientation="vertical"
value={tab} value={activeTab}
onChange={this.handleTabChange} onChange={this.handleTabChange}
sx={{ sx={{
'& .MuiTabs-indicator': { '& .MuiTabs-indicator': {
@@ -182,7 +235,7 @@ export default class SettingsDialog extends Component {
}, },
}} }}
> >
{TABS.map((t) => ( {visibleTabs.map((t) => (
<Tab <Tab
key={t.id} key={t.id}
disableRipple disableRipple
@@ -205,15 +258,15 @@ export default class SettingsDialog extends Component {
{/* Right content panel */} {/* Right content panel */}
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}> <DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
<Box sx={{ display: 'grid' }}> <Box sx={{ display: 'grid' }}>
{TABS.map((t) => ( {visibleTabs.map((t) => (
<Box <Box
key={t.id} key={t.id}
sx={{ sx={{
gridArea: '1 / 1', gridArea: '1 / 1',
display: t.id === tab ? 'block' : 'none', display: t.id === activeTab ? 'block' : 'none',
}} }}
> >
<t.Panel active={t.id === tab} /> <t.Panel active={t.id === activeTab} />
</Box> </Box>
))} ))}
</Box> </Box>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,15 +11,24 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper'; import Paper from '@mui/material/Paper';
import Popover from '@mui/material/Popover';
import Badge from '@mui/material/Badge';
import Checkbox from '@mui/material/Checkbox';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Divider from '@mui/material/Divider';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList'; import FilterListIcon from '@mui/icons-material/FilterList';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import articleSearchAutomat from '../automats/articleSearchAutomat'; import articleSearchAutomat from '../automats/articleSearchAutomat';
import articleIndexAutomat from '../automats/articleIndexAutomat';
import BackendError from '../components/BackendError'; import BackendError from '../components/BackendError';
import { formatPrice, getArticleGross } from '../utils/price'; import { formatPrice, getArticleGross } from '../utils/price';
import { thumb } from '../api'; import { thumb } from '../api';
@@ -50,6 +59,7 @@ import navigationAutomat, {
import categoryAutomat from '../automats/categoryAutomat'; import categoryAutomat from '../automats/categoryAutomat';
import companyAutomat from '../automats/companyAutomat'; import companyAutomat from '../automats/companyAutomat';
import { BrandLabel, BrandThumb } from '../components/brand'; import { BrandLabel, BrandThumb } from '../components/brand';
import { ArticleNameDialog } from '../components/article';
import { SCRIM_LABEL } from '../colors'; import { SCRIM_LABEL } from '../colors';
// #endregion // #endregion
@@ -263,6 +273,28 @@ class ResultRow extends Component {
} }
// #endregion // #endregion
// #region Constants
const EMPTY_FILTER_OPTIONS = [
{ key: 'ean', label: 'EAN' },
{ key: 'photo', label: 'Foto' },
{ key: 'price', label: 'Preis' },
{ key: 'brand', label: 'Marke' },
{ key: 'supplier', label: 'Lieferant' },
{ key: 'seo', label: 'SEO' },
{ key: 'desc', label: 'Beschreibung' },
];
const EMPTY_FILTER_LABELS = {
ean: 'Ohne EAN',
photo: 'Ohne Foto',
price: 'Ohne Preis',
brand: 'Ohne Marke',
supplier: 'Ohne Lieferant',
seo: 'Ohne SEO',
desc: 'Ohne Beschreibung',
};
// #endregion
// #region Helpers // #region Helpers
function initialSearchState(searchParams) { function initialSearchState(searchParams) {
const query = queryFromParams(searchParams); const query = queryFromParams(searchParams);
@@ -282,6 +314,8 @@ function initialSearchState(searchParams) {
categoryFilterOpen: false, categoryFilterOpen: false,
brandFilterOpen: false, brandFilterOpen: false,
supplierFilterOpen: false, supplierFilterOpen: false,
moreFilterAnchorEl: null,
newArticleOpen: false,
error: automatResult.error, error: automatResult.error,
categoryFacets: automatResult.categoryFacets, categoryFacets: automatResult.categoryFacets,
brandFacets: automatResult.brandFacets, brandFacets: automatResult.brandFacets,
@@ -354,6 +388,109 @@ class ArtikelSearchPage extends Component {
})); }));
}; };
handleOpenMoreFilters = (event) => {
this.setState({ moreFilterAnchorEl: event.currentTarget });
};
handleCloseMoreFilters = () => {
this.setState({ moreFilterAnchorEl: null });
};
handleToggleEmptyFilter = (emptyKey) => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
const selectedEmpty = parseFilter(emptyParam);
const nextEmpty = selectedEmpty.includes(emptyKey)
? selectedEmpty.filter((k) => k !== emptyKey)
: [...selectedEmpty, emptyKey];
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
buildFilter(nextEmpty),
hierarchyParam,
);
};
handleHierarchyChange = (nextHierarchy) => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchy = nextHierarchy === 'all' ? '' : nextHierarchy;
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
emptyParam,
hierarchy,
);
};
handleClearAllMoreFilters = () => {
const { input } = this.state;
const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? '';
this.updateParams(
input,
filterParam,
0,
brandParam,
supplierParam,
'',
'',
);
};
handleOpenNewArticle = () => {
this.setState({ newArticleOpen: true });
};
handleCloseNewArticle = () => {
this.setState({ newArticleOpen: false });
};
handleCreateArticle = async ({ names }) => {
const filterParam = this.props.searchParams.get('filter') ?? '';
const categories = parseFilter(filterParam);
const parentId =
categories[0] ||
categoryAutomat.state.rootCategoryId ||
companyAutomat.state.current?.rootCategoryId ||
companyAutomat.state.current?.rootcategory_id ||
1;
const list = (names ?? []).filter(Boolean);
if (list.length <= 1) {
const created = await categoryAutomat.actions.createArticle({
parentId,
name: list[0] ?? '',
});
void articleIndexAutomat.actions.reload().catch(() => { });
if (created) {
this.props.navigate(artikelDetailPath(created));
}
return;
}
const createdList = await categoryAutomat.actions.createArticles({ parentId, names: list });
void articleIndexAutomat.actions.reload().catch(() => { });
if (createdList?.[0]) {
this.props.navigate(artikelDetailPath(createdList[0]));
}
};
handleOpenArticle = (row) => { handleOpenArticle = (row) => {
this.persistQuery(); this.persistQuery();
this.props.navigate(artikelDetailPath(row)); this.props.navigate(artikelDetailPath(row));
@@ -367,6 +504,8 @@ class ArtikelSearchPage extends Component {
filter: query.filter, filter: query.filter,
brand: query.brand, brand: query.brand,
supplier: query.supplier, supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
offset: query.offset, offset: query.offset,
}); });
}; };
@@ -390,6 +529,8 @@ class ArtikelSearchPage extends Component {
offset, offset,
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
} = queryFromParams(this.props.searchParams); } = queryFromParams(this.props.searchParams);
const hasFilterInUrl = this.props.searchParams.has('filter'); const hasFilterInUrl = this.props.searchParams.has('filter');
const navCategory = navigationAutomat.state.lastCategoryId; const navCategory = navigationAutomat.state.lastCategoryId;
@@ -400,6 +541,8 @@ class ArtikelSearchPage extends Component {
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, { const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
}); });
setLastSearchTerm(activeTarget, syncedUrl); setLastSearchTerm(activeTarget, syncedUrl);
this.props.navigate(syncedUrl, { replace: true }); this.props.navigate(syncedUrl, { replace: true });
@@ -414,6 +557,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(activeTarget, filter, offset, { buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
}), }),
); );
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
@@ -421,8 +566,10 @@ class ArtikelSearchPage extends Component {
filter, filter,
brand: urlBrand, brand: urlBrand,
supplier: urlSupplier, supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
offset: 0, offset: 0,
}).catch(() => {}); }).catch(() => { });
} }
componentWillUnmount() { componentWillUnmount() {
@@ -439,6 +586,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(target, query.filter, query.offset, { buildSearchUrl(target, query.filter, query.offset, {
brand: query.brand, brand: query.brand,
supplier: query.supplier, supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
}), }),
); );
} }
@@ -459,6 +608,8 @@ class ArtikelSearchPage extends Component {
searchParams.get('filter') !== prev.get('filter') searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('brand') !== prev.get('brand') || searchParams.get('brand') !== prev.get('brand')
|| searchParams.get('supplier') !== prev.get('supplier') || searchParams.get('supplier') !== prev.get('supplier')
|| searchParams.get('empty') !== prev.get('empty')
|| searchParams.get('hierarchy') !== prev.get('hierarchy')
|| searchParams.get('offset') !== prev.get('offset') || searchParams.get('offset') !== prev.get('offset')
) { ) {
this.fetchResults(); this.fetchResults();
@@ -473,17 +624,23 @@ class ArtikelSearchPage extends Component {
nextOffset = 0, nextOffset = 0,
nextBrand = this.props.searchParams.get('brand') ?? '', nextBrand = this.props.searchParams.get('brand') ?? '',
nextSupplier = this.props.searchParams.get('supplier') ?? '', nextSupplier = this.props.searchParams.get('supplier') ?? '',
nextEmpty = this.props.searchParams.get('empty') ?? '',
nextHierarchy = this.props.searchParams.get('hierarchy') ?? '',
) => { ) => {
writeArtikelQuery({ writeArtikelQuery({
target: nextTarget, target: nextTarget,
filter: nextFilter, filter: nextFilter,
brand: nextBrand, brand: nextBrand,
supplier: nextSupplier, supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
offset: nextOffset, offset: nextOffset,
}); });
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, { const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
brand: nextBrand, brand: nextBrand,
supplier: nextSupplier, supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
}); });
setLastSearchTerm(nextTarget, nextUrl); setLastSearchTerm(nextTarget, nextUrl);
@@ -508,18 +665,20 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
const { filter, brand, supplier } = queryFromParams(this.props.searchParams); const { filter, brand, supplier, empty, hierarchy } = queryFromParams(this.props.searchParams);
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier }); const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier, empty, hierarchy });
setLastSearchTerm(value, currentUrl); setLastSearchTerm(value, currentUrl);
writeArtikelQuery({ target: value, filter, brand, supplier, offset: 0 }); writeArtikelQuery({ target: value, filter, brand, supplier, empty, hierarchy, offset: 0 });
this.props.navigate(currentUrl, { replace: true }); this.props.navigate(currentUrl, { replace: true });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: value, target: value,
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset: 0, offset: 0,
}).catch(() => {}); }).catch(() => { });
}; };
handleClearInput = () => { handleClearInput = () => {
@@ -527,17 +686,19 @@ class ArtikelSearchPage extends Component {
}; };
fetchResults = () => { fetchResults = () => {
const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams); const { target, filter, brand, supplier, empty, hierarchy, offset } = queryFromParams(this.props.searchParams);
writeArtikelQuery({ target, filter, brand, supplier, offset }); writeArtikelQuery({ target, filter, brand, supplier, empty, hierarchy, offset });
void articleSearchAutomat.actions.search({ void articleSearchAutomat.actions.search({
target: target.trim(), target: target.trim(),
filter, filter,
brand, brand,
supplier, supplier,
empty,
hierarchy,
offset, offset,
}).catch(() => {}); }).catch(() => { });
}; };
// #endregion // #endregion
@@ -548,6 +709,8 @@ class ArtikelSearchPage extends Component {
categoryFilterOpen, categoryFilterOpen,
brandFilterOpen, brandFilterOpen,
supplierFilterOpen, supplierFilterOpen,
moreFilterAnchorEl,
newArticleOpen,
error, error,
totalCount, totalCount,
categoryFacets, categoryFacets,
@@ -558,10 +721,15 @@ class ArtikelSearchPage extends Component {
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const brandParam = this.props.searchParams.get('brand') ?? ''; const brandParam = this.props.searchParams.get('brand') ?? '';
const supplierParam = this.props.searchParams.get('supplier') ?? ''; const supplierParam = this.props.searchParams.get('supplier') ?? '';
const emptyParam = this.props.searchParams.get('empty') ?? '';
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
const categories = parseFilter(filterParam); const categories = parseFilter(filterParam);
const selectedBrands = parseFilter(brandParam); const selectedBrands = parseFilter(brandParam);
const selectedSuppliers = parseFilter(supplierParam); const selectedSuppliers = parseFilter(supplierParam);
const selectedEmpty = parseFilter(emptyParam);
const activeMoreFilterCount = selectedEmpty.length + (hierarchyParam ? 1 : 0);
const isAnyMoreFilterActive = activeMoreFilterCount > 0;
const rows = recordset ?? []; const rows = recordset ?? [];
const categoriesMap = categoryAutomat.state?.categories; const categoriesMap = categoryAutomat.state?.categories;
@@ -599,16 +767,16 @@ class ArtikelSearchPage extends Component {
const brandFacetEntries = brandFacets const brandFacetEntries = brandFacets
? Object.values(brandFacets).sort((a, b) => { ? Object.values(brandFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count; if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de'); return String(a.name).localeCompare(String(b.name), 'de');
}) })
: []; : [];
const supplierFacetEntries = supplierFacets const supplierFacetEntries = supplierFacets
? Object.values(supplierFacets).sort((a, b) => { ? Object.values(supplierFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count; if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de'); return String(a.name).localeCompare(String(b.name), 'de');
}) })
: []; : [];
// Per-render values the static `renderItem` row reads through the context. // Per-render values the static `renderItem` row reads through the context.
@@ -696,6 +864,29 @@ class ArtikelSearchPage extends Component {
> >
Lieferanten Lieferanten
</Button> </Button>
<IconButton
onClick={this.handleOpenMoreFilters}
color={isAnyMoreFilterActive ? 'primary' : 'default'}
sx={{
height: 40,
width: 40,
border: '1px solid',
borderColor: isAnyMoreFilterActive ? 'primary.main' : 'rgba(0, 0, 0, 0.23)',
borderRadius: 1,
flexShrink: 0,
}}
>
<Badge badgeContent={activeMoreFilterCount} color="primary">
<FilterAltIcon fontSize="small" />
</Badge>
</IconButton>
<Button
variant="contained"
onClick={this.handleOpenNewArticle}
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
>
Neuer Artikel
</Button>
{categories.map((catId) => { {categories.map((catId) => {
const label = getCategoryBreadcrumbLabel( const label = getCategoryBreadcrumbLabel(
@@ -718,6 +909,8 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(selectedBrands), buildFilter(selectedBrands),
buildFilter(selectedSuppliers), buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -744,6 +937,8 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(next), buildFilter(next),
buildFilter(selectedSuppliers), buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -770,12 +965,78 @@ class ArtikelSearchPage extends Component {
0, 0,
buildFilter(selectedBrands), buildFilter(selectedBrands),
buildFilter(next), buildFilter(next),
emptyParam,
hierarchyParam,
); );
}} }}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/> />
); );
})} })}
{hierarchyParam === 'father' && (
<Chip
key="hierarchy-father"
label="Typ: Vater / Normal"
color="primary"
variant="outlined"
onDelete={() => {
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
emptyParam,
'',
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
)}
{hierarchyParam === 'child' && (
<Chip
key="hierarchy-child"
label="Typ: Kind / Normal"
color="primary"
variant="outlined"
onDelete={() => {
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
emptyParam,
'',
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
)}
{selectedEmpty.map((emptyKey) => (
<Chip
key={`empty-${emptyKey}`}
label={EMPTY_FILTER_LABELS[emptyKey] || emptyKey}
color="primary"
variant="outlined"
onDelete={() => {
const next = selectedEmpty.filter((k) => k !== emptyKey);
this.updateParams(
input,
buildFilter(categories),
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
buildFilter(next),
hierarchyParam,
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
/>
))}
</Box> </Box>
{error && ( {error && (
@@ -1047,6 +1308,97 @@ class ArtikelSearchPage extends Component {
</PanelTile> </PanelTile>
</Box> </Box>
</Box> </Box>
<ArticleNameDialog
open={newArticleOpen}
initialName={input}
onClose={this.handleCloseNewArticle}
onSubmit={this.handleCreateArticle}
/>
<Popover
open={Boolean(moreFilterAnchorEl)}
anchorEl={moreFilterAnchorEl}
onClose={this.handleCloseMoreFilters}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
width: 320,
p: 2,
borderRadius: 2,
boxShadow: 4,
},
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
{isAnyMoreFilterActive && (
<Button
size="small"
color="secondary"
onClick={this.handleClearAllMoreFilters}
sx={{ py: 0.25, px: 1, minWidth: 0, textTransform: 'none', fontSize: 13 }}
>
Zurücksetzen
</Button>
)}
</Box>
<RadioGroup
value={hierarchyParam || 'all'}
onChange={(e) => this.handleHierarchyChange(e.target.value)}
sx={{ mb: 1.5, mt: 0.5 }}
>
<FormControlLabel
value="all"
control={<Radio size="small" />}
label={<Typography variant="body2">Alle Artikel</Typography>}
/>
<FormControlLabel
value="father"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2">Vater- oder Normalartikel</Typography>
</Box>
}
/>
<FormControlLabel
value="child"
control={<Radio size="small" />}
label={
<Box>
<Typography variant="body2">Kind- oder Normalartikel</Typography>
</Box>
}
/>
</RadioGroup>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 0.5 }}>
Artikel ohne:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', mt: 0.5 }}>
{EMPTY_FILTER_OPTIONS.map(({ key, label }) => {
const checked = selectedEmpty.includes(key);
return (
<FormControlLabel
key={key}
control={
<Checkbox
size="small"
checked={checked}
onChange={() => this.handleToggleEmptyFilter(key)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
sx={{ my: -0.25 }}
/>
);
})}
</Box>
</Popover>
</> </>
); );
} }

View File

@@ -555,16 +555,19 @@ class ArtikelKategorienPage extends PureComponent {
); );
})} })}
{!renameMode && articles.map((item, index) => ( {!renameMode &&
<ArticleTile articles
key={item.id} .filter((item) => item.parentArticleId == null && !item.parentSku)
label={item.name != null ? item.name.trim() : undefined} .map((item, index) => (
article={item} <ArticleTile
disabled={!articleSku(item)} key={item.id}
onClick={() => this.handleArticleClick(item)} label={item.name != null ? item.name.trim() : undefined}
sx={index === 0 ? { gridColumnStart: 1 } : undefined} article={item}
/> disabled={!articleSku(item)}
))} onClick={() => this.handleArticleClick(item)}
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />} {renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
{renameMode && loaded && ( {renameMode && loaded && (

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import CircularProgress from '@mui/material/CircularProgress';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton'; import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
@@ -147,8 +146,8 @@ class GlobalHistoryRow extends PureComponent {
onClick={ onClick={
isArticle isArticle
? () => { ? () => {
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`); void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
} }
: undefined : undefined
} }
> >
@@ -207,11 +206,9 @@ class GlobalHistoryRow extends PureComponent {
{/* 4. Meldung / Beschreibung */} {/* 4. Meldung / Beschreibung */}
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}> <Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}> <Typography variant="body2" sx={ellipsisSx}>
<Typography variant="body2" sx={ellipsisSx}> {item.text || item.value || '–'}
{item.text || item.value || '–'} </Typography>
</Typography>
</Tooltip>
</Box> </Box>
</ListItemButton> </ListItemButton>
); );
@@ -445,11 +442,11 @@ export default class GlobalHistoryPage extends PureComponent {
} }
handleReload() { handleReload() {
void globalHistoryAutomat.actions.reload().catch(() => {}); void globalHistoryAutomat.actions.reload().catch(() => { });
} }
handleRetry() { handleRetry() {
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {}); void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => { });
} }
render() { render() {

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 693 KiB

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