Compare commits

...

11 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
79 changed files with 1011 additions and 222 deletions

View File

@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 307 API endpoints across all modules. Overview of all 314 API endpoints across all modules.
### Notation ### Notation
@@ -140,17 +140,17 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types ### Reusable types
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T32|null, payments?: T32[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }` - `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T33|null, payments?: T33[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }` - `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }` - `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T39 }` - `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }` - `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }` - `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }` - `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }` - `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }` - `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }` - `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T11` = `{ vfs: T40, storage: T40, database: T34 }` - `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 }` - `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 }` - `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 }` - `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 }`
@@ -158,7 +158,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `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 }` - `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 }` - `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 }` - `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 }` - `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 }` - `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 }` - `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 }` - `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
@@ -170,17 +170,18 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }` - `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 }` - `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }` - `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T31` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }` - `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
- `T32` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }` - `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T33` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }` - `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T34` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }` - `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
- `T35` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }` - `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T38` = `{ id: int, name: str, createdAt: str, updatedAt: str }` - `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T39` = `{ totalPositions: int, completelyOnLedgerPositions: int }` - `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T40` = `{ name: str, sizeBytes: int, sourceExists: bool }` - `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T41` = `{ net: num, taxId: int, taxRate?: num|null }` - `T41` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
### Routes ### Routes
@@ -387,25 +388,37 @@ _none_
- Returns: `200 { attributevalue: T30 }` - Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image` #### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }` - Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image` #### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }` - Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /auth ### Section: /auth
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T35 }` - Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### Current user — `GET /auth/me` #### Current user — `GET /auth/me`
_none_ _none_
- Returns: `200 { user: T35, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }` - Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
#### Set selected company — `PUT /auth/selected-company` #### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T35 }` - Returns: `200 { user: T36 }`
### Section: /autoinvite
#### List auto-invitations — `GET /autoinvite`
_none_
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
#### Create auto-invitation — `POST /autoinvite`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
- Params: `id: int`
- Returns: `204 void`
### Section: /backup ### Section: /backup
@@ -426,7 +439,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` - Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -435,19 +448,19 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }` - Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }` - Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
#### Add or replace brand image — `POST /brand/{id}/image` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }` - Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
### Section: /carrier ### Section: /carrier
#### List carriers — `GET /carrier` #### List carriers — `GET /carrier`
_none_ _none_
- Returns: `200 { carriers: T33[] }` - Returns: `200 { carriers: T34[] }`
#### Create carrier — `POST /carrier` #### Create carrier — `POST /carrier`
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `201 { carrier: T33 }` - Returns: `201 { carrier: T34 }`
#### Reorder carriers — `PUT /carrier/order` #### Reorder carriers — `PUT /carrier/order`
- Params: `body: { carrierIds: int[] }` - Params: `body: { carrierIds: int[] }`
- Returns: `200 { carrierIds: int[] }` - Returns: `200 { carrierIds: int[] }`
@@ -456,10 +469,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get carrier by ID — `GET /carrier/{id}` #### Get carrier by ID — `GET /carrier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { carrier: T33 }` - Returns: `200 { carrier: T34 }`
#### Update carrier — `PATCH /carrier/{id}` #### Update carrier — `PATCH /carrier/{id}`
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }` - Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
- Returns: `200 { carrier: T33 }` - Returns: `200 { carrier: T34 }`
### Section: /category ### Section: /category
@@ -516,7 +529,7 @@ _none_
- Returns: `200 { companies: T19[] }` - Returns: `200 { companies: T19[] }`
#### Create company — `POST /company` #### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T19, user: T35 }` - Returns: `201 { company: T19, user: T36 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export` #### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str` - Params: `query: stream?: str`
- Returns: `200 SSE` - Returns: `200 SSE`
@@ -548,6 +561,21 @@ _none_
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T19 }` - Returns: `200 { company: T19 }`
### Section: /companyuser
#### List company users — `GET /companyuser`
_none_
- Returns: `200 { companyusers: T31[] }`
#### Add an existing user to the company — `POST /companyuser`
- Params: `body: { email: str, role?: str }`
- Returns: `201 { companyuser: T31 }`
#### Remove a user from the company — `DELETE /companyuser/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Change a company user's role — `PATCH /companyuser/{id}`
- Params: `id: int, body: { role: str }`
- Returns: `200 { companyuser: T31 }`
### Section: /customergroup ### Section: /customergroup
#### List customer groups — `GET /customergroup` #### List customer groups — `GET /customergroup`
@@ -570,7 +598,7 @@ _none_
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int` - Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T39 }[], total: int, unfilteredTotal: int, offset: int, limit: int }` - Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create delivery — `POST /delivery` #### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }` - Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 { delivery: T4 }` - Returns: `201 { delivery: T4 }`
@@ -618,16 +646,16 @@ _none_
- Returns: `200 { address: T14 }` - Returns: `200 { address: T14 }`
#### List entity contacts — `GET /entity/{entityId}/contact` #### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { contacts: T37[] }` - Returns: `200 { contacts: T38[] }`
#### Create entity contact — `POST /entity/{entityId}/contact` #### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }` - Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T37 }` - Returns: `201 { contact: T38 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` - Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T37 }` - Returns: `200 { contact: T38 }`
#### Delete entity — `DELETE /entity/{id}` #### Delete entity — `DELETE /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -654,6 +682,8 @@ _none_
- `ledger`: `{}` - `ledger`: `{}`
- `attributename`: `{}` - `attributename`: `{}`
- `propertyname`: `{}` - `propertyname`: `{}`
- `companyuser`: `{}`
- `autoinvite`: `{}`
- `labeltemplate`: `{}` - `labeltemplate`: `{}`
- `printer`: `{}` - `printer`: `{}`
- `printeragent`: `{}` - `printeragent`: `{}`
@@ -817,10 +847,10 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment` #### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { payments: T32[] }` - Returns: `200 { payments: T33[] }`
#### Add payment to an order — `POST /order/{id}/payment` #### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }` - Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T32 }` - Returns: `201 { payment: T33 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf` #### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 { attachment: T10, fileUrl: str }` - Returns: `201 { attachment: T10, fileUrl: str }`
@@ -946,10 +976,10 @@ _none_
#### List property names — `GET /propertyname` #### List property names — `GET /propertyname`
_none_ _none_
- Returns: `200 { propertynames: T31[] }` - Returns: `200 { propertynames: T32[] }`
#### Create property name — `POST /propertyname` #### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T31 }` - Returns: `201 { propertyname: T32 }`
#### Reorder property names — `PUT /propertyname/order` #### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }` - Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -958,7 +988,7 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}` #### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T31 }` - Returns: `200 { propertyname: T32 }`
### Section: /public ### Section: /public
@@ -1153,22 +1183,22 @@ _none_
#### List suppliers — `GET /supplier` #### List suppliers — `GET /supplier`
_none_ _none_
- Returns: `200 { suppliers: T38[] }` - Returns: `200 { suppliers: T39[] }`
#### Create supplier — `POST /supplier` #### Create supplier — `POST /supplier`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { supplier: T38 }` - Returns: `201 { supplier: T39 }`
#### Delete supplier — `DELETE /supplier/{id}` #### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}` #### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T38 }` - Returns: `200 { supplier: T39 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles` #### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: str` - Params: `id: int, query: skus?: str`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }[] }` - Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }` - Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T41|null, incoming?: { date: str, amount: num }[] }[] }` - Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
### Section: /thumb ### Section: /thumb

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

