Compare commits

..

35 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
97 changed files with 4334 additions and 1493 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All)
Overview of all 284 API endpoints across all modules.
Overview of all 314 API endpoints across all modules.
### Notation
@@ -140,44 +140,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: 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[] }`
- `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 }`
- `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[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T21[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T33|null, payments?: T33[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|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 }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ vfs: T37, storage: T37, database: T31 }`
- `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 }`
- `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 }`
- `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 }`
- `T15` = `{ id: int, name: str, sort: int, exclusive: bool, values: T27[], articleCount?: int }`
- `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 }`
- `T17` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T18` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T19` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T20` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T23` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T24` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T25` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T26` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T27` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T28` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T29` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T30` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T31` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T32` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T33` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T34` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T35` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T36` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T37` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T38` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T11` = `{ vfs: T41, storage: T41, database: T35 }`
- `T12` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int, enabled?: bool }`
- `T13` = `{ id: int, printeragentId: int, type: str, enabled: bool, sort: int, model?: str|null, serial?: str|null, open?: bool, pause?: bool, error?: bool, online?: bool, labelLength?: int|null }`
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T15` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
- `T23` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T24` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T41` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
### Routes
@@ -203,6 +207,39 @@ _none_
- Params: `id: int`
- Returns: `204 void`
### Section: /agent
#### Printer agent SSE stream — `GET /agent/events`
- Params: `query: token?: str, agentversion?: str`
- Returns: `200 SSE`
#### Pair printer agent via query parameters — `GET /agent/pair`
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
- Returns: `200`
#### Pair printer agent — `POST /agent/pair`
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
#### Post agent state — `POST /agent/state`
- Params: `body: { state?: any }`
- Returns: `200 { success: bool, agent: T12 }`
### Section: /agentprinter
#### List agent printers — `GET /agentprinter`
- Params: `query: printeragentId?: int`
- Returns: `200 { agentprinters: T13[] }`
#### Reorder agent printers — `PUT /agentprinter/order`
- Params: `body: { agentprinterIds: int[], printeragentId?: int }`
- Returns: `200 { agentprinterIds: int[] }`
#### Delete agent printer — `DELETE /agentprinter/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get agent printer — `GET /agentprinter/{id}`
- Params: `id: int`
- Returns: `200 { agentprinter: T13 }`
#### Update agent printer — `PATCH /agentprinter/{id}`
- Params: `id: int, body: { enabled?: bool, sort?: int }`
- Returns: `200 { agentprinter: T13 }`
### Section: /article
#### Create article in category — `POST /article`
@@ -219,7 +256,7 @@ _none_
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- 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}`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `204 void`
@@ -277,11 +314,11 @@ _none_
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `204 void`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
#### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
- Returns: `204 void`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
- Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
- Returns: `200 { success: bool }`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: str`
@@ -318,19 +355,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T15[] }`
- Returns: `200 { attributenames: T17[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T15 }`
- Returns: `201 { attributename: T17 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T27[] }`
- Returns: `200 { values: T30[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- 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`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -339,7 +376,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T15 }`
- Returns: `200 { attributename: T17 }`
### Section: /attributevalue
@@ -348,28 +385,40 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T27 }`
- Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
- 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
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T32 }`
- Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_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`
- 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
@@ -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 }`
#### Create brand — `POST /brand`
- 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}`
- Params: `id: int`
- Returns: `204 void`
@@ -399,19 +448,19 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
- 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
#### List carriers — `GET /carrier`
_none_
- Returns: `200 { carriers: T30[] }`
- Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T30 }`
- Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }`
@@ -420,10 +469,10 @@ _none_
- Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int`
- Returns: `200 { carrier: T30 }`
- Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T30 }`
- Returns: `200 { carrier: T34 }`
### Section: /category
@@ -446,8 +495,8 @@ _none_
- Params: `id: int, body: { name?: str, parentId?: int }`
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, 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 }[] }[] }`
- Params: `id: int, query: includeChildren?: bool`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T28|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -477,10 +526,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T17[] }`
- Returns: `200 { companies: T19[] }`
#### Create company — `POST /company`
- 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`
- Params: `query: stream?: str`
- Returns: `200 SSE`
@@ -510,7 +559,22 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: 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
@@ -534,7 +598,7 @@ _none_
#### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: 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`
- 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 }`
@@ -561,46 +625,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T24[] }`
- Returns: `200 { entities: T27[] }`
#### Create entity — `POST /entity`
- 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`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T12[] }`
- Returns: `200 { addresses: T14[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `201 { address: T12 }`
- Returns: `201 { address: T14 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- Returns: `200 { address: T12 }`
- Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T34[] }`
- Returns: `200 { contacts: T38[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T34 }`
- Returns: `201 { contact: T38 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T34 }`
- Returns: `200 { contact: T38 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 { entity: T24 }`
- Returns: `200 { entity: T27 }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 { entity: T24 }`
- Returns: `200 { entity: T27 }`
### Section: /events
@@ -618,8 +682,13 @@ _none_
- `ledger`: `{}`
- `attributename`: `{}`
- `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}`
- `printer`: `{}`
- `printeragent`: `{}`
- `agentprinter`: `{ printers } or {}`
- `printerjob`: `{ job } or { jobId, state }`
- `warehouse`: `{}`
- `supplier`: `{}`
- `vfs`: `{}`
@@ -648,8 +717,11 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] } }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] } }`
- Returns: `201 { labeltemplate: T9 }`
#### Test print label template — `POST /labeltemplate/testprint`
- Params: `body: { agentprinterId: int, templateId?: int, objects?: any[], width?: int, height?: int, dpi?: int, sku?: str, quantity?: int }`
- Returns: `200 { success: bool }`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -657,26 +729,26 @@ _none_
- Params: `id: int`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T9 }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
- Returns: `200 { labeltemplate: T9 }`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 { ledger: T14[] }`
- Returns: `200 { ledger: T16[] }`
#### Query company-wide ledger history — `GET /ledger/history`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 { ledger: T14[] }`
- Returns: `200 { ledger: T16[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 { ledger: T14[] }`
- Returns: `200 { ledger: T16[] }`
### Section: /mcp
@@ -706,16 +778,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T16[] }`
- Returns: `200 { conversations: T18[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T16 }`
- Returns: `201 { conversation: T18 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T16 }`
- Returns: `200 { conversation: T18 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -756,7 +828,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, entityId: int, lines: T19[], externalNumber?: str|null }`
- Params: `body: { number: str, date: str, entityId: int, lines: T21[], externalNumber?: str|null }`
- Returns: `201 { order: T1 }`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -765,7 +837,7 @@ _none_
- Params: `id: int`
- Returns: `200 { order: T1 }`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `200 { order: T1 }`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
@@ -775,10 +847,10 @@ _none_
- Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T29[] }`
- Returns: `200 { payments: T33[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T29 }`
- Returns: `201 { payment: T33 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 { attachment: T10, fileUrl: str }`
@@ -790,10 +862,10 @@ _none_
- Returns: `201 { shipment: T2 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T20[] }`
- Returns: `200 { shippings: T23[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T20 }`
- Returns: `201 { shipping: T23 }`
### Section: /package
@@ -838,34 +910,76 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T23[] }`
- Returns: `200 { printers: T26[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 { printer: T23 }`
- Returns: `201 { printer: T26 }`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
#### Get live printer states — `GET /printer/state`
- Params: `query: serial?: str, agentId?: int`
- Returns: `200 { printers: {}[] }`
#### Delete printer — `DELETE /printer/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 { printer: T23 }`
- Returns: `200 { printer: T26 }`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: T23 }`
- Returns: `200 { printer: T26 }`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 { printer: 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
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T28[] }`
- Returns: `200 { propertynames: T32[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T28 }`
- Returns: `201 { propertyname: T32 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -874,7 +988,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T28 }`
- Returns: `200 { propertyname: T32 }`
### 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 }[] }`
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: 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`
- 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 }[] }`
@@ -897,7 +1011,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### 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 }`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
@@ -906,7 +1020,7 @@ _none_
- Params: `id: int`
- Returns: `200 { quote: T3 }`
#### 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 }`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
@@ -915,7 +1029,7 @@ _none_
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Create order from quote — `POST /quote/{id}/order`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
- Returns: `201 { order: T1 }`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
@@ -1009,10 +1123,10 @@ _none_
- Returns: `201 { attachment: T10, fileUrl: str }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T20[] }`
- Returns: `200 { shippings: T23[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
- Returns: `201 { shipping: T20 }`
- Returns: `201 { shipping: T23 }`
### Section: /shipping
@@ -1021,7 +1135,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
- Returns: `200 { shipping: T20 }`
- Returns: `200 { shipping: T23 }`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -1042,7 +1156,7 @@ _none_
- Returns: `200`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: 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`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200`
@@ -1051,40 +1165,40 @@ _none_
#### List subagent schedules — `GET /subagentschedule`
_none_
- Returns: `200 { schedules: T13[] }`
- Returns: `200 { schedules: T15[] }`
#### Create subagent schedule — `POST /subagentschedule`
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
- Returns: `201 { schedule: T13 }`
- Returns: `201 { schedule: T15 }`
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
- Params: `id: int`
- Returns: `200 { schedule: T13 }`
- Returns: `200 { schedule: T15 }`
#### Update subagent schedule — `PUT /subagentschedule/{id}`
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
- Returns: `200 { schedule: T13 }`
- Returns: `200 { schedule: T15 }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T35[] }`
- Returns: `200 { suppliers: T39[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T35 }`
- Returns: `201 { supplier: T39 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T35 }`
- Returns: `200 { supplier: T39 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: str`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: 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`
- 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
@@ -1099,10 +1213,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T26[] }`
- Returns: `200 { units: T29[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T26 }`
- Returns: `200 { unit: T29 }`
### Section: /v1
@@ -1193,3 +1307,9 @@ _none_
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 { warehouse: { id: int, name: str, sort: int } }`
### Section: /zpljob
#### Create ZPL print job — `POST /zpljob`
- Params: `body: { agentprinterId: int, data: str, copiescount?: int }`
- Returns: `201 { printerjob: T22 }`

View File

@@ -2,6 +2,67 @@ import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
const banMuiTooltipRule = {
meta: {
type: 'problem',
docs: {
description: 'Ban MUI Tooltip component',
},
messages: {
badDesign: '<Tooltip> is bad design',
},
},
create(context) {
const muiTooltipIdentifiers = new Set();
return {
ImportDeclaration(node) {
if (node.source.value === '@mui/material/Tooltip') {
context.report({
node,
messageId: 'badDesign',
});
for (const specifier of node.specifiers) {
if (specifier.local?.name) {
muiTooltipIdentifiers.add(specifier.local.name);
}
}
} else if (node.source.value === '@mui/material') {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported?.name === 'Tooltip'
) {
context.report({
node: specifier,
messageId: 'badDesign',
});
if (specifier.local?.name) {
muiTooltipIdentifiers.add(specifier.local.name);
}
}
}
}
},
JSXOpeningElement(node) {
let tagName = '';
if (node.name.type === 'JSXIdentifier') {
tagName = node.name.name;
} else if (node.name.type === 'JSXMemberExpression') {
tagName = node.name.property.name;
}
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
context.report({
node,
messageId: 'badDesign',
});
}
},
};
},
};
export default [
{
ignores: [
@@ -32,10 +93,16 @@ export default [
},
plugins: {
'react-hooks': reactHooks,
react: {
rules: {
'no-mui-tooltip': banMuiTooltipRule,
},
},
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react/no-mui-tooltip': 'error',
'no-unused-vars': [
'warn',
{

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

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

24
src/api/autoinvite.js Normal file
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 { posterminal } from './posterminal.js';
export { printer } from './printer.js';
export { printeragent } from './printeragent.js';
export { agentprinter } from './agentprinter.js';
export { printerjob } from './printerjob.js';
export { propertyname } from './propertyname.js';
export { quote } from './quote.js';
export { retoure } from './retoure.js';
@@ -31,6 +34,8 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js';
export { payment } from './payment.js';
export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js';
export { backup } from './backup.js';
export { carrier } from './carrier.js';

View File

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

View File

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

58
src/api/printeragent.js Normal file
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

@@ -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.
*
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
* field stays empty until that endpoint exposes EANs. EAN matching in
* `articleSearchAutomat` is therefore not functional yet.
*
* @param {object} article Raw category-view article row.
* @param {number|string|null} categoryId Category the article was loaded from.
* @returns {ArticleIndexRecord} Index record.
@@ -126,11 +122,15 @@ function toIndexRecord(article, categoryId) {
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
return {
...article,
id: article.id,
sku: article.sku,
name: article.name,
descLength,
seo: article.seo ?? null,
price,
net: article.net ?? price,
stock,
@@ -143,6 +143,8 @@ function toIndexRecord(article, categoryId) {
categories,
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
childCount,
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
parentSku: article.parentSku ?? article.parent_sku ?? null,
fullHash,
thumbHash,
photos,
@@ -200,6 +202,24 @@ export function buildArticleIndex(views) {
existing.articlewarehousesummary = rec.articlewarehousesummary;
}
}
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
existing.descLength = incomingDescLen;
}
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
existing.seo = article.seo;
}
if (Array.isArray(article.eans) && article.eans.length > 0) {
const mergedEans = new Set(existing.eans ?? []);
for (const e of article.eans) if (e) mergedEans.add(e);
existing.eans = Array.from(mergedEans);
}
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
}
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
existing.parentSku = article.parentSku ?? article.parent_sku;
}
}
}

View File

@@ -67,6 +67,8 @@ const initialState = {
filter: '',
brand: '',
supplier: '',
empty: '',
hierarchy: '',
offset: 0,
pageSize: null,
loading: false,
@@ -343,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
extraArticles = [],
allowParent = false,
categories = [],
hierarchy = '',
empty = '',
offset = 0,
pageSize = 10,
limit,
@@ -367,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
let pool = rawMatches;
if (!allowParent) {
if (!allowParent && !hierarchy) {
pool = pool.filter((a) => !a.isParent && !a.is_parent);
}
if (excludedSet.size > 0) {
@@ -378,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
}
if (hierarchy === 'father') {
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
} else if (hierarchy === 'child') {
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
}
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
pool = pool.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
let matchesList = pool;
if (categories && categories.length > 0) {
@@ -669,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
return facets;
}
function getSkusWithSupplier() {
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const set = new Set();
for (const list of Object.values(articlesBySupplier)) {
if (Array.isArray(list)) {
for (const item of list) {
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
if (item.sku) set.add(String(item.sku).toLowerCase());
}
}
}
return set;
}
// #endregion
// #endregion
@@ -689,6 +759,8 @@ const actions = {
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
@@ -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 totalCount = sortedArticles.length;
@@ -781,6 +911,8 @@ const actions = {
filter,
brand,
supplier,
empty,
hierarchy,
offset,
pageSize: safePageSize,
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 {string|null} googleClientId Google Identity Services client ID.
* @property {number|null} schemaVersion Database schema version returned by the backend.
* @property {boolean} vhost Whether vhost mode is enabled.
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
* @property {boolean} hasAuth True when the session has a valid authenticated user.
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
@@ -49,6 +51,8 @@ const initialState = {
user: null,
googleClientId: null,
schemaVersion: null,
vhost: false,
vhostSettings: null,
status: 'loading',
hasAuth: false,
hasCompany: false,
@@ -80,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
emit = dispatch;
}
function normalizePicture(picture) {
export function normalizePicture(picture) {
if (!picture || typeof picture !== 'string') return null;
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
return picture;
@@ -124,6 +128,8 @@ function buildAuthState(user, status, extra = {}) {
status,
hasAuth,
hasCompany,
vhost: authAutomat?.state?.vhost ?? false,
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
retryNotification: null,
...extra,
};
@@ -143,22 +149,30 @@ function applySession(nextSession) {
nextSession.schemaVersion !== undefined
? nextSession.schemaVersion
: (authAutomat.state.schemaVersion ?? null);
const vhost =
nextSession.vhost !== undefined
? Boolean(nextSession.vhost)
: (authAutomat.state.vhost ?? false);
const vhostSettings =
nextSession.vhostSettings !== undefined
? nextSession.vhostSettings
: (authAutomat.state.vhostSettings ?? null);
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { googleClientId, schemaVersion, retryNotification: null })
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
} else {
authAutomat.setState({ googleClientId, schemaVersion, retryNotification: null });
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { googleClientId, schemaVersion, retryNotification: null })
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED });
@@ -352,6 +366,8 @@ export const selectSelectedCompanyId = () =>
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
export const selectRetryNotification = () => authAutomat.state.retryNotification;
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
export const selectVhost = () => Boolean(authAutomat.state.vhost);
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
/**
* Re-reads `GET /api/auth/me` from the server.

View File

@@ -15,6 +15,7 @@
* - Brands (`brands`, `filteredBrands`, `brandFilter`)
* - Product attributes & values (`attributes`)
* - Payment accounts (`accounts`)
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
*
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
@@ -25,6 +26,10 @@ import {
company as companyApi,
number as numberApi,
posterminal as posterminalApi,
printer as printerApi,
printeragent as printeragentApi,
agentprinter as agentprinterApi,
printerjob as printerjobApi,
supplier as supplierApi,
unit as unitApi,
warehouse as warehouseApi,
@@ -35,6 +40,8 @@ import {
attributename as attributenameApi,
attributevalue as attributevalueApi,
account as accountApi,
companyuser as companyuserApi,
autoinvite as autoinviteApi,
} from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
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) =>
[...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
@@ -237,6 +262,19 @@ const initialState = {
terminalsStatus: 'idle',
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
suppliers: [],
articlesBySupplier: {},
@@ -295,6 +333,14 @@ const initialState = {
accounts: [],
accountsStatus: 'idle',
accountsError: null,
// 14. Members & invites (admin only; never part of loadAll)
members: [],
membersStatus: 'idle',
membersError: null,
invites: [],
invitesStatus: 'idle',
invitesError: null,
};
let companyAutomat;
@@ -303,6 +349,9 @@ let companyAutomat;
let inflightCompany = null;
let inflightNumbers = null;
let inflightTerminals = null;
let inflightPrinterAgents = null;
let inflightAgentPrinters = null;
let inflightLivePrinters = null;
let inflightSuppliers = null;
let inflightUnits = null;
let inflightWarehouses = null;
@@ -312,6 +361,8 @@ let inflightCarriers = null;
let inflightBrands = null;
let inflightAttributes = null;
let inflightAccounts = null;
let inflightMembers = null;
let inflightInvites = null;
// #endregion
// #region internal state updaters & fetchers
@@ -496,6 +547,9 @@ const actions = {
actions.load(),
actions.loadNumbers(),
actions.loadTerminals(),
actions.loadPrinterAgents(),
actions.loadAgentPrinters(),
actions.loadLivePrinters(),
actions.loadSuppliers(),
actions.loadUnits(),
actions.loadWarehouses(),
@@ -512,6 +566,9 @@ const actions = {
inflightCompany = null;
inflightNumbers = null;
inflightTerminals = null;
inflightPrinterAgents = null;
inflightAgentPrinters = null;
inflightLivePrinters = null;
inflightSuppliers = null;
inflightUnits = null;
inflightWarehouses = null;
@@ -521,6 +578,8 @@ const actions = {
inflightBrands = null;
inflightAttributes = null;
inflightAccounts = null;
inflightMembers = null;
inflightInvites = null;
companyAutomat.setState({
...initialState,
viewerPosition: companyAutomat.state.viewerPosition,
@@ -698,6 +757,180 @@ const actions = {
},
// #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)
// MARK: calls supplier.list from ../api/index.js
loadSuppliers() {
@@ -1491,6 +1724,122 @@ const actions = {
return accounts;
},
// #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', () => {
void companyAutomat.actions.loadTerminals().catch(() => {});
});
sseAutomat.actions.on('printeragent', () => {
void companyAutomat.actions.loadPrinterAgents().catch(() => {});
});
sseAutomat.actions.on('agentprinter', () => {
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
});
sseAutomat.actions.on('printerstate', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
return;
}
} catch {}
void companyAutomat.actions.loadLivePrinters().catch(() => {});
});
sseAutomat.actions.on('printer', (ev) => {
try {
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
if (Array.isArray(payload.printers)) {
companyAutomat.actions.setLivePrinters(payload.printers);
}
} catch {}
});
sseAutomat.actions.on('customergroup', () => {
void companyAutomat.actions.reloadCustomergroups().catch(() => {});
});
@@ -1565,6 +1938,15 @@ sseAutomat.actions.on('attributename', () => {
sseAutomat.actions.on('account', () => {
void companyAutomat.actions.reloadAccounts().catch(() => {});
});
// Admin-only lists: only refresh once an admin view has loaded them.
sseAutomat.actions.on('companyuser', () => {
if (companyAutomat.state.membersStatus !== 'ready') return;
void companyAutomat.actions.reloadMembers().catch(() => {});
});
sseAutomat.actions.on('autoinvite', () => {
if (companyAutomat.state.invitesStatus !== 'ready') return;
void companyAutomat.actions.reloadInvites().catch(() => {});
});
sseAutomat.actions.on('company', () => {
void companyAutomat.actions.reload().catch(() => {});
});
@@ -1593,6 +1975,16 @@ export const selectPosterminals = () => companyAutomat.state.terminals;
export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus;
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
export const selectSuppliers = () => companyAutomat.state.suppliers;
export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus;
@@ -1633,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus
// Accounts selectors
export const selectAccounts = () => companyAutomat.state.accounts;
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
export default companyAutomat;

View File

@@ -151,6 +151,12 @@ export {
selectPosterminals,
selectPosterminalStatus,
selectHasPosterminals,
selectPrinterAgents,
selectPrinterAgentStatus,
selectHasPrinterAgents,
selectAgentPrinters,
selectAgentPrinterStatus,
selectLivePrinters,
selectSuppliers,
selectSuppliersStatus,
selectUnits,
@@ -172,6 +178,12 @@ export {
selectAttributeStatus,
selectAccounts,
selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat';
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 { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import OnlinePrinterIcons from './OnlinePrinterIcons';
import HelpBadge from './HelpBadge';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
@@ -198,8 +199,20 @@ class AppSidebar extends PureComponent {
})}
</List>
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
<Box
sx={{
ml: { xs: 'auto', lg: 0 },
flexShrink: 0,
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'flex-start' },
width: { xs: 'auto', lg: '100%' },
gap: { xs: 0.5, lg: 0 },
}}
>
<OnlinePrinterIcons />
<UserMenu />
<ChatFab />
</Box>
</Box>
);
@@ -418,7 +431,6 @@ export default function AppShell() {
</Suspense>
</Box>
</Box>
<ChatFab />
<ChatDrawer />
<PinnedAppletFabs pathname={pathname} />
<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 = {
anchorEl: null,
settingsOpen: false,
settingsInitialTab: null,
user: authAutomat.state.user,
helpEnabled: helpAutomat.state?.enabled ?? false,
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
@@ -37,6 +38,7 @@ class UserMenu extends PureComponent {
this.handleToggleHelp = this.handleToggleHelp.bind(this);
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleOpenSettingsEvent = this.handleOpenSettingsEvent.bind(this);
}
componentDidMount() {
@@ -47,12 +49,23 @@ class UserMenu extends PureComponent {
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
pwaUpdateAvailable: Boolean(state?.needRefresh),
}));
if (typeof window !== 'undefined') {
window.addEventListener('open-settings', this.handleOpenSettingsEvent);
}
}
componentWillUnmount() {
this.unsubscribeAuth?.();
this.unsubscribeHelp?.();
this.unsubscribePwa?.();
if (typeof window !== 'undefined') {
window.removeEventListener('open-settings', this.handleOpenSettingsEvent);
}
}
handleOpenSettingsEvent(e) {
const tab = e?.detail?.tab;
this.setState({ settingsOpen: true, settingsInitialTab: tab || null });
}
handleOpenPwaUpdate() {
@@ -74,11 +87,11 @@ class UserMenu extends PureComponent {
}
handleOpenSettings() {
this.setState({ anchorEl: null, settingsOpen: true });
this.setState({ anchorEl: null, settingsOpen: true, settingsInitialTab: null });
}
handleCloseSettings() {
this.setState({ settingsOpen: false });
this.setState({ settingsOpen: false, settingsInitialTab: null });
}
handleToggleHelp(e) {
@@ -98,7 +111,7 @@ class UserMenu extends PureComponent {
}
render() {
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
const { user, anchorEl, settingsOpen, settingsInitialTab, helpEnabled, pwaUpdateAvailable } = this.state;
const open = Boolean(anchorEl);
const pictureSrc = (() => {
@@ -201,7 +214,7 @@ class UserMenu extends PureComponent {
<ListItemText>Sign out</ListItemText>
</MenuItem>
</Menu>
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
<SettingsDialog open={settingsOpen} initialTab={settingsInitialTab} onClose={this.handleCloseSettings} />
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,6 +50,7 @@ export default class ImportExportSettingsPanel extends Component {
constructor(props) {
super(props);
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
// Export state
downloading: false,
downloadProgress: null, // null = unknown, number 0-100
@@ -84,7 +85,14 @@ export default class ImportExportSettingsPanel extends Component {
this.handleCancelUpload = this.handleCancelUpload.bind(this);
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsub?.();
if (this.uploadAbortController) {
this.uploadAbortController.abort();
}
@@ -147,6 +155,7 @@ export default class ImportExportSettingsPanel extends Component {
}
handleFileChange(event) {
if (!this.state.isAdmin) return;
const file = event.target.files?.[0];
if (file) {
if (!isZipFile(file)) {
@@ -169,6 +178,7 @@ export default class ImportExportSettingsPanel extends Component {
}
async handleStartUpload() {
if (!this.state.isAdmin) return;
const { selectedFile } = this.state;
if (!selectedFile) return;
@@ -282,6 +292,7 @@ export default class ImportExportSettingsPanel extends Component {
render() {
const {
isAdmin,
downloading,
downloadProgress,
downloadStatus,
@@ -583,7 +594,7 @@ export default class ImportExportSettingsPanel extends Component {
accept=".zip,application/zip,application/x-zip-compressed"
style={{ display: 'none' }}
onChange={this.handleFileChange}
disabled={uploading}
disabled={!isAdmin || uploading}
/>
{!selectedFile ? (
@@ -592,7 +603,7 @@ export default class ImportExportSettingsPanel extends Component {
variant="outlined"
startIcon={<InsertDriveFileOutlinedIcon />}
onClick={() => this.fileInputRef.current?.click()}
disabled={uploading}
disabled={!isAdmin || uploading}
>
ZIP-Datei auswählen
</Button>
@@ -625,6 +636,7 @@ export default class ImportExportSettingsPanel extends Component {
{!uploading && (
<IconButton
size="small"
disabled={!isAdmin}
onClick={() => {
this.setState({ selectedFile: null });
if (this.fileInputRef.current) this.fileInputRef.current.value = '';
@@ -760,7 +772,7 @@ export default class ImportExportSettingsPanel extends Component {
<CloudUploadOutlinedIcon />
)
}
disabled={uploading || !selectedFile}
disabled={!isAdmin || uploading || !selectedFile}
onClick={this.handleStartUpload}
>
{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;
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status:
numbersStatus === 'ready'
? 'ready'
@@ -81,6 +82,7 @@ export default class NumberSettingsPanel extends PureComponent {
this.unsub = companyAutomat.subscribe(this, (state) => {
const numbers = Array.isArray(state.numbers) ? state.numbers : [];
return {
isAdmin: state.current?.role === 'admin',
status:
state.numbersStatus === 'ready'
? 'ready'
@@ -121,6 +123,7 @@ export default class NumberSettingsPanel extends PureComponent {
}
async handleSave(id) {
if (!this.state.isAdmin) return;
const draft = this.state.drafts[id];
if (!draft || draft.busy) return;
const row = this.state.numbers.find((r) => r.id === id);
@@ -211,7 +214,7 @@ export default class NumberSettingsPanel extends PureComponent {
}
render() {
const { status, error, numbers, drafts } = this.state;
const { isAdmin, status, error, numbers, drafts } = this.state;
if (status === 'loading') {
return (
@@ -249,7 +252,7 @@ export default class NumberSettingsPanel extends PureComponent {
<TextInput
size="small"
value={draft.prefix}
disabled={draft.busy}
disabled={!isAdmin || draft.busy}
onChange={(e) => this.handleChange(row.id, 'prefix', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }}
/>
@@ -259,7 +262,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small"
type="number"
value={draft.padding}
disabled={draft.busy}
disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 1, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'padding', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }}
@@ -270,7 +273,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small"
type="number"
value={draft.next}
disabled={draft.busy}
disabled={!isAdmin || draft.busy}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
onChange={(e) => this.handleChange(row.id, 'next', e.target.value)}
sx={{ input: { fontFamily: 'monospace' } }}
@@ -295,7 +298,7 @@ export default class NumberSettingsPanel extends PureComponent {
size="small"
aria-label="Speichern"
color="primary"
disabled={draft.busy}
disabled={!isAdmin || draft.busy}
onClick={() => void this.handleSave(row.id)}
>
<SaveOutlinedIcon fontSize="small" />

View File

@@ -19,12 +19,29 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import CategoryPickerDialog from '../article/CategoryPickerDialog';
import authAutomat from '../../automats/authAutomat';
import companyAutomat from '../../automats/companyAutomat';
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
import { categoryDisplayName } from '../../utils/categoryUtils';
import { backendErrorFrom } from '../../utils/backendError';
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) {
if (!val) return null;
try {
@@ -47,11 +64,13 @@ export default class PosTerminalSettingsPanel extends Component {
super(props);
const terminals = companyAutomat.state.terminals ?? [];
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
error: null,
terminals,
categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId,
vhostSettings: authAutomat.state.vhostSettings,
newTerminalName: '',
creatingTerminal: false,
enableAllForNew: true,
@@ -75,6 +94,7 @@ export default class PosTerminalSettingsPanel extends Component {
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
terminals: Array.isArray(state.terminals) ? state.terminals : [],
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,
rootCategoryId: state.rootCategoryId,
}));
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
vhostSettings: state.vhostSettings,
}));
if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree();
}
@@ -90,6 +113,7 @@ export default class PosTerminalSettingsPanel extends Component {
componentWillUnmount() {
this.unsub?.();
this.unsubCategory?.();
this.unsubAuth?.();
}
async loadTerminals() {
@@ -111,6 +135,7 @@ export default class PosTerminalSettingsPanel extends Component {
}
async handleCreateTerminal() {
if (!this.state.isAdmin) return;
const { newTerminalName, enableAllForNew, terminals } = this.state;
const trimmed = newTerminalName.trim();
if (!trimmed) return;
@@ -272,9 +297,11 @@ export default class PosTerminalSettingsPanel extends Component {
render() {
const {
isAdmin,
status,
error,
terminals,
vhostSettings,
newTerminalName,
creatingTerminal,
enableAllForNew,
@@ -284,6 +311,11 @@ export default class PosTerminalSettingsPanel extends Component {
rootCategoryId,
} = 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) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
@@ -298,13 +330,43 @@ export default class PosTerminalSettingsPanel extends Component {
return (
<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 }}>
<TextField
size="small"
placeholder="Neues Terminal..."
value={newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={creatingTerminal}
disabled={!isAdmin || creatingTerminal}
autoComplete="off"
slotProps={{
htmlInput: {
@@ -329,7 +391,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small"
checked={enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={creatingTerminal}
disabled={!isAdmin || creatingTerminal}
/>
</Box>
</Tooltip>
@@ -338,7 +400,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small"
startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />}
onClick={this.handleCreateTerminal}
disabled={creatingTerminal || !newTerminalName.trim()}
disabled={!isAdmin || creatingTerminal || !newTerminalName.trim()}
>
Hinzufügen
</Button>
@@ -434,7 +496,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch
size="small"
checked={isEnabled}
disabled={isPatching || isDeleting}
disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
color="success"
/>
@@ -458,7 +520,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small"
aria-label={`Terminal ${terminal.name} löschen`}
onClick={() => void this.handleDeleteTerminal(terminal)}
disabled={isDeleting || creatingTerminal || isPatching}
disabled={!isAdmin || isDeleting || creatingTerminal || isPatching}
color="error"
>
{isDeleting ? (
@@ -488,13 +550,14 @@ export default class PosTerminalSettingsPanel extends Component {
<Chip
icon={<FolderIcon />}
label={this.getCategoryLabel(terminal)}
onClick={() => this.setState({ pickerTerminal: terminal })}
onClick={isAdmin ? () => this.setState({ pickerTerminal: terminal }) : undefined}
onDelete={
terminal.rootCategoryId != null
isAdmin && terminal.rootCategoryId != null
? () => void this.handleSetRootCategory(terminal, null)
: undefined
}
clickable
clickable={isAdmin}
disabled={!isAdmin || isPatching || isDeleting}
variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'}
color={terminal.rootCategoryId != null ? 'primary' : 'default'}
size="small"
@@ -502,6 +565,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Button
size="small"
variant="text"
disabled={!isAdmin || isPatching || isDeleting}
onClick={() => this.setState({ pickerTerminal: terminal })}
sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }}
>
@@ -518,7 +582,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span>
<IconButton
size="small"
disabled={index === 0 || isPatching || isDeleting}
disabled={!isAdmin || index === 0 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'up')}
sx={{ p: 0.25 }}
>
@@ -530,7 +594,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span>
<IconButton
size="small"
disabled={index === terminals.length - 1 || isPatching || isDeleting}
disabled={!isAdmin || index === terminals.length - 1 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'down')}
sx={{ p: 0.25 }}
>
@@ -541,7 +605,7 @@ export default class PosTerminalSettingsPanel extends Component {
<TextField
type="number"
size="small"
disabled={isPatching || isDeleting}
disabled={!isAdmin || isPatching || isDeleting}
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
onBlur={() => void this.handleSortSubmit(terminal)}
@@ -566,7 +630,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch
size="small"
checked={Boolean(terminal.syncRootArticles)}
disabled={isPatching || isDeleting}
disabled={!isAdmin || isPatching || isDeleting}
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 BackendError from '../BackendError';
import { printer as printerApi } from '../../api';
import companyAutomat from '../../automats/companyAutomat';
import { requireArray, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
@@ -189,6 +190,7 @@ export default class PrinterSettingsPanel extends Component {
constructor(props) {
super(props);
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: 'loading',
error: null,
printers: [],
@@ -208,6 +210,9 @@ export default class PrinterSettingsPanel extends Component {
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
void this.load();
}
@@ -326,10 +331,12 @@ export default class PrinterSettingsPanel extends Component {
componentWillUnmount() {
this._unmounted = true;
this.unsub?.();
}
render() {
const {
isAdmin,
status, error, printers,
draggingId, dragOverId,
addOpen, editPrinter,
@@ -354,6 +361,7 @@ export default class PrinterSettingsPanel extends Component {
size="small"
variant="outlined"
startIcon={<AddIcon />}
disabled={!isAdmin}
onClick={this.handleAdd}
>
Drucker hinzufügen
@@ -386,17 +394,17 @@ export default class PrinterSettingsPanel extends Component {
<TableRow
key={printer.id}
hover
draggable
onDragStart={() => this.handleDragStart(printer.id)}
onDragOver={(e) => this.handleDragOver(e, printer.id)}
onDragLeave={() => this.handleDragLeave()}
onDrop={() => this.handleDrop(printer.id)}
onDragEnd={() => this.handleDragEnd()}
draggable={isAdmin}
onDragStart={isAdmin ? () => this.handleDragStart(printer.id) : undefined}
onDragOver={isAdmin ? (e) => this.handleDragOver(e, printer.id) : undefined}
onDragLeave={isAdmin ? () => this.handleDragLeave() : undefined}
onDrop={isAdmin ? () => this.handleDrop(printer.id) : undefined}
onDragEnd={isAdmin ? () => this.handleDragEnd() : undefined}
sx={{
opacity: isDragging ? 0.4 : 1,
outline: isDragOver ? '2px solid' : 'none',
outlineColor: 'primary.main',
cursor: 'grab',
cursor: isAdmin ? 'grab' : 'default',
transition: 'opacity 0.15s',
}}
>
@@ -426,7 +434,7 @@ export default class PrinterSettingsPanel extends Component {
<IconButton
size="small"
aria-label="Bearbeiten"
disabled={isDeleting}
disabled={!isAdmin || isDeleting}
onClick={() => this.setState({ editPrinter: printer })}
>
<EditOutlinedIcon fontSize="small" />
@@ -435,7 +443,7 @@ export default class PrinterSettingsPanel extends Component {
size="small"
aria-label="Löschen"
color="error"
disabled={isDeleting}
disabled={!isAdmin || isDeleting}
onClick={() => void this.handleDelete(printer.id)}
>
{isDeleting ? (

View File

@@ -18,15 +18,20 @@ import authAutomat from '../../automats/authAutomat';
import CompanySettingsPanel from './CompanySettingsPanel';
import NumberSettingsPanel from './NumberSettingsPanel';
import PrinterSettingsPanel from './PrinterSettingsPanel';
import PrinterAgentSettingsPanel from './PrinterAgentSettingsPanel';
import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
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 HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import LocalPrintshopOutlinedIcon from '@mui/icons-material/LocalPrintshopOutlined';
import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = {
@@ -35,10 +40,12 @@ const TAB_KEYS = {
keys: 'keys',
numbers: 'numbers',
printers: 'printers',
printeragent: 'printeragent',
importExport: 'importExport',
backup: 'backup',
posterminal: 'posterminal',
security: 'security',
members: 'members',
};
const TABS = [
@@ -47,38 +54,82 @@ const TABS = [
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ 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.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ 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 },
];
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 {
constructor(props) {
super(props);
const vhost = Boolean(authAutomat.state.vhost);
this.state = {
tab: TAB_KEYS.health,
tab: getDefaultTab(vhost),
schemaVersion: authAutomat.state.schemaVersion,
vhost,
isAdmin: companyAutomat.state.current?.role === 'admin',
};
this.handleTabChange = this.handleTabChange.bind(this);
}
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() {
this.unsubAuth?.();
this.unsubCompany?.();
}
componentDidUpdate(prevProps) {
const { open } = this.props;
const { vhost } = this.state;
if (!open && prevProps.open) {
this.setState({ tab: TAB_KEYS.health });
this.setState({ tab: getDefaultTab(vhost) });
}
if (open && !prevProps.open) {
const currentVhost = Boolean(authAutomat.state.vhost);
const updates = {};
if (this.props.initialTab) {
updates.tab = this.props.initialTab;
}
if (this.state.schemaVersion !== authAutomat.state.schemaVersion) {
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() {
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 = {
height: '85vh',
@@ -146,7 +199,7 @@ export default class SettingsDialog extends Component {
>
<Tabs
orientation="vertical"
value={tab}
value={activeTab}
onChange={this.handleTabChange}
sx={{
'& .MuiTabs-indicator': {
@@ -182,7 +235,7 @@ export default class SettingsDialog extends Component {
},
}}
>
{TABS.map((t) => (
{visibleTabs.map((t) => (
<Tab
key={t.id}
disableRipple
@@ -205,15 +258,15 @@ export default class SettingsDialog extends Component {
{/* Right content panel */}
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
<Box sx={{ display: 'grid' }}>
{TABS.map((t) => (
{visibleTabs.map((t) => (
<Box
key={t.id}
sx={{
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>

View File

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

View File

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

View File

@@ -27,11 +27,25 @@ import CreateNewFolderRoundedIcon from '@mui/icons-material/CreateNewFolderRound
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import companyAutomat from '../../automats/companyAutomat';
import VfsFileIcon from './VfsFileIcon';
import { formatVfsSize, formatVfsDate, isAppletFile } from './vfsUtils';
import { formatVfsSize, formatVfsDate, isAppletFile, isProtectedVfsFile } from './vfsUtils';
import VfsNewFolderDialog from './VfsNewFolderDialog';
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.
* 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 = {
isAdmin: companyAutomat.state.current?.role === 'admin',
menuAnchor: null,
menuFile: null,
folderMenuAnchor: null,
@@ -143,6 +158,16 @@ export default class VfsFileExplorer extends PureComponent {
this.fileInputRef = React.createRef();
}
componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsubCompany?.();
}
componentDidUpdate(prevProps) {
if (this.props.selectedFileName && this.props.selectedFileName !== prevProps.selectedFileName) {
const parents = getParentFolderPaths(this.props.selectedFileName);
@@ -632,7 +657,12 @@ export default class VfsFileExplorer extends PureComponent {
isRenaming,
isDraggingOver,
} = this.state;
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const isText = menuFile?.isText;
const isMenuFileProtected = Boolean(menuFile && isProtectedVfsFile(menuFile.name));
const isFolderProtected = Boolean(
folderMenuNode && folderContainsProtectedFiles(folderMenuNode.path, this.props.files)
);
return (
<Box
@@ -832,6 +862,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem>
<MenuItem
disabled={!isAdmin && isMenuFileProtected}
onClick={() => {
const file = menuFile;
this.handleCloseMenu();
@@ -860,6 +891,7 @@ export default class VfsFileExplorer extends PureComponent {
)}
<MenuItem
disabled={!isAdmin && isMenuFileProtected}
onClick={() => {
if (menuFile && window.confirm(`Möchtest du "${menuFile.name}" wirklich aus dem VFS löschen?`)) {
this.props.onDelete(menuFile.name);
@@ -915,6 +947,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem>
<MenuItem
disabled={!isAdmin && isFolderProtected}
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
@@ -934,6 +967,7 @@ export default class VfsFileExplorer extends PureComponent {
</MenuItem>
<MenuItem
disabled={!isAdmin && isFolderProtected}
onClick={() => {
const folder = folderMenuNode;
this.handleCloseFolderMenu();
@@ -963,6 +997,7 @@ export default class VfsFileExplorer extends PureComponent {
<VfsRenameDialog
open={renameDialogOpen}
item={renameItem}
isAdmin={isAdmin}
onClose={() => this.setState({ renameDialogOpen: false, renameItem: null })}
onRename={this.handleRenameItem}
isRenaming={isRenaming}

View File

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

View File

@@ -7,16 +7,29 @@ import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import DriveFileRenameOutlineRoundedIcon from '@mui/icons-material/DriveFileRenameOutlineRounded';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import companyAutomat from '../../automats/companyAutomat';
import { isProtectedVfsFile } from './vfsUtils';
export default class VfsRenameDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
targetPath: props.item?.path || '',
error: '',
};
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
}
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) {
if (!prevProps.open && this.props.open) {
this.setState({
@@ -28,6 +41,7 @@ export default class VfsRenameDialog extends PureComponent {
handleSubmit = (e) => {
e?.preventDefault();
const isAdmin = this.props.isAdmin ?? this.state.isAdmin;
const raw = this.state.targetPath.trim();
if (!raw) {
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 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) {
this.props.onClose?.();
return;
@@ -53,6 +72,11 @@ export default class VfsRenameDialog extends PureComponent {
const { open, onClose, isRenaming, item } = this.props;
const { targetPath, error } = this.state;
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 (
<Dialog
@@ -81,8 +105,8 @@ export default class VfsRenameDialog extends PureComponent {
label=""
value={targetPath}
onChange={(nextVal) => this.setState({ targetPath: nextVal, error: '' })}
error={Boolean(error)}
helperText={error}
error={Boolean(displayError)}
helperText={displayError}
disabled={isRenaming}
/>
</DialogContent>
@@ -94,7 +118,7 @@ export default class VfsRenameDialog extends PureComponent {
<Button
type="submit"
variant="contained"
disabled={isRenaming || !targetPath.trim()}
disabled={isRenaming || !cleanNew || isProtected}
sx={{ borderRadius: 2 }}
>
{isRenaming ? 'Speichere…' : 'Umbenennen'}

View File

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

View File

@@ -87,10 +87,30 @@ export function getVfsCategory(filePath, mime = '') {
* @returns {boolean}
*/
export function isAppletFile(filePath) {
const p = String(filePath || '').toLowerCase();
const p = String(filePath || '').trim().toLowerCase();
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.
* @param {string} path

View File

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

View File

@@ -11,11 +11,19 @@ import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
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 Typography from '@mui/material/Typography';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import FilterListIcon from '@mui/icons-material/FilterList';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
@@ -265,6 +273,28 @@ class ResultRow extends Component {
}
// #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
function initialSearchState(searchParams) {
const query = queryFromParams(searchParams);
@@ -284,6 +314,7 @@ function initialSearchState(searchParams) {
categoryFilterOpen: false,
brandFilterOpen: false,
supplierFilterOpen: false,
moreFilterAnchorEl: null,
newArticleOpen: false,
error: automatResult.error,
categoryFacets: automatResult.categoryFacets,
@@ -357,6 +388,73 @@ 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 });
};
@@ -380,14 +478,14 @@ class ArtikelSearchPage extends Component {
parentId,
name: list[0] ?? '',
});
void articleIndexAutomat.actions.reload().catch(() => {});
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(() => {});
void articleIndexAutomat.actions.reload().catch(() => { });
if (createdList?.[0]) {
this.props.navigate(artikelDetailPath(createdList[0]));
}
@@ -406,6 +504,8 @@ class ArtikelSearchPage extends Component {
filter: query.filter,
brand: query.brand,
supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
offset: query.offset,
});
};
@@ -429,6 +529,8 @@ class ArtikelSearchPage extends Component {
offset,
brand: urlBrand,
supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
} = queryFromParams(this.props.searchParams);
const hasFilterInUrl = this.props.searchParams.has('filter');
const navCategory = navigationAutomat.state.lastCategoryId;
@@ -439,6 +541,8 @@ class ArtikelSearchPage extends Component {
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand,
supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
});
setLastSearchTerm(activeTarget, syncedUrl);
this.props.navigate(syncedUrl, { replace: true });
@@ -453,6 +557,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(activeTarget, filter, offset, {
brand: urlBrand,
supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
}),
);
void articleSearchAutomat.actions.search({
@@ -460,8 +566,10 @@ class ArtikelSearchPage extends Component {
filter,
brand: urlBrand,
supplier: urlSupplier,
empty: urlEmpty,
hierarchy: urlHierarchy,
offset: 0,
}).catch(() => {});
}).catch(() => { });
}
componentWillUnmount() {
@@ -478,6 +586,8 @@ class ArtikelSearchPage extends Component {
buildSearchUrl(target, query.filter, query.offset, {
brand: query.brand,
supplier: query.supplier,
empty: query.empty,
hierarchy: query.hierarchy,
}),
);
}
@@ -498,6 +608,8 @@ class ArtikelSearchPage extends Component {
searchParams.get('filter') !== prev.get('filter')
|| searchParams.get('brand') !== prev.get('brand')
|| searchParams.get('supplier') !== prev.get('supplier')
|| searchParams.get('empty') !== prev.get('empty')
|| searchParams.get('hierarchy') !== prev.get('hierarchy')
|| searchParams.get('offset') !== prev.get('offset')
) {
this.fetchResults();
@@ -512,17 +624,23 @@ class ArtikelSearchPage extends Component {
nextOffset = 0,
nextBrand = this.props.searchParams.get('brand') ?? '',
nextSupplier = this.props.searchParams.get('supplier') ?? '',
nextEmpty = this.props.searchParams.get('empty') ?? '',
nextHierarchy = this.props.searchParams.get('hierarchy') ?? '',
) => {
writeArtikelQuery({
target: nextTarget,
filter: nextFilter,
brand: nextBrand,
supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
offset: nextOffset,
});
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
brand: nextBrand,
supplier: nextSupplier,
empty: nextEmpty,
hierarchy: nextHierarchy,
});
setLastSearchTerm(nextTarget, nextUrl);
@@ -547,18 +665,20 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => {
const value = event.target.value;
this.setState({ input: value });
const { filter, brand, supplier } = queryFromParams(this.props.searchParams);
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier });
const { filter, brand, supplier, empty, hierarchy } = queryFromParams(this.props.searchParams);
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier, empty, hierarchy });
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 });
void articleSearchAutomat.actions.search({
target: value,
filter,
brand,
supplier,
empty,
hierarchy,
offset: 0,
}).catch(() => {});
}).catch(() => { });
};
handleClearInput = () => {
@@ -566,17 +686,19 @@ class ArtikelSearchPage extends Component {
};
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({
target: target.trim(),
filter,
brand,
supplier,
empty,
hierarchy,
offset,
}).catch(() => {});
}).catch(() => { });
};
// #endregion
@@ -587,6 +709,7 @@ class ArtikelSearchPage extends Component {
categoryFilterOpen,
brandFilterOpen,
supplierFilterOpen,
moreFilterAnchorEl,
newArticleOpen,
error,
totalCount,
@@ -598,10 +721,15 @@ class ArtikelSearchPage extends Component {
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 categories = parseFilter(filterParam);
const selectedBrands = parseFilter(brandParam);
const selectedSuppliers = parseFilter(supplierParam);
const selectedEmpty = parseFilter(emptyParam);
const activeMoreFilterCount = selectedEmpty.length + (hierarchyParam ? 1 : 0);
const isAnyMoreFilterActive = activeMoreFilterCount > 0;
const rows = recordset ?? [];
const categoriesMap = categoryAutomat.state?.categories;
@@ -639,16 +767,16 @@ class ArtikelSearchPage extends Component {
const brandFacetEntries = brandFacets
? Object.values(brandFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
: [];
const supplierFacetEntries = supplierFacets
? Object.values(supplierFacets).sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
if (b.count !== a.count) return b.count - a.count;
return String(a.name).localeCompare(String(b.name), 'de');
})
: [];
// Per-render values the static `renderItem` row reads through the context.
@@ -736,6 +864,22 @@ class ArtikelSearchPage extends Component {
>
Lieferanten
</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}
@@ -765,6 +909,8 @@ class ArtikelSearchPage extends Component {
0,
buildFilter(selectedBrands),
buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -791,6 +937,8 @@ class ArtikelSearchPage extends Component {
0,
buildFilter(next),
buildFilter(selectedSuppliers),
emptyParam,
hierarchyParam,
);
}}
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
@@ -817,12 +965,78 @@ class ArtikelSearchPage extends Component {
0,
buildFilter(selectedBrands),
buildFilter(next),
emptyParam,
hierarchyParam,
);
}}
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>
{error && (
@@ -1100,6 +1314,91 @@ class ArtikelSearchPage extends Component {
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) => (
<ArticleTile
key={item.id}
label={item.name != null ? item.name.trim() : undefined}
article={item}
disabled={!articleSku(item)}
onClick={() => this.handleArticleClick(item)}
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
{!renameMode &&
articles
.filter((item) => item.parentArticleId == null && !item.parentSku)
.map((item, index) => (
<ArticleTile
key={item.id}
label={item.name != null ? item.name.trim() : undefined}
article={item}
disabled={!articleSku(item)}
onClick={() => this.handleArticleClick(item)}
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
/>
))}
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
{renameMode && loaded && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,5 +22,6 @@ export function normalizeCompany(raw, overrides = {}) {
reducedtaxrate: overrides.reducedtaxrate !== undefined ? overrides.reducedtaxrate : raw.reducedtaxrate,
taxfreerate: overrides.taxfreerate !== undefined ? overrides.taxfreerate : raw.taxfreerate,
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 167 KiB