Compare commits
45 Commits
d21def186e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283ce3dc5f | ||
|
|
863422df8e | ||
|
|
9841002398 | ||
|
|
bc11a2c3ab | ||
|
|
ec9f2d9816 | ||
|
|
bc284c6d42 | ||
|
|
0181e08657 | ||
|
|
6071e59d30 | ||
|
|
e7103ac340 | ||
|
|
9c6d5d79e4 | ||
|
|
7c33c097d4 | ||
|
|
76e911ebaf | ||
|
|
d2139fee45 | ||
|
|
8a416ce384 | ||
|
|
7fe9f90f2c | ||
|
|
456389be1b | ||
|
|
e788c960d5 | ||
|
|
b7046d3bc6 | ||
|
|
fcaf3d5821 | ||
|
|
ca28f56f3f | ||
|
|
545eb98191 | ||
|
|
ee80646c1c | ||
|
|
df015da8fc | ||
|
|
2d1a7364f2 | ||
|
|
f6eb31b70d | ||
|
|
6719cf9d51 | ||
|
|
cdb031921b | ||
|
|
9cb82745e3 | ||
|
|
56d376c9bd | ||
|
|
72f168f188 | ||
|
|
eed979bf11 | ||
|
|
28d6fece46 | ||
|
|
1b60965cea | ||
|
|
d3e16fd2b7 | ||
|
|
3bb861907e | ||
|
|
6ca7597c59 | ||
|
|
05f1311892 | ||
|
|
dba9ae5cf0 | ||
|
|
8b680b5a1a | ||
|
|
f555a1c9dd | ||
|
|
867d550814 | ||
|
|
afb33cde38 | ||
|
|
5fcc5670d9 | ||
|
|
6851e7c16f | ||
|
|
6e9d706bd6 |
@@ -131,7 +131,7 @@ Strip supplierName?: str from T11 (resolve via entityId in cached supplier list)
|
|||||||
|
|
||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 284 API endpoints across all modules.
|
Overview of all 314 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -140,44 +140,48 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
|
|
||||||
### Reusable types
|
### Reusable types
|
||||||
|
|
||||||
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T18[], externalNumber?: str|null, createdAt?: str, shipping?: T20|null, shippings?: T20[], payment?: T29|null, payments?: T29[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T9[] }`
|
- `T1` = `{ id: int, number: str, type: str, entityId: int, date: str, lines: T20[], externalNumber?: str|null, createdAt?: str, shipping?: T23|null, shippings?: T23[], payment?: T33|null, payments?: T33[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T2[], files?: T10[] }`
|
||||||
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T18[], shippings: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T9[], state?: str }`
|
- `T2` = `{ id: int, number: str, type: str, date: str, lines: T20[], shippings: T23[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T10[], state?: str }`
|
||||||
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T18[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T9[] }`
|
- `T3` = `{ id: int, number: str, type: str, date: str, lines: T20[], entityId?: int|null, externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T10[] }`
|
||||||
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T21[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
|
- `T4` = `{ id: int, number: str, type: str, supplierId: int, date: str, lines: T24[], files: T10[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
|
||||||
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
|
- `T5` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T6|null }`
|
||||||
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
|
- `T6` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T11, totalSizeBytes: int, durationMs: int }`
|
||||||
- `T7` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }`
|
- `T7` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, clientversion?: str|null, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||||
- `T8` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] }, thumb?: str|null }`
|
- `T8` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }`
|
||||||
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] }, thumb?: str|null }`
|
||||||
- `T10` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
- `T10` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||||
- `T11` = `{ vfs: T37, storage: T37, database: T31 }`
|
- `T11` = `{ vfs: T41, storage: T41, database: T35 }`
|
||||||
- `T12` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- `T12` = `{ id: int, name: str, companyId?: int, token?: str|null, pairingpin?: str|null, pairedAt?: str|null, agentversion?: str|null, lastseenAt?: str|null, updatedAt?: str, sort?: int, enabled?: bool }`
|
||||||
- `T13` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
|
- `T13` = `{ id: int, printeragentId: int, type: str, enabled: bool, sort: int, model?: str|null, serial?: str|null, open?: bool, pause?: bool, error?: bool, online?: bool, labelLength?: int|null }`
|
||||||
- `T14` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
- `T14` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- `T15` = `{ id: int, name: str, sort: int, exclusive: bool, values: T27[], articleCount?: int }`
|
- `T15` = `{ id: int, company_id: int, prompt: str, max_itr: int, cron: str, created_at: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, lastrun_at?: str|null }`
|
||||||
- `T16` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
|
- `T16` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||||
- `T17` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
- `T17` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
|
||||||
- `T18` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
|
- `T18` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
|
||||||
- `T19` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
|
- `T19` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num, role: str }`
|
||||||
- `T20` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null, shippable: bool }`
|
||||||
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
- `T21` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null, shippable?: bool }`
|
||||||
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
- `T22` = `{ id: int, agentprinterId: int, type: str, state: str, data: str, createdAt: str, printeragentId?: int|null, completedAt?: str|null }`
|
||||||
- `T23` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
- `T23` = `{ id: int, manifestId: int, number: str, carrierId: int, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||||
- `T24` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
- `T24` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||||
- `T25` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
- `T25` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||||
- `T26` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
- `T26` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||||
- `T27` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||||
- `T28` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||||
- `T29` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||||
- `T30` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
|
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||||
- `T31` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
- `T31` = `{ id: int, googleAuthId: int, email: str, role: str, picture?: str|null }`
|
||||||
- `T32` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
- `T32` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||||
- `T33` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T33` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||||
- `T34` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
- `T34` = `{ id: int, name: str, sort: int, hasPackages: bool, isReverse: bool }`
|
||||||
- `T35` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
- `T35` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||||
- `T36` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
- `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||||
- `T37` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
- `T37` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T38` = `{ net: num, taxId: int, taxRate?: num|null }`
|
- `T38` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||||
|
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||||
|
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||||
|
- `T41` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||||
|
- `T42` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -203,6 +207,39 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
|
|
||||||
|
### Section: /agent
|
||||||
|
|
||||||
|
#### Printer agent SSE stream — `GET /agent/events`
|
||||||
|
- Params: `query: token?: str, agentversion?: str`
|
||||||
|
- Returns: `200 SSE`
|
||||||
|
#### Pair printer agent via query parameters — `GET /agent/pair`
|
||||||
|
- Params: `query: authCode?: str, pairingpin?: str, serial?: str, model?: str, version?: str`
|
||||||
|
- Returns: `200`
|
||||||
|
#### Pair printer agent — `POST /agent/pair`
|
||||||
|
- Params: `body: { pairingpin: str, clientserial?: str, clientmodel?: str, agentversion?: str }`
|
||||||
|
- Returns: `200 { id: int, name: str, companyId: int, token: str, pairedAt: str, clientserial: str|null, clientmodel: str|null, agentversion: str|null, sort: int }`
|
||||||
|
#### Post agent state — `POST /agent/state`
|
||||||
|
- Params: `body: { state?: any }`
|
||||||
|
- Returns: `200 { success: bool, agent: T12 }`
|
||||||
|
|
||||||
|
### Section: /agentprinter
|
||||||
|
|
||||||
|
#### List agent printers — `GET /agentprinter`
|
||||||
|
- Params: `query: printeragentId?: int`
|
||||||
|
- Returns: `200 { agentprinters: T13[] }`
|
||||||
|
#### Reorder agent printers — `PUT /agentprinter/order`
|
||||||
|
- Params: `body: { agentprinterIds: int[], printeragentId?: int }`
|
||||||
|
- Returns: `200 { agentprinterIds: int[] }`
|
||||||
|
#### Delete agent printer — `DELETE /agentprinter/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
#### Get agent printer — `GET /agentprinter/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { agentprinter: T13 }`
|
||||||
|
#### Update agent printer — `PATCH /agentprinter/{id}`
|
||||||
|
- Params: `id: int, body: { enabled?: bool, sort?: int }`
|
||||||
|
- Returns: `200 { agentprinter: T13 }`
|
||||||
|
|
||||||
### Section: /article
|
### Section: /article
|
||||||
|
|
||||||
#### Create article in category — `POST /article`
|
#### Create article in category — `POST /article`
|
||||||
@@ -219,7 +256,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get article by SKU — `GET /article/{sku}`
|
#### Get article by SKU — `GET /article/{sku}`
|
||||||
- Params: `sku: str`
|
- Params: `sku: str`
|
||||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T26, brand: T25|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T7[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
|
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T28|null, categoryIds: int[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], prices: { minmultiplier: num, net: num, taxRate: num, customergroupId: int }[], suppliers: T8[], undeliveredOrders: { orderId: int, orderNumber: str, date: str, amount: num, customerName?: str|null }[] } }`
|
||||||
#### Update article by SKU — `PATCH /article/{sku}`
|
#### Update article by SKU — `PATCH /article/{sku}`
|
||||||
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
|
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -277,11 +314,11 @@ _none_
|
|||||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
#### Replace POS prices by SKU (net > gross) — `PUT /article/{sku}/prices`
|
||||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
- Params: `sku: str, body: { prices: { net?: num, gross?: num, taxRate?: num, minmultiplier?: num }[] }`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Print article label — `POST /article/{sku}/print`
|
#### Print article label — `POST /article/{sku}/print`
|
||||||
- Params: `sku: str, body: { templateId: int, printerId: int, quantity: int }`
|
- Params: `sku: str, body: { templateId: int, agentprinterId: int, quantity: int }`
|
||||||
- Returns: `200 { success: bool }`
|
- Returns: `200 { success: bool }`
|
||||||
#### List article properties — `GET /article/{sku}/property`
|
#### List article properties — `GET /article/{sku}/property`
|
||||||
- Params: `sku: str`
|
- Params: `sku: str`
|
||||||
@@ -294,10 +331,10 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### List article suppliers — `GET /article/{sku}/supplier`
|
#### List article suppliers — `GET /article/{sku}/supplier`
|
||||||
- Params: `sku: str`
|
- Params: `sku: str`
|
||||||
- Returns: `200 { suppliers: T7[] }`
|
- Returns: `200 { suppliers: T8[] }`
|
||||||
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
||||||
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||||
- Returns: `201 { supplier: T7 }`
|
- Returns: `201 { supplier: T8 }`
|
||||||
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
||||||
- Params: `sku: str, body: { ids: int[] }`
|
- Params: `sku: str, body: { ids: int[] }`
|
||||||
- Returns: `200 { ids: int[] }`
|
- Returns: `200 { ids: int[] }`
|
||||||
@@ -306,7 +343,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
||||||
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||||
- Returns: `200 { supplier: T7 }`
|
- Returns: `200 { supplier: T8 }`
|
||||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||||
- Params: `sku: str, body: { track_stock: bool }`
|
- Params: `sku: str, body: { track_stock: bool }`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -318,19 +355,19 @@ _none_
|
|||||||
|
|
||||||
#### List attribute names — `GET /attributename`
|
#### List attribute names — `GET /attributename`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { attributenames: T15[] }`
|
- Returns: `200 { attributenames: T17[] }`
|
||||||
#### Create attribute name — `POST /attributename`
|
#### Create attribute name — `POST /attributename`
|
||||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `201 { attributename: T15 }`
|
- Returns: `201 { attributename: T17 }`
|
||||||
#### Reorder attribute names — `PUT /attributename/order`
|
#### Reorder attribute names — `PUT /attributename/order`
|
||||||
- Params: `body: { attributenameIds: int[] }`
|
- Params: `body: { attributenameIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||||
- Params: `attributeId: int`
|
- Params: `attributeId: int`
|
||||||
- Returns: `200 { values: T27[] }`
|
- Returns: `200 { values: T30[] }`
|
||||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||||
- Returns: `201 { attributevalue: T27 }`
|
- Returns: `201 { attributevalue: T30 }`
|
||||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
@@ -339,7 +376,7 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update attribute name — `PATCH /attributename/{id}`
|
#### Update attribute name — `PATCH /attributename/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `200 { attributename: T15 }`
|
- Returns: `200 { attributename: T17 }`
|
||||||
|
|
||||||
### Section: /attributevalue
|
### Section: /attributevalue
|
||||||
|
|
||||||
@@ -348,28 +385,40 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||||
- Returns: `200 { attributevalue: T27 }`
|
- Returns: `200 { attributevalue: T30 }`
|
||||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }`
|
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||||
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
||||||
- Params: `id: int, image/avif`
|
- Params: `id: int, image/avif`
|
||||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }`
|
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||||
|
|
||||||
### Section: /auth
|
### Section: /auth
|
||||||
|
|
||||||
#### Login with Google ID token — `POST /auth/login`
|
#### Login with Google ID token — `POST /auth/login`
|
||||||
- Params: `body: { idToken: str }`
|
- Params: `body: { idToken: str }`
|
||||||
- Returns: `200 { message: str, user: T32 }`
|
- Returns: `200 { message: str, user: T36 }`
|
||||||
#### Logout — `POST /auth/logout`
|
#### Logout — `POST /auth/logout`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { message: str }`
|
- Returns: `200 { message: str }`
|
||||||
#### Current user — `GET /auth/me`
|
#### Current user — `GET /auth/me`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { user: T32, googleClientId: str }`
|
- Returns: `200 { user: T36, googleClientId: str, schemaVersion: int, vhost: bool, vhostSettings: str }`
|
||||||
#### Set selected company — `PUT /auth/selected-company`
|
#### Set selected company — `PUT /auth/selected-company`
|
||||||
- Params: `body: { companyId: int }`
|
- Params: `body: { companyId: int }`
|
||||||
- Returns: `200 { user: T32 }`
|
- Returns: `200 { user: T36 }`
|
||||||
|
|
||||||
|
### Section: /autoinvite
|
||||||
|
|
||||||
|
#### List auto-invitations — `GET /autoinvite`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { autoinvites: { id: int, email: str, role: str, createdAt: str }[] }`
|
||||||
|
#### Create auto-invitation — `POST /autoinvite`
|
||||||
|
- Params: `body: { email: str, role?: str }`
|
||||||
|
- Returns: `201 { autoinvite: { id: int, email: str, role: str } }`
|
||||||
|
#### Delete auto-invitation — `DELETE /autoinvite/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
|
||||||
### Section: /backup
|
### Section: /backup
|
||||||
|
|
||||||
@@ -390,7 +439,7 @@ _none_
|
|||||||
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
||||||
#### Create brand — `POST /brand`
|
#### Create brand — `POST /brand`
|
||||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||||
- Returns: `201 { brand: { id: int, name: str, pics: T33[], gsprpinfo?: str|null } }`
|
- Returns: `201 { brand: { id: int, name: str, pics: T37[], gsprpinfo?: str|null } }`
|
||||||
#### Delete brand — `DELETE /brand/{id}`
|
#### Delete brand — `DELETE /brand/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -399,19 +448,19 @@ _none_
|
|||||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T33[] } }`
|
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T37[] } }`
|
||||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||||
- Params: `id: int, image/avif`
|
- Params: `id: int, image/avif`
|
||||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T33[] } }`
|
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T37[] } }`
|
||||||
|
|
||||||
### Section: /carrier
|
### Section: /carrier
|
||||||
|
|
||||||
#### List carriers — `GET /carrier`
|
#### List carriers — `GET /carrier`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { carriers: T30[] }`
|
- Returns: `200 { carriers: T34[] }`
|
||||||
#### Create carrier — `POST /carrier`
|
#### Create carrier — `POST /carrier`
|
||||||
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
- Params: `body: { name: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
||||||
- Returns: `201 { carrier: T30 }`
|
- Returns: `201 { carrier: T34 }`
|
||||||
#### Reorder carriers — `PUT /carrier/order`
|
#### Reorder carriers — `PUT /carrier/order`
|
||||||
- Params: `body: { carrierIds: int[] }`
|
- Params: `body: { carrierIds: int[] }`
|
||||||
- Returns: `200 { carrierIds: int[] }`
|
- Returns: `200 { carrierIds: int[] }`
|
||||||
@@ -420,10 +469,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get carrier by ID — `GET /carrier/{id}`
|
#### Get carrier by ID — `GET /carrier/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { carrier: T30 }`
|
- Returns: `200 { carrier: T34 }`
|
||||||
#### Update carrier — `PATCH /carrier/{id}`
|
#### Update carrier — `PATCH /carrier/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
- Params: `id: int, body: { name?: str, sort?: int, hasPackages?: bool, isReverse?: bool }`
|
||||||
- Returns: `200 { carrier: T30 }`
|
- Returns: `200 { carrier: T34 }`
|
||||||
|
|
||||||
### Section: /category
|
### Section: /category
|
||||||
|
|
||||||
@@ -446,8 +495,8 @@ _none_
|
|||||||
- Params: `id: int, body: { name?: str, parentId?: int }`
|
- Params: `id: int, body: { name?: str, parentId?: int }`
|
||||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||||
#### Articles in category — `GET /category/{id}/articles`
|
#### Articles in category — `GET /category/{id}/articles`
|
||||||
- Params: `id: int`
|
- Params: `id: int, query: includeChildren?: bool`
|
||||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T25|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
|
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null, brand?: T28|null, articlewarehousesummary?: { id: int, articleId: int, warehouseId: int, stock: num, article_id?: int, warehouse_id?: int, warehouseName?: str|null }[], warehouses?: { id: int, name: str, stock: num }[] }[] }`
|
||||||
#### List category attribute values — `GET /category/{id}/attributevalue`
|
#### List category attribute values — `GET /category/{id}/attributevalue`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
||||||
@@ -477,10 +526,10 @@ _none_
|
|||||||
|
|
||||||
#### List companies — `GET /company`
|
#### List companies — `GET /company`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { companies: T17[] }`
|
- Returns: `200 { companies: T19[] }`
|
||||||
#### Create company — `POST /company`
|
#### Create company — `POST /company`
|
||||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `201 { company: T17, user: T32 }`
|
- Returns: `201 { company: T19, user: T36 }`
|
||||||
#### Export company catalog as zip archive (SSE) — `GET /company/export`
|
#### Export company catalog as zip archive (SSE) — `GET /company/export`
|
||||||
- Params: `query: stream?: str`
|
- Params: `query: stream?: str`
|
||||||
- Returns: `200 SSE`
|
- Returns: `200 SSE`
|
||||||
@@ -510,7 +559,22 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update company — `PUT /company/{id}`
|
#### Update company — `PUT /company/{id}`
|
||||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `200 { company: T17 }`
|
- Returns: `200 { company: T19 }`
|
||||||
|
|
||||||
|
### Section: /companyuser
|
||||||
|
|
||||||
|
#### List company users — `GET /companyuser`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { companyusers: T31[] }`
|
||||||
|
#### Add an existing user to the company — `POST /companyuser`
|
||||||
|
- Params: `body: { email: str, role?: str }`
|
||||||
|
- Returns: `201 { companyuser: T31 }`
|
||||||
|
#### Remove a user from the company — `DELETE /companyuser/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
#### Change a company user's role — `PATCH /companyuser/{id}`
|
||||||
|
- Params: `id: int, body: { role: str }`
|
||||||
|
- Returns: `200 { companyuser: T31 }`
|
||||||
|
|
||||||
### Section: /customergroup
|
### Section: /customergroup
|
||||||
|
|
||||||
@@ -534,7 +598,7 @@ _none_
|
|||||||
|
|
||||||
#### List deliveries — `GET /delivery`
|
#### List deliveries — `GET /delivery`
|
||||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
|
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
|
||||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T36 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create delivery — `POST /delivery`
|
#### Create delivery — `POST /delivery`
|
||||||
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
- Params: `body: { externalNumber: str, date: str, supplierId: int, lines: { amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||||
- Returns: `201 { delivery: T4 }`
|
- Returns: `201 { delivery: T4 }`
|
||||||
@@ -555,52 +619,52 @@ _none_
|
|||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
|
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `201 { attachment: T9, fileUrl: str }`
|
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||||
|
|
||||||
### Section: /entity
|
### Section: /entity
|
||||||
|
|
||||||
#### List entities — `GET /entity`
|
#### List entities — `GET /entity`
|
||||||
- Params: `query: customergroupId?: int, role?: str`
|
- Params: `query: customergroupId?: int, role?: str`
|
||||||
- Returns: `200 { entities: T24[] }`
|
- Returns: `200 { entities: T27[] }`
|
||||||
#### Create entity — `POST /entity`
|
#### Create entity — `POST /entity`
|
||||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||||
- Returns: `201 { entity: T24 }`
|
- Returns: `201 { entity: T27 }`
|
||||||
#### Peek next customer number — `GET /entity/next-number`
|
#### Peek next customer number — `GET /entity/next-number`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { number: str }`
|
- Returns: `200 { number: str }`
|
||||||
#### List entity addresses — `GET /entity/{entityId}/address`
|
#### List entity addresses — `GET /entity/{entityId}/address`
|
||||||
- Params: `entityId: int`
|
- Params: `entityId: int`
|
||||||
- Returns: `200 { addresses: T12[] }`
|
- Returns: `200 { addresses: T14[] }`
|
||||||
#### Create entity address — `POST /entity/{entityId}/address`
|
#### Create entity address — `POST /entity/{entityId}/address`
|
||||||
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- Returns: `201 { address: T12 }`
|
- Returns: `201 { address: T14 }`
|
||||||
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
||||||
- Params: `entityId: int, id: int`
|
- Params: `entityId: int, id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
||||||
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- Returns: `200 { address: T12 }`
|
- Returns: `200 { address: T14 }`
|
||||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||||
- Params: `entityId: int`
|
- Params: `entityId: int`
|
||||||
- Returns: `200 { contacts: T34[] }`
|
- Returns: `200 { contacts: T38[] }`
|
||||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||||
- Returns: `201 { contact: T34 }`
|
- Returns: `201 { contact: T38 }`
|
||||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||||
- Params: `entityId: int, id: int`
|
- Params: `entityId: int, id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||||
- Returns: `200 { contact: T34 }`
|
- Returns: `200 { contact: T38 }`
|
||||||
#### Delete entity — `DELETE /entity/{id}`
|
#### Delete entity — `DELETE /entity/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get entity — `GET /entity/{id}`
|
#### Get entity — `GET /entity/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { entity: T24 }`
|
- Returns: `200 { entity: T27 }`
|
||||||
#### Update entity — `PATCH /entity/{id}`
|
#### Update entity — `PATCH /entity/{id}`
|
||||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||||
- Returns: `200 { entity: T24 }`
|
- Returns: `200 { entity: T27 }`
|
||||||
|
|
||||||
### Section: /events
|
### Section: /events
|
||||||
|
|
||||||
@@ -618,9 +682,15 @@ _none_
|
|||||||
- `ledger`: `{}`
|
- `ledger`: `{}`
|
||||||
- `attributename`: `{}`
|
- `attributename`: `{}`
|
||||||
- `propertyname`: `{}`
|
- `propertyname`: `{}`
|
||||||
|
- `companyuser`: `{}`
|
||||||
|
- `autoinvite`: `{}`
|
||||||
- `labeltemplate`: `{}`
|
- `labeltemplate`: `{}`
|
||||||
- `printer`: `{}`
|
- `printer`: `{}`
|
||||||
|
- `printeragent`: `{}`
|
||||||
|
- `agentprinter`: `{ printers } or {}`
|
||||||
|
- `printerjob`: `{ job } or { jobId, state }`
|
||||||
- `warehouse`: `{}`
|
- `warehouse`: `{}`
|
||||||
|
- `supplier`: `{}`
|
||||||
- `vfs`: `{}`
|
- `vfs`: `{}`
|
||||||
- `modelconversation`: `{}`
|
- `modelconversation`: `{}`
|
||||||
- `modelconversation-<id>`: `{}`
|
- `modelconversation-<id>`: `{}`
|
||||||
@@ -647,35 +717,38 @@ _none_
|
|||||||
_none_
|
_none_
|
||||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||||
#### Create label template — `POST /labeltemplate`
|
#### Create label template — `POST /labeltemplate`
|
||||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T22[] } }`
|
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: { objects: T25[] } }`
|
||||||
- Returns: `201 { labeltemplate: T8 }`
|
- Returns: `201 { labeltemplate: T9 }`
|
||||||
|
#### Test print label template — `POST /labeltemplate/testprint`
|
||||||
|
- Params: `body: { agentprinterId: int, templateId?: int, objects?: any[], width?: int, height?: int, dpi?: int, sku?: str, quantity?: int }`
|
||||||
|
- Returns: `200 { success: bool }`
|
||||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get label template by ID — `GET /labeltemplate/{id}`
|
#### Get label template by ID — `GET /labeltemplate/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { labeltemplate: T8 }`
|
- Returns: `200 { labeltemplate: T9 }`
|
||||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
|
||||||
- Returns: `200 { labeltemplate: T8 }`
|
- Returns: `200 { labeltemplate: T9 }`
|
||||||
#### Update label template — `PUT /labeltemplate/{id}`
|
#### Update label template — `PUT /labeltemplate/{id}`
|
||||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T22[] } }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: { objects: T25[] } }`
|
||||||
- Returns: `200 { labeltemplate: T8 }`
|
- Returns: `200 { labeltemplate: T9 }`
|
||||||
|
|
||||||
### Section: /ledger
|
### Section: /ledger
|
||||||
|
|
||||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||||
- Returns: `200 { ledger: T14[] }`
|
- Returns: `200 { ledger: T16[] }`
|
||||||
#### Query company-wide ledger history — `GET /ledger/history`
|
#### Query company-wide ledger history — `GET /ledger/history`
|
||||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||||
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { ledger: { id: int, date: str, transaction_type: str, transaction_text: str, sku?: str|null, name?: str|null, amount?: num }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||||
- Returns: `200 { ledger: T14[] }`
|
- Returns: `200 { ledger: T16[] }`
|
||||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||||
- Params: `sku: str, query: transaction_type?: str`
|
- Params: `sku: str, query: transaction_type?: str`
|
||||||
- Returns: `200 { ledger: T14[] }`
|
- Returns: `200 { ledger: T16[] }`
|
||||||
|
|
||||||
### Section: /mcp
|
### Section: /mcp
|
||||||
|
|
||||||
@@ -705,16 +778,16 @@ _none_
|
|||||||
|
|
||||||
#### List conversations — `GET /modelconversation`
|
#### List conversations — `GET /modelconversation`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { conversations: T16[] }`
|
- Returns: `200 { conversations: T18[] }`
|
||||||
#### Create conversation — `POST /modelconversation`
|
#### Create conversation — `POST /modelconversation`
|
||||||
- Params: `body: { provider: str, model: str }`
|
- Params: `body: { provider: str, model: str }`
|
||||||
- Returns: `201 { conversation: T16 }`
|
- Returns: `201 { conversation: T18 }`
|
||||||
#### Delete conversation — `DELETE /modelconversation/{id}`
|
#### Delete conversation — `DELETE /modelconversation/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get conversation — `GET /modelconversation/{id}`
|
#### Get conversation — `GET /modelconversation/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { conversation: T16 }`
|
- Returns: `200 { conversation: T18 }`
|
||||||
#### List conversation messages — `GET /modelconversation/{id}/message`
|
#### List conversation messages — `GET /modelconversation/{id}/message`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
||||||
@@ -755,7 +828,7 @@ _none_
|
|||||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
||||||
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { orders: T1[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create order — `POST /order`
|
#### Create order — `POST /order`
|
||||||
- Params: `body: { number: str, date: str, entityId: int, lines: T19[], externalNumber?: str|null }`
|
- Params: `body: { number: str, date: str, entityId: int, lines: T21[], externalNumber?: str|null }`
|
||||||
- Returns: `201 { order: T1 }`
|
- Returns: `201 { order: T1 }`
|
||||||
#### Delete order — `DELETE /order/{id}`
|
#### Delete order — `DELETE /order/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -764,7 +837,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { order: T1 }`
|
- Returns: `200 { order: T1 }`
|
||||||
#### Update order — `PATCH /order/{id}`
|
#### Update order — `PATCH /order/{id}`
|
||||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||||
- Returns: `200 { order: T1 }`
|
- Returns: `200 { order: T1 }`
|
||||||
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
|
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
|
||||||
- Params: `id: int, fileId: int`
|
- Params: `id: int, fileId: int`
|
||||||
@@ -774,13 +847,13 @@ _none_
|
|||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### List payments of an order — `GET /order/{id}/payment`
|
#### List payments of an order — `GET /order/{id}/payment`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { payments: T29[] }`
|
- Returns: `200 { payments: T33[] }`
|
||||||
#### Add payment to an order — `POST /order/{id}/payment`
|
#### Add payment to an order — `POST /order/{id}/payment`
|
||||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||||
- Returns: `201 { payment: T29 }`
|
- Returns: `201 { payment: T33 }`
|
||||||
#### Generate and attach order PDF — `POST /order/{id}/pdf`
|
#### Generate and attach order PDF — `POST /order/{id}/pdf`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `201 { attachment: T9, fileUrl: str }`
|
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shipments: T2[] }`
|
- Returns: `200 { shipments: T2[] }`
|
||||||
@@ -789,10 +862,10 @@ _none_
|
|||||||
- Returns: `201 { shipment: T2 }`
|
- Returns: `201 { shipment: T2 }`
|
||||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shippings: T20[] }`
|
- Returns: `200 { shippings: T23[] }`
|
||||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||||
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
||||||
- Returns: `201 { shipping: T20 }`
|
- Returns: `201 { shipping: T23 }`
|
||||||
|
|
||||||
### Section: /package
|
### Section: /package
|
||||||
|
|
||||||
@@ -810,16 +883,16 @@ _none_
|
|||||||
|
|
||||||
#### List POS terminals — `GET /posterminal`
|
#### List POS terminals — `GET /posterminal`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { posterminals: T10[] }`
|
- Returns: `200 { posterminals: T7[] }`
|
||||||
#### Create POS terminal — `PUT /posterminal`
|
#### Create POS terminal — `PUT /posterminal`
|
||||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool }`
|
||||||
- Returns: `200 { posterminal: T10 }`
|
- Returns: `200 { posterminal: T7 }`
|
||||||
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update POS terminal — `PATCH /posterminal/{id}`
|
#### Update POS terminal — `PATCH /posterminal/{id}`
|
||||||
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
|
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, sort?: int, enabled?: bool, pairingpin?: str|null }`
|
||||||
- Returns: `200 { posterminal: T10 }`
|
- Returns: `200 { posterminal: T7 }`
|
||||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { id: int, categoryIds: int[] }`
|
- Returns: `200 { id: int, categoryIds: int[] }`
|
||||||
@@ -837,34 +910,76 @@ _none_
|
|||||||
|
|
||||||
#### List printers — `GET /printer`
|
#### List printers — `GET /printer`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { printers: T23[] }`
|
- Returns: `200 { printers: T26[] }`
|
||||||
#### Create printer — `POST /printer`
|
#### Create printer — `POST /printer`
|
||||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `201 { printer: T23 }`
|
- Returns: `201 { printer: T26 }`
|
||||||
#### Reorder printers — `PUT /printer/order`
|
#### Reorder printers — `PUT /printer/order`
|
||||||
- Params: `body: { printerIds: int[] }`
|
- Params: `body: { printerIds: int[] }`
|
||||||
- Returns: `200 { printerIds: int[] }`
|
- Returns: `200 { printerIds: int[] }`
|
||||||
|
#### Get live printer states — `GET /printer/state`
|
||||||
|
- Params: `query: serial?: str, agentId?: int`
|
||||||
|
- Returns: `200 { printers: {}[] }`
|
||||||
#### Delete printer — `DELETE /printer/{id}`
|
#### Delete printer — `DELETE /printer/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get printer by ID — `GET /printer/{id}`
|
#### Get printer by ID — `GET /printer/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { printer: T23 }`
|
- Returns: `200 { printer: T26 }`
|
||||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `200 { printer: T23 }`
|
- Returns: `200 { printer: T26 }`
|
||||||
#### Update printer — `PUT /printer/{id}`
|
#### Update printer — `PUT /printer/{id}`
|
||||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `200 { printer: T23 }`
|
- Returns: `200 { printer: T26 }`
|
||||||
|
#### Get live state for a printer — `GET /printer/{id}/state`
|
||||||
|
- Params: `id: str`
|
||||||
|
- Returns: `200 { printer: {} }`
|
||||||
|
|
||||||
|
### Section: /printeragent
|
||||||
|
|
||||||
|
#### List printer agents — `GET /printeragent`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { printeragents: T12[] }`
|
||||||
|
#### Create printer agent — `PUT /printeragent`
|
||||||
|
- Params: `body: { name: str, sort?: int, enabled?: bool }`
|
||||||
|
- Returns: `200 { printeragent: T12 }`
|
||||||
|
#### Delete printer agent — `DELETE /printeragent/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
#### Update printer agent — `PATCH /printeragent/{id}`
|
||||||
|
- Params: `id: int, body: { name?: str, sort?: int, enabled?: bool }`
|
||||||
|
- Returns: `200 { printeragent: T12 }`
|
||||||
|
|
||||||
|
### Section: /printerjob
|
||||||
|
|
||||||
|
#### List printer jobs — `GET /printerjob`
|
||||||
|
- Params: `query: agentprinterId?: int, state?: str`
|
||||||
|
- Returns: `200 { printerjobs: T22[] }`
|
||||||
|
#### Create print job — `POST /printerjob`
|
||||||
|
- Params: `body: { agentprinterId: int, data: str, type?: str }`
|
||||||
|
- Returns: `201 { printerjob: T22 }`
|
||||||
|
#### Cancel all pending and processing printer jobs — `POST /printerjob/cancel-all`
|
||||||
|
- Params: `body?: { agentprinterId?: int }`
|
||||||
|
- Returns: `200 { success: bool, cancelledCount: int, jobs: T22[] }`
|
||||||
|
#### Cancel and delete printer job — `DELETE /printerjob/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { success: bool, deletedJob: T22 }`
|
||||||
|
#### Get printer job — `GET /printerjob/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { printerjob: T22 }`
|
||||||
|
#### Cancel printer job — `POST /printerjob/{id}/cancel`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { success: bool, printerjob: T22 }`
|
||||||
|
|
||||||
### Section: /propertyname
|
### Section: /propertyname
|
||||||
|
|
||||||
#### List property names — `GET /propertyname`
|
#### List property names — `GET /propertyname`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { propertynames: T28[] }`
|
- Returns: `200 { propertynames: T32[] }`
|
||||||
#### Create property name — `POST /propertyname`
|
#### Create property name — `POST /propertyname`
|
||||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `201 { propertyname: T28 }`
|
- Returns: `201 { propertyname: T32 }`
|
||||||
#### Reorder property names — `PUT /propertyname/order`
|
#### Reorder property names — `PUT /propertyname/order`
|
||||||
- Params: `body: { propertynameIds: int[] }`
|
- Params: `body: { propertynameIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
@@ -873,7 +988,7 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update property name — `PATCH /propertyname/{id}`
|
#### Update property name — `PATCH /propertyname/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `200 { propertyname: T28 }`
|
- Returns: `200 { propertyname: T32 }`
|
||||||
|
|
||||||
### Section: /public
|
### Section: /public
|
||||||
|
|
||||||
@@ -885,7 +1000,7 @@ _none_
|
|||||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
||||||
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
|
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
|
||||||
- Params: `companyId: int, sku: str`
|
- Params: `companyId: int, sku: str`
|
||||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T26|null, brand: T25|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
|
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T29|null, brand: T28|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: { sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
|
||||||
#### List public categories — `GET /public/{companyId}/categories`
|
#### List public categories — `GET /public/{companyId}/categories`
|
||||||
- Params: `companyId: int`
|
- Params: `companyId: int`
|
||||||
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
|
||||||
@@ -896,7 +1011,7 @@ _none_
|
|||||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||||
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { quotes: T3[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create quote — `POST /quote`
|
#### Create quote — `POST /quote`
|
||||||
- Params: `body: { number: str, date: str, lines: T19[], entityId?: int|null, externalNumber?: str|null }`
|
- Params: `body: { number: str, date: str, lines: T21[], entityId?: int|null, externalNumber?: str|null }`
|
||||||
- Returns: `201 { quote: T3 }`
|
- Returns: `201 { quote: T3 }`
|
||||||
#### Delete quote — `DELETE /quote/{id}`
|
#### Delete quote — `DELETE /quote/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -905,7 +1020,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { quote: T3 }`
|
- Returns: `200 { quote: T3 }`
|
||||||
#### Update quote — `PATCH /quote/{id}`
|
#### Update quote — `PATCH /quote/{id}`
|
||||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||||
- Returns: `200 { quote: T3 }`
|
- Returns: `200 { quote: T3 }`
|
||||||
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
|
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
|
||||||
- Params: `id: int, fileId: int`
|
- Params: `id: int, fileId: int`
|
||||||
@@ -914,11 +1029,11 @@ _none_
|
|||||||
- Params: `id: int, fileId: int, query: download?: bool`
|
- Params: `id: int, fileId: int, query: download?: bool`
|
||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### Create order from quote — `POST /quote/{id}/order`
|
#### Create order from quote — `POST /quote/{id}/order`
|
||||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T19[] }`
|
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T21[] }`
|
||||||
- Returns: `201 { order: T1 }`
|
- Returns: `201 { order: T1 }`
|
||||||
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
|
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `201 { attachment: T9, fileUrl: str }`
|
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||||
|
|
||||||
### Section: /retoure
|
### Section: /retoure
|
||||||
|
|
||||||
@@ -1005,13 +1120,13 @@ _none_
|
|||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
|
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `201 { attachment: T9, fileUrl: str }`
|
- Returns: `201 { attachment: T10, fileUrl: str }`
|
||||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shippings: T20[] }`
|
- Returns: `200 { shippings: T23[] }`
|
||||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||||
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
- Params: `id: int, body: { carrierId: int, type: str, state: str, trackingCode?: str, warehouseId?: int }`
|
||||||
- Returns: `201 { shipping: T20 }`
|
- Returns: `201 { shipping: T23 }`
|
||||||
|
|
||||||
### Section: /shipping
|
### Section: /shipping
|
||||||
|
|
||||||
@@ -1020,7 +1135,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update shipping — `PATCH /shipping/{id}`
|
#### Update shipping — `PATCH /shipping/{id}`
|
||||||
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
|
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrierId?: int, warehouseId?: int }`
|
||||||
- Returns: `200 { shipping: T20 }`
|
- Returns: `200 { shipping: T23 }`
|
||||||
#### List packages of a shipping — `GET /shipping/{id}/package`
|
#### List packages of a shipping — `GET /shipping/{id}/package`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
||||||
@@ -1041,7 +1156,7 @@ _none_
|
|||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
#### Adjust stock manually — `POST /stock/adjust`
|
#### Adjust stock manually — `POST /stock/adjust`
|
||||||
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
||||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T14[] }`
|
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T16[] }`
|
||||||
#### Ship shipment manifest item(s) — `POST /stock/ship`
|
#### Ship shipment manifest item(s) — `POST /stock/ship`
|
||||||
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
||||||
- Returns: `200`
|
- Returns: `200`
|
||||||
@@ -1050,40 +1165,40 @@ _none_
|
|||||||
|
|
||||||
#### List subagent schedules — `GET /subagentschedule`
|
#### List subagent schedules — `GET /subagentschedule`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { schedules: T13[] }`
|
- Returns: `200 { schedules: T15[] }`
|
||||||
#### Create subagent schedule — `POST /subagentschedule`
|
#### Create subagent schedule — `POST /subagentschedule`
|
||||||
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
|
- Params: `body: { prompt: str, cron: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int }`
|
||||||
- Returns: `201 { schedule: T13 }`
|
- Returns: `201 { schedule: T15 }`
|
||||||
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
|
#### Delete subagent schedule — `DELETE /subagentschedule/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
|
#### Get subagent schedule by ID — `GET /subagentschedule/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { schedule: T13 }`
|
- Returns: `200 { schedule: T15 }`
|
||||||
#### Update subagent schedule — `PUT /subagentschedule/{id}`
|
#### Update subagent schedule — `PUT /subagentschedule/{id}`
|
||||||
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
|
- Params: `id: int, body: { prompt?: str, cron?: str, desc?: str|null, skills?: str|null, lastagentnote?: str|null, max_itr?: int, lastrun_at?: str|null }`
|
||||||
- Returns: `200 { schedule: T13 }`
|
- Returns: `200 { schedule: T15 }`
|
||||||
|
|
||||||
### Section: /supplier
|
### Section: /supplier
|
||||||
|
|
||||||
#### List suppliers — `GET /supplier`
|
#### List suppliers — `GET /supplier`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { suppliers: T35[] }`
|
- Returns: `200 { suppliers: T39[] }`
|
||||||
#### Create supplier — `POST /supplier`
|
#### Create supplier — `POST /supplier`
|
||||||
- Params: `body: { name: str }`
|
- Params: `body: { name: str }`
|
||||||
- Returns: `201 { supplier: T35 }`
|
- Returns: `201 { supplier: T39 }`
|
||||||
#### Delete supplier — `DELETE /supplier/{id}`
|
#### Delete supplier — `DELETE /supplier/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update supplier — `PATCH /supplier/{id}`
|
#### Update supplier — `PATCH /supplier/{id}`
|
||||||
- Params: `id: int, body: { name: str }`
|
- Params: `id: int, body: { name: str }`
|
||||||
- Returns: `200 { supplier: T35 }`
|
- Returns: `200 { supplier: T39 }`
|
||||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||||
- Params: `id: int, query: skus?: str`
|
- Params: `id: int, query: skus?: str`
|
||||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }[] }`
|
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||||
- Params: `id: int, body?: { skus?: str[] }`
|
- Params: `id: int, body?: { skus?: str[] }`
|
||||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T38|null, incoming?: { date: str, amount: num }[] }[] }`
|
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T42|null, incoming?: { date: str, amount: num }[] }[] }`
|
||||||
|
|
||||||
### Section: /thumb
|
### Section: /thumb
|
||||||
|
|
||||||
@@ -1098,10 +1213,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### List units — `GET /unit`
|
#### List units — `GET /unit`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { units: T26[] }`
|
- Returns: `200 { units: T29[] }`
|
||||||
#### Upsert unit — `PUT /unit`
|
#### Upsert unit — `PUT /unit`
|
||||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||||
- Returns: `200 { unit: T26 }`
|
- Returns: `200 { unit: T29 }`
|
||||||
|
|
||||||
### Section: /v1
|
### Section: /v1
|
||||||
|
|
||||||
@@ -1112,7 +1227,7 @@ _none_
|
|||||||
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
|
- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str`
|
||||||
- Returns: `200 jpeg`
|
- Returns: `200 jpeg`
|
||||||
#### POS discovery and pairing — `GET /v1/client`
|
#### POS discovery and pairing — `GET /v1/client`
|
||||||
- Params: `query: authCode: str, name?: str`
|
- Params: `query: authCode: str, name?: str, clientversion?: str`
|
||||||
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
|
- Returns: `200 { authCode: str|null, authToken: str|null, certificateFingerprint: str, certificateSerialNumber: str, mandantId: str, mandantName: str|null, mandantDatabase: str|null, serverFingerprint: str|null, name: str|null, serverTimestamp: str }`
|
||||||
#### JTL-POS customer catalog synchronization — `GET /v1/customer`
|
#### JTL-POS customer catalog synchronization — `GET /v1/customer`
|
||||||
- Params: `query: lastChangedCustomer?: str, limit?: int`
|
- Params: `query: lastChangedCustomer?: str, limit?: int`
|
||||||
@@ -1192,3 +1307,9 @@ _none_
|
|||||||
#### Update warehouse — `PUT /warehouse/{id}`
|
#### Update warehouse — `PUT /warehouse/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||||
- Returns: `200 { warehouse: { id: int, name: str, sort: int } }`
|
- Returns: `200 { warehouse: { id: int, name: str, sort: int } }`
|
||||||
|
|
||||||
|
### Section: /zpljob
|
||||||
|
|
||||||
|
#### Create ZPL print job — `POST /zpljob`
|
||||||
|
- Params: `body: { agentprinterId: int, data: str, copiescount?: int }`
|
||||||
|
- Returns: `201 { printerjob: T22 }`
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
<polygon fill="#1e2430" stroke="none" points="-36,36 -36,-2323.9 6416.78,-2323.9 6416.78,36 -36,36"/>
|
<polygon fill="#1e2430" stroke="none" points="-36,36 -36,-2323.9 6416.78,-2323.9 6416.78,36 -36,36"/>
|
||||||
<g id="clust1" class="cluster">
|
<g id="clust1" class="cluster">
|
||||||
<title>cluster_session_realtime</title>
|
<title>cluster_session_realtime</title>
|
||||||
<path fill="#202a3a" stroke="#3f4c5f" d="M1200.78,-1631C1200.78,-1631 2347.78,-1631 2347.78,-1631 2353.78,-1631 2359.78,-1637 2359.78,-1643 2359.78,-1643 2359.78,-1940 2359.78,-1940 2359.78,-1946 2353.78,-1952 2347.78,-1952 2347.78,-1952 1200.78,-1952 1200.78,-1952 1194.78,-1952 1188.78,-1946 1188.78,-1940 1188.78,-1940 1188.78,-1643 1188.78,-1643 1188.78,-1637 1194.78,-1631 1200.78,-1631"/>
|
<path fill="#202a3a" stroke="#3f4c5f" d="M1200.78,-1611C1200.78,-1611 2347.78,-1611 2347.78,-1611 2353.78,-1611 2359.78,-1617 2359.78,-1623 2359.78,-1623 2359.78,-1960 2359.78,-1960 2359.78,-1966 2353.78,-1972 2347.78,-1972 2347.78,-1972 1200.78,-1972 1200.78,-1972 1194.78,-1972 1188.78,-1966 1188.78,-1960 1188.78,-1960 1188.78,-1623 1188.78,-1623 1188.78,-1617 1194.78,-1611 1200.78,-1611"/>
|
||||||
<text xml:space="preserve" text-anchor="middle" x="1283.06" y="-1934.5" font-family="Helvetica,sans-Serif" font-size="15.00" fill="#e2e8f0">Session & Real-Time Hub</text>
|
<text xml:space="preserve" text-anchor="middle" x="1283.06" y="-1954.5" font-family="Helvetica,sans-Serif" font-size="15.00" fill="#e2e8f0">Session & Real-Time Hub</text>
|
||||||
</g>
|
</g>
|
||||||
<g id="clust2" class="cluster">
|
<g id="clust2" class="cluster">
|
||||||
<title>cluster_company_basedata</title>
|
<title>cluster_company_basedata</title>
|
||||||
@@ -57,64 +57,76 @@
|
|||||||
<!-- authAutomat -->
|
<!-- authAutomat -->
|
||||||
<g id="node1" class="node">
|
<g id="node1" class="node">
|
||||||
<title>authAutomat</title>
|
<title>authAutomat</title>
|
||||||
<polygon fill="#33415c" stroke="none" points="1210.95,-1849 1210.95,-1880 1530.61,-1880 1530.61,-1849 1210.95,-1849"/>
|
<polygon fill="#33415c" stroke="none" points="1210.95,-1871 1210.95,-1902 1530.61,-1902 1530.61,-1871 1210.95,-1871"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1849 1210.95,-1880 1530.61,-1880 1530.61,-1849 1210.95,-1849"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1871 1210.95,-1902 1530.61,-1902 1530.61,-1871 1210.95,-1871"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1333.78" y="-1865.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">authAutomat</text>
|
<text xml:space="preserve" text-anchor="start" x="1333.78" y="-1887.2" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="12.00" fill="#e2e8f0">authAutomat</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1282.24" y="-1855.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">Always-On Session & Multi-Tenant Gateway</text>
|
<text xml:space="preserve" text-anchor="start" x="1282.24" y="-1877.9" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#8b95a7">Always-On Session & Multi-Tenant Gateway</text>
|
||||||
<polygon fill="#2a3242" stroke="none" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1849 1210.95,-1871 1530.61,-1871 1530.61,-1849 1210.95,-1849"/>
|
||||||
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1849 1210.95,-1871 1530.61,-1871 1530.61,-1849 1210.95,-1849"/>
|
||||||
|
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1850 1211.95,-1870 1286.71,-1870 1286.71,-1850 1211.95,-1850"/>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1236.22" y="-1858.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1290.71" y="-1858" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasAuth: boolean</text>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1436.66" y="-1856.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Authenticated session bit</text>
|
||||||
|
<polygon fill="#272e3d" stroke="none" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1827 1210.95,-1849 1530.61,-1849 1530.61,-1827 1210.95,-1827"/>
|
||||||
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1828 1211.95,-1848 1286.71,-1848 1286.71,-1828 1211.95,-1828"/>
|
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1828 1211.95,-1848 1270.36,-1848 1270.36,-1828 1211.95,-1828"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1236.22" y="-1836.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
<text xml:space="preserve" text-anchor="start" x="1228.05" y="-1836.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1290.71" y="-1836" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasAuth: boolean</text>
|
<text xml:space="preserve" text-anchor="start" x="1274.36" y="-1836" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasCompany: boolean</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1436.66" y="-1834.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Authenticated session bit</text>
|
<text xml:space="preserve" text-anchor="start" x="1410.43" y="-1834.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">hasAuth && selectedCompanyId</text>
|
||||||
<polygon fill="#272e3d" stroke="none" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1805 1210.95,-1827 1530.61,-1827 1530.61,-1805 1210.95,-1805"/>
|
||||||
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1806 1211.95,-1826 1270.36,-1826 1270.36,-1806 1211.95,-1806"/>
|
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1806 1211.95,-1826 1291.52,-1826 1291.52,-1806 1211.95,-1806"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1228.05" y="-1814.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
<text xml:space="preserve" text-anchor="start" x="1238.63" y="-1814.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1274.36" y="-1814" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">hasCompany: boolean</text>
|
<text xml:space="preserve" text-anchor="start" x="1295.52" y="-1814" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">user: AuthUser</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1410.43" y="-1812.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">hasAuth && selectedCompanyId</text>
|
<text xml:space="preserve" text-anchor="start" x="1438.9" y="-1812.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Current user & company</text>
|
||||||
<polygon fill="#2a3242" stroke="none" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
|
<polygon fill="#272e3d" stroke="none" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1783 1210.95,-1805 1530.61,-1805 1530.61,-1783 1210.95,-1783"/>
|
||||||
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1784 1211.95,-1804 1291.52,-1804 1291.52,-1784 1211.95,-1784"/>
|
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1784 1211.95,-1804 1264.51,-1804 1264.51,-1784 1211.95,-1784"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1238.63" y="-1792.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
<text xml:space="preserve" text-anchor="start" x="1225.12" y="-1792.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1295.52" y="-1792" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">user: AuthUser</text>
|
<text xml:space="preserve" text-anchor="start" x="1268.51" y="-1792" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">schemaVersion: number|null</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1438.9" y="-1790.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Current user & company</text>
|
<text xml:space="preserve" text-anchor="start" x="1422.89" y="-1790.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Backend DB schema version</text>
|
||||||
<polygon fill="#272e3d" stroke="none" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1761 1210.95,-1783 1530.61,-1783 1530.61,-1761 1210.95,-1761"/>
|
||||||
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1762 1211.95,-1782 1304.59,-1782 1304.59,-1762 1211.95,-1762"/>
|
<polygon fill="#2b1a42" stroke="none" points="1211.95,-1762 1211.95,-1782 1304.59,-1782 1304.59,-1762 1211.95,-1762"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1245.16" y="-1770.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
<text xml:space="preserve" text-anchor="start" x="1245.16" y="-1770.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#c084fc">STATE</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1308.59" y="-1770" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">status</text>
|
<text xml:space="preserve" text-anchor="start" x="1308.59" y="-1770" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">status</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1435.33" y="-1768.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">'anon' | 'authed' | 'loading'</text>
|
<text xml:space="preserve" text-anchor="start" x="1435.33" y="-1768.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">'anon' | 'authed' | 'loading'</text>
|
||||||
<polygon fill="#2a3242" stroke="none" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
|
<polygon fill="#272e3d" stroke="none" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1739 1210.95,-1761 1530.61,-1761 1530.61,-1739 1210.95,-1739"/>
|
||||||
<polygon fill="#143826" stroke="none" points="1211.95,-1740 1211.95,-1760 1285.38,-1760 1285.38,-1740 1211.95,-1740"/>
|
<polygon fill="#143826" stroke="none" points="1211.95,-1740 1211.95,-1760 1285.38,-1760 1285.38,-1740 1211.95,-1740"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1233.33" y="-1748.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
<text xml:space="preserve" text-anchor="start" x="1233.33" y="-1748.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1289.38" y="-1748" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">bootstrapSession()</text>
|
<text xml:space="preserve" text-anchor="start" x="1289.38" y="-1748" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">bootstrapSession()</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1439.35" y="-1746.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Restore or fetch session</text>
|
<text xml:space="preserve" text-anchor="start" x="1439.35" y="-1746.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Restore or fetch session</text>
|
||||||
<polygon fill="#272e3d" stroke="none" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1717 1210.95,-1739 1530.61,-1739 1530.61,-1717 1210.95,-1717"/>
|
||||||
<polygon fill="#143826" stroke="none" points="1211.95,-1718 1211.95,-1738 1291.68,-1738 1291.68,-1718 1211.95,-1718"/>
|
<polygon fill="#143826" stroke="none" points="1211.95,-1718 1211.95,-1738 1291.68,-1738 1291.68,-1718 1211.95,-1718"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1236.48" y="-1726.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
<text xml:space="preserve" text-anchor="start" x="1236.48" y="-1726.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1295.68" y="-1726" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">switchCompany(id)</text>
|
<text xml:space="preserve" text-anchor="start" x="1295.68" y="-1726" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">switchCompany(id)</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1459.36" y="-1724.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Multi-tenant switch</text>
|
<text xml:space="preserve" text-anchor="start" x="1459.36" y="-1724.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Multi-tenant switch</text>
|
||||||
<polygon fill="#2a3242" stroke="none" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
|
<polygon fill="#272e3d" stroke="none" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1695 1210.95,-1717 1530.61,-1717 1530.61,-1695 1210.95,-1695"/>
|
||||||
<polygon fill="#143826" stroke="none" points="1211.95,-1696 1211.95,-1716 1307.5,-1716 1307.5,-1696 1211.95,-1696"/>
|
<polygon fill="#143826" stroke="none" points="1211.95,-1696 1211.95,-1716 1307.5,-1716 1307.5,-1696 1211.95,-1696"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1244.39" y="-1704.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
<text xml:space="preserve" text-anchor="start" x="1244.39" y="-1704.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#4ade80">ACTION</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1311.5" y="-1704" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">logout()</text>
|
<text xml:space="preserve" text-anchor="start" x="1311.5" y="-1704" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">logout()</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1451.8" y="-1702.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Clear session cookie</text>
|
<text xml:space="preserve" text-anchor="start" x="1451.8" y="-1702.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Clear session cookie</text>
|
||||||
<polygon fill="#272e3d" stroke="none" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1673 1210.95,-1695 1530.61,-1695 1530.61,-1673 1210.95,-1673"/>
|
||||||
<polygon fill="#182b40" stroke="none" points="1211.95,-1674 1211.95,-1694 1259.95,-1694 1259.95,-1674 1211.95,-1674"/>
|
<polygon fill="#182b40" stroke="none" points="1211.95,-1674 1211.95,-1694 1259.95,-1694 1259.95,-1674 1211.95,-1674"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1220.17" y="-1682.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text>
|
<text xml:space="preserve" text-anchor="start" x="1220.17" y="-1682.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1263.95" y="-1682" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectHasAuth() / selectHasCompany()</text>
|
<text xml:space="preserve" text-anchor="start" x="1263.95" y="-1682" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectHasAuth() / selectHasCompany()</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1455.33" y="-1680.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Auth & tenant gates</text>
|
<text xml:space="preserve" text-anchor="start" x="1455.33" y="-1680.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Auth & tenant gates</text>
|
||||||
<polygon fill="#2a3242" stroke="none" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
|
<polygon fill="#272e3d" stroke="none" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
|
||||||
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1651 1210.95,-1673 1530.61,-1673 1530.61,-1651 1210.95,-1651"/>
|
||||||
<polygon fill="#3b2c12" stroke="none" points="1211.95,-1652 1211.95,-1672 1283.89,-1672 1283.89,-1652 1211.95,-1652"/>
|
<polygon fill="#182b40" stroke="none" points="1211.95,-1652 1211.95,-1672 1276.7,-1672 1276.7,-1652 1211.95,-1652"/>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1241.25" y="-1660.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#fbbf24">API</text>
|
<text xml:space="preserve" text-anchor="start" x="1228.54" y="-1660.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#38bdf8">SELECT</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1287.89" y="-1660" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">GET /api/auth/session</text>
|
<text xml:space="preserve" text-anchor="start" x="1280.7" y="-1660" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">selectSchemaVersion()</text>
|
||||||
<text xml:space="preserve" text-anchor="start" x="1448.24" y="-1658.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Cookie session check</text>
|
<text xml:space="preserve" text-anchor="start" x="1432.24" y="-1658.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Active DB schema version</text>
|
||||||
|
<polygon fill="#2a3242" stroke="none" points="1210.95,-1629 1210.95,-1651 1530.61,-1651 1530.61,-1629 1210.95,-1629"/>
|
||||||
|
<polygon fill="none" stroke="#3f4c5f" points="1210.95,-1629 1210.95,-1651 1530.61,-1651 1530.61,-1629 1210.95,-1629"/>
|
||||||
|
<polygon fill="#3b2c12" stroke="none" points="1211.95,-1630 1211.95,-1650 1283.89,-1650 1283.89,-1630 1211.95,-1630"/>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1241.25" y="-1638.6" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="8.00" fill="#fbbf24">API</text>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1287.89" y="-1638" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="10.00" fill="#d8dee9">GET /api/auth/session</text>
|
||||||
|
<text xml:space="preserve" text-anchor="start" x="1448.24" y="-1636.8" font-family="Helvetica,sans-Serif" font-size="8.00" fill="#8b95a7">Cookie session check</text>
|
||||||
</g>
|
</g>
|
||||||
<!-- sseAutomat -->
|
<!-- sseAutomat -->
|
||||||
<g id="node2" class="node">
|
<g id="node2" class="node">
|
||||||
@@ -184,11 +196,11 @@
|
|||||||
<g id="edge1" class="edge">
|
<g id="edge1" class="edge">
|
||||||
<title>authAutomat:e->sseAutomat:n</title>
|
<title>authAutomat:e->sseAutomat:n</title>
|
||||||
<g id="a_edge1"><a xlink:title="authAutomat → sseAutomat (hasAuth -> connect SSE)">
|
<g id="a_edge1"><a xlink:title="authAutomat → sseAutomat (hasAuth -> connect SSE)">
|
||||||
<path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1531.61,-1838C1597.66,-1838 2098.6,-1938.58 2155.13,-1891.19"/>
|
<path fill="none" stroke="#5aa7ff" stroke-width="1.4" d="M1531.61,-1860C1667.44,-1860 2132.91,-2011.81 2159.16,-1892.16"/>
|
||||||
<polygon fill="#5aa7ff" stroke="#5aa7ff" stroke-width="1.4" points="2158.36,-1890.24 2159.36,-1882.89 2154,-1888.02 2158.36,-1890.24"/>
|
<polygon fill="#5aa7ff" stroke="#5aa7ff" stroke-width="1.4" points="2161.81,-1890.32 2160.1,-1883.11 2156.94,-1889.82 2161.81,-1890.32"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
<text xml:space="preserve" text-anchor="middle" x="1815.23" y="-1886.08" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#5aa7ff">  hasAuth -> connect SSE  </text>
|
<text xml:space="preserve" text-anchor="middle" x="1815.23" y="-1919.86" font-family="Helvetica,sans-Serif" font-size="9.00" fill="#5aa7ff">  hasAuth -> connect SSE  </text>
|
||||||
</g>
|
</g>
|
||||||
<!-- companyAutomat -->
|
<!-- companyAutomat -->
|
||||||
<g id="node3" class="node">
|
<g id="node3" class="node">
|
||||||
@@ -301,7 +313,7 @@
|
|||||||
<g id="edge2" class="edge">
|
<g id="edge2" class="edge">
|
||||||
<title>authAutomat:e->companyAutomat:n</title>
|
<title>authAutomat:e->companyAutomat:n</title>
|
||||||
<g id="a_edge2"><a xlink:title="authAutomat → companyAutomat (hasCompany -> loadAll())">
|
<g id="a_edge2"><a xlink:title="authAutomat → companyAutomat (hasCompany -> loadAll())">
|
||||||
<path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1531.61,-1816C1778.34,-1816 1814.29,-1953.13 2057.78,-1992.98 2197.7,-2015.88 2235.25,-2001.4 2376.78,-1992.98 2504.73,-1985.38 2662.43,-2084.44 2669.75,-1966.85"/>
|
<path fill="none" stroke="#6bdf9a" stroke-width="1.4" d="M1531.61,-1838C1775.4,-1838 1816.49,-1958.19 2057.78,-1992.98 2198.11,-2013.22 2235.25,-2001.4 2376.78,-1992.98 2504.73,-1985.38 2662.43,-2084.44 2669.75,-1966.85"/>
|
||||||
<polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="2672.19,-1967.19 2669.95,-1960.12 2667.29,-1967.04 2672.19,-1967.19"/>
|
<polygon fill="#6bdf9a" stroke="#6bdf9a" stroke-width="1.4" points="2672.19,-1967.19 2669.95,-1960.12 2667.29,-1967.04 2672.19,-1967.19"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
@@ -379,7 +391,7 @@
|
|||||||
<g id="edge3" class="edge">
|
<g id="edge3" class="edge">
|
||||||
<title>authAutomat:w->categoryAutomat:n</title>
|
<title>authAutomat:w->categoryAutomat:n</title>
|
||||||
<g id="a_edge3"><a xlink:title="authAutomat → categoryAutomat (sync tree on hasCompany)">
|
<g id="a_edge3"><a xlink:title="authAutomat → categoryAutomat (sync tree on hasCompany)">
|
||||||
<path fill="none" stroke="#f5c15c" stroke-width="1.4" d="M1209.95,-1816C1048.33,-1816 1105.62,-1619.29 957.78,-1554 867.57,-1514.16 132.3,-1590.62 71.22,-1513.2 -17.14,-1401.19 -29.32,-1293.41 71.22,-1192.2 136.5,-1126.49 3340.5,-1235.91 3405.78,-1170.2 3409.16,-1166.79 3409.13,-1162.83 3405.78,-1159.4 3399.24,-1152.7 3078.14,-1151.63 3068.78,-1151.4 3003.45,-1149.82 786.43,-1158.3 647.93,-1097.5"/>
|
<path fill="none" stroke="#f5c15c" stroke-width="1.4" d="M1209.95,-1838C1041.15,-1838 1110.79,-1625.28 957.78,-1554 868.39,-1512.36 132.3,-1590.62 71.22,-1513.2 -17.14,-1401.19 -29.32,-1293.41 71.22,-1192.2 136.5,-1126.49 3340.5,-1235.91 3405.78,-1170.2 3409.16,-1166.79 3409.13,-1162.83 3405.78,-1159.4 3399.24,-1152.7 3078.14,-1151.63 3068.78,-1151.4 3003.45,-1149.82 786.43,-1158.3 647.93,-1097.5"/>
|
||||||
<polygon fill="#f5c15c" stroke="#f5c15c" stroke-width="1.4" points="649.57,-1095.68 642.73,-1092.82 646.29,-1099.32 649.57,-1095.68"/>
|
<polygon fill="#f5c15c" stroke="#f5c15c" stroke-width="1.4" points="649.57,-1095.68 642.73,-1092.82 646.29,-1099.32 649.57,-1095.68"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
@@ -429,7 +441,7 @@
|
|||||||
<g id="edge4" class="edge">
|
<g id="edge4" class="edge">
|
||||||
<title>authAutomat:e->deliveryAutomat:n</title>
|
<title>authAutomat:e->deliveryAutomat:n</title>
|
||||||
<g id="a_edge4"><a xlink:title="authAutomat → deliveryAutomat (sync deliveries on authed)">
|
<g id="a_edge4"><a xlink:title="authAutomat → deliveryAutomat (sync deliveries on authed)">
|
||||||
<path fill="none" stroke="#ff7fc2" stroke-width="1.4" d="M1531.61,-1772C1587.17,-1772 1566.78,-1689.17 1530.61,-1647 1362.69,-1451.22 1214.44,-1579.47 957.78,-1554 924.81,-1550.73 384.77,-1555.86 361.78,-1532 250.74,-1416.78 350.13,-1284.6 480.78,-1192.2 514.55,-1168.32 3418.71,-1180.47 3458.78,-1170.2 3744.41,-1097 3758.45,-933.49 4042.78,-855.4 4135.32,-829.98 4839.35,-888.66 4905.78,-819.4 4974.8,-747.44 4704.08,-842.24 4687.09,-754.66"/>
|
<path fill="none" stroke="#ff7fc2" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1574.04,-1673.81 1530.61,-1625 1445.34,-1529.17 1085.64,-1564.26 957.78,-1554 924.75,-1551.35 384.77,-1555.86 361.78,-1532 250.74,-1416.78 350.13,-1284.6 480.78,-1192.2 514.55,-1168.32 3418.71,-1180.47 3458.78,-1170.2 3744.41,-1097 3758.45,-933.49 4042.78,-855.4 4135.32,-829.98 4839.35,-888.66 4905.78,-819.4 4974.8,-747.44 4704.08,-842.24 4687.09,-754.66"/>
|
||||||
<polygon fill="#ff7fc2" stroke="#ff7fc2" stroke-width="1.4" points="4689.54,-754.46 4686.46,-747.71 4684.66,-754.9 4689.54,-754.46"/>
|
<polygon fill="#ff7fc2" stroke="#ff7fc2" stroke-width="1.4" points="4689.54,-754.46 4686.46,-747.71 4684.66,-754.9 4689.54,-754.46"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
@@ -541,7 +553,7 @@
|
|||||||
<g id="edge6" class="edge">
|
<g id="edge6" class="edge">
|
||||||
<title>authAutomat:e->orderAutomat:n</title>
|
<title>authAutomat:e->orderAutomat:n</title>
|
||||||
<g id="a_edge6"><a xlink:title="authAutomat → orderAutomat (sync orders on authed)">
|
<g id="a_edge6"><a xlink:title="authAutomat → orderAutomat (sync orders on authed)">
|
||||||
<path fill="none" stroke="#5cd6d6" stroke-width="1.4" d="M1531.61,-1772C1790.16,-1772 2107.92,-1668.89 957.78,-1554 925.91,-1550.82 403.93,-1555.07 381.72,-1532 378.39,-1528.54 378.33,-1524.6 381.72,-1521.2 393.82,-1509.06 2832.79,-1515.48 2849.78,-1513.2 2985.29,-1495.01 3146.2,-1567.68 3153.36,-1440.3"/>
|
<path fill="none" stroke="#5cd6d6" stroke-width="1.4" d="M1531.61,-1772C1873.92,-1772 2250.85,-1658.64 957.78,-1554 925.86,-1551.42 403.93,-1555.07 381.72,-1532 378.39,-1528.54 378.33,-1524.6 381.72,-1521.2 393.82,-1509.06 2832.79,-1515.48 2849.78,-1513.2 2985.29,-1495.01 3146.2,-1567.68 3153.36,-1440.3"/>
|
||||||
<polygon fill="#5cd6d6" stroke="#5cd6d6" stroke-width="1.4" points="3155.81,-1440.38 3153.55,-1433.32 3150.91,-1440.25 3155.81,-1440.38"/>
|
<polygon fill="#5cd6d6" stroke="#5cd6d6" stroke-width="1.4" points="3155.81,-1440.38 3153.55,-1433.32 3150.91,-1440.25 3155.81,-1440.38"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
@@ -585,7 +597,7 @@
|
|||||||
<g id="edge7" class="edge">
|
<g id="edge7" class="edge">
|
||||||
<title>authAutomat:e->entityAutomat:n</title>
|
<title>authAutomat:e->entityAutomat:n</title>
|
||||||
<g id="a_edge7"><a xlink:title="authAutomat → entityAutomat (sync entities on authed)">
|
<g id="a_edge7"><a xlink:title="authAutomat → entityAutomat (sync entities on authed)">
|
||||||
<path fill="none" stroke="#ff9f5a" stroke-width="1.4" d="M1531.61,-1772C1779.83,-1772 2059.97,-1668.89 957.78,-1554 931.78,-1551.29 505.83,-1550.91 487.78,-1532 484.47,-1528.53 484.41,-1524.62 487.78,-1521.2 496.93,-1511.89 945.01,-1515.93 957.78,-1513.2 1312.73,-1437.36 1353.55,-1267.01 1708.72,-1192.2 1761.92,-1180.99 3627.55,-1208.86 3665.78,-1170.2 3815.97,-1018.31 3214.43,-1246.44 3197.36,-1045.33"/>
|
<path fill="none" stroke="#ff9f5a" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1574.04,-1673.81 1530.61,-1625 1445.34,-1529.17 1085.58,-1564.9 957.78,-1554 931.73,-1551.78 505.83,-1550.91 487.78,-1532 484.47,-1528.53 484.41,-1524.62 487.78,-1521.2 496.93,-1511.89 945.01,-1515.93 957.78,-1513.2 1312.73,-1437.36 1353.55,-1267.01 1708.72,-1192.2 1761.92,-1180.99 3627.55,-1208.86 3665.78,-1170.2 3815.97,-1018.31 3214.43,-1246.44 3197.36,-1045.33"/>
|
||||||
<polygon fill="#ff9f5a" stroke="#ff9f5a" stroke-width="1.4" points="3199.82,-1045.41 3197.08,-1038.52 3194.92,-1045.61 3199.82,-1045.41"/>
|
<polygon fill="#ff9f5a" stroke="#ff9f5a" stroke-width="1.4" points="3199.82,-1045.41 3197.08,-1038.52 3194.92,-1045.61 3199.82,-1045.41"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
@@ -723,7 +735,7 @@
|
|||||||
<g id="edge9" class="edge">
|
<g id="edge9" class="edge">
|
||||||
<title>authAutomat:e->vfsAutomat:n</title>
|
<title>authAutomat:e->vfsAutomat:n</title>
|
||||||
<g id="a_edge9"><a xlink:title="authAutomat → vfsAutomat (load company vfs)">
|
<g id="a_edge9"><a xlink:title="authAutomat → vfsAutomat (load company vfs)">
|
||||||
<path fill="none" stroke="#ff6b6b" stroke-width="1.4" d="M1531.61,-1772C1587.17,-1772 1565.57,-1690.18 1530.61,-1647 1381.03,-1462.24 843.22,-1689.87 1010.74,-1521.2 1022.22,-1509.64 2167.97,-1517.14 2183.78,-1513.2 2267.24,-1492.38 2355.1,-1508.15 2361.17,-1429.21"/>
|
<path fill="none" stroke="#ff6b6b" stroke-width="1.4" d="M1531.61,-1772C1596.94,-1772 1572.71,-1674.96 1530.61,-1625 1378.77,-1444.84 844.71,-1688.37 1010.74,-1521.2 1022.22,-1509.64 2167.97,-1517.14 2183.78,-1513.2 2267.24,-1492.38 2355.1,-1508.15 2361.17,-1429.21"/>
|
||||||
<polygon fill="#ff6b6b" stroke="#ff6b6b" stroke-width="1.4" points="2363.62,-1429.4 2361.43,-1422.32 2358.72,-1429.22 2363.62,-1429.4"/>
|
<polygon fill="#ff6b6b" stroke="#ff6b6b" stroke-width="1.4" points="2363.62,-1429.4 2361.43,-1422.32 2358.72,-1429.22 2363.62,-1429.4"/>
|
||||||
</a>
|
</a>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 199 KiB |
@@ -2,6 +2,67 @@ import js from '@eslint/js';
|
|||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
|
const banMuiTooltipRule = {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Ban MUI Tooltip component',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
badDesign: '<Tooltip> is bad design',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const muiTooltipIdentifiers = new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ImportDeclaration(node) {
|
||||||
|
if (node.source.value === '@mui/material/Tooltip') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
for (const specifier of node.specifiers) {
|
||||||
|
if (specifier.local?.name) {
|
||||||
|
muiTooltipIdentifiers.add(specifier.local.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.source.value === '@mui/material') {
|
||||||
|
for (const specifier of node.specifiers) {
|
||||||
|
if (
|
||||||
|
specifier.type === 'ImportSpecifier' &&
|
||||||
|
specifier.imported?.name === 'Tooltip'
|
||||||
|
) {
|
||||||
|
context.report({
|
||||||
|
node: specifier,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
if (specifier.local?.name) {
|
||||||
|
muiTooltipIdentifiers.add(specifier.local.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
JSXOpeningElement(node) {
|
||||||
|
let tagName = '';
|
||||||
|
if (node.name.type === 'JSXIdentifier') {
|
||||||
|
tagName = node.name.name;
|
||||||
|
} else if (node.name.type === 'JSXMemberExpression') {
|
||||||
|
tagName = node.name.property.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: 'badDesign',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
@@ -32,10 +93,16 @@ export default [
|
|||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
'react-hooks': reactHooks,
|
||||||
|
react: {
|
||||||
|
rules: {
|
||||||
|
'no-mui-tooltip': banMuiTooltipRule,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'react/no-mui-tooltip': 'error',
|
||||||
'no-unused-vars': [
|
'no-unused-vars': [
|
||||||
'warn',
|
'warn',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ const AUTOMAT_SPECS = [
|
|||||||
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
{ type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' },
|
||||||
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
{ type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' },
|
||||||
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
{ type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' },
|
||||||
|
{ type: 'STATE', name: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
|
||||||
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
{ type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' },
|
||||||
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
{ type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' },
|
||||||
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
{ type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' },
|
||||||
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
{ type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' },
|
||||||
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
{ type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' },
|
||||||
|
{ type: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
|
||||||
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
1081
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
|
||||||
"cert": "./scripts/generate-cert.sh",
|
"cert": "./scripts/generate-cert.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:https": "VITE_HTTPS=1 vite preview",
|
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||||
"rig:collect": "node test/collect.mjs",
|
"rig:collect": "node test/collect.mjs",
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
"pdfconvert-wasm": "^0.3.0",
|
"pdfconvert-wasm": "^0.3.0",
|
||||||
"react": "^19.3.1",
|
"react": "^19.3.1",
|
||||||
"react-automat": "file:../automat",
|
"react-automat": "file:../automat",
|
||||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
|
||||||
"react-dom": "^19.3.1",
|
"react-dom": "^19.3.1",
|
||||||
"react-router-dom": "^7.18.4",
|
"react-router-dom": "^7.18.4",
|
||||||
"react-window": "^2.3.4",
|
"react-window": "^2.3.4",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"canvas@3.2.3": true,
|
"canvas@3.2.3": true,
|
||||||
|
"react-canvas-label-editor@20.0.0": true,
|
||||||
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/api/agentprinter.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const agentprinter = {
|
||||||
|
/**
|
||||||
|
* GET /api/agentprinter
|
||||||
|
* Query: ?printeragentId=<id>
|
||||||
|
* → { agentprinters: Agentprinter[] }
|
||||||
|
*/
|
||||||
|
list(printeragentId) {
|
||||||
|
const url =
|
||||||
|
printeragentId != null
|
||||||
|
? `/api/agentprinter?printeragentId=${encodeURIComponent(printeragentId)}`
|
||||||
|
: '/api/agentprinter';
|
||||||
|
return http(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/agentprinter/:id
|
||||||
|
* → { agentprinter: Agentprinter }
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/agentprinter/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/agentprinter/:id
|
||||||
|
* Body: { enabled?: boolean, sort?: number }
|
||||||
|
* → { agentprinter: Agentprinter }
|
||||||
|
*/
|
||||||
|
update(id, patch = {}) {
|
||||||
|
const body = {};
|
||||||
|
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||||
|
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||||
|
body.sort = Number(patch.sort);
|
||||||
|
}
|
||||||
|
return http(`/api/agentprinter/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/agentprinter/order
|
||||||
|
* Body: { printeragentId?: number, agentprinterIds: number[] }
|
||||||
|
* → { agentprinterIds: number[] }
|
||||||
|
*/
|
||||||
|
order({ printeragentId, agentprinterIds }) {
|
||||||
|
const body = {
|
||||||
|
agentprinterIds: Array.isArray(agentprinterIds) ? agentprinterIds.map(Number) : [],
|
||||||
|
};
|
||||||
|
if (printeragentId != null) {
|
||||||
|
body.printeragentId = Number(printeragentId);
|
||||||
|
}
|
||||||
|
return http('/api/agentprinter/order', {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/agentprinter/:id → 204
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/agentprinter/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -8,12 +8,18 @@ export const auth = {
|
|||||||
return {
|
return {
|
||||||
user: data.user ?? null,
|
user: data.user ?? null,
|
||||||
googleClientId: data.googleClientId ?? null,
|
googleClientId: data.googleClientId ?? null,
|
||||||
|
schemaVersion: data.schemaVersion ?? null,
|
||||||
|
vhost: Boolean(data.vhost),
|
||||||
|
vhostSettings: data.vhostSettings ?? null,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401 || err.status === 403) {
|
if (err.status === 401 || err.status === 403) {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
googleClientId: err.data?.googleClientId ?? null,
|
googleClientId: err.data?.googleClientId ?? null,
|
||||||
|
schemaVersion: err.data?.schemaVersion ?? null,
|
||||||
|
vhost: Boolean(err.data?.vhost),
|
||||||
|
vhostSettings: err.data?.vhostSettings ?? null,
|
||||||
forbidden: err.status === 403,
|
forbidden: err.status === 403,
|
||||||
message: err.message,
|
message: err.message,
|
||||||
};
|
};
|
||||||
|
|||||||
24
src/api/autoinvite.js
Normal file
@@ -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
@@ -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
|
||||||
|
};
|
||||||
@@ -15,6 +15,9 @@ export { number } from './number.js';
|
|||||||
export { order } from './order.js';
|
export { order } from './order.js';
|
||||||
export { posterminal } from './posterminal.js';
|
export { posterminal } from './posterminal.js';
|
||||||
export { printer } from './printer.js';
|
export { printer } from './printer.js';
|
||||||
|
export { printeragent } from './printeragent.js';
|
||||||
|
export { agentprinter } from './agentprinter.js';
|
||||||
|
export { printerjob } from './printerjob.js';
|
||||||
export { propertyname } from './propertyname.js';
|
export { propertyname } from './propertyname.js';
|
||||||
export { quote } from './quote.js';
|
export { quote } from './quote.js';
|
||||||
export { retoure } from './retoure.js';
|
export { retoure } from './retoure.js';
|
||||||
@@ -31,6 +34,8 @@ export { shipment } from './shipment.js';
|
|||||||
export { shipping } from './shipping.js';
|
export { shipping } from './shipping.js';
|
||||||
export { payment } from './payment.js';
|
export { payment } from './payment.js';
|
||||||
export { account } from './account.js';
|
export { account } from './account.js';
|
||||||
|
export { companyuser } from './companyuser.js';
|
||||||
|
export { autoinvite } from './autoinvite.js';
|
||||||
export { mcp } from './mcp.js';
|
export { mcp } from './mcp.js';
|
||||||
export { backup } from './backup.js';
|
export { backup } from './backup.js';
|
||||||
export { carrier } from './carrier.js';
|
export { carrier } from './carrier.js';
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { http } from './http.js';
|
|||||||
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
|
||||||
*/
|
*/
|
||||||
export const labeltemplate = {
|
export const labeltemplate = {
|
||||||
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
|
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
|
||||||
// #region GET /api/labeltemplate — list
|
// #region GET /api/labeltemplate — list
|
||||||
list() {
|
list(params) {
|
||||||
return http('/api/labeltemplate');
|
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
|
||||||
|
return http(`/api/labeltemplate${search}`);
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -48,5 +49,23 @@ export const labeltemplate = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
|
||||||
|
* @param {number|object} idOrBody
|
||||||
|
* @param {object} [maybeBody]
|
||||||
|
* → 200 { success: true, printerjob?: PrinterJob }
|
||||||
|
*/
|
||||||
|
// #region POST /api/labeltemplate/testprint — testprint
|
||||||
|
testprint(idOrBody, maybeBody) {
|
||||||
|
if (typeof idOrBody === 'object' && idOrBody !== null) {
|
||||||
|
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
|
||||||
|
}
|
||||||
|
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: maybeBody || {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// thumb is returned inline as a base64 string in list/get responses
|
// thumb is returned inline as a base64 string in list/get responses
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { http } from './http.js';
|
|||||||
|
|
||||||
export const posterminal = {
|
export const posterminal = {
|
||||||
/**
|
/**
|
||||||
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
|
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
|
||||||
* List all terminals for the selected company.
|
* List all terminals for the selected company, sorted by sort ASC, id ASC.
|
||||||
*/
|
*/
|
||||||
// #region GET /api/posterminal — list
|
// #region GET /api/posterminal — list
|
||||||
list() {
|
list() {
|
||||||
@@ -13,24 +13,29 @@ export const posterminal = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/posterminal
|
* PUT /api/posterminal
|
||||||
* Body: { name: string }
|
* Body: { name: string, sort?: number, enabled?: boolean, rootCategoryId?: number|null, syncRootArticles?: boolean }
|
||||||
* Creates a new terminal (auto-generates 6-digit pin).
|
* Creates a new terminal (auto-generates 6-digit pin).
|
||||||
* → 200 { posterminal: { id, name, token, pairingpin } }
|
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||||
*/
|
*/
|
||||||
// #region PUT /api/posterminal — create
|
// #region PUT /api/posterminal — create
|
||||||
create({ name }) {
|
create({ name, sort, enabled, rootCategoryId, syncRootArticles } = {}) {
|
||||||
|
const body = { name };
|
||||||
|
if (sort !== undefined) body.sort = Number(sort);
|
||||||
|
if (enabled !== undefined) body.enabled = Boolean(enabled);
|
||||||
|
if (rootCategoryId !== undefined) body.rootCategoryId = rootCategoryId != null ? Number(rootCategoryId) : null;
|
||||||
|
if (syncRootArticles !== undefined) body.syncRootArticles = Boolean(syncRootArticles);
|
||||||
return http('/api/posterminal', {
|
return http('/api/posterminal', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { name },
|
body,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/posterminal/:id
|
* PATCH /api/posterminal/:id
|
||||||
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
|
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
|
||||||
* Update posterminal configuration.
|
* Update posterminal configuration.
|
||||||
* → 200 { posterminal }
|
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
|
||||||
*/
|
*/
|
||||||
// #region PATCH /api/posterminal/:id — update
|
// #region PATCH /api/posterminal/:id — update
|
||||||
update(terminalId, patch) {
|
update(terminalId, patch) {
|
||||||
|
|||||||
@@ -51,4 +51,29 @@ export const printer = {
|
|||||||
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printer/state
|
||||||
|
* Query: ?serial=, ?agentId=
|
||||||
|
* → { printers: LivePrinter[] }
|
||||||
|
*/
|
||||||
|
// #region GET /api/printer/state — live state
|
||||||
|
state({ serial, agentId } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (serial) params.set('serial', serial);
|
||||||
|
if (agentId != null) params.set('agentId', agentId);
|
||||||
|
const query = params.toString();
|
||||||
|
return http(`/api/printer/state${query ? `?${query}` : ''}`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printer/:id/state
|
||||||
|
* → { printer: LivePrinter }
|
||||||
|
*/
|
||||||
|
// #region GET /api/printer/:id/state — single printer state
|
||||||
|
printerState(id) {
|
||||||
|
return http(`/api/printer/${id}/state`);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
58
src/api/printeragent.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const printeragent = {
|
||||||
|
/**
|
||||||
|
* GET /api/printeragent → { printeragents: Printeragent[] }
|
||||||
|
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
return http('/api/printeragent');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/printeragent
|
||||||
|
* Body: { name: string, sort?: number }
|
||||||
|
* Creates a new printer agent (auto-generates 6-digit pairingpin).
|
||||||
|
* → 200 { printeragent: Printeragent }
|
||||||
|
*/
|
||||||
|
create({ name, sort } = {}) {
|
||||||
|
const body = { name };
|
||||||
|
if (sort !== undefined && sort !== null && sort !== '') {
|
||||||
|
body.sort = Number(sort);
|
||||||
|
}
|
||||||
|
return http('/api/printeragent', {
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/printeragent/:id
|
||||||
|
* Body: { name?: string, sort?: number, enabled?: boolean }
|
||||||
|
* Updates name, sort, enabled, or any combination.
|
||||||
|
* → 200 { printeragent: Printeragent }
|
||||||
|
*/
|
||||||
|
update(id, patch = {}) {
|
||||||
|
const body = {};
|
||||||
|
if (patch.name !== undefined) body.name = patch.name;
|
||||||
|
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
|
||||||
|
body.sort = Number(patch.sort);
|
||||||
|
}
|
||||||
|
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
|
||||||
|
return http(`/api/printeragent/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/printeragent/:id → 204 No Content
|
||||||
|
* Permanently deletes agent.
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/printeragent/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
142
src/api/printerjob.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
export const printerjob = {
|
||||||
|
/**
|
||||||
|
* POST /api/zpljob
|
||||||
|
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
createZplJob({ agentprinterId, data, copiescount = 1 }) {
|
||||||
|
return http('/api/zpljob', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
data: String(data),
|
||||||
|
copiescount: Number(copiescount) || 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a minimal ZPL test label to a specific agent printer.
|
||||||
|
* @param {number} agentprinterId
|
||||||
|
* @param {{ model?: string }} [options]
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
|
||||||
|
const timeStr = new Date().toLocaleTimeString('de-DE');
|
||||||
|
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
|
||||||
|
const zpl = [
|
||||||
|
'^XA',
|
||||||
|
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
|
||||||
|
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
|
||||||
|
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
|
||||||
|
'^XZ',
|
||||||
|
].join('\n');
|
||||||
|
return this.createZplJob({
|
||||||
|
agentprinterId,
|
||||||
|
data: zpl,
|
||||||
|
copiescount: 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printerjob
|
||||||
|
* @param {{ agentprinterId?: number, state?: string }} [query]
|
||||||
|
* → { printerjobs: PrinterJob[] }
|
||||||
|
*/
|
||||||
|
list(query = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
|
||||||
|
if (query.state != null) params.set('state', String(query.state));
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/printerjob/:id
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
return http(`/api/printerjob/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob
|
||||||
|
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
|
||||||
|
* → { printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
create({ agentprinterId, type = 'zpl', data }) {
|
||||||
|
return http('/api/printerjob', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob/:id/cancel
|
||||||
|
* → { success: true, printerjob: PrinterJob }
|
||||||
|
*/
|
||||||
|
cancel(id) {
|
||||||
|
return http(`/api/printerjob/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/printerjob/cancel-all
|
||||||
|
* Sends ~JA to clear printer buffer and cancels all queued jobs.
|
||||||
|
* @param {{ agentprinterId?: number }} [payload]
|
||||||
|
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
|
||||||
|
*/
|
||||||
|
async cancelAll({ agentprinterId } = {}) {
|
||||||
|
if (agentprinterId != null) {
|
||||||
|
try {
|
||||||
|
await this.createZplJob({
|
||||||
|
agentprinterId: Number(agentprinterId),
|
||||||
|
data: '~JA',
|
||||||
|
copiescount: 1,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to send ~JA to printer:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await http('/api/printerjob/cancel-all', {
|
||||||
|
method: 'POST',
|
||||||
|
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 404) {
|
||||||
|
const { printerjobs = [] } = await this.list({ agentprinterId });
|
||||||
|
const cancellable = printerjobs.filter(
|
||||||
|
(j) => j.state === 'pending' || j.state === 'processing',
|
||||||
|
);
|
||||||
|
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
|
||||||
|
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
cancelledCount: results.length,
|
||||||
|
jobs: results.map((r) => r.printerjob),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/printerjob/:id
|
||||||
|
* → { success: true, deletedJob: PrinterJob }
|
||||||
|
*/
|
||||||
|
delete(id) {
|
||||||
|
return http(`/api/printerjob/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -12,4 +12,10 @@ export const thumb = {
|
|||||||
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
return `/api/thumb/${encodeURIComponent(fullHash)}`;
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
// #region GET /api/smallthumb/:fullHash — smallUrl
|
||||||
|
smallUrl(fullHash) {
|
||||||
|
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
15
src/assets/printers/index.js
Normal 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;
|
||||||
BIN
src/assets/printers/zd41x.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
@@ -11,7 +11,7 @@
|
|||||||
* history or supplier details of a specific article.
|
* history or supplier details of a specific article.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { KeyedAutomat } from 'react-automat';
|
import { Automat, KeyedAutomat } from 'react-automat';
|
||||||
import { article as articleApi } from '../api';
|
import { article as articleApi } from '../api';
|
||||||
import { requireArray, requireObject } from '../utils/apiShape';
|
import { requireArray, requireObject } from '../utils/apiShape';
|
||||||
|
|
||||||
@@ -50,14 +50,15 @@ const skuHistoryAutomats = new Map();
|
|||||||
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
|
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
|
||||||
*/
|
*/
|
||||||
export function getArticleHistoryAutomat(sku) {
|
export function getArticleHistoryAutomat(sku) {
|
||||||
const key = String(sku);
|
const rawKey = String(sku);
|
||||||
|
const key = rawKey.trim().toLowerCase();
|
||||||
let inst = skuHistoryAutomats.get(key);
|
let inst = skuHistoryAutomats.get(key);
|
||||||
if (!inst) {
|
if (!inst) {
|
||||||
inst = createChunkListAutomat({
|
inst = createChunkListAutomat({
|
||||||
name: `articleHistory:${key}`,
|
name: `articleHistory:${rawKey}`,
|
||||||
chunkSize: 50,
|
chunkSize: 50,
|
||||||
maxLruChunks: 50,
|
maxLruChunks: 50,
|
||||||
fetchPage: (params) => articleApi.getBySkuHistory(key, params),
|
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
|
||||||
});
|
});
|
||||||
skuHistoryAutomats.set(key, inst);
|
skuHistoryAutomats.set(key, inst);
|
||||||
}
|
}
|
||||||
@@ -74,14 +75,14 @@ export const articleHistoryAutomat = {
|
|||||||
get: getArticleHistoryAutomat,
|
get: getArticleHistoryAutomat,
|
||||||
key: getArticleHistoryAutomat,
|
key: getArticleHistoryAutomat,
|
||||||
has(sku) {
|
has(sku) {
|
||||||
return skuHistoryAutomats.has(String(sku));
|
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
|
||||||
},
|
},
|
||||||
async load(sku) {
|
async load(sku) {
|
||||||
const inst = getArticleHistoryAutomat(sku);
|
const inst = getArticleHistoryAutomat(sku);
|
||||||
return inst.getItems();
|
return inst.getItems();
|
||||||
},
|
},
|
||||||
async reload(sku) {
|
async reload(sku) {
|
||||||
const inst = skuHistoryAutomats.get(String(sku));
|
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
|
||||||
if (inst) {
|
if (inst) {
|
||||||
await inst.actions.reload();
|
await inst.actions.reload();
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,12 @@ export const articleSupplierAutomat = new KeyedAutomat(
|
|||||||
loader: async (sku) => {
|
loader: async (sku) => {
|
||||||
const res = await articleApi.getSuppliers(String(sku));
|
const res = await articleApi.getSuppliers(String(sku));
|
||||||
requireObject(res, 'GET /api/article/:sku/supplier');
|
requireObject(res, 'GET /api/article/:sku/supplier');
|
||||||
return requireArray(res.suppliers, 'suppliers');
|
const list = requireArray(res.suppliers, 'suppliers');
|
||||||
|
const comp = Automat.get('company');
|
||||||
|
if (comp?.actions?.syncArticleSuppliers) {
|
||||||
|
comp.actions.syncArticleSuppliers(sku, list);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,10 +92,6 @@ function scheduleRebuild() {
|
|||||||
/**
|
/**
|
||||||
* Normalizes a category-view article row into an index record.
|
* Normalizes a category-view article row into an index record.
|
||||||
*
|
*
|
||||||
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
|
|
||||||
* field stays empty until that endpoint exposes EANs. EAN matching in
|
|
||||||
* `articleSearchAutomat` is therefore not functional yet.
|
|
||||||
*
|
|
||||||
* @param {object} article Raw category-view article row.
|
* @param {object} article Raw category-view article row.
|
||||||
* @param {number|string|null} categoryId Category the article was loaded from.
|
* @param {number|string|null} categoryId Category the article was loaded from.
|
||||||
* @returns {ArticleIndexRecord} Index record.
|
* @returns {ArticleIndexRecord} Index record.
|
||||||
@@ -126,11 +122,15 @@ function toIndexRecord(article, categoryId) {
|
|||||||
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
|
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
|
||||||
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
|
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
|
||||||
|
|
||||||
|
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...article,
|
...article,
|
||||||
id: article.id,
|
id: article.id,
|
||||||
sku: article.sku,
|
sku: article.sku,
|
||||||
name: article.name,
|
name: article.name,
|
||||||
|
descLength,
|
||||||
|
seo: article.seo ?? null,
|
||||||
price,
|
price,
|
||||||
net: article.net ?? price,
|
net: article.net ?? price,
|
||||||
stock,
|
stock,
|
||||||
@@ -143,6 +143,8 @@ function toIndexRecord(article, categoryId) {
|
|||||||
categories,
|
categories,
|
||||||
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
|
||||||
childCount,
|
childCount,
|
||||||
|
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
|
||||||
|
parentSku: article.parentSku ?? article.parent_sku ?? null,
|
||||||
fullHash,
|
fullHash,
|
||||||
thumbHash,
|
thumbHash,
|
||||||
photos,
|
photos,
|
||||||
@@ -200,6 +202,24 @@ export function buildArticleIndex(views) {
|
|||||||
existing.articlewarehousesummary = rec.articlewarehousesummary;
|
existing.articlewarehousesummary = rec.articlewarehousesummary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
|
||||||
|
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
|
||||||
|
existing.descLength = incomingDescLen;
|
||||||
|
}
|
||||||
|
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
|
||||||
|
existing.seo = article.seo;
|
||||||
|
}
|
||||||
|
if (Array.isArray(article.eans) && article.eans.length > 0) {
|
||||||
|
const mergedEans = new Set(existing.eans ?? []);
|
||||||
|
for (const e of article.eans) if (e) mergedEans.add(e);
|
||||||
|
existing.eans = Array.from(mergedEans);
|
||||||
|
}
|
||||||
|
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
|
||||||
|
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
|
||||||
|
}
|
||||||
|
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
|
||||||
|
existing.parentSku = article.parentSku ?? article.parent_sku;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ const initialState = {
|
|||||||
filter: '',
|
filter: '',
|
||||||
brand: '',
|
brand: '',
|
||||||
supplier: '',
|
supplier: '',
|
||||||
|
empty: '',
|
||||||
|
hierarchy: '',
|
||||||
offset: 0,
|
offset: 0,
|
||||||
pageSize: null,
|
pageSize: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -343,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
|
|||||||
extraArticles = [],
|
extraArticles = [],
|
||||||
allowParent = false,
|
allowParent = false,
|
||||||
categories = [],
|
categories = [],
|
||||||
|
hierarchy = '',
|
||||||
|
empty = '',
|
||||||
offset = 0,
|
offset = 0,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
limit,
|
limit,
|
||||||
@@ -367,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
|
|||||||
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
||||||
|
|
||||||
let pool = rawMatches;
|
let pool = rawMatches;
|
||||||
if (!allowParent) {
|
if (!allowParent && !hierarchy) {
|
||||||
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
pool = pool.filter((a) => !a.isParent && !a.is_parent);
|
||||||
}
|
}
|
||||||
if (excludedSet.size > 0) {
|
if (excludedSet.size > 0) {
|
||||||
@@ -378,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
|
|||||||
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
|
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hierarchy === 'father') {
|
||||||
|
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
|
||||||
|
} else if (hierarchy === 'child') {
|
||||||
|
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyFilters = parseFilter(empty);
|
||||||
|
if (emptyFilters.length > 0) {
|
||||||
|
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||||
|
pool = pool.filter((row) => {
|
||||||
|
for (const f of emptyFilters) {
|
||||||
|
if (f === 'ean') {
|
||||||
|
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||||
|
(row.ean && String(row.ean).trim());
|
||||||
|
if (hasEan) return false;
|
||||||
|
} else if (f === 'photo') {
|
||||||
|
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||||
|
const hasPhoto = Boolean(
|
||||||
|
row.fullHash ||
|
||||||
|
row.thumbHash ||
|
||||||
|
row.smallThumbBinary ||
|
||||||
|
firstPhoto?.fullHash ||
|
||||||
|
firstPhoto?.thumbHash ||
|
||||||
|
firstPhoto?.smallThumbBinary
|
||||||
|
);
|
||||||
|
if (hasPhoto) return false;
|
||||||
|
} else if (f === 'price') {
|
||||||
|
const p = row.price ?? row.net;
|
||||||
|
const hasPrice = p != null && Number(p) > 0;
|
||||||
|
if (hasPrice) return false;
|
||||||
|
} else if (f === 'brand') {
|
||||||
|
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||||
|
if (hasBrand) return false;
|
||||||
|
} else if (f === 'supplier') {
|
||||||
|
const hasSupplier = Boolean(
|
||||||
|
(row.suppliers && row.suppliers.length > 0) ||
|
||||||
|
row.supplierId != null ||
|
||||||
|
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||||
|
);
|
||||||
|
if (hasSupplier) return false;
|
||||||
|
} else if (f === 'seo') {
|
||||||
|
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||||
|
if (hasSeo) return false;
|
||||||
|
} else if (f === 'desc') {
|
||||||
|
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||||
|
if (hasDesc) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let matchesList = pool;
|
let matchesList = pool;
|
||||||
|
|
||||||
if (categories && categories.length > 0) {
|
if (categories && categories.length > 0) {
|
||||||
@@ -669,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
|
|||||||
|
|
||||||
return facets;
|
return facets;
|
||||||
}
|
}
|
||||||
|
function getSkusWithSupplier() {
|
||||||
|
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
|
||||||
|
const set = new Set();
|
||||||
|
for (const list of Object.values(articlesBySupplier)) {
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
|
||||||
|
if (item.sku) set.add(String(item.sku).toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
@@ -689,6 +759,8 @@ const actions = {
|
|||||||
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
|
||||||
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
|
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
|
||||||
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
|
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
|
||||||
|
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
|
||||||
|
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
|
||||||
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
|
||||||
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
|
||||||
|
|
||||||
@@ -752,6 +824,64 @@ const actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply hierarchy filter:
|
||||||
|
// - "father": "father or normal article" -> brand editable, exclude child variants
|
||||||
|
// - "child": "child or normal article" -> price editable, exclude parents
|
||||||
|
if (hierarchy === 'father') {
|
||||||
|
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
|
||||||
|
} else if (hierarchy === 'child') {
|
||||||
|
filteredArticles = filteredArticles.filter(
|
||||||
|
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
|
||||||
|
const emptyFilters = parseFilter(empty);
|
||||||
|
if (emptyFilters.length > 0) {
|
||||||
|
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
|
||||||
|
filteredArticles = filteredArticles.filter((row) => {
|
||||||
|
for (const f of emptyFilters) {
|
||||||
|
if (f === 'ean') {
|
||||||
|
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
|
||||||
|
(row.ean && String(row.ean).trim());
|
||||||
|
if (hasEan) return false;
|
||||||
|
} else if (f === 'photo') {
|
||||||
|
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
|
||||||
|
const hasPhoto = Boolean(
|
||||||
|
row.fullHash ||
|
||||||
|
row.thumbHash ||
|
||||||
|
row.smallThumbBinary ||
|
||||||
|
firstPhoto?.fullHash ||
|
||||||
|
firstPhoto?.thumbHash ||
|
||||||
|
firstPhoto?.smallThumbBinary
|
||||||
|
);
|
||||||
|
if (hasPhoto) return false;
|
||||||
|
} else if (f === 'price') {
|
||||||
|
const p = row.price ?? row.net;
|
||||||
|
const hasPrice = p != null && Number(p) > 0;
|
||||||
|
if (hasPrice) return false;
|
||||||
|
} else if (f === 'brand') {
|
||||||
|
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
|
||||||
|
if (hasBrand) return false;
|
||||||
|
} else if (f === 'supplier') {
|
||||||
|
const hasSupplier = Boolean(
|
||||||
|
(row.suppliers && row.suppliers.length > 0) ||
|
||||||
|
row.supplierId != null ||
|
||||||
|
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
|
||||||
|
);
|
||||||
|
if (hasSupplier) return false;
|
||||||
|
} else if (f === 'seo') {
|
||||||
|
const hasSeo = Boolean(row.seo && String(row.seo).trim());
|
||||||
|
if (hasSeo) return false;
|
||||||
|
} else if (f === 'desc') {
|
||||||
|
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
|
||||||
|
if (hasDesc) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
|
||||||
|
|
||||||
const totalCount = sortedArticles.length;
|
const totalCount = sortedArticles.length;
|
||||||
@@ -781,6 +911,8 @@ const actions = {
|
|||||||
filter,
|
filter,
|
||||||
brand,
|
brand,
|
||||||
supplier,
|
supplier,
|
||||||
|
empty,
|
||||||
|
hierarchy,
|
||||||
offset,
|
offset,
|
||||||
pageSize: safePageSize,
|
pageSize: safePageSize,
|
||||||
loading: articleIndexAutomat.state.status === 'loading',
|
loading: articleIndexAutomat.state.status === 'loading',
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import { auth, company, number as numberApi } from '../api';
|
|||||||
* @typedef {Object} AuthState
|
* @typedef {Object} AuthState
|
||||||
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
|
||||||
* @property {string|null} googleClientId Google Identity Services client ID.
|
* @property {string|null} googleClientId Google Identity Services client ID.
|
||||||
|
* @property {number|null} schemaVersion Database schema version returned by the backend.
|
||||||
|
* @property {boolean} vhost Whether vhost mode is enabled.
|
||||||
|
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
|
||||||
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
* @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
|
||||||
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
* @property {boolean} hasAuth True when the session has a valid authenticated user.
|
||||||
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
* @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected.
|
||||||
@@ -47,6 +50,9 @@ export const AUTH_LOGGED_OUT = 'auth/loggedOut';
|
|||||||
const initialState = {
|
const initialState = {
|
||||||
user: null,
|
user: null,
|
||||||
googleClientId: null,
|
googleClientId: null,
|
||||||
|
schemaVersion: null,
|
||||||
|
vhost: false,
|
||||||
|
vhostSettings: null,
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
hasAuth: false,
|
hasAuth: false,
|
||||||
hasCompany: false,
|
hasCompany: false,
|
||||||
@@ -78,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;
|
||||||
@@ -122,6 +128,8 @@ function buildAuthState(user, status, extra = {}) {
|
|||||||
status,
|
status,
|
||||||
hasAuth,
|
hasAuth,
|
||||||
hasCompany,
|
hasCompany,
|
||||||
|
vhost: authAutomat?.state?.vhost ?? false,
|
||||||
|
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
|
||||||
retryNotification: null,
|
retryNotification: null,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -137,22 +145,34 @@ function syncSse(state) {
|
|||||||
function applySession(nextSession) {
|
function applySession(nextSession) {
|
||||||
const googleClientId =
|
const googleClientId =
|
||||||
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
nextSession.googleClientId ?? authAutomat.state.googleClientId;
|
||||||
|
const schemaVersion =
|
||||||
|
nextSession.schemaVersion !== undefined
|
||||||
|
? nextSession.schemaVersion
|
||||||
|
: (authAutomat.state.schemaVersion ?? null);
|
||||||
|
const vhost =
|
||||||
|
nextSession.vhost !== undefined
|
||||||
|
? Boolean(nextSession.vhost)
|
||||||
|
: (authAutomat.state.vhost ?? false);
|
||||||
|
const vhostSettings =
|
||||||
|
nextSession.vhostSettings !== undefined
|
||||||
|
? nextSession.vhostSettings
|
||||||
|
: (authAutomat.state.vhostSettings ?? null);
|
||||||
|
|
||||||
if (!nextSession.user) {
|
if (!nextSession.user) {
|
||||||
if (authAutomat.state.status !== 'authed') {
|
if (authAutomat.state.status !== 'authed') {
|
||||||
const nextState = authAutomat.setState(
|
const nextState = authAutomat.setState(
|
||||||
buildAuthState(null, 'anon', { googleClientId, retryNotification: null })
|
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||||
);
|
);
|
||||||
syncSse(nextState);
|
syncSse(nextState);
|
||||||
} else {
|
} else {
|
||||||
authAutomat.setState({ googleClientId, retryNotification: null });
|
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
|
||||||
}
|
}
|
||||||
return nextSession;
|
return nextSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
const user = mergeUser(authAutomat.state.user, nextSession.user);
|
||||||
const nextState = authAutomat.setState(
|
const nextState = authAutomat.setState(
|
||||||
buildAuthState(user, 'authed', { googleClientId, retryNotification: null })
|
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
|
||||||
);
|
);
|
||||||
syncSse(nextState);
|
syncSse(nextState);
|
||||||
emit({ type: AUTH_SESSION_ESTABLISHED });
|
emit({ type: AUTH_SESSION_ESTABLISHED });
|
||||||
@@ -234,6 +254,7 @@ const actions = {
|
|||||||
const nextState = authAutomat.setState(
|
const nextState = authAutomat.setState(
|
||||||
buildAuthState(user, 'authed', {
|
buildAuthState(user, 'authed', {
|
||||||
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
googleClientId: session.googleClientId ?? authAutomat.state.googleClientId,
|
||||||
|
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
syncSse(nextState);
|
syncSse(nextState);
|
||||||
@@ -344,6 +365,9 @@ export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
|
|||||||
export const selectSelectedCompanyId = () =>
|
export const selectSelectedCompanyId = () =>
|
||||||
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
|
||||||
export const selectRetryNotification = () => authAutomat.state.retryNotification;
|
export const selectRetryNotification = () => authAutomat.state.retryNotification;
|
||||||
|
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
|
||||||
|
export const selectVhost = () => Boolean(authAutomat.state.vhost);
|
||||||
|
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-reads `GET /api/auth/me` from the server.
|
* Re-reads `GET /api/auth/me` from the server.
|
||||||
@@ -351,7 +375,7 @@ export const selectRetryNotification = () => authAutomat.state.retryNotification
|
|||||||
* Called after login, logout, and company switches so the automat reflects
|
* Called after login, logout, and company switches so the automat reflects
|
||||||
* the current server session rather than an optimistic guess.
|
* the current server session rather than an optimistic guess.
|
||||||
*
|
*
|
||||||
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
|
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null, schemaVersion?: number|null}>} Session.
|
||||||
*/
|
*/
|
||||||
export function bootstrapAuthMe() {
|
export function bootstrapAuthMe() {
|
||||||
return authAutomat.actions.bootstrapSession();
|
return authAutomat.actions.bootstrapSession();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* - Brands (`brands`, `filteredBrands`, `brandFilter`)
|
* - Brands (`brands`, `filteredBrands`, `brandFilter`)
|
||||||
* - Product attributes & values (`attributes`)
|
* - Product attributes & values (`attributes`)
|
||||||
* - Payment accounts (`accounts`)
|
* - Payment accounts (`accounts`)
|
||||||
|
* - Members & pending invites (`members`, `invites`; admin only, loaded on demand)
|
||||||
*
|
*
|
||||||
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when
|
* Lives with the 1st-level navigation shell (`AppShell`) and triggers when
|
||||||
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
|
* `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`).
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
company as companyApi,
|
company as companyApi,
|
||||||
number as numberApi,
|
number as numberApi,
|
||||||
posterminal as posterminalApi,
|
posterminal as posterminalApi,
|
||||||
|
printer as printerApi,
|
||||||
|
printeragent as printeragentApi,
|
||||||
|
agentprinter as agentprinterApi,
|
||||||
|
printerjob as printerjobApi,
|
||||||
supplier as supplierApi,
|
supplier as supplierApi,
|
||||||
unit as unitApi,
|
unit as unitApi,
|
||||||
warehouse as warehouseApi,
|
warehouse as warehouseApi,
|
||||||
@@ -35,10 +40,13 @@ import {
|
|||||||
attributename as attributenameApi,
|
attributename as attributenameApi,
|
||||||
attributevalue as attributevalueApi,
|
attributevalue as attributevalueApi,
|
||||||
account as accountApi,
|
account as accountApi,
|
||||||
|
companyuser as companyuserApi,
|
||||||
|
autoinvite as autoinviteApi,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
|
||||||
import { normalizeCompany } from '../utils/companyForm';
|
import { normalizeCompany } from '../utils/companyForm';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
// #region userSettings persistence helpers
|
// #region userSettings persistence helpers
|
||||||
const USER_SETTINGS_STORAGE_KEY = 'dash.userSettings';
|
const USER_SETTINGS_STORAGE_KEY = 'dash.userSettings';
|
||||||
@@ -158,6 +166,33 @@ const sortBrands = (brands) =>
|
|||||||
const sortGroups = (items) =>
|
const sortGroups = (items) =>
|
||||||
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
|
[...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0));
|
||||||
|
|
||||||
|
const sortTerminals = (terminals) => {
|
||||||
|
if (!Array.isArray(terminals)) return [];
|
||||||
|
return [...terminals].sort((a, b) => {
|
||||||
|
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
|
||||||
|
if (sDiff !== 0) return sDiff;
|
||||||
|
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortPrinterAgents = (agents) => {
|
||||||
|
if (!Array.isArray(agents)) return [];
|
||||||
|
return [...agents].sort((a, b) => {
|
||||||
|
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
|
||||||
|
if (sDiff !== 0) return sDiff;
|
||||||
|
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortAgentPrinters = (printers) => {
|
||||||
|
if (!Array.isArray(printers)) return [];
|
||||||
|
return [...printers].sort((a, b) => {
|
||||||
|
const sDiff = (Number(a.sort) || 0) - (Number(b.sort) || 0);
|
||||||
|
if (sDiff !== 0) return sDiff;
|
||||||
|
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const sortAccounts = (items) =>
|
const sortAccounts = (items) =>
|
||||||
[...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
[...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||||
|
|
||||||
@@ -227,6 +262,19 @@ const initialState = {
|
|||||||
terminalsStatus: 'idle',
|
terminalsStatus: 'idle',
|
||||||
terminalsError: null,
|
terminalsError: null,
|
||||||
|
|
||||||
|
// Printer agents & agent printers
|
||||||
|
printeragents: [],
|
||||||
|
printeragentsCompanyId: null,
|
||||||
|
printeragentsStatus: 'idle',
|
||||||
|
printeragentsError: null,
|
||||||
|
|
||||||
|
agentprinters: [],
|
||||||
|
agentprintersCompanyId: null,
|
||||||
|
agentprintersStatus: 'idle',
|
||||||
|
agentprintersError: null,
|
||||||
|
|
||||||
|
livePrinters: [], // LivePrinter[] from /api/printer/state or SSE printer/printerstate
|
||||||
|
|
||||||
// 4. Suppliers
|
// 4. Suppliers
|
||||||
suppliers: [],
|
suppliers: [],
|
||||||
articlesBySupplier: {},
|
articlesBySupplier: {},
|
||||||
@@ -285,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;
|
||||||
@@ -293,6 +349,9 @@ let companyAutomat;
|
|||||||
let inflightCompany = null;
|
let inflightCompany = null;
|
||||||
let inflightNumbers = null;
|
let inflightNumbers = null;
|
||||||
let inflightTerminals = null;
|
let inflightTerminals = null;
|
||||||
|
let inflightPrinterAgents = null;
|
||||||
|
let inflightAgentPrinters = null;
|
||||||
|
let inflightLivePrinters = null;
|
||||||
let inflightSuppliers = null;
|
let inflightSuppliers = null;
|
||||||
let inflightUnits = null;
|
let inflightUnits = null;
|
||||||
let inflightWarehouses = null;
|
let inflightWarehouses = null;
|
||||||
@@ -302,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
|
||||||
@@ -486,6 +547,9 @@ const actions = {
|
|||||||
actions.load(),
|
actions.load(),
|
||||||
actions.loadNumbers(),
|
actions.loadNumbers(),
|
||||||
actions.loadTerminals(),
|
actions.loadTerminals(),
|
||||||
|
actions.loadPrinterAgents(),
|
||||||
|
actions.loadAgentPrinters(),
|
||||||
|
actions.loadLivePrinters(),
|
||||||
actions.loadSuppliers(),
|
actions.loadSuppliers(),
|
||||||
actions.loadUnits(),
|
actions.loadUnits(),
|
||||||
actions.loadWarehouses(),
|
actions.loadWarehouses(),
|
||||||
@@ -502,6 +566,9 @@ const actions = {
|
|||||||
inflightCompany = null;
|
inflightCompany = null;
|
||||||
inflightNumbers = null;
|
inflightNumbers = null;
|
||||||
inflightTerminals = null;
|
inflightTerminals = null;
|
||||||
|
inflightPrinterAgents = null;
|
||||||
|
inflightAgentPrinters = null;
|
||||||
|
inflightLivePrinters = null;
|
||||||
inflightSuppliers = null;
|
inflightSuppliers = null;
|
||||||
inflightUnits = null;
|
inflightUnits = null;
|
||||||
inflightWarehouses = null;
|
inflightWarehouses = null;
|
||||||
@@ -511,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,
|
||||||
@@ -577,7 +646,7 @@ const actions = {
|
|||||||
inflightTerminals = (async () => {
|
inflightTerminals = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await posterminalApi.list();
|
const res = await posterminalApi.list();
|
||||||
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
|
const terminals = sortTerminals(Array.isArray(res.posterminals) ? res.posterminals : []);
|
||||||
companyAutomat.setState({
|
companyAutomat.setState({
|
||||||
terminals,
|
terminals,
|
||||||
terminalsCompanyId: companyId,
|
terminalsCompanyId: companyId,
|
||||||
@@ -602,11 +671,15 @@ const actions = {
|
|||||||
return actions.loadTerminals();
|
return actions.loadTerminals();
|
||||||
},
|
},
|
||||||
|
|
||||||
async createTerminal({ name }) {
|
reloadTerminals() {
|
||||||
const res = await posterminalApi.create({ name });
|
return actions.loadTerminals();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTerminal(payload) {
|
||||||
|
const res = await posterminalApi.create(payload);
|
||||||
const terminal = res.posterminal;
|
const terminal = res.posterminal;
|
||||||
companyAutomat.setState({
|
companyAutomat.setState({
|
||||||
terminals: [...companyAutomat.state.terminals, terminal],
|
terminals: sortTerminals([...companyAutomat.state.terminals, terminal]),
|
||||||
});
|
});
|
||||||
return terminal;
|
return terminal;
|
||||||
},
|
},
|
||||||
@@ -619,8 +692,10 @@ const actions = {
|
|||||||
const res = await posterminalApi.update(terminalId, patch);
|
const res = await posterminalApi.update(terminalId, patch);
|
||||||
const updated = res.posterminal;
|
const updated = res.posterminal;
|
||||||
companyAutomat.setState({
|
companyAutomat.setState({
|
||||||
terminals: companyAutomat.state.terminals.map((t) =>
|
terminals: sortTerminals(
|
||||||
t.id === terminalId ? { ...t, ...updated } : t,
|
companyAutomat.state.terminals.map((t) =>
|
||||||
|
t.id === terminalId ? { ...t, ...updated } : t,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
@@ -682,6 +757,180 @@ const actions = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
// #region Printer agents (printeragent)
|
||||||
|
// MARK: calls printeragent.list from ../api/index.js
|
||||||
|
async loadPrinterAgents() {
|
||||||
|
const companyId =
|
||||||
|
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
|
||||||
|
if (!companyId) return [];
|
||||||
|
if (inflightPrinterAgents) return inflightPrinterAgents;
|
||||||
|
companyAutomat.setState({ printeragentsStatus: 'loading', printeragentsError: null });
|
||||||
|
inflightPrinterAgents = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await printeragentApi.list();
|
||||||
|
const printeragents = sortPrinterAgents(Array.isArray(res.printeragents) ? res.printeragents : []);
|
||||||
|
companyAutomat.setState({
|
||||||
|
printeragents,
|
||||||
|
printeragentsCompanyId: companyId,
|
||||||
|
printeragentsStatus: 'succeeded',
|
||||||
|
printeragentsError: null,
|
||||||
|
});
|
||||||
|
return printeragents;
|
||||||
|
} catch (err) {
|
||||||
|
companyAutomat.setState({
|
||||||
|
printeragentsStatus: 'failed',
|
||||||
|
printeragentsError: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightPrinterAgents = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightPrinterAgents;
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadPrinterAgents() {
|
||||||
|
return actions.loadPrinterAgents();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPrinterAgent(payload) {
|
||||||
|
const res = await printeragentApi.create(payload);
|
||||||
|
const agent = res.printeragent;
|
||||||
|
companyAutomat.setState({
|
||||||
|
printeragents: sortPrinterAgents([...companyAutomat.state.printeragents, agent]),
|
||||||
|
});
|
||||||
|
return agent;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePrinterAgent(agentId, patch) {
|
||||||
|
const res = await printeragentApi.update(agentId, patch);
|
||||||
|
const updated = res.printeragent;
|
||||||
|
companyAutomat.setState({
|
||||||
|
printeragents: sortPrinterAgents(
|
||||||
|
companyAutomat.state.printeragents.map((a) =>
|
||||||
|
a.id === agentId ? { ...a, ...updated } : a,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePrinterAgent(agentId) {
|
||||||
|
await printeragentApi.delete(agentId);
|
||||||
|
companyAutomat.setState({
|
||||||
|
printeragents: companyAutomat.state.printeragents.filter((a) => a.id !== agentId),
|
||||||
|
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.printeragentId !== agentId),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Agent printers (agentprinter) & live telemetry
|
||||||
|
// MARK: calls agentprinter.list from ../api/index.js
|
||||||
|
async loadAgentPrinters(printeragentId) {
|
||||||
|
const companyId =
|
||||||
|
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
|
||||||
|
if (!companyId) return [];
|
||||||
|
if (inflightAgentPrinters) return inflightAgentPrinters;
|
||||||
|
companyAutomat.setState({ agentprintersStatus: 'loading', agentprintersError: null });
|
||||||
|
inflightAgentPrinters = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await agentprinterApi.list(printeragentId);
|
||||||
|
const agentprinters = sortAgentPrinters(Array.isArray(res.agentprinters) ? res.agentprinters : []);
|
||||||
|
companyAutomat.setState({
|
||||||
|
agentprinters,
|
||||||
|
agentprintersCompanyId: companyId,
|
||||||
|
agentprintersStatus: 'succeeded',
|
||||||
|
agentprintersError: null,
|
||||||
|
});
|
||||||
|
return agentprinters;
|
||||||
|
} catch (err) {
|
||||||
|
companyAutomat.setState({
|
||||||
|
agentprintersStatus: 'failed',
|
||||||
|
agentprintersError: err?.message ?? String(err),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightAgentPrinters = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightAgentPrinters;
|
||||||
|
},
|
||||||
|
|
||||||
|
reloadAgentPrinters(printeragentId) {
|
||||||
|
return actions.loadAgentPrinters(printeragentId);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAgentPrinter(id, patch) {
|
||||||
|
const res = await agentprinterApi.update(id, patch);
|
||||||
|
const updated = res.agentprinter;
|
||||||
|
companyAutomat.setState({
|
||||||
|
agentprinters: sortAgentPrinters(
|
||||||
|
companyAutomat.state.agentprinters.map((p) =>
|
||||||
|
p.id === id ? { ...p, ...updated } : p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
|
||||||
|
async reorderAgentPrinters({ printeragentId, agentprinterIds }) {
|
||||||
|
await agentprinterApi.order({ printeragentId, agentprinterIds });
|
||||||
|
const idOrder = new Map(agentprinterIds.map((id, index) => [id, index]));
|
||||||
|
companyAutomat.setState({
|
||||||
|
agentprinters: sortAgentPrinters(
|
||||||
|
companyAutomat.state.agentprinters.map((p) =>
|
||||||
|
idOrder.has(p.id) ? { ...p, sort: idOrder.get(p.id) } : p,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAgentPrinter(id) {
|
||||||
|
await agentprinterApi.delete(id);
|
||||||
|
companyAutomat.setState({
|
||||||
|
agentprinters: companyAutomat.state.agentprinters.filter((p) => p.id !== id),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendTestPrint(agentprinterId, options = {}) {
|
||||||
|
return printerjobApi.sendTestZpl(agentprinterId, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelAllPrinterJobs(agentprinterId) {
|
||||||
|
return printerjobApi.cancelAll({ agentprinterId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async createZplJob(payload) {
|
||||||
|
return printerjobApi.createZplJob(payload);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadLivePrinters() {
|
||||||
|
const companyId =
|
||||||
|
authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id;
|
||||||
|
if (!companyId) return [];
|
||||||
|
if (inflightLivePrinters) return inflightLivePrinters;
|
||||||
|
inflightLivePrinters = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await printerApi.state();
|
||||||
|
const livePrinters = Array.isArray(res.printers) ? res.printers : [];
|
||||||
|
companyAutomat.setState({ livePrinters });
|
||||||
|
return livePrinters;
|
||||||
|
} catch (err) {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
inflightLivePrinters = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLivePrinters;
|
||||||
|
},
|
||||||
|
|
||||||
|
setLivePrinters(printers) {
|
||||||
|
companyAutomat.setState({
|
||||||
|
livePrinters: Array.isArray(printers) ? printers : [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// #region 4. Suppliers (supplier)
|
// #region 4. Suppliers (supplier)
|
||||||
// MARK: calls supplier.list from ../api/index.js
|
// MARK: calls supplier.list from ../api/index.js
|
||||||
loadSuppliers() {
|
loadSuppliers() {
|
||||||
@@ -704,15 +953,21 @@ const actions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async reloadSuppliers() {
|
async reloadSuppliers() {
|
||||||
try {
|
if (inflightSuppliers) return inflightSuppliers;
|
||||||
const suppliers = await fetchSuppliersList();
|
inflightSuppliers = (async () => {
|
||||||
companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null });
|
try {
|
||||||
void actions.loadAllSupplierArticles().catch(() => {});
|
const suppliers = await fetchSuppliersList();
|
||||||
return suppliers;
|
companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null });
|
||||||
} catch (error) {
|
void actions.loadAllSupplierArticles().catch(() => {});
|
||||||
companyAutomat.setState({ suppliersError: error });
|
return suppliers;
|
||||||
throw error;
|
} catch (error) {
|
||||||
}
|
companyAutomat.setState({ suppliersError: error });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
inflightSuppliers = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightSuppliers;
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadArticlesForSupplier(supplierId) {
|
async loadArticlesForSupplier(supplierId) {
|
||||||
@@ -756,6 +1011,44 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncArticleSuppliers(sku, suppliers) {
|
||||||
|
const artSku = String(sku).trim().toLowerCase();
|
||||||
|
const assignedSupplierIds = new Set(
|
||||||
|
(suppliers || []).map((s) => String(s.entityId ?? s.supplierId ?? s.id))
|
||||||
|
);
|
||||||
|
const prevMap = companyAutomat.state.articlesBySupplier || {};
|
||||||
|
let changed = false;
|
||||||
|
const nextMap = { ...prevMap };
|
||||||
|
|
||||||
|
for (const sId of assignedSupplierIds) {
|
||||||
|
const currentList = nextMap[sId] ? [...nextMap[sId]] : [];
|
||||||
|
const hasSku = currentList.some(
|
||||||
|
(item) => String(item.article?.sku ?? item.sku ?? '').toLowerCase() === artSku
|
||||||
|
);
|
||||||
|
if (!hasSku) {
|
||||||
|
currentList.push({ sku, article: { sku } });
|
||||||
|
nextMap[sId] = currentList;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [sId, list] of Object.entries(nextMap)) {
|
||||||
|
if (!assignedSupplierIds.has(sId) && Array.isArray(list)) {
|
||||||
|
const filtered = list.filter(
|
||||||
|
(item) => String(item.article?.sku ?? item.sku ?? '').toLowerCase() !== artSku
|
||||||
|
);
|
||||||
|
if (filtered.length !== list.length) {
|
||||||
|
nextMap[sId] = filtered;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
companyAutomat.setState({ articlesBySupplier: nextMap });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async createSupplier(body) {
|
async createSupplier(body) {
|
||||||
const data = await supplierApi.create(body);
|
const data = await supplierApi.create(body);
|
||||||
requireObject(data, 'POST /api/supplier');
|
requireObject(data, 'POST /api/supplier');
|
||||||
@@ -1431,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
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1474,6 +1883,75 @@ authAutomat.subscribe(syncWithAuth);
|
|||||||
syncWithAuth(authAutomat.state);
|
syncWithAuth(authAutomat.state);
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
// #region sseAutomat synchronization
|
||||||
|
sseAutomat.actions.on('supplier', () => {
|
||||||
|
void companyAutomat.actions.reloadSuppliers().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('warehouse', () => {
|
||||||
|
void companyAutomat.actions.reloadWarehouses().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('brand', () => {
|
||||||
|
void companyAutomat.actions.loadBrands(true).catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('unit', () => {
|
||||||
|
void companyAutomat.actions.loadUnits().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('carrier', () => {
|
||||||
|
void companyAutomat.actions.reloadCarriers().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('posterminal', () => {
|
||||||
|
void companyAutomat.actions.loadTerminals().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('printeragent', () => {
|
||||||
|
void companyAutomat.actions.loadPrinterAgents().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('agentprinter', () => {
|
||||||
|
void companyAutomat.actions.loadAgentPrinters().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('printerstate', (ev) => {
|
||||||
|
try {
|
||||||
|
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
|
||||||
|
if (Array.isArray(payload.printers)) {
|
||||||
|
companyAutomat.actions.setLivePrinters(payload.printers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
void companyAutomat.actions.loadLivePrinters().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('printer', (ev) => {
|
||||||
|
try {
|
||||||
|
const payload = typeof ev?.data === 'string' ? JSON.parse(ev.data || '{}') : (ev?.data || {});
|
||||||
|
if (Array.isArray(payload.printers)) {
|
||||||
|
companyAutomat.actions.setLivePrinters(payload.printers);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('customergroup', () => {
|
||||||
|
void companyAutomat.actions.reloadCustomergroups().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('propertyname', () => {
|
||||||
|
void companyAutomat.actions.loadProperties().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('attributename', () => {
|
||||||
|
void companyAutomat.actions.loadAttributes().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('account', () => {
|
||||||
|
void companyAutomat.actions.reloadAccounts().catch(() => {});
|
||||||
|
});
|
||||||
|
// Admin-only lists: only refresh once an admin view has loaded them.
|
||||||
|
sseAutomat.actions.on('companyuser', () => {
|
||||||
|
if (companyAutomat.state.membersStatus !== 'ready') return;
|
||||||
|
void companyAutomat.actions.reloadMembers().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('autoinvite', () => {
|
||||||
|
if (companyAutomat.state.invitesStatus !== 'ready') return;
|
||||||
|
void companyAutomat.actions.reloadInvites().catch(() => {});
|
||||||
|
});
|
||||||
|
sseAutomat.actions.on('company', () => {
|
||||||
|
void companyAutomat.actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// #region selectors & exported action helpers
|
// #region selectors & exported action helpers
|
||||||
// Company profile selectors
|
// Company profile selectors
|
||||||
export const selectCompanies = () => companyAutomat.state.companies;
|
export const selectCompanies = () => companyAutomat.state.companies;
|
||||||
@@ -1497,6 +1975,16 @@ export const selectPosterminals = () => companyAutomat.state.terminals;
|
|||||||
export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus;
|
export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus;
|
||||||
export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0;
|
export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0;
|
||||||
|
|
||||||
|
// Printer agents selectors
|
||||||
|
export const selectPrinterAgents = () => companyAutomat.state.printeragents;
|
||||||
|
export const selectPrinterAgentStatus = () => companyAutomat.state.printeragentsStatus;
|
||||||
|
export const selectHasPrinterAgents = () => companyAutomat.state.printeragents.length > 0;
|
||||||
|
|
||||||
|
// Agent printers selectors
|
||||||
|
export const selectAgentPrinters = () => companyAutomat.state.agentprinters;
|
||||||
|
export const selectAgentPrinterStatus = () => companyAutomat.state.agentprintersStatus;
|
||||||
|
export const selectLivePrinters = () => companyAutomat.state.livePrinters;
|
||||||
|
|
||||||
// Suppliers selectors
|
// Suppliers selectors
|
||||||
export const selectSuppliers = () => companyAutomat.state.suppliers;
|
export const selectSuppliers = () => companyAutomat.state.suppliers;
|
||||||
export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus;
|
export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus;
|
||||||
@@ -1537,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;
|
||||||
|
|||||||
@@ -151,6 +151,12 @@ export {
|
|||||||
selectPosterminals,
|
selectPosterminals,
|
||||||
selectPosterminalStatus,
|
selectPosterminalStatus,
|
||||||
selectHasPosterminals,
|
selectHasPosterminals,
|
||||||
|
selectPrinterAgents,
|
||||||
|
selectPrinterAgentStatus,
|
||||||
|
selectHasPrinterAgents,
|
||||||
|
selectAgentPrinters,
|
||||||
|
selectAgentPrinterStatus,
|
||||||
|
selectLivePrinters,
|
||||||
selectSuppliers,
|
selectSuppliers,
|
||||||
selectSuppliersStatus,
|
selectSuppliersStatus,
|
||||||
selectUnits,
|
selectUnits,
|
||||||
@@ -172,6 +178,12 @@ export {
|
|||||||
selectAttributeStatus,
|
selectAttributeStatus,
|
||||||
selectAccounts,
|
selectAccounts,
|
||||||
selectAccountsStatus,
|
selectAccountsStatus,
|
||||||
|
selectCompanyRole,
|
||||||
|
selectIsCompanyAdmin,
|
||||||
|
selectMembers,
|
||||||
|
selectMembersStatus,
|
||||||
|
selectInvites,
|
||||||
|
selectInvitesStatus,
|
||||||
} from './companyAutomat';
|
} from './companyAutomat';
|
||||||
|
|
||||||
export { default as categoryAutomat } from './categoryAutomat';
|
export { default as categoryAutomat } from './categoryAutomat';
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
|
import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} SseState
|
* @typedef {Object} SseState
|
||||||
@@ -202,6 +203,24 @@ function dispatchSseEvent(event) {
|
|||||||
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
|
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
|
||||||
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
|
||||||
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
|
||||||
|
} else if (eventType === 'supplier') {
|
||||||
|
void Automat.get('company')?.actions?.reloadSuppliers?.().catch(() => {});
|
||||||
|
} else if (eventType === 'brand') {
|
||||||
|
void Automat.get('company')?.actions?.loadBrands?.(true).catch(() => {});
|
||||||
|
} else if (eventType === 'unit') {
|
||||||
|
void Automat.get('company')?.actions?.loadUnits?.().catch(() => {});
|
||||||
|
} else if (eventType === 'carrier') {
|
||||||
|
void Automat.get('company')?.actions?.reloadCarriers?.().catch(() => {});
|
||||||
|
} else if (eventType === 'posterminal') {
|
||||||
|
void Automat.get('company')?.actions?.loadTerminals?.().catch(() => {});
|
||||||
|
} else if (eventType === 'customergroup') {
|
||||||
|
void Automat.get('company')?.actions?.reloadCustomergroups?.().catch(() => {});
|
||||||
|
} else if (eventType === 'propertyname') {
|
||||||
|
void Automat.get('company')?.actions?.loadProperties?.().catch(() => {});
|
||||||
|
} else if (eventType === 'attributename') {
|
||||||
|
void Automat.get('company')?.actions?.loadAttributes?.().catch(() => {});
|
||||||
|
} else if (eventType === 'account') {
|
||||||
|
void Automat.get('company')?.actions?.reloadAccounts?.().catch(() => {});
|
||||||
} else if (eventType === 'order') {
|
} else if (eventType === 'order') {
|
||||||
void Automat.get('order')?.actions?.reload?.().catch(() => {});
|
void Automat.get('order')?.actions?.reload?.().catch(() => {});
|
||||||
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
|
||||||
@@ -219,9 +238,10 @@ function dispatchSseEvent(event) {
|
|||||||
// 3. Dispatch to article-specific handlers if it starts with article-
|
// 3. Dispatch to article-specific handlers if it starts with article-
|
||||||
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
|
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
|
||||||
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
|
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
|
||||||
|
const lowerSku = sku.trim().toLowerCase();
|
||||||
|
|
||||||
// Watchers registered via watchArticle(sku, handler)
|
// Watchers registered via watchArticle(sku, handler)
|
||||||
const watchers = articleWatchers.get(sku);
|
const watchers = articleWatchers.get(lowerSku) || articleWatchers.get(sku);
|
||||||
if (watchers) {
|
if (watchers) {
|
||||||
for (const handler of watchers) {
|
for (const handler of watchers) {
|
||||||
try {
|
try {
|
||||||
@@ -522,20 +542,21 @@ const actions = {
|
|||||||
* @returns {() => void} Unsubscribe callback.
|
* @returns {() => void} Unsubscribe callback.
|
||||||
*/
|
*/
|
||||||
watchArticle(sku, handler) {
|
watchArticle(sku, handler) {
|
||||||
const key = String(sku);
|
const rawKey = String(sku).trim();
|
||||||
let set = articleWatchers.get(key);
|
const lowerKey = rawKey.toLowerCase();
|
||||||
|
let set = articleWatchers.get(lowerKey);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
set = new Set();
|
set = new Set();
|
||||||
articleWatchers.set(key, set);
|
articleWatchers.set(lowerKey, set);
|
||||||
attachArticleListener(key);
|
attachArticleListener(rawKey);
|
||||||
}
|
}
|
||||||
set.add(handler);
|
set.add(handler);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = articleWatchers.get(key);
|
const current = articleWatchers.get(lowerKey);
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
current.delete(handler);
|
current.delete(handler);
|
||||||
if (current.size === 0) articleWatchers.delete(key);
|
if (current.size === 0) articleWatchers.delete(lowerKey);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -575,14 +596,11 @@ export function invalidateArticle(sku) {
|
|||||||
if (art?.actions?.reloadArticle) {
|
if (art?.actions?.reloadArticle) {
|
||||||
void art.actions.reloadArticle(key).catch(() => {});
|
void art.actions.reloadArticle(key).catch(() => {});
|
||||||
}
|
}
|
||||||
const hist = Automat.get('articleHistory');
|
if (articleHistoryAutomat.has(key)) {
|
||||||
if (hist?.has?.(key)) {
|
void articleHistoryAutomat.reload(key).catch(() => {});
|
||||||
void hist.reload(key).catch(() => {});
|
|
||||||
}
|
|
||||||
const sup = Automat.get('articleSupplier');
|
|
||||||
if (sup?.has?.(key)) {
|
|
||||||
void sup.reload(key).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
// Reload assigned suppliers and sync with companyAutomat
|
||||||
|
void articleSupplierAutomat.reload(key).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
authAutomat.subscribe(handleAuthSync);
|
authAutomat.subscribe(handleAuthSync);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ButtonBase from '@mui/material/ButtonBase';
|
|||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
|
import OnlinePrinterIcons from './OnlinePrinterIcons';
|
||||||
import HelpBadge from './HelpBadge';
|
import HelpBadge from './HelpBadge';
|
||||||
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
|
||||||
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
|
||||||
@@ -198,8 +199,20 @@ class AppSidebar extends PureComponent {
|
|||||||
})}
|
})}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
ml: { xs: 'auto', lg: 0 },
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'row', lg: 'column' },
|
||||||
|
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||||
|
width: { xs: 'auto', lg: '100%' },
|
||||||
|
gap: { xs: 0.5, lg: 0 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OnlinePrinterIcons />
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
|
<ChatFab />
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -418,7 +431,6 @@ export default function AppShell() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<ChatFab />
|
|
||||||
<ChatDrawer />
|
<ChatDrawer />
|
||||||
<PinnedAppletFabs pathname={pathname} />
|
<PinnedAppletFabs pathname={pathname} />
|
||||||
<PinnedAppletDialog />
|
<PinnedAppletDialog />
|
||||||
|
|||||||
318
src/components/OnlinePrinterIcons.jsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
|
||||||
|
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
|
import { notifyError } from '../utils/notify';
|
||||||
|
import { getPrinterImage } from '../assets/printers';
|
||||||
|
|
||||||
|
export default class OnlinePrinterIcons extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
|
||||||
|
? companyAutomat.state.agentprinters
|
||||||
|
: [],
|
||||||
|
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||||
|
? companyAutomat.state.livePrinters
|
||||||
|
: [],
|
||||||
|
menuAnchor: null,
|
||||||
|
};
|
||||||
|
this.handleOpenMenu = this.handleOpenMenu.bind(this);
|
||||||
|
this.handleCloseMenu = this.handleCloseMenu.bind(this);
|
||||||
|
this.handleTestPrint = this.handleTestPrint.bind(this);
|
||||||
|
this.handleCancelAllJobs = this.handleCancelAllJobs.bind(this);
|
||||||
|
this.handleOpenPrinterSettings = this.handleOpenPrinterSettings.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
|
||||||
|
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
getOnlinePrinters() {
|
||||||
|
const { agentprinters = [], livePrinters = [] } = this.state;
|
||||||
|
const liveBySerial = new Map();
|
||||||
|
for (const lp of livePrinters) {
|
||||||
|
if (lp.serial) liveBySerial.set(String(lp.serial).toLowerCase(), lp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentprinters.length > 0) {
|
||||||
|
return agentprinters
|
||||||
|
.filter((p) => {
|
||||||
|
if (p.online === true) return true;
|
||||||
|
const live =
|
||||||
|
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||||
|
livePrinters.find(
|
||||||
|
(lp) =>
|
||||||
|
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||||
|
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||||
|
);
|
||||||
|
if (!live) return false;
|
||||||
|
return live.status !== 'offline' && !live.unreachable && !live.errors?.unreachable;
|
||||||
|
})
|
||||||
|
.map((p) => {
|
||||||
|
const live =
|
||||||
|
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
|
||||||
|
livePrinters.find(
|
||||||
|
(lp) =>
|
||||||
|
(lp.agentprinterId && lp.agentprinterId === p.id) ||
|
||||||
|
(lp.agentId === p.printeragentId && lp.serial === p.serial),
|
||||||
|
);
|
||||||
|
return { ...p, live };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return livePrinters
|
||||||
|
.filter((lp) => lp.status !== 'offline' && !lp.unreachable && !lp.errors?.unreachable)
|
||||||
|
.map((lp) => ({
|
||||||
|
id: lp.agentprinterId || lp.id || lp.serial,
|
||||||
|
model: lp.model,
|
||||||
|
serial: lp.serial,
|
||||||
|
type: lp.type,
|
||||||
|
online: true,
|
||||||
|
open: Boolean(lp.errors?.headOpen || lp.headOpen),
|
||||||
|
pause: Boolean(lp.errors?.pause || lp.pause),
|
||||||
|
error: Boolean(lp.errors?.other || lp.error),
|
||||||
|
labelLength: lp.labelLength,
|
||||||
|
live: lp,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenMenu(event, printer) {
|
||||||
|
this.setState({
|
||||||
|
menuAnchor: {
|
||||||
|
el: event.currentTarget,
|
||||||
|
printer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseMenu() {
|
||||||
|
this.setState({ menuAnchor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTestPrint(printer) {
|
||||||
|
if (!printer?.id) return;
|
||||||
|
this.handleCloseMenu();
|
||||||
|
companyAutomat.actions
|
||||||
|
.sendTestPrint(printer.id, {
|
||||||
|
model: printer.model || printer.type || 'Zebra',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
enqueueSnackbar(
|
||||||
|
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Warteschlange gesendet.`,
|
||||||
|
{ variant: 'success' },
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCancelAllJobs(printer) {
|
||||||
|
if (!printer?.id) return;
|
||||||
|
this.handleCloseMenu();
|
||||||
|
companyAutomat.actions
|
||||||
|
.cancelAllPrinterJobs(printer.id)
|
||||||
|
.then(() => {
|
||||||
|
enqueueSnackbar(`Druckaufträge für ${printer.model || 'Drucker'} abgebrochen.`, {
|
||||||
|
variant: 'info',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(notifyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenPrinterSettings() {
|
||||||
|
this.handleCloseMenu();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('open-settings', { detail: { tab: 'printeragent' } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { menuAnchor } = this.state;
|
||||||
|
const onlinePrinters = this.getOnlinePrinters();
|
||||||
|
|
||||||
|
if (onlinePrinters.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePrinter = menuAnchor?.printer;
|
||||||
|
const activeHasError = Boolean(
|
||||||
|
activePrinter?.error ||
|
||||||
|
activePrinter?.open ||
|
||||||
|
activePrinter?.live?.errors?.other ||
|
||||||
|
activePrinter?.live?.errors?.headOpen,
|
||||||
|
);
|
||||||
|
const activeIsPaused = Boolean(activePrinter?.pause || activePrinter?.live?.errors?.pause);
|
||||||
|
const activeStatusText = activeHasError
|
||||||
|
? activePrinter?.open || activePrinter?.live?.errors?.headOpen
|
||||||
|
? 'Druckkopf offen'
|
||||||
|
: 'Fehler'
|
||||||
|
: activeIsPaused
|
||||||
|
? 'Pausiert'
|
||||||
|
: 'Bereit';
|
||||||
|
|
||||||
|
const anchorEl = menuAnchor?.el;
|
||||||
|
const isTop = Boolean(
|
||||||
|
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: { xs: 'row', lg: 'column' },
|
||||||
|
alignItems: { xs: 'center', lg: 'flex-start' },
|
||||||
|
alignSelf: { xs: 'center', lg: 'flex-start' },
|
||||||
|
px: { xs: 0.5, sm: 1, lg: 1.5 },
|
||||||
|
py: { xs: 0.5, lg: 0.5 },
|
||||||
|
gap: { xs: 0.5, lg: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{onlinePrinters.map((p, index) => {
|
||||||
|
const hasError = Boolean(
|
||||||
|
p.error || p.open || p.live?.errors?.other || p.live?.errors?.headOpen,
|
||||||
|
);
|
||||||
|
const isPaused = Boolean(p.pause || p.live?.errors?.pause);
|
||||||
|
const label = p.model || 'Drucker';
|
||||||
|
const statusText = hasError
|
||||||
|
? p.open || p.live?.errors?.headOpen
|
||||||
|
? 'Druckkopf offen'
|
||||||
|
: 'Fehler'
|
||||||
|
: isPaused
|
||||||
|
? 'Pausiert'
|
||||||
|
: 'Bereit';
|
||||||
|
const tooltipTitle = `${label}${p.serial ? ` (${p.serial})` : ''} • ${statusText}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
key={p.id || p.serial || index}
|
||||||
|
onClick={(e) => this.handleOpenMenu(e, p)}
|
||||||
|
title={tooltipTitle}
|
||||||
|
sx={(theme) => ({
|
||||||
|
p: 0,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '50%',
|
||||||
|
color: theme.nav?.item || 'inherit',
|
||||||
|
'&:hover': {
|
||||||
|
color: theme.nav?.itemHoverText || 'inherit',
|
||||||
|
bgcolor: theme.nav?.itemHover || 'action.hover',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
aria-label={`Drucker ${label}`}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="dot"
|
||||||
|
color={hasError ? 'error' : isPaused ? 'warning' : 'success'}
|
||||||
|
overlap="circular"
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<PrintOutlinedIcon sx={{ fontSize: 20 }} />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={Boolean(menuAnchor)}
|
||||||
|
onClose={this.handleCloseMenu}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: isTop ? 'bottom' : 'top',
|
||||||
|
horizontal: isTop ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: isTop ? 'top' : 'bottom',
|
||||||
|
horizontal: isTop ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activePrinter && (
|
||||||
|
<Box sx={{ px: 2, py: 1, minWidth: 210 }}>
|
||||||
|
{getPrinterImage(activePrinter.model) ? (
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={getPrinterImage(activePrinter.model)}
|
||||||
|
alt={activePrinter.model}
|
||||||
|
sx={{ display: 'block', mx: 'auto', mb: 1, maxWidth: 80, height: 'auto' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2" fontWeight={600} noWrap>
|
||||||
|
{activePrinter.model || 'Drucker'}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={activeStatusText}
|
||||||
|
size="small"
|
||||||
|
color={activeHasError ? 'error' : activeIsPaused ? 'warning' : 'success'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ fontSize: '0.65rem', height: 18, fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{activePrinter.serial ? (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontFamily: 'monospace', display: 'block' }}
|
||||||
|
>
|
||||||
|
S/N: {activePrinter.serial}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
{activePrinter.labelLength ? (
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Etikett: {activePrinter.labelLength} px
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<MenuItem onClick={() => this.handleTestPrint(activePrinter)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PrintOutlinedIcon fontSize="small" color="primary" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Testdruck"
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={() => this.handleCancelAllJobs(activePrinter)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary="Aufträge abbrechen"
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onClick={this.handleOpenPrinterSettings}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Einstellungen" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class UserMenu extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
anchorEl: null,
|
anchorEl: null,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
|
settingsInitialTab: null,
|
||||||
user: authAutomat.state.user,
|
user: authAutomat.state.user,
|
||||||
helpEnabled: helpAutomat.state?.enabled ?? false,
|
helpEnabled: helpAutomat.state?.enabled ?? false,
|
||||||
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
|
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
|
||||||
@@ -37,6 +38,7 @@ class UserMenu extends PureComponent {
|
|||||||
this.handleToggleHelp = this.handleToggleHelp.bind(this);
|
this.handleToggleHelp = this.handleToggleHelp.bind(this);
|
||||||
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
|
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
|
||||||
this.handleLogout = this.handleLogout.bind(this);
|
this.handleLogout = this.handleLogout.bind(this);
|
||||||
|
this.handleOpenSettingsEvent = this.handleOpenSettingsEvent.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -47,12 +49,23 @@ class UserMenu extends PureComponent {
|
|||||||
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
|
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
|
||||||
pwaUpdateAvailable: Boolean(state?.needRefresh),
|
pwaUpdateAvailable: Boolean(state?.needRefresh),
|
||||||
}));
|
}));
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('open-settings', this.handleOpenSettingsEvent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unsubscribeAuth?.();
|
this.unsubscribeAuth?.();
|
||||||
this.unsubscribeHelp?.();
|
this.unsubscribeHelp?.();
|
||||||
this.unsubscribePwa?.();
|
this.unsubscribePwa?.();
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('open-settings', this.handleOpenSettingsEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleOpenSettingsEvent(e) {
|
||||||
|
const tab = e?.detail?.tab;
|
||||||
|
this.setState({ settingsOpen: true, settingsInitialTab: tab || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOpenPwaUpdate() {
|
handleOpenPwaUpdate() {
|
||||||
@@ -74,11 +87,11 @@ class UserMenu extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleOpenSettings() {
|
handleOpenSettings() {
|
||||||
this.setState({ anchorEl: null, settingsOpen: true });
|
this.setState({ anchorEl: null, settingsOpen: true, settingsInitialTab: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCloseSettings() {
|
handleCloseSettings() {
|
||||||
this.setState({ settingsOpen: false });
|
this.setState({ settingsOpen: false, settingsInitialTab: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleToggleHelp(e) {
|
handleToggleHelp(e) {
|
||||||
@@ -98,7 +111,7 @@ class UserMenu extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
|
const { user, anchorEl, settingsOpen, settingsInitialTab, helpEnabled, pwaUpdateAvailable } = this.state;
|
||||||
const open = Boolean(anchorEl);
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
const pictureSrc = (() => {
|
const pictureSrc = (() => {
|
||||||
@@ -201,7 +214,7 @@ class UserMenu extends PureComponent {
|
|||||||
<ListItemText>Sign out</ListItemText>
|
<ListItemText>Sign out</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
|
<SettingsDialog open={settingsOpen} initialTab={settingsInitialTab} onClose={this.handleCloseSettings} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ArticleNameDialog extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
mode: SINGLE_MODE,
|
mode: SINGLE_MODE,
|
||||||
name: '',
|
name: props.initialName ?? '',
|
||||||
bulkText: '',
|
bulkText: '',
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -46,7 +46,7 @@ class ArticleNameDialog extends Component {
|
|||||||
if (this.props.open && !prevProps.open) {
|
if (this.props.open && !prevProps.open) {
|
||||||
this.setState({
|
this.setState({
|
||||||
mode: SINGLE_MODE,
|
mode: SINGLE_MODE,
|
||||||
name: '',
|
name: this.props.initialName ?? '',
|
||||||
bulkText: '',
|
bulkText: '',
|
||||||
busy: false,
|
busy: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -9,14 +9,17 @@ import {
|
|||||||
class ArticlePhoto extends Component {
|
class ArticlePhoto extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { thumbLoaded: false };
|
this.state = { thumbLoaded: false, smallThumbError: false };
|
||||||
this.handleThumbLoad = this.handleThumbLoad.bind(this);
|
this.handleThumbLoad = this.handleThumbLoad.bind(this);
|
||||||
this.handleThumbError = this.handleThumbError.bind(this);
|
this.handleThumbError = this.handleThumbError.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) {
|
if (
|
||||||
this.setState({ thumbLoaded: false });
|
prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
|
||||||
|
prevProps.useSmallThumb !== this.props.useSmallThumb
|
||||||
|
) {
|
||||||
|
this.setState({ thumbLoaded: false, smallThumbError: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +28,11 @@ class ArticlePhoto extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleThumbError() {
|
handleThumbError() {
|
||||||
this.setState({ thumbLoaded: false });
|
if (this.props.useSmallThumb && !this.state.smallThumbError) {
|
||||||
|
this.setState({ smallThumbError: true, thumbLoaded: false });
|
||||||
|
} else {
|
||||||
|
this.setState({ thumbLoaded: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -34,9 +41,11 @@ class ArticlePhoto extends Component {
|
|||||||
size = 200,
|
size = 200,
|
||||||
fill = false,
|
fill = false,
|
||||||
objectFit = fill ? 'cover' : 'contain',
|
objectFit = fill ? 'cover' : 'contain',
|
||||||
|
useSmallThumb = false,
|
||||||
sx,
|
sx,
|
||||||
|
...rest
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { thumbLoaded } = this.state;
|
const { thumbLoaded, smallThumbError } = this.state;
|
||||||
const fullHash = photo?.fullHash;
|
const fullHash = photo?.fullHash;
|
||||||
const hasPreview = photo?.thumbHash != null;
|
const hasPreview = photo?.thumbHash != null;
|
||||||
const aspectRatio =
|
const aspectRatio =
|
||||||
@@ -44,8 +53,15 @@ class ArticlePhoto extends Component {
|
|||||||
? `${photo.thumbW} / ${photo.thumbH}`
|
? `${photo.thumbW} / ${photo.thumbH}`
|
||||||
: '1 / 1';
|
: '1 / 1';
|
||||||
|
|
||||||
|
const imgSrc = fullHash
|
||||||
|
? useSmallThumb && !smallThumbError
|
||||||
|
? thumb.smallUrl(fullHash)
|
||||||
|
: thumb.url(fullHash)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
{...rest}
|
||||||
sx={{
|
sx={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
...(fill
|
...(fill
|
||||||
@@ -78,10 +94,10 @@ class ArticlePhoto extends Component {
|
|||||||
<ThumbHashImage thumbHash={photo.thumbHash} />
|
<ThumbHashImage thumbHash={photo.thumbHash} />
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{fullHash ? (
|
{imgSrc ? (
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={thumb.url(fullHash)}
|
src={imgSrc}
|
||||||
alt=""
|
alt=""
|
||||||
onLoad={this.handleThumbLoad}
|
onLoad={this.handleThumbLoad}
|
||||||
onError={this.handleThumbError}
|
onError={this.handleThumbError}
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
import { primaryPhoto } from '../../utils/articleDetail';
|
import { sortedPhotos, primaryPhoto } from '../../utils/articleDetail';
|
||||||
import ArticlePhoto from './ArticlePhoto';
|
import ArticlePhoto from './ArticlePhoto';
|
||||||
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
||||||
|
|
||||||
class ArticlePhotoTile extends PureComponent {
|
class ArticlePhotoTile extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { editOpen: false };
|
this.state = {
|
||||||
|
editOpen: false,
|
||||||
|
selectedHash: null,
|
||||||
|
};
|
||||||
this.handleEditOpen = this.handleEditOpen.bind(this);
|
this.handleEditOpen = this.handleEditOpen.bind(this);
|
||||||
this.handleEditClose = this.handleEditClose.bind(this);
|
this.handleEditClose = this.handleEditClose.bind(this);
|
||||||
|
this.handleSelectPhoto = this.handleSelectPhoto.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.article?.sku !== this.props.article?.sku) {
|
||||||
|
this.setState({ selectedHash: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEditOpen() {
|
handleEditOpen() {
|
||||||
@@ -21,6 +32,10 @@ class ArticlePhotoTile extends PureComponent {
|
|||||||
this.setState({ editOpen: false });
|
this.setState({ editOpen: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSelectPhoto(fullHash) {
|
||||||
|
this.setState({ selectedHash: fullHash });
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
article: articleData,
|
article: articleData,
|
||||||
@@ -30,8 +45,14 @@ class ArticlePhotoTile extends PureComponent {
|
|||||||
sx,
|
sx,
|
||||||
color,
|
color,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { editOpen } = this.state;
|
const { editOpen, selectedHash } = this.state;
|
||||||
const photo = primaryPhoto(articleData);
|
|
||||||
|
const photos = sortedPhotos(articleData);
|
||||||
|
const primary = primaryPhoto(articleData);
|
||||||
|
const activePhoto =
|
||||||
|
(selectedHash && photos.find((p) => p.fullHash === selectedHash)) ||
|
||||||
|
primary;
|
||||||
|
const otherPhotos = photos.filter((p) => p.fullHash !== activePhoto?.fullHash);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent {
|
|||||||
busy: false,
|
busy: false,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArticlePhoto photo={photo} />
|
{otherPhotos.length === 0 ? (
|
||||||
|
<ArticlePhoto photo={activePhoto} />
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArticlePhoto photo={activePhoto} />
|
||||||
|
|
||||||
|
{/* Smallthumbs of all other images beside the main image */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0.75,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
mt: '32px',
|
||||||
|
py: 0.25,
|
||||||
|
px: 0.25,
|
||||||
|
flexShrink: 0,
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
'&::-webkit-scrollbar': { width: 4 },
|
||||||
|
'&::-webkit-scrollbar-thumb': {
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{otherPhotos.map((p) => (
|
||||||
|
<Box
|
||||||
|
key={p.fullHash}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleSelectPhoto(p.fullHash);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
p: 0,
|
||||||
|
m: 0,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '2px solid transparent',
|
||||||
|
bgcolor: 'transparent',
|
||||||
|
outline: 'none',
|
||||||
|
display: 'block',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
transform: 'scale(1.08)',
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
boxShadow: 2,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
borderColor: 'primary.main',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArticlePhoto
|
||||||
|
photo={p}
|
||||||
|
size={40}
|
||||||
|
useSmallThumb
|
||||||
|
objectFit="cover"
|
||||||
|
sx={{ width: '100%', height: '100%', borderRadius: 1 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
<ArticlePhotosDialog
|
<ArticlePhotosDialog
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
|
|||||||
@@ -42,14 +42,6 @@ import {
|
|||||||
SURFACE_RAISED,
|
SURFACE_RAISED,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
const REASON_PRESETS = [
|
|
||||||
'Inventurdifferenz',
|
|
||||||
'Korrektur',
|
|
||||||
'Bruch / Beschädigung',
|
|
||||||
'Schwund',
|
|
||||||
'Eigenbedarf / Test',
|
|
||||||
];
|
|
||||||
|
|
||||||
export default class ArticleStockAdjustDialog extends PureComponent {
|
export default class ArticleStockAdjustDialog extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
@@ -255,7 +247,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reload article in automat cache
|
// Reload article in automat cache
|
||||||
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
|
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => { });
|
||||||
|
|
||||||
if (updatedArticle) {
|
if (updatedArticle) {
|
||||||
onArticleUpdated?.(updatedArticle);
|
onArticleUpdated?.(updatedArticle);
|
||||||
@@ -379,11 +371,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
Bestand verfolgen
|
Bestand verfolgen
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{trackStock
|
|
||||||
? 'Lagerbestand wird für diesen Artikel geführt und bei Buchungen aktualisiert.'
|
|
||||||
: 'Keine Bestandsführung. Artikel hat unbegrenzte Verfügbarkeit.'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Switch
|
<Switch
|
||||||
checked={Boolean(trackStock)}
|
checked={Boolean(trackStock)}
|
||||||
@@ -473,11 +460,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
>
|
>
|
||||||
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
|
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
|
||||||
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||||
Menge zu-/abbuchen (+ / -)
|
Zu-/abbuchen (+ / -)
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
|
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
|
||||||
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||||
Neuen Zählbestand setzen
|
Bestand setzen
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -491,7 +478,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
value={diffValue}
|
value={diffValue}
|
||||||
onChange={this.handleDiffChange}
|
onChange={this.handleDiffChange}
|
||||||
disabled={submitting || !trackStock}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. +5 oder -2"
|
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
@@ -505,28 +491,15 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
helperText={
|
|
||||||
valid
|
|
||||||
? `Neuer Bestand: ${formatStock(newStock)}${unit ? ` ${unit}` : ''}`
|
|
||||||
: 'Geben Sie einen positiven oder negativen Wert ein'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextInput
|
<TextInput
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Tatsächlicher Zählbestand (Ist-Bestand)"
|
label="Bestand"
|
||||||
type="number"
|
type="number"
|
||||||
value={targetValue}
|
value={targetValue}
|
||||||
onChange={this.handleTargetChange}
|
onChange={this.handleTargetChange}
|
||||||
disabled={submitting || !trackStock}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. 10"
|
|
||||||
helperText={
|
|
||||||
isZeroChange
|
|
||||||
? 'Der eingegebene Zählbestand entspricht dem aktuellen Bestand (keine Änderung).'
|
|
||||||
: valid
|
|
||||||
? `Berechnete Korrektur: ${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`
|
|
||||||
: 'Geben Sie den neu gezählten Bestand ein'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -564,26 +537,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
|
|||||||
<Box>
|
<Box>
|
||||||
<TextInput
|
<TextInput
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Grund / Buchungstext"
|
label="Buchungstext"
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={this.handleReasonChange}
|
onChange={this.handleReasonChange}
|
||||||
disabled={submitting || !trackStock}
|
disabled={submitting || !trackStock}
|
||||||
placeholder="z.B. Inventur, Bruch, Schwund"
|
|
||||||
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
|
|
||||||
/>
|
/>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
|
|
||||||
{REASON_PRESETS.map((preset) => (
|
|
||||||
<Chip
|
|
||||||
key={preset}
|
|
||||||
label={preset}
|
|
||||||
size="small"
|
|
||||||
clickable
|
|
||||||
disabled={submitting || !trackStock}
|
|
||||||
onClick={() => this.setState({ reason: preset })}
|
|
||||||
sx={{ fontSize: '0.75rem' }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -43,14 +43,12 @@ export default class CategoryPickerDialog extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
this._unsub = categoryAutomat.subscribe((s) => {
|
||||||
this._unsub = categoryAutomat.subscribe((s) => {
|
this.setState({
|
||||||
this.setState(() => ({
|
categories: s.categories,
|
||||||
categories: this.props.categories ?? s.categories,
|
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
|
||||||
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
|
|||||||
} else if (this.state.rootCategoryId != null) {
|
} else if (this.state.rootCategoryId != null) {
|
||||||
nextSelectedId = this.state.rootCategoryId;
|
nextSelectedId = this.state.rootCategoryId;
|
||||||
}
|
}
|
||||||
this.setState({ selectedId: nextSelectedId });
|
this.setState({
|
||||||
}
|
selectedId: nextSelectedId,
|
||||||
|
categories: categoryAutomat.state.categories ?? this.props.categories,
|
||||||
if (
|
rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
this.props.categories !== prevProps.categories ||
|
this.props.categories !== prevProps.categories ||
|
||||||
this.props.rootCategoryId !== prevProps.rootCategoryId
|
this.props.rootCategoryId !== prevProps.rootCategoryId
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export function queryFromParams(searchParams) {
|
|||||||
filter: searchParams.get('filter') ?? '',
|
filter: searchParams.get('filter') ?? '',
|
||||||
brand: searchParams.get('brand') ?? '',
|
brand: searchParams.get('brand') ?? '',
|
||||||
supplier: searchParams.get('supplier') ?? '',
|
supplier: searchParams.get('supplier') ?? '',
|
||||||
|
empty: searchParams.get('empty') ?? '',
|
||||||
|
hierarchy: searchParams.get('hierarchy') ?? '',
|
||||||
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
|
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -13,12 +15,16 @@ export function queryFromParams(searchParams) {
|
|||||||
export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
|
export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
|
||||||
let brand = '';
|
let brand = '';
|
||||||
let supplier = '';
|
let supplier = '';
|
||||||
|
let empty = '';
|
||||||
|
let hierarchy = '';
|
||||||
if (typeof options === 'string') {
|
if (typeof options === 'string') {
|
||||||
brand = options;
|
brand = options;
|
||||||
supplier = arguments[4] || '';
|
supplier = arguments[4] || '';
|
||||||
} else if (options && typeof options === 'object') {
|
} else if (options && typeof options === 'object') {
|
||||||
brand = options.brand || '';
|
brand = options.brand || '';
|
||||||
supplier = options.supplier || '';
|
supplier = options.supplier || '';
|
||||||
|
empty = options.empty || '';
|
||||||
|
hierarchy = options.hierarchy || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -26,6 +32,8 @@ export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
|
|||||||
if (filter) params.set('filter', filter);
|
if (filter) params.set('filter', filter);
|
||||||
if (brand) params.set('brand', brand);
|
if (brand) params.set('brand', brand);
|
||||||
if (supplier) params.set('supplier', supplier);
|
if (supplier) params.set('supplier', supplier);
|
||||||
|
if (empty) params.set('empty', empty);
|
||||||
|
if (hierarchy) params.set('hierarchy', hierarchy);
|
||||||
if (offset > 0) params.set('offset', String(offset));
|
if (offset > 0) params.set('offset', String(offset));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return qs ? `/artikel?${qs}` : '/artikel';
|
return qs ? `/artikel?${qs}` : '/artikel';
|
||||||
@@ -46,6 +54,8 @@ export function writeArtikelQuery({
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
brand = '',
|
brand = '',
|
||||||
supplier = '',
|
supplier = '',
|
||||||
|
empty = '',
|
||||||
|
hierarchy = '',
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
if (!target || target.trim().length < 3) {
|
if (!target || target.trim().length < 3) {
|
||||||
@@ -54,7 +64,7 @@ export function writeArtikelQuery({
|
|||||||
}
|
}
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
ARTIKEL_QUERY_KEY,
|
ARTIKEL_QUERY_KEY,
|
||||||
JSON.stringify({ target, filter, offset, brand, supplier }),
|
JSON.stringify({ target, filter, offset, brand, supplier, empty, hierarchy }),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore quota / private mode
|
// ignore quota / private mode
|
||||||
@@ -76,5 +86,7 @@ export function artikelQueryPath() {
|
|||||||
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
|
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
|
||||||
brand: query.brand ?? '',
|
brand: query.brand ?? '',
|
||||||
supplier: query.supplier ?? '',
|
supplier: query.supplier ?? '',
|
||||||
|
empty: query.empty ?? '',
|
||||||
|
hierarchy: query.hierarchy ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
alpha,
|
alpha,
|
||||||
} from '../../../colors';
|
} from '../../../colors';
|
||||||
|
|
||||||
function getVariantDetails(child, allArticles, defaultTaxRate) {
|
function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
|
||||||
const cached = child?.sku ? getArticle(child.sku) : null;
|
const cached = child?.sku ? getArticle(child.sku) : null;
|
||||||
const fromIndex = !cached && child?.sku
|
const fromIndex = !cached && child?.sku
|
||||||
? (allArticles || []).find((a) => a.sku === child.sku)
|
? (allArticles || []).find((a) => a.sku === child.sku)
|
||||||
@@ -56,14 +56,30 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
|||||||
const gross = getArticleGross(merged, defaultTaxRate);
|
const gross = getArticleGross(merged, defaultTaxRate);
|
||||||
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
|
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
|
||||||
|
|
||||||
// 2. Stock & Unit
|
// 2. Stock tracking
|
||||||
|
const rawTrack =
|
||||||
|
merged.track_stock ??
|
||||||
|
merged.trackStock ??
|
||||||
|
child?.trackStock ??
|
||||||
|
child?.track_stock ??
|
||||||
|
parentArticle?.track_stock ??
|
||||||
|
parentArticle?.trackStock;
|
||||||
|
|
||||||
|
const trackStock =
|
||||||
|
rawTrack !== undefined
|
||||||
|
? rawTrack !== false && rawTrack !== 0 && rawTrack !== '0'
|
||||||
|
: true;
|
||||||
|
|
||||||
|
// 3. Stock & Unit
|
||||||
let stockVal = null;
|
let stockVal = null;
|
||||||
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
|
if (trackStock) {
|
||||||
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
|
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
|
||||||
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
|
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
|
||||||
stockVal = Number(merged.stock);
|
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
|
||||||
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
|
stockVal = Number(merged.stock);
|
||||||
stockVal = Number(child.stock);
|
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
|
||||||
|
stockVal = Number(child.stock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUnit =
|
const rawUnit =
|
||||||
@@ -76,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
|||||||
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
|
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
|
||||||
? ` ${rawUnit}`
|
? ` ${rawUnit}`
|
||||||
: '';
|
: '';
|
||||||
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
|
const stockDisplay = trackStock && stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
|
||||||
|
|
||||||
// 3. EAN(s)
|
// 4. EAN(s)
|
||||||
const eanCandidates = [];
|
const eanCandidates = [];
|
||||||
const rawEans = merged.eans ?? child?.eans;
|
const rawEans = merged.eans ?? child?.eans;
|
||||||
if (Array.isArray(rawEans)) {
|
if (Array.isArray(rawEans)) {
|
||||||
@@ -103,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
|
|||||||
stockDisplay,
|
stockDisplay,
|
||||||
primaryEan,
|
primaryEan,
|
||||||
allEans: eanCandidates,
|
allEans: eanCandidates,
|
||||||
|
trackStock,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
attributes: initialAttributes,
|
attributes: initialAttributes,
|
||||||
|
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
|
||||||
assignedAttributes: [],
|
assignedAttributes: [],
|
||||||
allArticles: [],
|
allArticles: [],
|
||||||
busy: false,
|
busy: false,
|
||||||
@@ -170,10 +188,11 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
return {
|
return {
|
||||||
company: state.current,
|
company: state.current,
|
||||||
attributes: state.attributes,
|
attributes: state.attributes,
|
||||||
|
attributesStatus: state.attributesStatus,
|
||||||
selectedAttributeId: nextSelectedId,
|
selectedAttributeId: nextSelectedId,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, ['current', 'attributes']);
|
}, ['current', 'attributes', 'attributesStatus']);
|
||||||
|
|
||||||
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
this.unsubArtikel = artikelAutomat.subscribe(() => {
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -197,7 +216,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
if (!this._cancelled && parent?.children) {
|
if (!this._cancelled && parent?.children) {
|
||||||
this.ensureChildren(parent.children);
|
this.ensureChildren(parent.children);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
void this.loadAllArticles();
|
void this.loadAllArticles();
|
||||||
@@ -218,7 +237,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
if (!this._cancelled && parent?.children) {
|
if (!this._cancelled && parent?.children) {
|
||||||
this.ensureChildren(parent.children);
|
this.ensureChildren(parent.children);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
} else {
|
} else {
|
||||||
const parent = getArticle(this.props.article.parentSku);
|
const parent = getArticle(this.props.article.parentSku);
|
||||||
if (parent?.children) {
|
if (parent?.children) {
|
||||||
@@ -266,7 +285,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
if (!Array.isArray(children)) return;
|
if (!Array.isArray(children)) return;
|
||||||
for (const child of children) {
|
for (const child of children) {
|
||||||
if (child?.sku && !getArticle(child.sku)) {
|
if (child?.sku && !getArticle(child.sku)) {
|
||||||
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
|
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
|
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
|
||||||
if (busy || !article?.sku) return;
|
if (busy || !article?.sku) return;
|
||||||
|
|
||||||
|
const val = newChildText.trim();
|
||||||
const payload = {};
|
const payload = {};
|
||||||
if (isCustomChildValue) {
|
|
||||||
const val = newChildText.trim();
|
if (val && (isCustomChildValue || !selectedChildValueId)) {
|
||||||
if (!val) return;
|
payload.value = val;
|
||||||
|
} else if (selectedChildValueId) {
|
||||||
|
payload.attributeValueId = Number(selectedChildValueId);
|
||||||
|
} else if (val) {
|
||||||
payload.value = val;
|
payload.value = val;
|
||||||
} else {
|
} else {
|
||||||
if (!selectedChildValueId) return;
|
return;
|
||||||
payload.attributeValueId = Number(selectedChildValueId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
const api = this.getApi();
|
const api = this.getApi();
|
||||||
await api.addChild(article.sku, payload);
|
const res = await api.addChild(article.sku, payload);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
|
if (res?.sku) {
|
||||||
|
void artikelAutomat.actions.loadArticle(res.sku).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
await artikelAutomat.actions.reloadArticle(article.sku);
|
await artikelAutomat.actions.reloadArticle(article.sku);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
|
|
||||||
|
void articleIndexAutomat.actions.reload().catch(() => { });
|
||||||
|
void companyAutomat.actions.loadAttributes?.().catch?.(() => { });
|
||||||
|
|
||||||
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
|
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
|
||||||
this.setState({
|
this.setState({
|
||||||
busy: false,
|
busy: false,
|
||||||
selectedChildValueId: '',
|
selectedChildValueId: '',
|
||||||
newChildText: '',
|
newChildText: '',
|
||||||
|
isCustomChildValue: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -489,17 +519,17 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
const nextItems =
|
const nextItems =
|
||||||
existingIndex >= 0
|
existingIndex >= 0
|
||||||
? currentItems.map((item, idx) =>
|
? currentItems.map((item, idx) =>
|
||||||
idx === existingIndex ? { ...item, count: item.count + count } : item
|
idx === existingIndex ? { ...item, count: item.count + count } : item
|
||||||
)
|
)
|
||||||
: [
|
: [
|
||||||
...currentItems,
|
...currentItems,
|
||||||
{
|
{
|
||||||
articleId: stuecklisteTargetArticle.id,
|
articleId: stuecklisteTargetArticle.id,
|
||||||
sku: stuecklisteTargetArticle.sku,
|
sku: stuecklisteTargetArticle.sku,
|
||||||
count,
|
count,
|
||||||
sort: currentItems.length,
|
sort: currentItems.length,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
@@ -613,20 +643,20 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
const nextOptions =
|
const nextOptions =
|
||||||
existingIndex >= 0
|
existingIndex >= 0
|
||||||
? currentOptions.map((opt, idx) =>
|
? currentOptions.map((opt, idx) =>
|
||||||
idx === existingIndex
|
idx === existingIndex
|
||||||
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
|
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
|
||||||
: opt
|
: opt
|
||||||
)
|
)
|
||||||
: [
|
: [
|
||||||
...currentOptions,
|
...currentOptions,
|
||||||
{
|
{
|
||||||
articleId: optionTargetArticle.id,
|
articleId: optionTargetArticle.id,
|
||||||
sku: optionTargetArticle.sku,
|
sku: optionTargetArticle.sku,
|
||||||
count,
|
count,
|
||||||
sort: currentOptions.length,
|
sort: currentOptions.length,
|
||||||
mandated: Boolean(optionMandated),
|
mandated: Boolean(optionMandated),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
this.setState({ busy: true });
|
this.setState({ busy: true });
|
||||||
try {
|
try {
|
||||||
@@ -832,70 +862,70 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stuecklisteItems.map((item) => (
|
{stuecklisteItems.map((item) => (
|
||||||
<Box
|
<Box
|
||||||
key={item.id || item.sku}
|
key={item.id || item.sku}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
px: 1,
|
||||||
|
py: 0.4,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Chip
|
||||||
|
label={`${item.count}x`}
|
||||||
|
size="small"
|
||||||
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name || item.sku}
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={artikelDetailPath(item.sku)}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: 'text.secondary',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.sku}
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
p: 0.25,
|
||||||
alignItems: 'center',
|
color: 'text.secondary',
|
||||||
justifyContent: 'space-between',
|
'&:hover': { color: 'error.main' },
|
||||||
px: 1,
|
|
||||||
py: 0.4,
|
|
||||||
borderRadius: 1,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
<Chip
|
</IconButton>
|
||||||
label={`${item.count}x`}
|
</Box>
|
||||||
size="small"
|
))
|
||||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
|
||||||
/>
|
|
||||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: '0.82rem',
|
|
||||||
lineHeight: 1.2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.name || item.sku}
|
|
||||||
</Typography>
|
|
||||||
<Link
|
|
||||||
component={RouterLink}
|
|
||||||
to={artikelDetailPath(item.sku)}
|
|
||||||
sx={{
|
|
||||||
fontSize: '0.72rem',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
color: 'text.secondary',
|
|
||||||
textDecoration: 'none',
|
|
||||||
'&:hover': { textDecoration: 'underline' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.sku}
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
|
|
||||||
sx={{
|
|
||||||
p: 0.25,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': { color: 'error.main' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1042,81 +1072,81 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{optionsItems.map((opt) => (
|
{optionsItems.map((opt) => (
|
||||||
<Box
|
<Box
|
||||||
key={opt.id || opt.sku}
|
key={opt.id || opt.sku}
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
px: 1,
|
px: 1,
|
||||||
py: 0.4,
|
py: 0.4,
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={`${opt.count}x`}
|
label={`${opt.count}x`}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.82rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.name || opt.sku}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={artikelDetailPath(opt.sku)}
|
||||||
sx={{
|
sx={{
|
||||||
fontWeight: 600,
|
fontSize: '0.72rem',
|
||||||
fontSize: '0.82rem',
|
fontFamily: 'monospace',
|
||||||
lineHeight: 1.2,
|
color: 'text.secondary',
|
||||||
overflow: 'hidden',
|
textDecoration: 'none',
|
||||||
textOverflow: 'ellipsis',
|
'&:hover': { textDecoration: 'underline' },
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.name || opt.sku}
|
{opt.sku}
|
||||||
</Typography>
|
</Link>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
{opt.mandated ? (
|
||||||
<Link
|
<Chip
|
||||||
component={RouterLink}
|
label="Fest"
|
||||||
to={artikelDetailPath(opt.sku)}
|
size="small"
|
||||||
sx={{
|
color="warning"
|
||||||
fontSize: '0.72rem',
|
variant="outlined"
|
||||||
fontFamily: 'monospace',
|
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
|
||||||
color: 'text.secondary',
|
/>
|
||||||
textDecoration: 'none',
|
) : null}
|
||||||
'&:hover': { textDecoration: 'underline' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.sku}
|
|
||||||
</Link>
|
|
||||||
{opt.mandated ? (
|
|
||||||
<Chip
|
|
||||||
label="Fest"
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => this.handleRemoveOption(opt.sku)}
|
|
||||||
sx={{
|
|
||||||
p: 0.25,
|
|
||||||
color: 'text.secondary',
|
|
||||||
'&:hover': { color: 'error.main' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
</Box>
|
||||||
))
|
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => this.handleRemoveOption(opt.sku)}
|
||||||
|
sx={{
|
||||||
|
p: 0.25,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': { color: 'error.main' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
}
|
}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1209,14 +1239,14 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
const siblings = hasSelf
|
const siblings = hasSelf
|
||||||
? childrenList
|
? childrenList
|
||||||
: [
|
: [
|
||||||
...childrenList,
|
...childrenList,
|
||||||
{
|
{
|
||||||
id: articleData.id,
|
id: articleData.id,
|
||||||
sku: articleData.sku,
|
sku: articleData.sku,
|
||||||
name: articleData.name,
|
name: articleData.name,
|
||||||
attributeValue: variationValue,
|
attributeValue: variationValue,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
|
||||||
@@ -1346,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
stockDisplay,
|
stockDisplay,
|
||||||
primaryEan,
|
primaryEan,
|
||||||
allEans,
|
allEans,
|
||||||
} = getVariantDetails(sibling, allArticles, defaultTaxRate);
|
trackStock,
|
||||||
|
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -1463,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.74rem',
|
fontSize: '0.74rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1582,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.74rem',
|
fontSize: '0.74rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1602,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
renderStandardView(selectableAttributes, assignedAttributeIds) {
|
||||||
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
|
const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{activeCreatorMode === 'parent' ? (
|
{activeCreatorMode === 'parent' ? (
|
||||||
@@ -1624,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{selectableAttributes.length === 0 ? (
|
{attributesStatus === 'loading' ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<CircularProgress size={14} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Merkmale werden geladen…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : !attributes || attributes.length === 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, py: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Im System sind noch keine Merkmale angelegt.
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to="/artikel/merkmale"
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'primary.main',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zu den Merkmalen →
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
) : selectableAttributes.length === 0 ? (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
|
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -1877,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
|
||||||
Varianten:
|
Varianten:
|
||||||
</Typography>
|
</Typography>
|
||||||
{children.length === 0 ? (
|
{children.length === 0 ? null : (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
|
|
||||||
Noch keine Varianten angelegt.
|
|
||||||
</Typography>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
{children.map((child) => {
|
{children.map((child) => {
|
||||||
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
|
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
|
||||||
@@ -1891,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
stockDisplay,
|
stockDisplay,
|
||||||
primaryEan,
|
primaryEan,
|
||||||
allEans,
|
allEans,
|
||||||
} = getVariantDetails(child, allArticles, defaultTaxRate);
|
trackStock,
|
||||||
|
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -2033,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
sx={{
|
sx={{
|
||||||
fontSize: '0.74rem',
|
fontSize: '0.74rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2046,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
size="small"
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => this.handleOpenRemoveDialog(child)}
|
onClick={() => this.handleOpenRemoveDialog(child)}
|
||||||
title="Variante entfernen"
|
|
||||||
sx={{
|
sx={{
|
||||||
p: 0.35,
|
p: 0.35,
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
@@ -2067,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
{isCustomChildValue || availableParentValues.length === 0 ? (
|
{isCustomChildValue || availableParentValues.length === 0 ? (
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Neuer Wert"
|
label="Neuer Wert"
|
||||||
placeholder="Neuer Wert..."
|
|
||||||
value={newChildText}
|
value={newChildText}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => this.setState({ newChildText: e.target.value })}
|
onChange={(e) => this.setState({ newChildText: e.target.value })}
|
||||||
@@ -2112,34 +2167,31 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
</TextInput>
|
</TextInput>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip title="Variante hinzufügen">
|
<IconButton
|
||||||
<span>
|
size="small"
|
||||||
<IconButton
|
color="primary"
|
||||||
size="small"
|
aria-label="Variante hinzufügen"
|
||||||
color="primary"
|
disabled={
|
||||||
disabled={
|
busy ||
|
||||||
busy ||
|
(isCustomChildValue || availableParentValues.length === 0
|
||||||
(isCustomChildValue || availableParentValues.length === 0
|
? !newChildText.trim()
|
||||||
? !newChildText.trim()
|
: !selectedChildValueId)
|
||||||
: !selectedChildValueId)
|
}
|
||||||
}
|
onClick={this.handleAddChild}
|
||||||
onClick={this.handleAddChild}
|
sx={{
|
||||||
sx={{
|
bgcolor: 'primary.main',
|
||||||
bgcolor: 'primary.main',
|
color: 'primary.contrastText',
|
||||||
color: 'primary.contrastText',
|
'&:hover': { bgcolor: 'primary.dark' },
|
||||||
'&:hover': { bgcolor: 'primary.dark' },
|
'&.Mui-disabled': {
|
||||||
'&.Mui-disabled': {
|
bgcolor: 'action.disabledBackground',
|
||||||
bgcolor: 'action.disabledBackground',
|
color: 'action.disabled',
|
||||||
color: 'action.disabled',
|
},
|
||||||
},
|
p: 0.6,
|
||||||
p: 0.6,
|
flexShrink: 0,
|
||||||
flexShrink: 0,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
||||||
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Toggle custom value / predefined value if values exist */}
|
{/* Toggle custom value / predefined value if values exist */}
|
||||||
@@ -2174,16 +2226,16 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
{isStueckliste ||
|
{isStueckliste ||
|
||||||
hasOptions ||
|
hasOptions ||
|
||||||
activeCreatorMode === 'stueckliste' ||
|
activeCreatorMode === 'stueckliste' ||
|
||||||
activeCreatorMode === 'options'
|
activeCreatorMode === 'options'
|
||||||
? this.renderCompoundManager(
|
? this.renderCompoundManager(
|
||||||
isStueckliste,
|
isStueckliste,
|
||||||
hasOptions,
|
hasOptions,
|
||||||
stuecklisteItems,
|
stuecklisteItems,
|
||||||
optionsItems,
|
optionsItems,
|
||||||
selectableOtherArticles
|
selectableOtherArticles
|
||||||
)
|
)
|
||||||
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
|
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -2262,11 +2314,11 @@ export default class ArticleViewLists extends PureComponent {
|
|||||||
<DialogContentText sx={{ fontSize: '0.88rem' }}>
|
<DialogContentText sx={{ fontSize: '0.88rem' }}>
|
||||||
{confirmClearType === 'stueckliste'
|
{confirmClearType === 'stueckliste'
|
||||||
? (hasOptions
|
? (hasOptions
|
||||||
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
|
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
|
||||||
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
|
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
|
||||||
: (isStueckliste
|
: (isStueckliste
|
||||||
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
|
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
|
||||||
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
|
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 2, pb: 2 }}>
|
<DialogActions sx={{ px: 2, pb: 2 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, PureComponent } from 'react';
|
import { Component, PureComponent } from 'react';
|
||||||
import { EditablePanelTile } from 'dashgui/panels';
|
import { EditablePanelTile } from 'dashgui/panels';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { getCategoryState } from '../../../automats/categoryAutomat';
|
import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat';
|
||||||
import artikelAutomat from '../../../automats/artikelAutomat';
|
import artikelAutomat from '../../../automats/artikelAutomat';
|
||||||
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
|
||||||
import { Box, Chip, IconButton, Typography } from '@mui/material';
|
import { Box, Chip, IconButton, Typography } from '@mui/material';
|
||||||
@@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
|
|||||||
.filter((c) => c.label);
|
.filter((c) => c.label);
|
||||||
}
|
}
|
||||||
|
|
||||||
class CategoryPills extends Component {
|
class CategoryPills extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { pickerOpen: false, targetPillId: null };
|
const catState = getCategoryState();
|
||||||
|
this.state = {
|
||||||
|
pickerOpen: false,
|
||||||
|
targetPillId: null,
|
||||||
|
categories: catState?.categories ?? categoryAutomat.state.categories,
|
||||||
|
rootCategoryId: catState?.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._unsubCat = categoryAutomat.subscribe((s) => {
|
||||||
|
this.setState({
|
||||||
|
categories: s.categories,
|
||||||
|
rootCategoryId: s.rootCategoryId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this._unsubCat?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRemoveCategory = (removeId) => {
|
handleRemoveCategory = (removeId) => {
|
||||||
@@ -135,8 +154,7 @@ class CategoryPills extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { categoryIds, editMode } = this.props;
|
const { categoryIds, editMode } = this.props;
|
||||||
const { pickerOpen, targetPillId } = this.state;
|
const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state;
|
||||||
const { categories, rootCategoryId } = getCategoryState() ?? {};
|
|
||||||
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
|
||||||
|
|
||||||
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import Link from '@mui/material/Link';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
@@ -562,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{merkmalOptions.length === 0 && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Im System sind noch keine Merkmale angelegt.
|
||||||
|
</Typography>
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to="/artikel/merkmale"
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
color: 'primary.main',
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zu den Merkmalen →
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Unified Assigned Merkmale List */}
|
{/* Unified Assigned Merkmale List */}
|
||||||
{assignedGroups.length > 0 && (
|
{assignedGroups.length > 0 && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
|||||||
@@ -9,20 +9,70 @@ import Grid from '@mui/material/Grid';
|
|||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import PrintIcon from '@mui/icons-material/Print';
|
import PrintIcon from '@mui/icons-material/Print';
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
import { labeltemplate, printer, article } from '../../../api';
|
import { labeltemplate, article } from '../../../api';
|
||||||
|
import { companyAutomat } from '../../../automats';
|
||||||
import { notifyError } from '../../../utils/notify';
|
import { notifyError } from '../../../utils/notify';
|
||||||
import BackendError from '../../BackendError';
|
import BackendError from '../../BackendError';
|
||||||
|
|
||||||
|
function filterZplPrinters(agentprinters) {
|
||||||
|
return (Array.isArray(agentprinters) ? agentprinters : []).filter(
|
||||||
|
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrinterLabel(p) {
|
||||||
|
let label;
|
||||||
|
if (p.model && p.serial) {
|
||||||
|
label = `${p.model} (${p.serial})`;
|
||||||
|
} else if (p.model) {
|
||||||
|
label = p.model;
|
||||||
|
} else if (p.serial) {
|
||||||
|
label = `Drucker (${p.serial})`;
|
||||||
|
} else if (p.desc && p.address) {
|
||||||
|
label = `${p.desc} (${p.address})`;
|
||||||
|
} else if (p.address) {
|
||||||
|
label = p.address;
|
||||||
|
} else if (p.desc) {
|
||||||
|
label = p.desc;
|
||||||
|
} else {
|
||||||
|
label = `Drucker #${p.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.online === false) {
|
||||||
|
return `${label} (offline)`;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrinterLabelLength(printer, livePrinters = []) {
|
||||||
|
if (!printer) return null;
|
||||||
|
const live = (livePrinters || []).find(
|
||||||
|
(lp) =>
|
||||||
|
(lp.agentprinterId && lp.agentprinterId === printer.id) ||
|
||||||
|
(lp.id && lp.id === printer.id) ||
|
||||||
|
(printer.serial && lp.serial && String(printer.serial).toLowerCase() === String(lp.serial).toLowerCase()),
|
||||||
|
);
|
||||||
|
const raw = printer.labelLength ?? live?.labelLength;
|
||||||
|
if (raw == null) return null;
|
||||||
|
const num = Number(raw);
|
||||||
|
return Number.isFinite(num) && num > 0 ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default class ArticleLabelTab extends PureComponent {
|
export default class ArticleLabelTab extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
|
||||||
this.state = {
|
this.state = {
|
||||||
templates: null,
|
templates: null,
|
||||||
printers: null,
|
printers: initialPrinters,
|
||||||
|
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||||
|
? companyAutomat.state.livePrinters
|
||||||
|
: [],
|
||||||
selectedTemplateId: '',
|
selectedTemplateId: '',
|
||||||
selectedPrinterId: '',
|
selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
loading: true,
|
loading: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -30,27 +80,61 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
this.unsub = companyAutomat.subscribe(this, (state) => {
|
||||||
|
const zplPrinters = filterZplPrinters(state.agentprinters);
|
||||||
|
this.setState((prev) => ({
|
||||||
|
printers: zplPrinters,
|
||||||
|
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
|
||||||
|
selectedPrinterId:
|
||||||
|
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
|
||||||
|
? prev.selectedPrinterId
|
||||||
|
: (zplPrinters[0]?.id ?? ''),
|
||||||
|
}));
|
||||||
|
});
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (
|
||||||
|
prevProps.article?.sku !== this.props.article?.sku ||
|
||||||
|
prevProps.article?.name !== this.props.article?.name ||
|
||||||
|
prevProps.article?.prices !== this.props.article?.prices ||
|
||||||
|
prevProps.article?.eans !== this.props.article?.eans
|
||||||
|
) {
|
||||||
|
this.loadData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
this.setState({ loading: true, error: null });
|
this.setState({ loading: true, error: null });
|
||||||
|
const sku = this.props.article?.sku;
|
||||||
try {
|
try {
|
||||||
const [resTemplates, resPrinters] = await Promise.all([
|
const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
|
||||||
labeltemplate.list(),
|
|
||||||
printer.list(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const templates = resTemplates.labeltemplates || [];
|
const templates = resTemplates.labeltemplates || [];
|
||||||
const zplPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl');
|
const zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
|
||||||
|
|
||||||
this.setState({
|
this.setState((prev) => ({
|
||||||
templates,
|
templates,
|
||||||
printers: zplPrinters,
|
printers: zplPrinters,
|
||||||
selectedTemplateId: templates.length > 0 ? templates[0].id : '',
|
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
|
||||||
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '',
|
? companyAutomat.state.livePrinters
|
||||||
|
: prev.livePrinters,
|
||||||
|
selectedTemplateId:
|
||||||
|
templates.length > 0
|
||||||
|
? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId)
|
||||||
|
? prev.selectedTemplateId
|
||||||
|
: templates[0].id
|
||||||
|
: '',
|
||||||
|
selectedPrinterId:
|
||||||
|
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
|
||||||
|
? prev.selectedPrinterId
|
||||||
|
: (zplPrinters[0]?.id ?? ''),
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.setState({ error, loading: false });
|
this.setState({ error, loading: false });
|
||||||
}
|
}
|
||||||
@@ -68,7 +152,7 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
try {
|
try {
|
||||||
await article.printLabel(articleData.sku, {
|
await article.printLabel(articleData.sku, {
|
||||||
templateId: selectedTemplateId,
|
templateId: selectedTemplateId,
|
||||||
printerId: selectedPrinterId,
|
agentprinterId: selectedPrinterId,
|
||||||
quantity,
|
quantity,
|
||||||
});
|
});
|
||||||
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
|
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
|
||||||
@@ -77,10 +161,24 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleCancelAllJobs = async () => {
|
||||||
|
const { selectedPrinterId, printers } = this.state;
|
||||||
|
const p = (printers || []).find((item) => item.id === selectedPrinterId);
|
||||||
|
const printerName = p ? getPrinterLabel(p) : 'Drucker';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await companyAutomat.actions.cancelAllPrinterJobs(selectedPrinterId || undefined);
|
||||||
|
enqueueSnackbar(`Druckaufträge für ${printerName} abgebrochen.`, { variant: 'info' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
templates,
|
templates,
|
||||||
printers,
|
printers,
|
||||||
|
livePrinters,
|
||||||
selectedTemplateId,
|
selectedTemplateId,
|
||||||
selectedPrinterId,
|
selectedPrinterId,
|
||||||
quantity,
|
quantity,
|
||||||
@@ -88,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
error,
|
error,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
|
||||||
|
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
|
||||||
@@ -130,8 +231,18 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{templates.map((t) => {
|
{templates.map((t) => {
|
||||||
const isSelected = t.id === selectedTemplateId;
|
const isSelected = t.id === selectedTemplateId;
|
||||||
|
const dpi = t.dpi === 300 ? 300 : 203;
|
||||||
|
const tmplHeight = t.height || 200;
|
||||||
|
const tmplWidth = t.width || 448;
|
||||||
|
const hasPrinterLength = printerLabelLength != null && printerLabelLength > 0;
|
||||||
|
const totalHeight = hasPrinterLength ? Math.max(tmplHeight, printerLabelLength) : tmplHeight;
|
||||||
|
const isCutoff = hasPrinterLength && printerLabelLength < tmplHeight;
|
||||||
|
const outlineColor = isCutoff ? '#d32f2f' : '#1976d2';
|
||||||
|
const tmplHeightPercent = (tmplHeight / totalHeight) * 100;
|
||||||
|
const printerHeightPercent = hasPrinterLength ? (printerLabelLength / totalHeight) * 100 : 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
|
||||||
<Paper
|
<Paper
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => this.setState({ selectedTemplateId: t.id })}
|
onClick={() => this.setState({ selectedTemplateId: t.id })}
|
||||||
@@ -175,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
bgcolor: 'background.default',
|
bgcolor: 'background.default',
|
||||||
borderRadius: 1,
|
borderRadius: 1,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t.thumb ? (
|
{t.thumb ? (
|
||||||
<img
|
<Box
|
||||||
src={`data:image/png;base64,${t.thumb}`}
|
sx={{
|
||||||
alt={t.name}
|
position: 'relative',
|
||||||
style={{
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
objectFit: 'contain',
|
aspectRatio: `${tmplWidth} / ${totalHeight}`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderRadius: 0.5,
|
||||||
|
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{/* Template preview image */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: '100%',
|
||||||
|
height: `${tmplHeightPercent}%`,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`data:image/png;base64,${t.thumb}`}
|
||||||
|
alt={t.name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* If template exceeds printer label length, shade the cutoff overflow */}
|
||||||
|
{isCutoff && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: `${(printerLabelLength / tmplHeight) * 100}%`,
|
||||||
|
bottom: 0,
|
||||||
|
bgcolor: 'rgba(211, 47, 47, 0.22)',
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Dotted outline for printer label length */}
|
||||||
|
{hasPrinterLength && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: `${printerHeightPercent}%`,
|
||||||
|
border: `2px dotted ${outlineColor}`,
|
||||||
|
borderRadius: 0.5,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Typography variant="caption" color="text.disabled">
|
<Typography variant="caption" color="text.disabled">
|
||||||
Keine Vorschau
|
Keine Vorschau
|
||||||
@@ -198,7 +372,7 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
{t.name}
|
{t.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{t.width}x{t.height} ({t.dpi} dpi)
|
{t.width}x{t.height} ({dpi} dpi)
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -217,11 +391,14 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
|
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
|
||||||
sx={{ minWidth: 200 }}
|
sx={{ minWidth: 200 }}
|
||||||
>
|
>
|
||||||
{printers.map((p) => (
|
{printers.map((p) => {
|
||||||
<MenuItem key={p.id} value={p.id}>
|
const len = getPrinterLabelLength(p, livePrinters);
|
||||||
{p.desc ? `${p.desc} (${p.address})` : p.address}
|
return (
|
||||||
</MenuItem>
|
<MenuItem key={p.id} value={p.id}>
|
||||||
))}
|
{getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TextInput>
|
</TextInput>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -246,6 +423,18 @@ export default class ArticleLabelTab extends PureComponent {
|
|||||||
>
|
>
|
||||||
Etiketten drucken
|
Etiketten drucken
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="large"
|
||||||
|
startIcon={<CancelScheduleSendOutlinedIcon />}
|
||||||
|
onClick={this.handleCancelAllJobs}
|
||||||
|
disabled={!selectedPrinterId}
|
||||||
|
sx={{ height: 40 }}
|
||||||
|
>
|
||||||
|
Druckaufträge abbrechen
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -416,14 +416,12 @@ export default class CategoryTree extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
|
this._unsub = categoryAutomat.subscribe((s) => {
|
||||||
this._unsub = categoryAutomat.subscribe((s) => {
|
this.setState({
|
||||||
this.setState({
|
categories: s.categories,
|
||||||
categories: this.props.categories ?? s.categories,
|
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
|
||||||
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
@@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent {
|
|||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
|
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
|
||||||
this.setState({ addDialogOpen: false });
|
const nextCategories = categoryAutomat.state.categories ?? {
|
||||||
|
...(this.state.categories || {}),
|
||||||
|
[String(created.id)]: created,
|
||||||
|
};
|
||||||
|
this.setState({
|
||||||
|
addDialogOpen: false,
|
||||||
|
categories: nextCategories,
|
||||||
|
});
|
||||||
this.props.onSelect?.(created.id, created);
|
this.props.onSelect?.(created.id, created);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
@@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent {
|
|||||||
if (!name || !renameNode) return;
|
if (!name || !renameNode) return;
|
||||||
this.setState({ isSubmitting: true });
|
this.setState({ isSubmitting: true });
|
||||||
try {
|
try {
|
||||||
await categoryAutomat.actions.renameCategory({
|
const updated = await categoryAutomat.actions.renameCategory({
|
||||||
categoryId: renameNode.id,
|
categoryId: renameNode.id,
|
||||||
name,
|
name,
|
||||||
});
|
});
|
||||||
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
|
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
|
||||||
this.setState({ renameDialogOpen: false });
|
const nextCategories = categoryAutomat.state.categories ?? {
|
||||||
|
...(this.state.categories || {}),
|
||||||
|
[String(updated.id)]: updated,
|
||||||
|
};
|
||||||
|
this.setState({
|
||||||
|
renameDialogOpen: false,
|
||||||
|
categories: nextCategories,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent {
|
|||||||
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
|
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
|
||||||
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
|
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
|
||||||
}
|
}
|
||||||
this.setState({ deleteDialogOpen: false });
|
this.setState({
|
||||||
|
deleteDialogOpen: false,
|
||||||
|
categories: categoryAutomat.state.categories,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Fab from '@mui/material/Fab';
|
import Avatar from '@mui/material/Avatar';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
|
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
|
||||||
@@ -70,144 +69,161 @@ export default class ChatFab extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={(theme) => ({
|
sx={{
|
||||||
position: 'fixed',
|
px: { xs: 0.5, sm: 1, lg: 1.5 },
|
||||||
bottom: { xs: 20, sm: 28 },
|
py: { xs: 0.5, lg: 1 },
|
||||||
left: { xs: 20, lg: 164 },
|
alignSelf: { xs: 'center', lg: 'flex-start' },
|
||||||
zIndex: theme.zIndex.speedDial || 1050,
|
display: 'flex',
|
||||||
})}
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
{/* Animated pulsing halo when awaiting user answer */}
|
{/* Animated pulsing halo when awaiting user answer */}
|
||||||
{hasPendingAsk && !isOpen && (
|
{hasPendingAsk && !isOpen && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -8,
|
top: -4,
|
||||||
left: -8,
|
left: -4,
|
||||||
right: -8,
|
right: -4,
|
||||||
bottom: -8,
|
bottom: -4,
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: CHAT_BUSY,
|
|
||||||
opacity: 0.5,
|
|
||||||
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
||||||
'@keyframes fabAskPulse': {
|
|
||||||
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
|
|
||||||
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
|
|
||||||
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Animated pulsating halo when model is busy thinking/working */}
|
|
||||||
{!hasPendingAsk && isBusy && !isOpen && (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -6,
|
|
||||||
left: -6,
|
|
||||||
right: -6,
|
|
||||||
bottom: -6,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: 'warning.main',
|
|
||||||
opacity: 0.4,
|
|
||||||
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
|
||||||
'@keyframes fabPulse': {
|
|
||||||
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
|
|
||||||
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
|
|
||||||
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Circular spinner ring when model is busy */}
|
|
||||||
{!hasPendingAsk && isBusy && !isOpen && (
|
|
||||||
<CircularProgress
|
|
||||||
size={66}
|
|
||||||
thickness={2.5}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: -6,
|
|
||||||
left: -6,
|
|
||||||
color: CHAT_BUSY,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Badge
|
|
||||||
badgeContent={hasPendingAsk ? '?' : undefined}
|
|
||||||
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
|
|
||||||
variant={hasPendingAsk ? 'standard' : 'dot'}
|
|
||||||
invisible={!hasPendingAsk && !isBusy}
|
|
||||||
overlap="circular"
|
|
||||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
|
||||||
sx={{
|
|
||||||
'& .MuiBadge-badge': hasPendingAsk
|
|
||||||
? {
|
|
||||||
bgcolor: RED,
|
|
||||||
color: WHITE,
|
|
||||||
fontWeight: 900,
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
minWidth: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
boxShadow: `0 2px 10px ${RED_GLOW}`,
|
bgcolor: CHAT_BUSY,
|
||||||
animation: 'badgePop 1.5s ease infinite',
|
opacity: 0.5,
|
||||||
'@keyframes badgePop': {
|
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
'0%, 100%': { transform: 'scale(1)' },
|
'@keyframes fabAskPulse': {
|
||||||
'50%': { transform: 'scale(1.2)' },
|
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||||
|
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
|
||||||
|
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
|
||||||
},
|
},
|
||||||
}
|
}}
|
||||||
: undefined,
|
/>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<Fab
|
{/* Animated pulsating halo when model is busy thinking/working */}
|
||||||
color="primary"
|
{!hasPendingAsk && isBusy && !isOpen && (
|
||||||
aria-label={ariaLabel}
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -4,
|
||||||
|
left: -4,
|
||||||
|
right: -4,
|
||||||
|
bottom: -4,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'warning.main',
|
||||||
|
opacity: 0.4,
|
||||||
|
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'@keyframes fabPulse': {
|
||||||
|
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||||
|
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
|
||||||
|
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Circular spinner ring when model is busy */}
|
||||||
|
{!hasPendingAsk && isBusy && !isOpen && (
|
||||||
|
<CircularProgress
|
||||||
|
size={38}
|
||||||
|
thickness={3}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -3,
|
||||||
|
left: -3,
|
||||||
|
color: CHAT_BUSY,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconButton
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
sx={(theme) => ({
|
aria-label={ariaLabel}
|
||||||
width: 54,
|
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
|
||||||
height: 54,
|
|
||||||
boxShadow: hasPendingAsk
|
|
||||||
? CHAT_FAB_SHADOW_PENDING
|
|
||||||
: isBusy
|
|
||||||
? CHAT_FAB_SHADOW_BUSY
|
|
||||||
: CHAT_FAB_SHADOW_IDLE,
|
|
||||||
background: hasPendingAsk
|
|
||||||
? CHAT_FAB_PENDING
|
|
||||||
: isBusy
|
|
||||||
? CHAT_FAB_BUSY
|
|
||||||
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{isOpen ? (
|
<Badge
|
||||||
<CloseRoundedIcon sx={{ fontSize: 24 }} />
|
badgeContent={hasPendingAsk ? '?' : undefined}
|
||||||
) : hasPendingAsk ? (
|
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
|
||||||
<HelpOutlineRoundedIcon
|
variant={hasPendingAsk ? 'standard' : 'dot'}
|
||||||
sx={{
|
invisible={!hasPendingAsk && !isBusy}
|
||||||
fontSize: 26,
|
overlap="circular"
|
||||||
animation: 'fabWiggle 2s ease infinite',
|
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
'@keyframes fabWiggle': {
|
sx={{
|
||||||
'0%, 100%': { transform: 'rotate(0deg)' },
|
'& .MuiBadge-badge': hasPendingAsk
|
||||||
'15%': { transform: 'rotate(-12deg)' },
|
? {
|
||||||
'30%': { transform: 'rotate(12deg)' },
|
bgcolor: RED,
|
||||||
'45%': { transform: 'rotate(-8deg)' },
|
color: WHITE,
|
||||||
'60%': { transform: 'rotate(0deg)' },
|
fontWeight: 900,
|
||||||
},
|
fontSize: '0.75rem',
|
||||||
}}
|
minWidth: 18,
|
||||||
/>
|
height: 18,
|
||||||
) : isBusy ? (
|
borderRadius: '50%',
|
||||||
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
|
boxShadow: `0 2px 8px ${RED_GLOW}`,
|
||||||
) : (
|
animation: 'badgePop 1.5s ease infinite',
|
||||||
<ChatRoundedIcon sx={{ fontSize: 24 }} />
|
'@keyframes badgePop': {
|
||||||
)}
|
'0%, 100%': { transform: 'scale(1)' },
|
||||||
</Fab>
|
'50%': { transform: 'scale(1.2)' },
|
||||||
</Badge>
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sx={(theme) => ({
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
bgcolor: hasPendingAsk
|
||||||
|
? CHAT_FAB_PENDING
|
||||||
|
: isBusy
|
||||||
|
? CHAT_FAB_BUSY
|
||||||
|
: isOpen
|
||||||
|
? theme.palette.primary.main
|
||||||
|
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||||
|
background: hasPendingAsk
|
||||||
|
? CHAT_FAB_PENDING
|
||||||
|
: isBusy
|
||||||
|
? CHAT_FAB_BUSY
|
||||||
|
: isOpen
|
||||||
|
? theme.palette.primary.main
|
||||||
|
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||||
|
color: WHITE,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: hasPendingAsk
|
||||||
|
? CHAT_FAB_SHADOW_PENDING
|
||||||
|
: isBusy
|
||||||
|
? CHAT_FAB_SHADOW_BUSY
|
||||||
|
: isOpen
|
||||||
|
? `0 2px 8px rgba(0,0,0,0.25)`
|
||||||
|
: 'none',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
) : hasPendingAsk ? (
|
||||||
|
<HelpOutlineRoundedIcon
|
||||||
|
sx={{
|
||||||
|
fontSize: 18,
|
||||||
|
animation: 'fabWiggle 2s ease infinite',
|
||||||
|
'@keyframes fabWiggle': {
|
||||||
|
'0%, 100%': { transform: 'rotate(0deg)' },
|
||||||
|
'15%': { transform: 'rotate(-12deg)' },
|
||||||
|
'30%': { transform: 'rotate(12deg)' },
|
||||||
|
'45%': { transform: 'rotate(-8deg)' },
|
||||||
|
'60%': { transform: 'rotate(0deg)' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : isBusy ? (
|
||||||
|
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import EditNoteIcon from '@mui/icons-material/EditNote';
|
|||||||
import PaidRoundedIcon from '@mui/icons-material/PaidRounded';
|
import PaidRoundedIcon from '@mui/icons-material/PaidRounded';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { router } from '../../appRouter';
|
||||||
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
|
import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat';
|
||||||
import { setLastDeliveryPath } from '../../automats/navigationAutomat';
|
import { setLastDeliveryPath } from '../../automats/navigationAutomat';
|
||||||
import HealingMarkdown from './HealingMarkdown';
|
import HealingMarkdown from './HealingMarkdown';
|
||||||
@@ -578,7 +579,6 @@ class DraftDeliveryManifestUseButton extends PureComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { router } = await import('../../appRouter');
|
|
||||||
if (router?.navigate) {
|
if (router?.navigate) {
|
||||||
const curPath =
|
const curPath =
|
||||||
router.state?.location?.pathname ||
|
router.state?.location?.pathname ||
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -19,17 +19,18 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
assigningSku: null,
|
assigningSku: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
showAllArticles: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
if (!prevProps.open && this.props.open) {
|
if (!prevProps.open && this.props.open) {
|
||||||
this.setState({ assigningSku: null, error: null });
|
this.setState({ assigningSku: null, error: null, showAllArticles: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
this.setState({ assigningSku: null, error: null });
|
this.setState({ assigningSku: null, error: null, showAllArticles: false });
|
||||||
this.props.onClose?.();
|
this.props.onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,9 +62,9 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props;
|
const { open, onClose, line, kind = 'delivery', supplierArticles: propSupplierArticles } = this.props;
|
||||||
const { assigningSku, error } = this.state;
|
const { assigningSku, error, showAllArticles } = this.state;
|
||||||
const isDelivery = kind === 'delivery';
|
const isDelivery = kind === 'delivery';
|
||||||
const onlySupplierArticles = isDelivery && !line;
|
const onlySupplierArticles = isDelivery && !line && !showAllArticles;
|
||||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||||
|
|
||||||
const supplierArticlesList = onlySupplierArticles
|
const supplierArticlesList = onlySupplierArticles
|
||||||
@@ -122,7 +123,7 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" component="div" fontWeight={600}>
|
<Typography variant="h6" component="div" fontWeight={600}>
|
||||||
{line
|
{line
|
||||||
? 'Existierenden Artikel zuordnen'
|
? (isDelivery ? 'Artikel neu zuordnen' : 'Existierenden Artikel zuordnen')
|
||||||
: isDelivery
|
: isDelivery
|
||||||
? 'Lieferanten-Artikel auswählen'
|
? 'Lieferanten-Artikel auswählen'
|
||||||
: 'Artikel auswählen'}
|
: 'Artikel auswählen'}
|
||||||
@@ -134,7 +135,21 @@ export default class ArticleSearchDialog extends PureComponent {
|
|||||||
{line?.name ? ` · ${line.name}` : ''}
|
{line?.name ? ` · ${line.name}` : ''}
|
||||||
</>
|
</>
|
||||||
) : isDelivery ? (
|
) : isDelivery ? (
|
||||||
'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'
|
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<span>
|
||||||
|
{showAllArticles
|
||||||
|
? 'Alle Artikel im Katalog durchsuchen'
|
||||||
|
: 'Nur mit dem ausgewählten Lieferanten verknüpfte Artikel'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
onClick={() => this.setState({ showAllArticles: !showAllArticles })}
|
||||||
|
sx={{ fontSize: '0.72rem', py: 0, px: 0.75, textTransform: 'none', minWidth: 'auto', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{showAllArticles ? 'Nur Lieferanten-Artikel' : 'Alle Artikel durchsuchen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
'Artikel für Beleg auswählen'
|
'Artikel für Beleg auswählen'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -766,6 +766,30 @@ class ManifestFormLine extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{this.props.onSearchArticle && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||||
|
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
|
||||||
|
onClick={this.handleSearchArticle}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 'auto',
|
||||||
|
py: 0.25,
|
||||||
|
px: 1,
|
||||||
|
color: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Artikel neu zuordnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
{cachedArticle.brand?.name && (
|
{cachedArticle.brand?.name && (
|
||||||
<BrandLabel
|
<BrandLabel
|
||||||
@@ -1124,6 +1148,30 @@ class ManifestFormLine extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{this.props.onSearchArticle && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||||
|
disabled={this.props.saving || this.props.supplierArticleBusy || this.isFullyLedgered()}
|
||||||
|
onClick={this.handleSearchArticle}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 'auto',
|
||||||
|
py: 0.25,
|
||||||
|
px: 1,
|
||||||
|
color: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Artikel neu zuordnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
{(fullArticle.brand?.name || article.brand?.name) && (
|
{(fullArticle.brand?.name || article.brand?.name) && (
|
||||||
<BrandLabel
|
<BrandLabel
|
||||||
@@ -1307,6 +1355,34 @@ class ManifestFormLine extends PureComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.props.onSearchArticle) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, py: 0.25 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<SearchIcon sx={{ fontSize: 14 }} />}
|
||||||
|
disabled={this.isFullyLedgered()}
|
||||||
|
onClick={this.handleSearchArticle}
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.7rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
minWidth: 'auto',
|
||||||
|
py: 0.25,
|
||||||
|
px: 1,
|
||||||
|
color: 'primary.main',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: 'primary.50',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Artikel suchen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
219
src/components/settings/MembersSettingsPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -11,18 +11,37 @@ import TextField from '@mui/material/TextField';
|
|||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import FolderIcon from '@mui/icons-material/Folder';
|
import FolderIcon from '@mui/icons-material/Folder';
|
||||||
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import BackendError from '../BackendError';
|
import BackendError from '../BackendError';
|
||||||
import CategoryPickerDialog from '../article/CategoryPickerDialog';
|
import CategoryPickerDialog from '../article/CategoryPickerDialog';
|
||||||
|
import authAutomat from '../../automats/authAutomat';
|
||||||
import companyAutomat from '../../automats/companyAutomat';
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
|
import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat';
|
||||||
import { categoryDisplayName } from '../../utils/categoryUtils';
|
import { categoryDisplayName } from '../../utils/categoryUtils';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
function parseVhostSettings(str) {
|
||||||
|
if (!str || typeof str !== 'string') return {};
|
||||||
|
const result = {};
|
||||||
|
for (const part of str.split(';')) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const colonIdx = trimmed.indexOf(':');
|
||||||
|
if (colonIdx !== -1) {
|
||||||
|
const key = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
||||||
|
const val = trimmed.slice(colonIdx + 1).trim();
|
||||||
|
result[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(val) {
|
function formatDateTime(val) {
|
||||||
if (!val) return null;
|
if (!val) return null;
|
||||||
try {
|
try {
|
||||||
@@ -45,17 +64,20 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
super(props);
|
super(props);
|
||||||
const terminals = companyAutomat.state.terminals ?? [];
|
const terminals = companyAutomat.state.terminals ?? [];
|
||||||
this.state = {
|
this.state = {
|
||||||
|
isAdmin: companyAutomat.state.current?.role === 'admin',
|
||||||
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
|
status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading',
|
||||||
error: null,
|
error: null,
|
||||||
terminals,
|
terminals,
|
||||||
categories: categoryAutomat.state.categories,
|
categories: categoryAutomat.state.categories,
|
||||||
rootCategoryId: categoryAutomat.state.rootCategoryId,
|
rootCategoryId: categoryAutomat.state.rootCategoryId,
|
||||||
|
vhostSettings: authAutomat.state.vhostSettings,
|
||||||
newTerminalName: '',
|
newTerminalName: '',
|
||||||
creatingTerminal: false,
|
creatingTerminal: false,
|
||||||
enableAllForNew: true,
|
enableAllForNew: true,
|
||||||
deletingId: null,
|
deletingId: null,
|
||||||
pickerTerminal: null,
|
pickerTerminal: null,
|
||||||
patchingTerminalId: null,
|
patchingTerminalId: null,
|
||||||
|
sortDrafts: {},
|
||||||
};
|
};
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
this.loadTerminals = this.loadTerminals.bind(this);
|
this.loadTerminals = this.loadTerminals.bind(this);
|
||||||
@@ -63,11 +85,16 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
|
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
|
||||||
this.handleSetRootCategory = this.handleSetRootCategory.bind(this);
|
this.handleSetRootCategory = this.handleSetRootCategory.bind(this);
|
||||||
this.handleToggleSyncRootArticles = this.handleToggleSyncRootArticles.bind(this);
|
this.handleToggleSyncRootArticles = this.handleToggleSyncRootArticles.bind(this);
|
||||||
|
this.handleToggleEnabled = this.handleToggleEnabled.bind(this);
|
||||||
|
this.handleSortDraftChange = this.handleSortDraftChange.bind(this);
|
||||||
|
this.handleSortSubmit = this.handleSortSubmit.bind(this);
|
||||||
|
this.handleMoveTerminal = this.handleMoveTerminal.bind(this);
|
||||||
this.getCategoryLabel = this.getCategoryLabel.bind(this);
|
this.getCategoryLabel = this.getCategoryLabel.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
this.unsub = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
isAdmin: state.current?.role === 'admin',
|
||||||
terminals: Array.isArray(state.terminals) ? state.terminals : [],
|
terminals: Array.isArray(state.terminals) ? state.terminals : [],
|
||||||
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
|
status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status,
|
||||||
}));
|
}));
|
||||||
@@ -75,6 +102,9 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
categories: state.categories,
|
categories: state.categories,
|
||||||
rootCategoryId: state.rootCategoryId,
|
rootCategoryId: state.rootCategoryId,
|
||||||
}));
|
}));
|
||||||
|
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
|
||||||
|
vhostSettings: state.vhostSettings,
|
||||||
|
}));
|
||||||
if (categoryAutomat.state.treeStatus === 'idle') {
|
if (categoryAutomat.state.treeStatus === 'idle') {
|
||||||
void categoryAutomat.actions.loadTree();
|
void categoryAutomat.actions.loadTree();
|
||||||
}
|
}
|
||||||
@@ -83,6 +113,7 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
this.unsubCategory?.();
|
this.unsubCategory?.();
|
||||||
|
this.unsubAuth?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTerminals() {
|
async loadTerminals() {
|
||||||
@@ -104,13 +135,21 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleCreateTerminal() {
|
async handleCreateTerminal() {
|
||||||
const { newTerminalName, enableAllForNew } = this.state;
|
if (!this.state.isAdmin) return;
|
||||||
|
const { newTerminalName, enableAllForNew, terminals } = this.state;
|
||||||
const trimmed = newTerminalName.trim();
|
const trimmed = newTerminalName.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
this.setState({ creatingTerminal: true });
|
this.setState({ creatingTerminal: true });
|
||||||
try {
|
try {
|
||||||
const created = await companyAutomat.actions.createTerminal({ name: trimmed });
|
const nextSort = terminals.length > 0
|
||||||
|
? Math.max(...terminals.map((t) => Number(t.sort) || 0)) + 1
|
||||||
|
: 0;
|
||||||
|
const created = await companyAutomat.actions.createTerminal({
|
||||||
|
name: trimmed,
|
||||||
|
sort: nextSort,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (enableAllForNew && created?.id != null) {
|
if (enableAllForNew && created?.id != null) {
|
||||||
await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id });
|
await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id });
|
||||||
@@ -126,6 +165,84 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleToggleEnabled(terminal, nextValue) {
|
||||||
|
this.setState({ patchingTerminalId: terminal.id });
|
||||||
|
try {
|
||||||
|
await companyAutomat.actions.updateTerminal(terminal.id, {
|
||||||
|
enabled: Boolean(nextValue),
|
||||||
|
});
|
||||||
|
enqueueSnackbar(nextValue ? 'Terminal aktiviert.' : 'Terminal deaktiviert.', {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ patchingTerminalId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSortDraftChange(terminalId, value) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
sortDrafts: { ...prev.sortDrafts, [terminalId]: value },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSortSubmit(terminal) {
|
||||||
|
const draft = this.state.sortDrafts[terminal.id];
|
||||||
|
if (draft === undefined) return;
|
||||||
|
const nextSort = Number(draft);
|
||||||
|
if (isNaN(nextSort) || nextSort === (terminal.sort ?? 0)) {
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = { ...prev.sortDrafts };
|
||||||
|
delete next[terminal.id];
|
||||||
|
return { sortDrafts: next };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ patchingTerminalId: terminal.id });
|
||||||
|
try {
|
||||||
|
await companyAutomat.actions.updateTerminal(terminal.id, { sort: nextSort });
|
||||||
|
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState((prev) => {
|
||||||
|
const next = { ...prev.sortDrafts };
|
||||||
|
delete next[terminal.id];
|
||||||
|
return { sortDrafts: next, patchingTerminalId: null };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleMoveTerminal(terminal, direction) {
|
||||||
|
const { terminals } = this.state;
|
||||||
|
const currentIndex = terminals.findIndex((t) => t.id === terminal.id);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= terminals.length) return;
|
||||||
|
|
||||||
|
const other = terminals[targetIndex];
|
||||||
|
const currentSort = terminal.sort ?? 0;
|
||||||
|
const otherSort = other.sort ?? 0;
|
||||||
|
|
||||||
|
let newSort;
|
||||||
|
if (direction === 'up') {
|
||||||
|
newSort = otherSort <= currentSort ? otherSort - 1 : otherSort;
|
||||||
|
} else {
|
||||||
|
newSort = otherSort >= currentSort ? otherSort + 1 : otherSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({ patchingTerminalId: terminal.id });
|
||||||
|
try {
|
||||||
|
await companyAutomat.actions.updateTerminal(terminal.id, { sort: newSort });
|
||||||
|
enqueueSnackbar('Reihenfolge aktualisiert.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
this.setState({ patchingTerminalId: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async handleDeleteTerminal(terminal) {
|
async handleDeleteTerminal(terminal) {
|
||||||
this.setState({ deletingId: terminal.id });
|
this.setState({ deletingId: terminal.id });
|
||||||
try {
|
try {
|
||||||
@@ -180,9 +297,11 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
|
isAdmin,
|
||||||
status,
|
status,
|
||||||
error,
|
error,
|
||||||
terminals,
|
terminals,
|
||||||
|
vhostSettings,
|
||||||
newTerminalName,
|
newTerminalName,
|
||||||
creatingTerminal,
|
creatingTerminal,
|
||||||
enableAllForNew,
|
enableAllForNew,
|
||||||
@@ -192,6 +311,11 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
rootCategoryId,
|
rootCategoryId,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
|
const parsedVhost = parseVhostSettings(vhostSettings);
|
||||||
|
const posIp = parsedVhost.posip || parsedVhost.ip || null;
|
||||||
|
const posPort = parsedVhost.posport || parsedVhost.port || null;
|
||||||
|
const hasVhostSettings = Boolean(posIp || posPort || vhostSettings);
|
||||||
|
|
||||||
if (status === 'loading' && terminals.length === 0) {
|
if (status === 'loading' && terminals.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
@@ -206,13 +330,43 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
|
<Box sx={{ maxWidth: 680, mx: 'auto', py: 2 }}>
|
||||||
|
{hasVhostSettings && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mb: 2,
|
||||||
|
p: 1.5,
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||||
|
Pos Sync IP:{' '}
|
||||||
|
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||||
|
{posIp ?? '—'}
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||||
|
Pos Sync Port:{' '}
|
||||||
|
<Box component="span" sx={{ color: 'text.primary', fontFamily: 'monospace', fontWeight: 600 }}>
|
||||||
|
{posPort ?? '—'}
|
||||||
|
</Box>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
placeholder="Neues Terminal..."
|
placeholder="Neues Terminal..."
|
||||||
value={newTerminalName}
|
value={newTerminalName}
|
||||||
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
|
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
|
||||||
disabled={creatingTerminal}
|
disabled={!isAdmin || creatingTerminal}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
htmlInput: {
|
htmlInput: {
|
||||||
@@ -237,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>
|
||||||
@@ -246,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>
|
||||||
@@ -261,9 +415,10 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
{terminals.map((terminal) => {
|
{terminals.map((terminal, index) => {
|
||||||
const isDeleting = deletingId === terminal.id;
|
const isDeleting = deletingId === terminal.id;
|
||||||
const isPatching = patchingTerminalId === terminal.id;
|
const isPatching = patchingTerminalId === terminal.id;
|
||||||
|
const isEnabled = terminal.enabled !== false && terminal.enabled !== 0 && terminal.enabled != null;
|
||||||
const pairedDateText = formatDateTime(terminal.pairedAt);
|
const pairedDateText = formatDateTime(terminal.pairedAt);
|
||||||
const lastseenText = formatDateTime(terminal.lastseenAt) ?? 'Nie';
|
const lastseenText = formatDateTime(terminal.lastseenAt) ?? 'Nie';
|
||||||
const updatedText = formatDateTime(terminal.updatedAt) ?? '—';
|
const updatedText = formatDateTime(terminal.updatedAt) ?? '—';
|
||||||
@@ -275,18 +430,24 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'divider',
|
borderColor: isEnabled ? 'divider' : 'action.disabledBackground',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
|
opacity: isEnabled ? 1 : 0.72,
|
||||||
|
transition: 'opacity 0.2s ease, border-color 0.2s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header: Icon, Name, PIN, Delete action */}
|
{/* Header: Icon, Name, PIN, Version, Status Switch, Delete action */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
<PointOfSaleIcon sx={{ color: 'primary.main', fontSize: 24 }} />
|
<PointOfSaleIcon sx={{ color: isEnabled ? 'primary.main' : 'text.disabled', fontSize: 24 }} />
|
||||||
<Typography variant="subtitle1" fontWeight={600}>
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
fontWeight={600}
|
||||||
|
sx={{ color: isEnabled ? 'text.primary' : 'text.secondary' }}
|
||||||
|
>
|
||||||
{terminal.name}
|
{terminal.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
{terminal.pairingpin ? (
|
{terminal.pairingpin ? (
|
||||||
@@ -310,23 +471,68 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
({terminal.clientserial})
|
({terminal.clientserial})
|
||||||
</Typography>
|
</Typography>
|
||||||
) : null}
|
) : null}
|
||||||
|
{terminal.clientversion ? (
|
||||||
|
<Tooltip title="POS Client Software-Version">
|
||||||
|
<Chip
|
||||||
|
label={`v${terminal.clientversion}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="info"
|
||||||
|
sx={{ fontFamily: 'monospace', fontWeight: 600, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
isEnabled
|
||||||
|
? 'Terminal aktiv (kann synchronisieren)'
|
||||||
|
: 'Terminal deaktiviert (POS-Zugriff blockiert)'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={isEnabled}
|
||||||
|
disabled={!isAdmin || isPatching || isDeleting}
|
||||||
|
onChange={(e) => void this.handleToggleEnabled(terminal, e.target.checked)}
|
||||||
|
color="success"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8125rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: isEnabled ? 'text.primary' : 'text.disabled',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEnabled ? 'Aktiv' : 'Inaktiv'}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Terminal ${terminal.name} löschen`}
|
||||||
|
onClick={() => void this.handleDeleteTerminal(terminal)}
|
||||||
|
disabled={!isAdmin || isDeleting || creatingTerminal || isPatching}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<CircularProgress size={16} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<DeleteOutlinedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`Terminal ${terminal.name} löschen`}
|
|
||||||
onClick={() => void this.handleDeleteTerminal(terminal)}
|
|
||||||
disabled={isDeleting || creatingTerminal || isPatching}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<CircularProgress size={16} color="inherit" />
|
|
||||||
) : (
|
|
||||||
<DeleteOutlinedIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Settings row: Root category picker and syncRootArticles toggle */}
|
{/* Settings row: Root category picker, sort order controls, and syncRootArticles toggle */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -344,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"
|
||||||
@@ -358,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' }}
|
||||||
>
|
>
|
||||||
@@ -365,22 +573,75 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<FormControlLabel
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
control={
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
<Switch
|
<Typography variant="body2" color="text.secondary">
|
||||||
size="small"
|
Reihenfolge:
|
||||||
checked={Boolean(terminal.syncRootArticles)}
|
|
||||||
disabled={isPatching || isDeleting}
|
|
||||||
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<Typography variant="body2">
|
|
||||||
Root-Artikel synchronisieren
|
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
<Tooltip title="Nach oben verschieben">
|
||||||
sx={{ m: 0 }}
|
<span>
|
||||||
/>
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={!isAdmin || index === 0 || isPatching || isDeleting}
|
||||||
|
onClick={() => void this.handleMoveTerminal(terminal, 'up')}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Nach unten verschieben">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={!isAdmin || index === terminals.length - 1 || isPatching || isDeleting}
|
||||||
|
onClick={() => void this.handleMoveTerminal(terminal, 'down')}
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
|
>
|
||||||
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
disabled={!isAdmin || isPatching || isDeleting}
|
||||||
|
value={this.state.sortDrafts[terminal.id] ?? terminal.sort ?? 0}
|
||||||
|
onChange={(e) => this.handleSortDraftChange(terminal.id, e.target.value)}
|
||||||
|
onBlur={() => void this.handleSortSubmit(terminal)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
step: 1,
|
||||||
|
style: { width: 44, padding: '3px 6px', textAlign: 'center', fontSize: '0.8125rem' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ width: 56 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={Boolean(terminal.syncRootArticles)}
|
||||||
|
disabled={!isAdmin || isPatching || isDeleting}
|
||||||
|
onChange={(e) => void this.handleToggleSyncRootArticles(terminal, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography variant="body2">
|
||||||
|
Root-Artikel synchronisieren
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Dates row */}
|
{/* Dates row */}
|
||||||
@@ -402,6 +663,11 @@ export default class PosTerminalSettingsPanel extends Component {
|
|||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
<strong>Zuletzt online:</strong> {lastseenText}
|
<strong>Zuletzt online:</strong> {lastseenText}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{terminal.clientversion && (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
<strong>POS-Software:</strong> {terminal.clientversion}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
<strong>Aktualisiert:</strong> {updatedText}
|
<strong>Aktualisiert:</strong> {updatedText}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
1167
src/components/settings/PrinterAgentSettingsPanel.jsx
Normal 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 ? (
|
||||||
|
|||||||
@@ -13,18 +13,25 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
|
|||||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import authAutomat from '../../automats/authAutomat';
|
||||||
import CompanySettingsPanel from './CompanySettingsPanel';
|
import CompanySettingsPanel from './CompanySettingsPanel';
|
||||||
import NumberSettingsPanel from './NumberSettingsPanel';
|
import NumberSettingsPanel from './NumberSettingsPanel';
|
||||||
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
import PrinterSettingsPanel from './PrinterSettingsPanel';
|
||||||
|
import PrinterAgentSettingsPanel from './PrinterAgentSettingsPanel';
|
||||||
import SettingsKeysPanel from './SettingsKeysPanel';
|
import SettingsKeysPanel from './SettingsKeysPanel';
|
||||||
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
||||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
||||||
|
import MembersSettingsPanel from './MembersSettingsPanel';
|
||||||
|
import GroupOutlinedIcon from '@mui/icons-material/GroupOutlined';
|
||||||
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
import BackupSettingsPanel from './BackupSettingsPanel';
|
import BackupSettingsPanel from './BackupSettingsPanel';
|
||||||
import HealthSettingsPanel from './HealthSettingsPanel';
|
import HealthSettingsPanel from './HealthSettingsPanel';
|
||||||
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
|
import MonitorHeartOutlinedIcon from '@mui/icons-material/MonitorHeartOutlined';
|
||||||
import SecurityIcon from '@mui/icons-material/Security';
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
||||||
|
import LocalPrintshopOutlinedIcon from '@mui/icons-material/LocalPrintshopOutlined';
|
||||||
import { SURFACE_BASE } from '../../colors';
|
import { SURFACE_BASE } from '../../colors';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
@@ -33,10 +40,12 @@ const TAB_KEYS = {
|
|||||||
keys: 'keys',
|
keys: 'keys',
|
||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
printers: 'printers',
|
printers: 'printers',
|
||||||
|
printeragent: 'printeragent',
|
||||||
importExport: 'importExport',
|
importExport: 'importExport',
|
||||||
backup: 'backup',
|
backup: 'backup',
|
||||||
posterminal: 'posterminal',
|
posterminal: 'posterminal',
|
||||||
security: 'security',
|
security: 'security',
|
||||||
|
members: 'members',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -45,23 +54,83 @@ const TABS = [
|
|||||||
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
{ id: TAB_KEYS.keys, label: 'Einstellungen', icon: <TuneIcon />, Panel: SettingsKeysPanel },
|
||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.printeragent, label: 'Drucker-Agent', icon: <LocalPrintshopOutlinedIcon />, Panel: PrinterAgentSettingsPanel },
|
||||||
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
||||||
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
|
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
|
||||||
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.members, label: 'Mitglieder', icon: <GroupOutlinedIcon />, Panel: MembersSettingsPanel },
|
||||||
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function getDefaultTab(vhost) {
|
||||||
|
return vhost ? TAB_KEYS.company : TAB_KEYS.health;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleTabs(vhost, isAdmin) {
|
||||||
|
const tabs = isAdmin ? TABS : TABS.filter((t) => t.id !== TAB_KEYS.members);
|
||||||
|
if (!vhost) return tabs;
|
||||||
|
return tabs.filter(
|
||||||
|
(t) => t.id !== TAB_KEYS.health && t.id !== TAB_KEYS.backup && t.id !== TAB_KEYS.printers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default class SettingsDialog extends Component {
|
export default class SettingsDialog extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { tab: TAB_KEYS.health };
|
const vhost = Boolean(authAutomat.state.vhost);
|
||||||
|
this.state = {
|
||||||
|
tab: getDefaultTab(vhost),
|
||||||
|
schemaVersion: authAutomat.state.schemaVersion,
|
||||||
|
vhost,
|
||||||
|
isAdmin: companyAutomat.state.current?.role === 'admin',
|
||||||
|
};
|
||||||
this.handleTabChange = this.handleTabChange.bind(this);
|
this.handleTabChange = this.handleTabChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsubAuth = authAutomat.subscribe(this, (state) => ({
|
||||||
|
schemaVersion: state.schemaVersion,
|
||||||
|
vhost: Boolean(state.vhost),
|
||||||
|
}));
|
||||||
|
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
isAdmin: state.current?.role === 'admin',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsubAuth?.();
|
||||||
|
this.unsubCompany?.();
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { open } = this.props;
|
const { open } = this.props;
|
||||||
|
const { vhost } = this.state;
|
||||||
if (!open && prevProps.open) {
|
if (!open && prevProps.open) {
|
||||||
this.setState({ tab: TAB_KEYS.health });
|
this.setState({ tab: getDefaultTab(vhost) });
|
||||||
|
}
|
||||||
|
if (open && !prevProps.open) {
|
||||||
|
const currentVhost = Boolean(authAutomat.state.vhost);
|
||||||
|
const updates = {};
|
||||||
|
if (this.props.initialTab) {
|
||||||
|
updates.tab = this.props.initialTab;
|
||||||
|
}
|
||||||
|
if (this.state.schemaVersion !== authAutomat.state.schemaVersion) {
|
||||||
|
updates.schemaVersion = authAutomat.state.schemaVersion;
|
||||||
|
}
|
||||||
|
if (this.state.vhost !== currentVhost) {
|
||||||
|
updates.vhost = currentVhost;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
currentVhost &&
|
||||||
|
(this.state.tab === TAB_KEYS.health ||
|
||||||
|
this.state.tab === TAB_KEYS.backup ||
|
||||||
|
this.state.tab === TAB_KEYS.printers)
|
||||||
|
) {
|
||||||
|
updates.tab = getDefaultTab(currentVhost);
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
this.setState(updates);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +140,9 @@ export default class SettingsDialog extends Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { open, onClose } = this.props;
|
const { open, onClose } = this.props;
|
||||||
const { tab } = this.state;
|
const { tab, schemaVersion, vhost, isAdmin } = this.state;
|
||||||
|
const visibleTabs = getVisibleTabs(vhost, isAdmin);
|
||||||
|
const activeTab = visibleTabs.some((t) => t.id === tab) ? tab : visibleTabs[0]?.id;
|
||||||
|
|
||||||
const paperSx = {
|
const paperSx = {
|
||||||
height: '85vh',
|
height: '85vh',
|
||||||
@@ -120,12 +191,15 @@ export default class SettingsDialog extends Component {
|
|||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
bgcolor: SURFACE_BASE,
|
bgcolor: SURFACE_BASE,
|
||||||
py: 2,
|
py: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
value={tab}
|
value={activeTab}
|
||||||
onChange={this.handleTabChange}
|
onChange={this.handleTabChange}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiTabs-indicator': {
|
'& .MuiTabs-indicator': {
|
||||||
@@ -161,7 +235,7 @@ export default class SettingsDialog extends Component {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{TABS.map((t) => (
|
{visibleTabs.map((t) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={t.id}
|
key={t.id}
|
||||||
disableRipple
|
disableRipple
|
||||||
@@ -172,20 +246,27 @@ export default class SettingsDialog extends Component {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
{schemaVersion != null && (
|
||||||
|
<Box sx={{ px: 2.5, py: 1.5, mt: 'auto', borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', px: 0.5 }}>
|
||||||
|
DB-Version: {schemaVersion}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right content panel */}
|
{/* Right content panel */}
|
||||||
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
|
<DialogContent sx={{ flex: 1, minHeight: 0, p: 3, overflowY: 'auto' }}>
|
||||||
<Box sx={{ display: 'grid' }}>
|
<Box sx={{ display: 'grid' }}>
|
||||||
{TABS.map((t) => (
|
{visibleTabs.map((t) => (
|
||||||
<Box
|
<Box
|
||||||
key={t.id}
|
key={t.id}
|
||||||
sx={{
|
sx={{
|
||||||
gridArea: '1 / 1',
|
gridArea: '1 / 1',
|
||||||
display: t.id === tab ? 'block' : 'none',
|
display: t.id === activeTab ? 'block' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<t.Panel active={t.id === tab} />
|
<t.Panel active={t.id === activeTab} />
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -11,15 +11,24 @@ import List from '@mui/material/List';
|
|||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import ListItemText from '@mui/material/ListItemText';
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
|
import Popover from '@mui/material/Popover';
|
||||||
|
import Badge from '@mui/material/Badge';
|
||||||
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
|
import Radio from '@mui/material/Radio';
|
||||||
|
import RadioGroup from '@mui/material/RadioGroup';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import ClearIcon from '@mui/icons-material/Clear';
|
import ClearIcon from '@mui/icons-material/Clear';
|
||||||
import FilterListIcon from '@mui/icons-material/FilterList';
|
import FilterListIcon from '@mui/icons-material/FilterList';
|
||||||
|
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
import articleSearchAutomat from '../automats/articleSearchAutomat';
|
||||||
|
import articleIndexAutomat from '../automats/articleIndexAutomat';
|
||||||
import BackendError from '../components/BackendError';
|
import BackendError from '../components/BackendError';
|
||||||
import { formatPrice, getArticleGross } from '../utils/price';
|
import { formatPrice, getArticleGross } from '../utils/price';
|
||||||
import { thumb } from '../api';
|
import { thumb } from '../api';
|
||||||
@@ -50,6 +59,7 @@ import navigationAutomat, {
|
|||||||
import categoryAutomat from '../automats/categoryAutomat';
|
import categoryAutomat from '../automats/categoryAutomat';
|
||||||
import companyAutomat from '../automats/companyAutomat';
|
import companyAutomat from '../automats/companyAutomat';
|
||||||
import { BrandLabel, BrandThumb } from '../components/brand';
|
import { BrandLabel, BrandThumb } from '../components/brand';
|
||||||
|
import { ArticleNameDialog } from '../components/article';
|
||||||
import { SCRIM_LABEL } from '../colors';
|
import { SCRIM_LABEL } from '../colors';
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -263,6 +273,28 @@ class ResultRow extends Component {
|
|||||||
}
|
}
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
// #region Constants
|
||||||
|
const EMPTY_FILTER_OPTIONS = [
|
||||||
|
{ key: 'ean', label: 'EAN' },
|
||||||
|
{ key: 'photo', label: 'Foto' },
|
||||||
|
{ key: 'price', label: 'Preis' },
|
||||||
|
{ key: 'brand', label: 'Marke' },
|
||||||
|
{ key: 'supplier', label: 'Lieferant' },
|
||||||
|
{ key: 'seo', label: 'SEO' },
|
||||||
|
{ key: 'desc', label: 'Beschreibung' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EMPTY_FILTER_LABELS = {
|
||||||
|
ean: 'Ohne EAN',
|
||||||
|
photo: 'Ohne Foto',
|
||||||
|
price: 'Ohne Preis',
|
||||||
|
brand: 'Ohne Marke',
|
||||||
|
supplier: 'Ohne Lieferant',
|
||||||
|
seo: 'Ohne SEO',
|
||||||
|
desc: 'Ohne Beschreibung',
|
||||||
|
};
|
||||||
|
// #endregion
|
||||||
|
|
||||||
// #region Helpers
|
// #region Helpers
|
||||||
function initialSearchState(searchParams) {
|
function initialSearchState(searchParams) {
|
||||||
const query = queryFromParams(searchParams);
|
const query = queryFromParams(searchParams);
|
||||||
@@ -282,6 +314,8 @@ function initialSearchState(searchParams) {
|
|||||||
categoryFilterOpen: false,
|
categoryFilterOpen: false,
|
||||||
brandFilterOpen: false,
|
brandFilterOpen: false,
|
||||||
supplierFilterOpen: false,
|
supplierFilterOpen: false,
|
||||||
|
moreFilterAnchorEl: null,
|
||||||
|
newArticleOpen: false,
|
||||||
error: automatResult.error,
|
error: automatResult.error,
|
||||||
categoryFacets: automatResult.categoryFacets,
|
categoryFacets: automatResult.categoryFacets,
|
||||||
brandFacets: automatResult.brandFacets,
|
brandFacets: automatResult.brandFacets,
|
||||||
@@ -354,6 +388,109 @@ class ArtikelSearchPage extends Component {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleOpenMoreFilters = (event) => {
|
||||||
|
this.setState({ moreFilterAnchorEl: event.currentTarget });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseMoreFilters = () => {
|
||||||
|
this.setState({ moreFilterAnchorEl: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleToggleEmptyFilter = (emptyKey) => {
|
||||||
|
const { input } = this.state;
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
const brandParam = this.props.searchParams.get('brand') ?? '';
|
||||||
|
const supplierParam = this.props.searchParams.get('supplier') ?? '';
|
||||||
|
const emptyParam = this.props.searchParams.get('empty') ?? '';
|
||||||
|
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
|
||||||
|
const selectedEmpty = parseFilter(emptyParam);
|
||||||
|
const nextEmpty = selectedEmpty.includes(emptyKey)
|
||||||
|
? selectedEmpty.filter((k) => k !== emptyKey)
|
||||||
|
: [...selectedEmpty, emptyKey];
|
||||||
|
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
filterParam,
|
||||||
|
0,
|
||||||
|
brandParam,
|
||||||
|
supplierParam,
|
||||||
|
buildFilter(nextEmpty),
|
||||||
|
hierarchyParam,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleHierarchyChange = (nextHierarchy) => {
|
||||||
|
const { input } = this.state;
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
const brandParam = this.props.searchParams.get('brand') ?? '';
|
||||||
|
const supplierParam = this.props.searchParams.get('supplier') ?? '';
|
||||||
|
const emptyParam = this.props.searchParams.get('empty') ?? '';
|
||||||
|
const hierarchy = nextHierarchy === 'all' ? '' : nextHierarchy;
|
||||||
|
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
filterParam,
|
||||||
|
0,
|
||||||
|
brandParam,
|
||||||
|
supplierParam,
|
||||||
|
emptyParam,
|
||||||
|
hierarchy,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClearAllMoreFilters = () => {
|
||||||
|
const { input } = this.state;
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
const brandParam = this.props.searchParams.get('brand') ?? '';
|
||||||
|
const supplierParam = this.props.searchParams.get('supplier') ?? '';
|
||||||
|
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
filterParam,
|
||||||
|
0,
|
||||||
|
brandParam,
|
||||||
|
supplierParam,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleOpenNewArticle = () => {
|
||||||
|
this.setState({ newArticleOpen: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseNewArticle = () => {
|
||||||
|
this.setState({ newArticleOpen: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCreateArticle = async ({ names }) => {
|
||||||
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
|
const categories = parseFilter(filterParam);
|
||||||
|
const parentId =
|
||||||
|
categories[0] ||
|
||||||
|
categoryAutomat.state.rootCategoryId ||
|
||||||
|
companyAutomat.state.current?.rootCategoryId ||
|
||||||
|
companyAutomat.state.current?.rootcategory_id ||
|
||||||
|
1;
|
||||||
|
const list = (names ?? []).filter(Boolean);
|
||||||
|
if (list.length <= 1) {
|
||||||
|
const created = await categoryAutomat.actions.createArticle({
|
||||||
|
parentId,
|
||||||
|
name: list[0] ?? '',
|
||||||
|
});
|
||||||
|
void articleIndexAutomat.actions.reload().catch(() => { });
|
||||||
|
if (created) {
|
||||||
|
this.props.navigate(artikelDetailPath(created));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const createdList = await categoryAutomat.actions.createArticles({ parentId, names: list });
|
||||||
|
void articleIndexAutomat.actions.reload().catch(() => { });
|
||||||
|
if (createdList?.[0]) {
|
||||||
|
this.props.navigate(artikelDetailPath(createdList[0]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handleOpenArticle = (row) => {
|
handleOpenArticle = (row) => {
|
||||||
this.persistQuery();
|
this.persistQuery();
|
||||||
this.props.navigate(artikelDetailPath(row));
|
this.props.navigate(artikelDetailPath(row));
|
||||||
@@ -367,6 +504,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
filter: query.filter,
|
filter: query.filter,
|
||||||
brand: query.brand,
|
brand: query.brand,
|
||||||
supplier: query.supplier,
|
supplier: query.supplier,
|
||||||
|
empty: query.empty,
|
||||||
|
hierarchy: query.hierarchy,
|
||||||
offset: query.offset,
|
offset: query.offset,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -390,6 +529,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
offset,
|
offset,
|
||||||
brand: urlBrand,
|
brand: urlBrand,
|
||||||
supplier: urlSupplier,
|
supplier: urlSupplier,
|
||||||
|
empty: urlEmpty,
|
||||||
|
hierarchy: urlHierarchy,
|
||||||
} = queryFromParams(this.props.searchParams);
|
} = queryFromParams(this.props.searchParams);
|
||||||
const hasFilterInUrl = this.props.searchParams.has('filter');
|
const hasFilterInUrl = this.props.searchParams.has('filter');
|
||||||
const navCategory = navigationAutomat.state.lastCategoryId;
|
const navCategory = navigationAutomat.state.lastCategoryId;
|
||||||
@@ -400,6 +541,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
|
const syncedUrl = buildSearchUrl(activeTarget, filter, offset, {
|
||||||
brand: urlBrand,
|
brand: urlBrand,
|
||||||
supplier: urlSupplier,
|
supplier: urlSupplier,
|
||||||
|
empty: urlEmpty,
|
||||||
|
hierarchy: urlHierarchy,
|
||||||
});
|
});
|
||||||
setLastSearchTerm(activeTarget, syncedUrl);
|
setLastSearchTerm(activeTarget, syncedUrl);
|
||||||
this.props.navigate(syncedUrl, { replace: true });
|
this.props.navigate(syncedUrl, { replace: true });
|
||||||
@@ -414,6 +557,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
buildSearchUrl(activeTarget, filter, offset, {
|
buildSearchUrl(activeTarget, filter, offset, {
|
||||||
brand: urlBrand,
|
brand: urlBrand,
|
||||||
supplier: urlSupplier,
|
supplier: urlSupplier,
|
||||||
|
empty: urlEmpty,
|
||||||
|
hierarchy: urlHierarchy,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
void articleSearchAutomat.actions.search({
|
void articleSearchAutomat.actions.search({
|
||||||
@@ -421,8 +566,10 @@ class ArtikelSearchPage extends Component {
|
|||||||
filter,
|
filter,
|
||||||
brand: urlBrand,
|
brand: urlBrand,
|
||||||
supplier: urlSupplier,
|
supplier: urlSupplier,
|
||||||
|
empty: urlEmpty,
|
||||||
|
hierarchy: urlHierarchy,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
@@ -439,6 +586,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
buildSearchUrl(target, query.filter, query.offset, {
|
buildSearchUrl(target, query.filter, query.offset, {
|
||||||
brand: query.brand,
|
brand: query.brand,
|
||||||
supplier: query.supplier,
|
supplier: query.supplier,
|
||||||
|
empty: query.empty,
|
||||||
|
hierarchy: query.hierarchy,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -459,6 +608,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
searchParams.get('filter') !== prev.get('filter')
|
searchParams.get('filter') !== prev.get('filter')
|
||||||
|| searchParams.get('brand') !== prev.get('brand')
|
|| searchParams.get('brand') !== prev.get('brand')
|
||||||
|| searchParams.get('supplier') !== prev.get('supplier')
|
|| searchParams.get('supplier') !== prev.get('supplier')
|
||||||
|
|| searchParams.get('empty') !== prev.get('empty')
|
||||||
|
|| searchParams.get('hierarchy') !== prev.get('hierarchy')
|
||||||
|| searchParams.get('offset') !== prev.get('offset')
|
|| searchParams.get('offset') !== prev.get('offset')
|
||||||
) {
|
) {
|
||||||
this.fetchResults();
|
this.fetchResults();
|
||||||
@@ -473,17 +624,23 @@ class ArtikelSearchPage extends Component {
|
|||||||
nextOffset = 0,
|
nextOffset = 0,
|
||||||
nextBrand = this.props.searchParams.get('brand') ?? '',
|
nextBrand = this.props.searchParams.get('brand') ?? '',
|
||||||
nextSupplier = this.props.searchParams.get('supplier') ?? '',
|
nextSupplier = this.props.searchParams.get('supplier') ?? '',
|
||||||
|
nextEmpty = this.props.searchParams.get('empty') ?? '',
|
||||||
|
nextHierarchy = this.props.searchParams.get('hierarchy') ?? '',
|
||||||
) => {
|
) => {
|
||||||
writeArtikelQuery({
|
writeArtikelQuery({
|
||||||
target: nextTarget,
|
target: nextTarget,
|
||||||
filter: nextFilter,
|
filter: nextFilter,
|
||||||
brand: nextBrand,
|
brand: nextBrand,
|
||||||
supplier: nextSupplier,
|
supplier: nextSupplier,
|
||||||
|
empty: nextEmpty,
|
||||||
|
hierarchy: nextHierarchy,
|
||||||
offset: nextOffset,
|
offset: nextOffset,
|
||||||
});
|
});
|
||||||
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
|
const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset, {
|
||||||
brand: nextBrand,
|
brand: nextBrand,
|
||||||
supplier: nextSupplier,
|
supplier: nextSupplier,
|
||||||
|
empty: nextEmpty,
|
||||||
|
hierarchy: nextHierarchy,
|
||||||
});
|
});
|
||||||
setLastSearchTerm(nextTarget, nextUrl);
|
setLastSearchTerm(nextTarget, nextUrl);
|
||||||
|
|
||||||
@@ -508,18 +665,20 @@ class ArtikelSearchPage extends Component {
|
|||||||
handleInputChange = (event) => {
|
handleInputChange = (event) => {
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
this.setState({ input: value });
|
this.setState({ input: value });
|
||||||
const { filter, brand, supplier } = queryFromParams(this.props.searchParams);
|
const { filter, brand, supplier, empty, hierarchy } = queryFromParams(this.props.searchParams);
|
||||||
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier });
|
const currentUrl = buildSearchUrl(value, filter, 0, { brand, supplier, empty, hierarchy });
|
||||||
setLastSearchTerm(value, currentUrl);
|
setLastSearchTerm(value, currentUrl);
|
||||||
writeArtikelQuery({ target: value, filter, brand, supplier, offset: 0 });
|
writeArtikelQuery({ target: value, filter, brand, supplier, empty, hierarchy, offset: 0 });
|
||||||
this.props.navigate(currentUrl, { replace: true });
|
this.props.navigate(currentUrl, { replace: true });
|
||||||
void articleSearchAutomat.actions.search({
|
void articleSearchAutomat.actions.search({
|
||||||
target: value,
|
target: value,
|
||||||
filter,
|
filter,
|
||||||
brand,
|
brand,
|
||||||
supplier,
|
supplier,
|
||||||
|
empty,
|
||||||
|
hierarchy,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleClearInput = () => {
|
handleClearInput = () => {
|
||||||
@@ -527,17 +686,19 @@ class ArtikelSearchPage extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchResults = () => {
|
fetchResults = () => {
|
||||||
const { target, filter, brand, supplier, offset } = queryFromParams(this.props.searchParams);
|
const { target, filter, brand, supplier, empty, hierarchy, offset } = queryFromParams(this.props.searchParams);
|
||||||
|
|
||||||
writeArtikelQuery({ target, filter, brand, supplier, offset });
|
writeArtikelQuery({ target, filter, brand, supplier, empty, hierarchy, offset });
|
||||||
|
|
||||||
void articleSearchAutomat.actions.search({
|
void articleSearchAutomat.actions.search({
|
||||||
target: target.trim(),
|
target: target.trim(),
|
||||||
filter,
|
filter,
|
||||||
brand,
|
brand,
|
||||||
supplier,
|
supplier,
|
||||||
|
empty,
|
||||||
|
hierarchy,
|
||||||
offset,
|
offset,
|
||||||
}).catch(() => {});
|
}).catch(() => { });
|
||||||
};
|
};
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -548,6 +709,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
categoryFilterOpen,
|
categoryFilterOpen,
|
||||||
brandFilterOpen,
|
brandFilterOpen,
|
||||||
supplierFilterOpen,
|
supplierFilterOpen,
|
||||||
|
moreFilterAnchorEl,
|
||||||
|
newArticleOpen,
|
||||||
error,
|
error,
|
||||||
totalCount,
|
totalCount,
|
||||||
categoryFacets,
|
categoryFacets,
|
||||||
@@ -558,10 +721,15 @@ class ArtikelSearchPage extends Component {
|
|||||||
const filterParam = this.props.searchParams.get('filter') ?? '';
|
const filterParam = this.props.searchParams.get('filter') ?? '';
|
||||||
const brandParam = this.props.searchParams.get('brand') ?? '';
|
const brandParam = this.props.searchParams.get('brand') ?? '';
|
||||||
const supplierParam = this.props.searchParams.get('supplier') ?? '';
|
const supplierParam = this.props.searchParams.get('supplier') ?? '';
|
||||||
|
const emptyParam = this.props.searchParams.get('empty') ?? '';
|
||||||
|
const hierarchyParam = this.props.searchParams.get('hierarchy') ?? '';
|
||||||
|
|
||||||
const categories = parseFilter(filterParam);
|
const categories = parseFilter(filterParam);
|
||||||
const selectedBrands = parseFilter(brandParam);
|
const selectedBrands = parseFilter(brandParam);
|
||||||
const selectedSuppliers = parseFilter(supplierParam);
|
const selectedSuppliers = parseFilter(supplierParam);
|
||||||
|
const selectedEmpty = parseFilter(emptyParam);
|
||||||
|
const activeMoreFilterCount = selectedEmpty.length + (hierarchyParam ? 1 : 0);
|
||||||
|
const isAnyMoreFilterActive = activeMoreFilterCount > 0;
|
||||||
const rows = recordset ?? [];
|
const rows = recordset ?? [];
|
||||||
|
|
||||||
const categoriesMap = categoryAutomat.state?.categories;
|
const categoriesMap = categoryAutomat.state?.categories;
|
||||||
@@ -599,16 +767,16 @@ class ArtikelSearchPage extends Component {
|
|||||||
|
|
||||||
const brandFacetEntries = brandFacets
|
const brandFacetEntries = brandFacets
|
||||||
? Object.values(brandFacets).sort((a, b) => {
|
? Object.values(brandFacets).sort((a, b) => {
|
||||||
if (b.count !== a.count) return b.count - a.count;
|
if (b.count !== a.count) return b.count - a.count;
|
||||||
return String(a.name).localeCompare(String(b.name), 'de');
|
return String(a.name).localeCompare(String(b.name), 'de');
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const supplierFacetEntries = supplierFacets
|
const supplierFacetEntries = supplierFacets
|
||||||
? Object.values(supplierFacets).sort((a, b) => {
|
? Object.values(supplierFacets).sort((a, b) => {
|
||||||
if (b.count !== a.count) return b.count - a.count;
|
if (b.count !== a.count) return b.count - a.count;
|
||||||
return String(a.name).localeCompare(String(b.name), 'de');
|
return String(a.name).localeCompare(String(b.name), 'de');
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Per-render values the static `renderItem` row reads through the context.
|
// Per-render values the static `renderItem` row reads through the context.
|
||||||
@@ -696,6 +864,29 @@ class ArtikelSearchPage extends Component {
|
|||||||
>
|
>
|
||||||
Lieferanten
|
Lieferanten
|
||||||
</Button>
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
onClick={this.handleOpenMoreFilters}
|
||||||
|
color={isAnyMoreFilterActive ? 'primary' : 'default'}
|
||||||
|
sx={{
|
||||||
|
height: 40,
|
||||||
|
width: 40,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isAnyMoreFilterActive ? 'primary.main' : 'rgba(0, 0, 0, 0.23)',
|
||||||
|
borderRadius: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Badge badgeContent={activeMoreFilterCount} color="primary">
|
||||||
|
<FilterAltIcon fontSize="small" />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.handleOpenNewArticle}
|
||||||
|
sx={{ height: 40, whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Neuer Artikel
|
||||||
|
</Button>
|
||||||
|
|
||||||
{categories.map((catId) => {
|
{categories.map((catId) => {
|
||||||
const label = getCategoryBreadcrumbLabel(
|
const label = getCategoryBreadcrumbLabel(
|
||||||
@@ -718,6 +909,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
0,
|
0,
|
||||||
buildFilter(selectedBrands),
|
buildFilter(selectedBrands),
|
||||||
buildFilter(selectedSuppliers),
|
buildFilter(selectedSuppliers),
|
||||||
|
emptyParam,
|
||||||
|
hierarchyParam,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
@@ -744,6 +937,8 @@ class ArtikelSearchPage extends Component {
|
|||||||
0,
|
0,
|
||||||
buildFilter(next),
|
buildFilter(next),
|
||||||
buildFilter(selectedSuppliers),
|
buildFilter(selectedSuppliers),
|
||||||
|
emptyParam,
|
||||||
|
hierarchyParam,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
@@ -770,12 +965,78 @@ class ArtikelSearchPage extends Component {
|
|||||||
0,
|
0,
|
||||||
buildFilter(selectedBrands),
|
buildFilter(selectedBrands),
|
||||||
buildFilter(next),
|
buildFilter(next),
|
||||||
|
emptyParam,
|
||||||
|
hierarchyParam,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{hierarchyParam === 'father' && (
|
||||||
|
<Chip
|
||||||
|
key="hierarchy-father"
|
||||||
|
label="Typ: Vater / Normal"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={() => {
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
buildFilter(categories),
|
||||||
|
0,
|
||||||
|
buildFilter(selectedBrands),
|
||||||
|
buildFilter(selectedSuppliers),
|
||||||
|
emptyParam,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hierarchyParam === 'child' && (
|
||||||
|
<Chip
|
||||||
|
key="hierarchy-child"
|
||||||
|
label="Typ: Kind / Normal"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={() => {
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
buildFilter(categories),
|
||||||
|
0,
|
||||||
|
buildFilter(selectedBrands),
|
||||||
|
buildFilter(selectedSuppliers),
|
||||||
|
emptyParam,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEmpty.map((emptyKey) => (
|
||||||
|
<Chip
|
||||||
|
key={`empty-${emptyKey}`}
|
||||||
|
label={EMPTY_FILTER_LABELS[emptyKey] || emptyKey}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={() => {
|
||||||
|
const next = selectedEmpty.filter((k) => k !== emptyKey);
|
||||||
|
this.updateParams(
|
||||||
|
input,
|
||||||
|
buildFilter(categories),
|
||||||
|
0,
|
||||||
|
buildFilter(selectedBrands),
|
||||||
|
buildFilter(selectedSuppliers),
|
||||||
|
buildFilter(next),
|
||||||
|
hierarchyParam,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -1047,6 +1308,97 @@ class ArtikelSearchPage extends Component {
|
|||||||
</PanelTile>
|
</PanelTile>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
<ArticleNameDialog
|
||||||
|
open={newArticleOpen}
|
||||||
|
initialName={input}
|
||||||
|
onClose={this.handleCloseNewArticle}
|
||||||
|
onSubmit={this.handleCreateArticle}
|
||||||
|
/>
|
||||||
|
<Popover
|
||||||
|
open={Boolean(moreFilterAnchorEl)}
|
||||||
|
anchorEl={moreFilterAnchorEl}
|
||||||
|
onClose={this.handleCloseMoreFilters}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
width: 320,
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||||
|
{isAnyMoreFilterActive && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
onClick={this.handleClearAllMoreFilters}
|
||||||
|
sx={{ py: 0.25, px: 1, minWidth: 0, textTransform: 'none', fontSize: 13 }}
|
||||||
|
>
|
||||||
|
Zurücksetzen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={hierarchyParam || 'all'}
|
||||||
|
onChange={(e) => this.handleHierarchyChange(e.target.value)}
|
||||||
|
sx={{ mb: 1.5, mt: 0.5 }}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value="all"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={<Typography variant="body2">Alle Artikel</Typography>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value="father"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">Vater- oder Normalartikel</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value="child"
|
||||||
|
control={<Radio size="small" />}
|
||||||
|
label={
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2">Kind- oder Normalartikel</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
Artikel ohne:
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', mt: 0.5 }}>
|
||||||
|
{EMPTY_FILTER_OPTIONS.map(({ key, label }) => {
|
||||||
|
const checked = selectedEmpty.includes(key);
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={key}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => this.handleToggleEmptyFilter(key)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
sx={{ my: -0.25 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -555,16 +555,19 @@ class ArtikelKategorienPage extends PureComponent {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!renameMode && articles.map((item, index) => (
|
{!renameMode &&
|
||||||
<ArticleTile
|
articles
|
||||||
key={item.id}
|
.filter((item) => item.parentArticleId == null && !item.parentSku)
|
||||||
label={item.name != null ? item.name.trim() : undefined}
|
.map((item, index) => (
|
||||||
article={item}
|
<ArticleTile
|
||||||
disabled={!articleSku(item)}
|
key={item.id}
|
||||||
onClick={() => this.handleArticleClick(item)}
|
label={item.name != null ? item.name.trim() : undefined}
|
||||||
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
|
article={item}
|
||||||
/>
|
disabled={!articleSku(item)}
|
||||||
))}
|
onClick={() => this.handleArticleClick(item)}
|
||||||
|
sx={index === 0 ? { gridColumnStart: 1 } : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
|
{renameMode && <NewCategoryTile parentId={resolvedCategoryId} />}
|
||||||
{renameMode && loaded && (
|
{renameMode && loaded && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,20 +9,27 @@ import DialogTitle from '@mui/material/DialogTitle';
|
|||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import LabelIcon from '@mui/icons-material/Label';
|
import LabelIcon from '@mui/icons-material/Label';
|
||||||
|
import PrintIcon from '@mui/icons-material/Print';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import { setLastArticleSku } from '../../automats/navigationAutomat';
|
import { setLastArticleSku } from '../../automats/navigationAutomat';
|
||||||
|
import companyAutomat from '../../automats/companyAutomat';
|
||||||
import { labeltemplate } from '../../api';
|
import { labeltemplate } from '../../api';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +37,36 @@ import {
|
|||||||
SURFACE_BASE,
|
SURFACE_BASE,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
|
function filterZplPrinters(agentprinters) {
|
||||||
|
return (Array.isArray(agentprinters) ? agentprinters : []).filter(
|
||||||
|
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrinterLabel(p) {
|
||||||
|
let label;
|
||||||
|
if (p.model && p.serial) {
|
||||||
|
label = `${p.model} (${p.serial})`;
|
||||||
|
} else if (p.model) {
|
||||||
|
label = p.model;
|
||||||
|
} else if (p.serial) {
|
||||||
|
label = `Drucker (${p.serial})`;
|
||||||
|
} else if (p.desc && p.address) {
|
||||||
|
label = `${p.desc} (${p.address})`;
|
||||||
|
} else if (p.address) {
|
||||||
|
label = p.address;
|
||||||
|
} else if (p.desc) {
|
||||||
|
label = p.desc;
|
||||||
|
} else {
|
||||||
|
label = `Drucker #${p.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.online === false) {
|
||||||
|
return `${label} (offline)`;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
const CanvasEditor = lazy(async () => {
|
const CanvasEditor = lazy(async () => {
|
||||||
await import('react-canvas-label-editor/style.css');
|
await import('react-canvas-label-editor/style.css');
|
||||||
const mod = await import('react-canvas-label-editor');
|
const mod = await import('react-canvas-label-editor');
|
||||||
@@ -65,11 +102,15 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
const initialDpi = DEFAULT_DPI;
|
const initialDpi = DEFAULT_DPI;
|
||||||
const initialWidthClass = '2';
|
const initialWidthClass = '2';
|
||||||
const initialHeightMm = DEFAULT_HEIGHT_MM;
|
const initialHeightMm = DEFAULT_HEIGHT_MM;
|
||||||
|
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
error: null,
|
error: null,
|
||||||
templates: [],
|
templates: [],
|
||||||
|
printers: initialPrinters,
|
||||||
|
printerMenuAnchor: null,
|
||||||
|
printing: false,
|
||||||
// Editor dialog state
|
// Editor dialog state
|
||||||
editorOpen: false,
|
editorOpen: false,
|
||||||
editorLoading: false,
|
editorLoading: false,
|
||||||
@@ -97,16 +138,23 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
this.handleDpiChange = this.handleDpiChange.bind(this);
|
this.handleDpiChange = this.handleDpiChange.bind(this);
|
||||||
this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
|
this.handleHeightMmChange = this.handleHeightMmChange.bind(this);
|
||||||
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
|
this.handleHeightMmBlur = this.handleHeightMmBlur.bind(this);
|
||||||
|
this.handlePrintClick = this.handlePrintClick.bind(this);
|
||||||
|
this.handleClosePrinterMenu = this.handleClosePrinterMenu.bind(this);
|
||||||
|
this.handleSelectPrinterForPrint = this.handleSelectPrinterForPrint.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
void this.loadTemplates();
|
void this.loadTemplates();
|
||||||
|
this.unsubPrinters = companyAutomat.subscribe(this, (state) => ({
|
||||||
|
printers: filterZplPrinters(state.agentprinters),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
|
this.unsubPrinters?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadTemplates() {
|
async loadTemplates() {
|
||||||
@@ -246,9 +294,66 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleEditorClose() {
|
handleEditorClose() {
|
||||||
if (this.state.saving) return;
|
if (this.state.saving || this.state.printing) return;
|
||||||
this._currentEditId = null;
|
this._currentEditId = null;
|
||||||
this.setState({ editorOpen: false, editorLoading: false, editorTemplate: null });
|
this.setState({
|
||||||
|
editorOpen: false,
|
||||||
|
editorLoading: false,
|
||||||
|
editorTemplate: null,
|
||||||
|
printerMenuAnchor: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePrintClick(event) {
|
||||||
|
const { printers } = this.state;
|
||||||
|
if (!printers || printers.length === 0) {
|
||||||
|
enqueueSnackbar('Kein ZPL-Drucker gefunden', { variant: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (printers.length === 1) {
|
||||||
|
void this.executeTestPrint(printers[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ printerMenuAnchor: event.currentTarget });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClosePrinterMenu() {
|
||||||
|
this.setState({ printerMenuAnchor: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSelectPrinterForPrint(targetPrinter) {
|
||||||
|
this.setState({ printerMenuAnchor: null });
|
||||||
|
void this.executeTestPrint(targetPrinter);
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeTestPrint(targetPrinter) {
|
||||||
|
if (!targetPrinter?.id) return;
|
||||||
|
const {
|
||||||
|
editorTemplate, editorObjects, editorWidth, editorHeight, editorDpi, editorName,
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
this.setState({ printing: true });
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
agentprinterId: targetPrinter.id,
|
||||||
|
templateId: editorTemplate?.id,
|
||||||
|
name: editorName,
|
||||||
|
width: editorWidth,
|
||||||
|
height: editorHeight,
|
||||||
|
dpi: editorDpi,
|
||||||
|
objects: editorObjects,
|
||||||
|
};
|
||||||
|
await labeltemplate.testprint(payload);
|
||||||
|
enqueueSnackbar(`Testdruck an ${getPrinterLabel(targetPrinter)} gesendet.`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
} finally {
|
||||||
|
if (!this._cancelled) {
|
||||||
|
this.setState({ printing: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleEditorSave() {
|
async handleEditorSave() {
|
||||||
@@ -320,7 +425,7 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
const {
|
const {
|
||||||
editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
|
editorOpen, editorLoading, editorTemplate, editorName, editorObjects,
|
||||||
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
|
editorHeight, editorWidth, editorDpi, editorWidthClass, editorHeightMm,
|
||||||
editorClipboard, saving,
|
editorClipboard, saving, printing, printers, printerMenuAnchor,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const minHeightPx = getMinHeightPx(editorDpi);
|
const minHeightPx = getMinHeightPx(editorDpi);
|
||||||
@@ -438,18 +543,66 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
<DialogActions sx={{ px: 3, pb: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Button onClick={this.handleEditorClose} disabled={saving}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
Abbrechen
|
<Tooltip
|
||||||
</Button>
|
title={
|
||||||
<Button
|
printers.length === 0
|
||||||
variant="contained"
|
? 'Kein ZPL-Drucker verfügbar'
|
||||||
onClick={this.handleEditorSave}
|
: printers.length === 1
|
||||||
disabled={saving || editorLoading || !editorName.trim()}
|
? `Drucken auf ${getPrinterLabel(printers[0])}`
|
||||||
>
|
: 'Testdruck an Drucker senden'
|
||||||
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
}
|
||||||
{saving ? 'Speichert…' : 'Speichern'}
|
>
|
||||||
</Button>
|
<span>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon={printing ? <CircularProgress size={18} /> : <PrintIcon />}
|
||||||
|
endIcon={printers.length > 1 ? <ArrowDropDownIcon /> : null}
|
||||||
|
onClick={this.handlePrintClick}
|
||||||
|
disabled={printing || saving || editorLoading || printers.length === 0}
|
||||||
|
>
|
||||||
|
{printing ? 'Druckt…' : 'Testdruck'}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
{printers.length > 1 && (
|
||||||
|
<Menu
|
||||||
|
anchorEl={printerMenuAnchor}
|
||||||
|
open={Boolean(printerMenuAnchor)}
|
||||||
|
onClose={this.handleClosePrinterMenu}
|
||||||
|
>
|
||||||
|
{printers.map((p) => (
|
||||||
|
<MenuItem
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => this.handleSelectPrinterForPrint(p)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PrintIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={getPrinterLabel(p)}
|
||||||
|
secondary={p.serial ? `Seriennummer: ${p.serial}` : undefined}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Button onClick={this.handleEditorClose} disabled={saving || printing}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={this.handleEditorSave}
|
||||||
|
disabled={saving || printing || editorLoading || !editorName.trim()}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
|
||||||
|
{saving ? 'Speichert…' : 'Speichern'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||||||
import ListItemButton from '@mui/material/ListItemButton';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
@@ -147,8 +146,8 @@ class GlobalHistoryRow extends PureComponent {
|
|||||||
onClick={
|
onClick={
|
||||||
isArticle
|
isArticle
|
||||||
? () => {
|
? () => {
|
||||||
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
|
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -207,11 +206,9 @@ class GlobalHistoryRow extends PureComponent {
|
|||||||
|
|
||||||
{/* 4. Meldung / Beschreibung */}
|
{/* 4. Meldung / Beschreibung */}
|
||||||
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
|
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
|
||||||
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}>
|
<Typography variant="body2" sx={ellipsisSx}>
|
||||||
<Typography variant="body2" sx={ellipsisSx}>
|
{item.text || item.value || '–'}
|
||||||
{item.text || item.value || '–'}
|
</Typography>
|
||||||
</Typography>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
@@ -445,11 +442,11 @@ export default class GlobalHistoryPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleReload() {
|
handleReload() {
|
||||||
void globalHistoryAutomat.actions.reload().catch(() => {});
|
void globalHistoryAutomat.actions.reload().catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRetry() {
|
handleRetry() {
|
||||||
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
|
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
import { withManifestId } from '../../components/manifest';
|
import { withManifestId } from '../../components/manifest';
|
||||||
import LieferungNeuPage from './neu';
|
|
||||||
|
const LieferungNeuPage = lazy(() => import('./neu'));
|
||||||
|
|
||||||
export default withManifestId('deliveryId', LieferungNeuPage);
|
export default withManifestId('deliveryId', LieferungNeuPage);
|
||||||
|
|||||||
@@ -32,8 +32,19 @@ function sortSuppliers(suppliers) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftsFrom(suppliers) {
|
function draftsFrom(suppliers, currentDrafts = {}, prevSuppliers = []) {
|
||||||
return Object.fromEntries(suppliers.map((row) => [row.id, row.name]));
|
const prevNames = new Map((prevSuppliers || []).map((s) => [s.id, s.name]));
|
||||||
|
const drafts = {};
|
||||||
|
for (const row of suppliers) {
|
||||||
|
const draft = currentDrafts[row.id];
|
||||||
|
const prevName = prevNames.get(row.id);
|
||||||
|
if (draft !== undefined && prevName !== undefined && draft !== prevName) {
|
||||||
|
drafts[row.id] = draft;
|
||||||
|
} else {
|
||||||
|
drafts[row.id] = row.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return drafts;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialParams(props) {
|
function getInitialParams(props) {
|
||||||
@@ -82,6 +93,10 @@ class LieferungLieferantenPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
|
|
||||||
|
if (companyAutomat.state.suppliersStatus === 'idle') {
|
||||||
|
void companyAutomat.actions.loadSuppliers().catch((err) => notifyError(err));
|
||||||
|
}
|
||||||
|
|
||||||
this.unsub = companyAutomat.subscribe(
|
this.unsub = companyAutomat.subscribe(
|
||||||
this,
|
this,
|
||||||
(state) => {
|
(state) => {
|
||||||
@@ -90,7 +105,7 @@ class LieferungLieferantenPage extends PureComponent {
|
|||||||
status: state.suppliersStatus,
|
status: state.suppliersStatus,
|
||||||
error: state.suppliersError ? backendErrorFrom(state.suppliersError) : null,
|
error: state.suppliersError ? backendErrorFrom(state.suppliersError) : null,
|
||||||
suppliers: sorted,
|
suppliers: sorted,
|
||||||
drafts: draftsFrom(sorted),
|
drafts: draftsFrom(sorted, this.state?.drafts, this.state?.suppliers),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
['suppliers', 'suppliersStatus', 'suppliersError'],
|
['suppliers', 'suppliersStatus', 'suppliersError'],
|
||||||
|
|||||||
@@ -576,6 +576,20 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// If this line was previously mapped to a different article, unlink the old supplier article
|
||||||
|
const existingSupplierArticle =
|
||||||
|
(line?.key != null ? this.state.supplierArticlesByLineKey?.[line.key] : null) ||
|
||||||
|
(supplierSku ? (this.state.supplierArticlesMap?.[supplierSku.toLowerCase()] || this.state.supplierArticlesMap?.[supplierSku]) : null);
|
||||||
|
const oldInternalSku = existingSupplierArticle?.article?.sku;
|
||||||
|
const oldAssignmentId = existingSupplierArticle?.id;
|
||||||
|
if (oldInternalSku && oldAssignmentId != null && String(oldInternalSku).toLowerCase() !== String(internalSku).toLowerCase()) {
|
||||||
|
try {
|
||||||
|
await this.automat.actions.unlinkSupplierArticle(oldInternalSku, oldAssignmentId, line?.key);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
console.warn('Vorherige Lieferanten-Zuordnung konnte nicht gelöst werden:', unlinkErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await articleApi.addSupplier(internalSku, {
|
await articleApi.addSupplier(internalSku, {
|
||||||
entityId: partyId,
|
entityId: partyId,
|
||||||
sku: supplierSku || undefined,
|
sku: supplierSku || undefined,
|
||||||
@@ -587,13 +601,16 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
|
|
||||||
if (supplierSku) {
|
if (supplierSku) {
|
||||||
await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
await this.automat.actions.fetchSupplierArticleForSku(supplierSku, line.key);
|
||||||
|
} else {
|
||||||
|
await this.automat.actions.fetchSupplierArticleForSku(internalSku, line.key);
|
||||||
}
|
}
|
||||||
await this.automat.actions.fetchSupplierArticles(partyId);
|
await this.automat.actions.fetchSupplierArticles(partyId);
|
||||||
|
|
||||||
if (!line.name && article.name) {
|
if (!line.name || (existingSupplierArticle?.article?.name && line.name === existingSupplierArticle.article.name)) {
|
||||||
this.automat.actions.updateLineitem({
|
this.automat.actions.updateLineitem({
|
||||||
...line,
|
...line,
|
||||||
name: article.name,
|
name: article.name || line.name,
|
||||||
|
...(!line.sku ? { sku: internalSku } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 695 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 530 KiB After Width: | Height: | Size: 529 KiB |
|
Before Width: | Height: | Size: 546 KiB After Width: | Height: | Size: 540 KiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 598 KiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 406 KiB |