@@ -34,6 +34,8 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js'; export { shipping } from './shipping.js';
export { payment } from './payment.js'; export { payment } from './payment.js';
export { account } from './account.js'; export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js'; export { mcp } from './mcp.js';
export { backup } from './backup.js'; export { backup } from './backup.js';
export { carrier } from './carrier.js'; export { carrier } from './carrier.js';

View File

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

@@ -84,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
emit = dispatch; emit = dispatch;
} }
function normalizePicture(picture) { export function normalizePicture(picture) {
if (!picture || typeof picture !== 'string') return null; if (!picture || typeof picture !== 'string') return null;
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) { if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
return picture; return picture;

View File

@@ -15,6 +15,7 @@
* - Brands (`brands`, `filteredBrands`, `brandFilter`) * - Brands (`brands`, `filteredBrands`, `brandFilter`)
* - Product attributes & values (`attributes`) * - Product attributes & values (`attributes`)
* - Payment accounts (`accounts`) * - Payment accounts (`accounts`)
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
* *
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when * Lives with the 1st-level navigation shell (`AppShell`) and triggers when
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
@@ -39,6 +40,8 @@ import {
attributename as attributenameApi, attributename as attributenameApi,
attributevalue as attributevalueApi, attributevalue as attributevalueApi,
account as accountApi, account as accountApi,
companyuser as companyuserApi,
autoinvite as autoinviteApi,
} from '../api'; } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { normalizeCompany } from '../utils/companyForm'; import { normalizeCompany } from '../utils/companyForm';
@@ -330,6 +333,14 @@ const initialState = {
accounts: [], accounts: [],
accountsStatus: 'idle', accountsStatus: 'idle',
accountsError: null, accountsError: null,
// 14. Members & invites (admin only; never part of loadAll)
members: [],
membersStatus: 'idle',
membersError: null,
invites: [],
invitesStatus: 'idle',
invitesError: null,
}; };
let companyAutomat; let companyAutomat;
@@ -350,6 +361,8 @@ let inflightCarriers = null;
let inflightBrands = null; let inflightBrands = null;
let inflightAttributes = null; let inflightAttributes = null;
let inflightAccounts = null; let inflightAccounts = null;
let inflightMembers = null;
let inflightInvites = null;
// #endregion // #endregion
// #region internal state updaters & fetchers // #region internal state updaters & fetchers
@@ -565,6 +578,8 @@ const actions = {
inflightBrands = null; inflightBrands = null;
inflightAttributes = null; inflightAttributes = null;
inflightAccounts = null; inflightAccounts = null;
inflightMembers = null;
inflightInvites = null;
companyAutomat.setState({ companyAutomat.setState({
...initialState, ...initialState,
viewerPosition: companyAutomat.state.viewerPosition, viewerPosition: companyAutomat.state.viewerPosition,
@@ -1709,6 +1724,122 @@ const actions = {
return accounts; return accounts;
}, },
// #endregion // #endregion
// #region 14. Members (companyuser) & invites (autoinvite) — admin only
// MARK: calls companyuser.* / autoinvite.* from ../api/index.js
loadMembers() {
if (inflightMembers) return inflightMembers;
inflightMembers = (async () => {
companyAutomat.setState({ membersStatus: 'loading', membersError: null });
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersStatus: 'failed', membersError: error });
throw error;
} finally {
inflightMembers = null;
}
})();
return inflightMembers;
},
async reloadMembers() {
try {
const res = await companyuserApi.list();
const members = requireArray(res?.companyusers, 'companyusers');
companyAutomat.setState({ members, membersStatus: 'ready', membersError: null });
return members;
} catch (error) {
companyAutomat.setState({ membersError: error });
throw error;
}
},
ensureMembersLoaded() {
if (companyAutomat.state.membersStatus === 'ready') {
return Promise.resolve(companyAutomat.state.members);
}
return actions.loadMembers();
},
/** Adds an already registered user by email. Rejects with 404/409 from the backend. */
async addMember(email, role = 'user') {
const res = await companyuserApi.create({ email, role });
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({ members: [...companyAutomat.state.members, member] });
return member;
},
/** Rejects with 409 when demoting the last admin. */
async setMemberRole(id, role) {
const res = await companyuserApi.setRole(id, role);
const member = requireObject(res?.companyuser, 'companyuser');
companyAutomat.setState({
members: companyAutomat.state.members.map((m) => (m.id === member.id ? member : m)),
});
return member;
},
/** Rejects with 409 when removing the last admin. */
async removeMember(id) {
await companyuserApi.delete(id);
companyAutomat.setState({ members: companyAutomat.state.members.filter((m) => m.id !== id) });
},
loadInvites() {
if (inflightInvites) return inflightInvites;
inflightInvites = (async () => {
companyAutomat.setState({ invitesStatus: 'loading', invitesError: null });
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesStatus: 'failed', invitesError: error });
throw error;
} finally {
inflightInvites = null;
}
})();
return inflightInvites;
},
async reloadInvites() {
try {
const res = await autoinviteApi.list();
const invites = requireArray(res?.autoinvites, 'autoinvites');
companyAutomat.setState({ invites, invitesStatus: 'ready', invitesError: null });
return invites;
} catch (error) {
companyAutomat.setState({ invitesError: error });
throw error;
}
},
ensureInvitesLoaded() {
if (companyAutomat.state.invitesStatus === 'ready') {
return Promise.resolve(companyAutomat.state.invites);
}
return actions.loadInvites();
},
/** Rejects with 409 when the email is already registered (use addMember then). */
async createInvite(email, role = 'user') {
const res = await autoinviteApi.create({ email, role });
const invite = requireObject(res?.autoinvite, 'autoinvite');
companyAutomat.setState({ invites: [...companyAutomat.state.invites, invite] });
return invite;
},
async deleteInvite(id) {
await autoinviteApi.delete(id);
companyAutomat.setState({ invites: companyAutomat.state.invites.filter((i) => i.id !== id) });
},
// #endregion
}; };
/** /**
@@ -1807,6 +1938,15 @@ sseAutomat.actions.on('attributename', () => {
sseAutomat.actions.on('account', () => { sseAutomat.actions.on('account', () => {
void companyAutomat.actions.reloadAccounts().catch(() => {}); void companyAutomat.actions.reloadAccounts().catch(() => {});
}); });
// Admin-only lists: only refresh once an admin view has loaded them.
sseAutomat.actions.on('companyuser', () => {
if (companyAutomat.state.membersStatus !== 'ready') return;
void companyAutomat.actions.reloadMembers().catch(() => {});
});
sseAutomat.actions.on('autoinvite', () => {
if (companyAutomat.state.invitesStatus !== 'ready') return;
void companyAutomat.actions.reloadInvites().catch(() => {});
});
sseAutomat.actions.on('company', () => { sseAutomat.actions.on('company', () => {
void companyAutomat.actions.reload().catch(() => {}); void companyAutomat.actions.reload().catch(() => {});
}); });
@@ -1885,6 +2025,16 @@ export const selectAttributeStatus = () => companyAutomat.state.attributesStatus
// Accounts selectors // Accounts selectors
export const selectAccounts = () => companyAutomat.state.accounts; export const selectAccounts = () => companyAutomat.state.accounts;
export const selectAccountsStatus = () => companyAutomat.state.accountsStatus; export const selectAccountsStatus = () => companyAutomat.state.accountsStatus;
// Own role in the selected company ('admin' | 'user' | undefined)
export const selectCompanyRole = () => companyAutomat.state.current?.role;
export const selectIsCompanyAdmin = () => companyAutomat.state.current?.role === 'admin';
// Members & invites selectors (admin only)
export const selectMembers = () => companyAutomat.state.members;
export const selectMembersStatus = () => companyAutomat.state.membersStatus;
export const selectInvites = () => companyAutomat.state.invites;
export const selectInvitesStatus = () => companyAutomat.state.invitesStatus;
// #endregion // #endregion
export default companyAutomat; export default companyAutomat;

View File

@@ -178,6 +178,12 @@ export {
selectAttributeStatus, selectAttributeStatus,
selectAccounts, selectAccounts,
selectAccountsStatus, selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat'; } from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat'; export { default as categoryAutomat } from './categoryAutomat';

View File

@@ -15,6 +15,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import companyAutomat from '../automats/companyAutomat'; import companyAutomat from '../automats/companyAutomat';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import { getPrinterImage } from '../assets/printers';
export default class OnlinePrinterIcons extends PureComponent { export default class OnlinePrinterIcons extends PureComponent {
constructor(props) { constructor(props) {
@@ -247,6 +248,14 @@ export default class OnlinePrinterIcons extends PureComponent {
> >
{activePrinter && ( {activePrinter && (
<Box sx={{ px: 2, py: 1, minWidth: 210 }}> <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 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" fontWeight={600} noWrap> <Typography variant="subtitle2" fontWeight={600} noWrap>
{activePrinter.model || 'Drucker'} {activePrinter.model || 'Drucker'}
@@ -283,8 +292,7 @@ export default class OnlinePrinterIcons extends PureComponent {
<PrintOutlinedIcon fontSize="small" color="primary" /> <PrintOutlinedIcon fontSize="small" color="primary" />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary="Testdruck senden" primary="Testdruck"
secondary="Minimales ZPL-Etikett"
/> />
</MenuItem> </MenuItem>
@@ -293,8 +301,7 @@ export default class OnlinePrinterIcons extends PureComponent {
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" /> <CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
</ListItemIcon> </ListItemIcon>
<ListItemText <ListItemText
primary="Alle Aufträge abbrechen" primary="Aufträge abbrechen"
secondary="Warteschlange leeren"
/> />
</MenuItem> </MenuItem>
@@ -302,7 +309,7 @@ export default class OnlinePrinterIcons extends PureComponent {
<ListItemIcon> <ListItemIcon>
<SettingsIcon fontSize="small" /> <SettingsIcon fontSize="small" />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Drucker-Einstellungen" /> <ListItemText primary="Einstellungen" />
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>

View File

@@ -12,20 +12,15 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined'; import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { labeltemplate, printer, article } from '../../../api'; import { labeltemplate, article } from '../../../api';
import { companyAutomat } from '../../../automats'; import { companyAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import BackendError from '../../BackendError'; import BackendError from '../../BackendError';
function filterZplPrinters(agentprinters, fallbackPrinters = []) { function filterZplPrinters(agentprinters) {
const filteredAgent = (Array.isArray(agentprinters) ? agentprinters : []).filter( return (Array.isArray(agentprinters) ? agentprinters : []).filter(
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'), (p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
); );
if (filteredAgent.length > 0) return filteredAgent;
return (Array.isArray(fallbackPrinters) ? fallbackPrinters : []).filter(
(p) => !p.type || p.type.toLowerCase() === 'zpl',
);
} }
function getPrinterLabel(p) { function getPrinterLabel(p) {
@@ -52,6 +47,20 @@ function getPrinterLabel(p) {
return label; return label;
} }
function getPrinterLabelLength(printer, livePrinters = []) {
if (!printer) return null;
const live = (livePrinters || []).find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === printer.id) ||
(lp.id && lp.id === printer.id) ||
(printer.serial && lp.serial && String(printer.serial).toLowerCase() === String(lp.serial).toLowerCase()),
);
const raw = printer.labelLength ?? live?.labelLength;
if (raw == null) return null;
const num = Number(raw);
return Number.isFinite(num) && num > 0 ? num : null;
}
export default class ArticleLabelTab extends PureComponent { export default class ArticleLabelTab extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
@@ -59,20 +68,23 @@ export default class ArticleLabelTab extends PureComponent {
this.state = { this.state = {
templates: null, templates: null,
printers: initialPrinters, printers: initialPrinters,
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
selectedTemplateId: '', selectedTemplateId: '',
selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '', selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
quantity: 1, quantity: 1,
loading: true, loading: true,
error: null, error: null,
}; };
this.legacyPrinters = [];
} }
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => { this.unsub = companyAutomat.subscribe(this, (state) => {
const zplPrinters = filterZplPrinters(state.agentprinters, this.legacyPrinters); const zplPrinters = filterZplPrinters(state.agentprinters);
this.setState((prev) => ({ this.setState((prev) => ({
printers: zplPrinters, printers: zplPrinters,
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
selectedPrinterId: selectedPrinterId:
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId) prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
? prev.selectedPrinterId ? prev.selectedPrinterId
@@ -101,27 +113,16 @@ export default class ArticleLabelTab extends PureComponent {
this.setState({ loading: true, error: null }); this.setState({ loading: true, error: null });
const sku = this.props.article?.sku; const sku = this.props.article?.sku;
try { try {
const [resTemplates] = await Promise.all([ const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
labeltemplate.list(sku ? { sku } : undefined),
companyAutomat.actions.loadAgentPrinters().catch(() => companyAutomat.state.agentprinters),
]);
const templates = resTemplates.labeltemplates || []; const templates = resTemplates.labeltemplates || [];
let zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters); const zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
if (zplPrinters.length === 0) {
try {
const resPrinters = await printer.list();
this.legacyPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl');
zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters, this.legacyPrinters);
} catch {
// ignore legacy fallback error
}
}
this.setState((prev) => ({ this.setState((prev) => ({
templates, templates,
printers: zplPrinters, printers: zplPrinters,
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: prev.livePrinters,
selectedTemplateId: selectedTemplateId:
templates.length > 0 templates.length > 0
? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId) ? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId)
@@ -152,7 +153,6 @@ export default class ArticleLabelTab extends PureComponent {
await article.printLabel(articleData.sku, { await article.printLabel(articleData.sku, {
templateId: selectedTemplateId, templateId: selectedTemplateId,
agentprinterId: selectedPrinterId, agentprinterId: selectedPrinterId,
printerId: selectedPrinterId,
quantity, quantity,
}); });
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' }); enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
@@ -178,6 +178,7 @@ export default class ArticleLabelTab extends PureComponent {
const { const {
templates, templates,
printers, printers,
livePrinters,
selectedTemplateId, selectedTemplateId,
selectedPrinterId, selectedPrinterId,
quantity, quantity,
@@ -185,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
error, error,
} = this.state; } = this.state;
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
if (loading) { if (loading) {
return ( return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}> <Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
@@ -227,6 +231,16 @@ export default class ArticleLabelTab extends PureComponent {
<Grid container spacing={2}> <Grid container spacing={2}>
{templates.map((t) => { {templates.map((t) => {
const isSelected = t.id === selectedTemplateId; const isSelected = t.id === selectedTemplateId;
const dpi = t.dpi === 300 ? 300 : 203;
const tmplHeight = t.height || 200;
const tmplWidth = t.width || 448;
const hasPrinterLength = printerLabelLength != null && printerLabelLength > 0;
const totalHeight = hasPrinterLength ? Math.max(tmplHeight, printerLabelLength) : tmplHeight;
const isCutoff = hasPrinterLength && printerLabelLength < tmplHeight;
const outlineColor = isCutoff ? '#d32f2f' : '#1976d2';
const tmplHeightPercent = (tmplHeight / totalHeight) * 100;
const printerHeightPercent = hasPrinterLength ? (printerLabelLength / totalHeight) * 100 : 100;
return ( return (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}> <Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
<Paper <Paper
@@ -272,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
bgcolor: 'background.default', bgcolor: 'background.default',
borderRadius: 1, borderRadius: 1,
overflow: 'hidden', overflow: 'hidden',
position: 'relative',
}} }}
> >
{t.thumb ? ( {t.thumb ? (
<img <Box
src={`data:image/png;base64,${t.thumb}`} sx={{
alt={t.name} position: 'relative',
style={{ display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%', maxHeight: '100%',
objectFit: 'contain', aspectRatio: `${tmplWidth} / ${totalHeight}`,
boxSizing: 'border-box',
bgcolor: '#fff',
borderRadius: 0.5,
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}} }}
/> >
{/* Template preview image */}
<Box
sx={{
width: '100%',
height: `${tmplHeightPercent}%`,
position: 'relative',
display: 'flex',
overflow: 'hidden',
borderRadius: 0.5,
}}
>
<img
src={`data:image/png;base64,${t.thumb}`}
alt={t.name}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
{/* If template exceeds printer label length, shade the cutoff overflow */}
{isCutoff && (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
top: `${(printerLabelLength / tmplHeight) * 100}%`,
bottom: 0,
bgcolor: 'rgba(211, 47, 47, 0.22)',
zIndex: 1,
pointerEvents: 'none',
}}
/>
)}
</Box>
{/* Dotted outline for printer label length */}
{hasPrinterLength && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${printerHeightPercent}%`,
border: `2px dotted ${outlineColor}`,
borderRadius: 0.5,
pointerEvents: 'none',
boxSizing: 'border-box',
zIndex: 2,
}}
/>
)}
</Box>
) : ( ) : (
<Typography variant="caption" color="text.disabled"> <Typography variant="caption" color="text.disabled">
Keine Vorschau Keine Vorschau
@@ -295,7 +372,7 @@ export default class ArticleLabelTab extends PureComponent {
{t.name} {t.name}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{t.width}x{t.height} ({t.dpi} dpi) {t.width}x{t.height} ({dpi} dpi)
</Typography> </Typography>
</Box> </Box>
</Paper> </Paper>
@@ -314,11 +391,14 @@ export default class ArticleLabelTab extends PureComponent {
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })} onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
sx={{ minWidth: 200 }} sx={{ minWidth: 200 }}
> >
{printers.map((p) => ( {printers.map((p) => {
<MenuItem key={p.id} value={p.id}> const len = getPrinterLabelLength(p, livePrinters);
{getPrinterLabel(p)} return (
</MenuItem> <MenuItem key={p.id} value={p.id}>
))} {getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
</MenuItem>
);
})}
</TextInput> </TextInput>
<TextInput <TextInput

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,7 @@ export default class PosTerminalSettingsPanel extends Component {
super(props); super(props);
const terminals = companyAutomat.state.terminals ?? []; const terminals = companyAutomat.state.terminals ?? [];
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading', status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
error: null, error: null,
terminals, terminals,
@@ -93,6 +94,7 @@ export default class PosTerminalSettingsPanel extends Component {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
terminals: Array.isArray(state.terminals) ? state.terminals : [], terminals: Array.isArray(state.terminals) ? state.terminals : [],
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status, status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
})); }));
@@ -133,6 +135,7 @@ export default class PosTerminalSettingsPanel extends Component {
} }
async handleCreateTerminal() { async handleCreateTerminal() {
if (!this.state.isAdmin) return;
const { newTerminalName, enableAllForNew, terminals } = this.state; const { newTerminalName, enableAllForNew, terminals } = this.state;
const trimmed = newTerminalName.trim(); const trimmed = newTerminalName.trim();
if (!trimmed) return; if (!trimmed) return;
@@ -294,6 +297,7 @@ export default class PosTerminalSettingsPanel extends Component {
render() { render() {
const { const {
isAdmin,
status, status,
error, error,
terminals, terminals,
@@ -362,7 +366,7 @@ export default class PosTerminalSettingsPanel extends Component {
placeholder="Neues Terminal..." placeholder="Neues Terminal..."
value={newTerminalName} value={newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })} onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
autoComplete="off" autoComplete="off"
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
@@ -387,7 +391,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
checked={enableAllForNew} checked={enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })} onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={creatingTerminal} disabled={!isAdmin || creatingTerminal}
/> />
</Box> </Box>
</Tooltip> </Tooltip>
@@ -396,7 +400,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />} startIcon={creatingTerminal ? <CircularProgress size={16} color="inherit" /> : <AddIcon />}
onClick={this.handleCreateTerminal} onClick={this.handleCreateTerminal}
disabled={creatingTerminal || !newTerminalName.trim()} disabled={!isAdmin || creatingTerminal || !newTerminalName.trim()}
> >
Hinzufügen Hinzufügen
</Button> </Button>
@@ -492,7 +496,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch <Switch
size="small" size="small"
checked={isEnabled} checked={isEnabled}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)} onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
color="success" color="success"
/> />
@@ -516,7 +520,7 @@ export default class PosTerminalSettingsPanel extends Component {
size="small" size="small"
aria-label={`Terminal ${terminal.name} löschen`} aria-label={`Terminal ${terminal.name} löschen`}
onClick={() => void this.handleDeleteTerminal(terminal)} onClick={() => void this.handleDeleteTerminal(terminal)}
disabled={isDeleting || creatingTerminal || isPatching} disabled={!isAdmin || isDeleting || creatingTerminal || isPatching}
color="error" color="error"
> >
{isDeleting ? ( {isDeleting ? (
@@ -546,13 +550,14 @@ export default class PosTerminalSettingsPanel extends Component {
<Chip <Chip
icon={<FolderIcon />} icon={<FolderIcon />}
label={this.getCategoryLabel(terminal)} label={this.getCategoryLabel(terminal)}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={isAdmin ? () => this.setState({ pickerTerminal: terminal }) : undefined}
onDelete={ onDelete={
terminal.rootCategoryId != null isAdmin && terminal.rootCategoryId != null
? () => void this.handleSetRootCategory(terminal, null) ? () => void this.handleSetRootCategory(terminal, null)
: undefined : undefined
} }
clickable clickable={isAdmin}
disabled={!isAdmin || isPatching || isDeleting}
variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'} variant={terminal.rootCategoryId != null ? 'filled' : 'outlined'}
color={terminal.rootCategoryId != null ? 'primary' : 'default'} color={terminal.rootCategoryId != null ? 'primary' : 'default'}
size="small" size="small"
@@ -560,6 +565,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Button <Button
size="small" size="small"
variant="text" variant="text"
disabled={!isAdmin || isPatching || isDeleting}
onClick={() => this.setState({ pickerTerminal: terminal })} onClick={() => this.setState({ pickerTerminal: terminal })}
sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }} sx={{ minWidth: 'auto', p: 0.5, fontSize: '0.75rem' }}
> >
@@ -576,7 +582,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === 0 || isPatching || isDeleting} disabled={!isAdmin || index === 0 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'up')} onClick={() => void this.handleMoveTerminal(terminal, 'up')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -588,7 +594,7 @@ export default class PosTerminalSettingsPanel extends Component {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === terminals.length - 1 || isPatching || isDeleting} disabled={!isAdmin || index === terminals.length - 1 || isPatching || isDeleting}
onClick={() => void this.handleMoveTerminal(terminal, 'down')} onClick={() => void this.handleMoveTerminal(terminal, 'down')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -599,7 +605,7 @@ export default class PosTerminalSettingsPanel extends Component {
<TextField <TextField
type="number" type="number"
size="small" size="small"
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0} value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)} onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
onBlur={() => void this.handleSortSubmit(terminal)} onBlur={() => void this.handleSortSubmit(terminal)}
@@ -624,7 +630,7 @@ export default class PosTerminalSettingsPanel extends Component {
<Switch <Switch
size="small" size="small"
checked={Boolean(terminal.syncRootArticles)} checked={Boolean(terminal.syncRootArticles)}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)} onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
/> />
} }

View File

@@ -215,6 +215,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
isAdmin: companyAutomat.state.current?.role === 'admin',
printeragents: Array.isArray(companyAutomat.state.printeragents) printeragents: Array.isArray(companyAutomat.state.printeragents)
? companyAutomat.state.printeragents ? companyAutomat.state.printeragents
: [], : [],
@@ -254,6 +255,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
componentDidMount() { componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({ this.unsub = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
printeragents: Array.isArray(state.printeragents) ? state.printeragents : [], printeragents: Array.isArray(state.printeragents) ? state.printeragents : [],
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [], agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [], livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
@@ -279,6 +281,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
} }
async handleCreateAgent() { async handleCreateAgent() {
if (!this.state.isAdmin) return;
const { newAgentName, printeragents } = this.state; const { newAgentName, printeragents } = this.state;
const trimmed = newAgentName.trim(); const trimmed = newAgentName.trim();
if (!trimmed) return; if (!trimmed) return;
@@ -497,6 +500,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
render() { render() {
const { const {
isAdmin,
error, error,
printeragents, printeragents,
agentprinters, agentprinters,
@@ -540,23 +544,42 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<Typography variant="h6" fontWeight={600}> <Typography variant="h6" fontWeight={600}>
Drucker-Agenten Drucker-Agenten
</Typography> </Typography>
<Link <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
href="https://github.com/sebseb7/zebragent/releases/latest" <Link
target="_blank" href="https://github.com/sebseb7/zebragent/releases/latest"
rel="noopener noreferrer" target="_blank"
underline="hover" rel="noopener noreferrer"
sx={{ underline="hover"
display: 'inline-flex', sx={{
alignItems: 'center', display: 'inline-flex',
gap: 0.5, alignItems: 'center',
fontSize: '0.8125rem', gap: 0.5,
color: 'primary.main', fontSize: '0.8125rem',
fontWeight: 500, color: 'primary.main',
}} fontWeight: 500,
> }}
zebragent Releases >
<OpenInNewIcon sx={{ fontSize: 15 }} /> zebragent (Linux / Windows)
</Link> <OpenInNewIcon sx={{ fontSize: 15 }} />
</Link>
<Link
href="https://github.com/QuixPos-com/AndroidPrinterAgent/releases/latest"
target="_blank"
rel="noopener noreferrer"
underline="hover"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
fontSize: '0.8125rem',
color: 'primary.main',
fontWeight: 500,
}}
>
Android Agent
<OpenInNewIcon sx={{ fontSize: 15 }} />
</Link>
</Box>
</Box> </Box>
{/* Add new agent row */} {/* Add new agent row */}
@@ -572,7 +595,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
void this.handleCreateAgent(); void this.handleCreateAgent();
} }
}} }}
disabled={creatingAgent} disabled={!isAdmin || creatingAgent}
autoComplete="off" autoComplete="off"
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
@@ -594,7 +617,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
size="small" size="small"
startIcon={<AddIcon />} startIcon={<AddIcon />}
onClick={this.handleCreateAgent} onClick={this.handleCreateAgent}
disabled={creatingAgent || !newAgentName.trim()} disabled={!isAdmin || creatingAgent || !newAgentName.trim()}
> >
Hinzufügen Hinzufügen
</Button> </Button>
@@ -716,7 +739,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<IconButton <IconButton
size="small" size="small"
onClick={() => this.handleStartRename(agent)} onClick={() => this.handleStartRename(agent)}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
sx={{ p: 0.5, color: 'text.secondary' }} sx={{ p: 0.5, color: 'text.secondary' }}
> >
<EditOutlinedIcon sx={{ fontSize: 16 }} /> <EditOutlinedIcon sx={{ fontSize: 16 }} />
@@ -764,7 +787,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<Switch <Switch
size="small" size="small"
checked={isEnabled} checked={isEnabled}
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
onChange={(e) => void this.handleToggleAgentEnabled(agent, e.target.checked)} onChange={(e) => void this.handleToggleAgentEnabled(agent, e.target.checked)}
color="success" color="success"
/> />
@@ -788,7 +811,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
size="small" size="small"
aria-label={`Drucker-Agent ${agent.name} löschen`} aria-label={`Drucker-Agent ${agent.name} löschen`}
onClick={() => void this.handleDeleteAgent(agent)} onClick={() => void this.handleDeleteAgent(agent)}
disabled={isDeleting || creatingAgent || isPatching} disabled={!isAdmin || isDeleting || creatingAgent || isPatching}
color="error" color="error"
> >
<DeleteOutlinedIcon fontSize="small" /> <DeleteOutlinedIcon fontSize="small" />
@@ -904,7 +927,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === 0 || isPatching || isDeleting} disabled={!isAdmin || index === 0 || isPatching || isDeleting}
onClick={() => void this.handleMoveAgent(agent, 'up')} onClick={() => void this.handleMoveAgent(agent, 'up')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -914,7 +937,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={index === printeragents.length - 1 || isPatching || isDeleting} disabled={!isAdmin || index === printeragents.length - 1 || isPatching || isDeleting}
onClick={() => void this.handleMoveAgent(agent, 'down')} onClick={() => void this.handleMoveAgent(agent, 'down')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -924,7 +947,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<TextField <TextField
type="number" type="number"
size="small" size="small"
disabled={isPatching || isDeleting} disabled={!isAdmin || isPatching || isDeleting}
value={this.state.sortDrafts[agent.id] ?? agent.sort ?? 0} value={this.state.sortDrafts[agent.id] ?? agent.sort ?? 0}
onChange={(e) => this.handleSortDraftChange(agent.id, e.target.value)} onChange={(e) => this.handleSortDraftChange(agent.id, e.target.value)}
onBlur={() => void this.handleSortSubmit(agent)} onBlur={() => void this.handleSortSubmit(agent)}
@@ -1078,6 +1101,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<Switch <Switch
size="small" size="small"
checked={pEnabled} checked={pEnabled}
disabled={!isAdmin}
onChange={(e) => onChange={(e) =>
void this.handleTogglePrinterEnabled(p, e.target.checked) void this.handleTogglePrinterEnabled(p, e.target.checked)
} }
@@ -1099,7 +1123,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={pIdx === 0} disabled={!isAdmin || pIdx === 0}
onClick={() => void this.handleMovePrinter(p, 'up')} onClick={() => void this.handleMovePrinter(p, 'up')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -1110,7 +1134,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<span> <span>
<IconButton <IconButton
size="small" size="small"
disabled={pIdx === agentprinters.length - 1} disabled={!isAdmin || pIdx === agentprinters.length - 1}
onClick={() => void this.handleMovePrinter(p, 'down')} onClick={() => void this.handleMovePrinter(p, 'down')}
sx={{ p: 0.25 }} sx={{ p: 0.25 }}
> >
@@ -1124,7 +1148,7 @@ export default class PrinterAgentSettingsPanel extends PureComponent {
<IconButton <IconButton
size="small" size="small"
color="error" color="error"
disabled={isOnline} disabled={!isAdmin || isOnline}
onClick={() => void this.handleDeletePrinter(p)} onClick={() => void this.handleDeletePrinter(p)}
sx={{ p: 0.5 }} sx={{ p: 0.5 }}
> >

View File

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

View File

@@ -23,6 +23,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel'; import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel'; import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel'; import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import MembersSettingsPanel from './MembersSettingsPanel';
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
import companyAutomat from '../../automats/companyAutomat';
import BackupSettingsPanel from './BackupSettingsPanel'; import BackupSettingsPanel from './BackupSettingsPanel';
import HealthSettingsPanel from './HealthSettingsPanel'; import HealthSettingsPanel from './HealthSettingsPanel';
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined'; import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
@@ -42,6 +45,7 @@ const TAB_KEYS = {
backup: 'backup', backup: 'backup',
posterminal: 'posterminal', posterminal: 'posterminal',
security: 'security', security: 'security',
members: 'members',
}; };
const TABS = [ const TABS = [
@@ -54,6 +58,7 @@ const TABS = [
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel }, { id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel }, { id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel }, { id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
{ id: TAB_KEYS.members, label: 'Mitglieder', icon: <GroupOutlinedIcon />, Panel: MembersSettingsPanel },
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel }, { id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
]; ];
@@ -61,9 +66,10 @@ function getDefaultTab(vhost) {
return vhost ? TAB_KEYS.company : TAB_KEYS.health; return vhost ? TAB_KEYS.company : TAB_KEYS.health;
} }
function getVisibleTabs(vhost) { function getVisibleTabs(vhost, isAdmin) {
if (!vhost) return TABS; const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
return TABS.filter( if (!vhost) return tabs;
return tabs.filter(
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers (t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers
); );
} }
@@ -76,6 +82,7 @@ export default class SettingsDialog extends Component {
tab: getDefaultTab(vhost), tab: getDefaultTab(vhost),
schemaVersion: authAutomat.state.schemaVersion, schemaVersion: authAutomat.state.schemaVersion,
vhost, vhost,
isAdmin: companyAutomat.state.current?.role === 'admin',
}; };
this.handleTabChange = this.handleTabChange.bind(this); this.handleTabChange = this.handleTabChange.bind(this);
} }
@@ -85,10 +92,14 @@ export default class SettingsDialog extends Component {
schemaVersion: state.schemaVersion, schemaVersion: state.schemaVersion,
vhost: Boolean(state.vhost), vhost: Boolean(state.vhost),
})); }));
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
isAdmin: state.current?.role === 'admin',
}));
} }
componentWillUnmount() { componentWillUnmount() {
this.unsubAuth?.(); this.unsubAuth?.();
this.unsubCompany?.();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -129,8 +140,8 @@ export default class SettingsDialog extends Component {
render() { render() {
const { open, onClose } = this.props; const { open, onClose } = this.props;
const { tab, schemaVersion, vhost } = this.state; const { tab, schemaVersion, vhost, isAdmin } = this.state;
const visibleTabs = getVisibleTabs(vhost); const visibleTabs = getVisibleTabs(vhost, isAdmin);
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id; const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
const paperSx = { const paperSx = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,22 +30,17 @@ import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats/navigationAutomat'; import { setLastArticleSku } from '../../automats/navigationAutomat';
import companyAutomat from '../../automats/companyAutomat'; import companyAutomat from '../../automats/companyAutomat';
import { labeltemplate, printer } from '../../api'; import { labeltemplate } from '../../api';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { import {
BLUE_BG, BLUE_BG,
SURFACE_BASE, SURFACE_BASE,
} from '../../colors'; } from '../../colors';
function filterZplPrinters(agentprinters, fallbackPrinters = []) { function filterZplPrinters(agentprinters) {
const filteredAgent = (Array.isArray(agentprinters) ? agentprinters : []).filter( return (Array.isArray(agentprinters) ? agentprinters : []).filter(
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'), (p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
); );
if (filteredAgent.length > 0) return filteredAgent;
return (Array.isArray(fallbackPrinters) ? fallbackPrinters : []).filter(
(p) => !p.type || p.type.toLowerCase() === 'zpl',
);
} }
function getPrinterLabel(p) { function getPrinterLabel(p) {
@@ -134,7 +129,6 @@ class ArtikelVorlagenPage extends PureComponent {
}; };
this._cancelled = false; this._cancelled = false;
this._currentEditId = null; this._currentEditId = null;
this._legacyPrinters = [];
this.handleRetry = this.handleRetry.bind(this); this.handleRetry = this.handleRetry.bind(this);
this.handleOpenNew = this.handleOpenNew.bind(this); this.handleOpenNew = this.handleOpenNew.bind(this);
this.handleOpenEdit = this.handleOpenEdit.bind(this); this.handleOpenEdit = this.handleOpenEdit.bind(this);
@@ -154,10 +148,8 @@ class ArtikelVorlagenPage extends PureComponent {
this._cancelled = false; this._cancelled = false;
void this.loadTemplates(); void this.loadTemplates();
this.unsubPrinters = companyAutomat.subscribe(this, (state) => ({ this.unsubPrinters = companyAutomat.subscribe(this, (state) => ({
printers: filterZplPrinters(state.agentprinters, this._legacyPrinters), printers: filterZplPrinters(state.agentprinters),
})); }));
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
void this.loadFallbackPrinters();
} }
componentWillUnmount() { componentWillUnmount() {
@@ -165,21 +157,6 @@ class ArtikelVorlagenPage extends PureComponent {
this.unsubPrinters?.(); this.unsubPrinters?.();
} }
async loadFallbackPrinters() {
try {
const resPrinters = await printer.list();
this._legacyPrinters = (resPrinters.printers || []).filter(
(p) => !p.type || p.type.toLowerCase() === 'zpl',
);
if (this._cancelled) return;
this.setState({
printers: filterZplPrinters(companyAutomat.state.agentprinters, this._legacyPrinters),
});
} catch {
// ignore
}
}
async loadTemplates() { async loadTemplates() {
this.setState({ status: 'loading', error: null }); this.setState({ status: 'loading', error: null });
try { try {
@@ -358,8 +335,7 @@ class ArtikelVorlagenPage extends PureComponent {
this.setState({ printing: true }); this.setState({ printing: true });
try { try {
const payload = { const payload = {
printerId: targetPrinter.id, agentprinterId: targetPrinter.id,
agentprinterId: targetPrinter.printeragentId || targetPrinter.id,
templateId: editorTemplate?.id, templateId: editorTemplate?.id,
name: editorName, name: editorName,
width: editorWidth, width: editorWidth,

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 695 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 KiB

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 936 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 693 KiB

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