Compare commits
16 Commits
ff165799d4
...
5d8ac3a8eb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d8ac3a8eb | ||
|
|
0851c31970 | ||
|
|
d8c1d44652 | ||
|
|
23a198b0c1 | ||
|
|
7ad8a3a301 | ||
|
|
4a7a02a0a4 | ||
|
|
8e098b145e | ||
|
|
7a4d452109 | ||
|
|
99d95ee234 | ||
|
|
f5a5cb51e3 | ||
|
|
52411ac45d | ||
|
|
5c57097ad0 | ||
|
|
a592cc30f6 | ||
|
|
764d6f850a | ||
|
|
d851c6e1e9 | ||
|
|
7bc1da4de6 |
@@ -7,7 +7,7 @@ description: >-
|
|||||||
|
|
||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 249 API endpoints across all modules.
|
Overview of all 250 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -17,15 +17,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
### Reusable types
|
### Reusable types
|
||||||
|
|
||||||
- `T1` = `{ order: T2 }`
|
- `T1` = `{ order: T2 }`
|
||||||
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, shipping?: T38|null, shippings?: T38[], payment?: T44|null, payments?: T44[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T6[], files?: T16[] }`
|
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], externalNumber?: str|null, createdAt?: str, shipping?: T36|null, shippings?: T36[], payment?: T42|null, payments?: T42[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T6[], files?: T16[] }`
|
||||||
- `T3` = `{ article: T4 }`
|
- `T3` = `{ article: T4 }`
|
||||||
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T41, brand: T39|null, categoryIds: int[], photos: T36[], eans: str[], prices: T48[], suppliers: T18[] }`
|
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T18[] }`
|
||||||
- `T5` = `{ shipment: T6 }`
|
- `T5` = `{ shipment: T6 }`
|
||||||
- `T6` = `{ id: int, number: str, type: str, date: str, lines: T30[], shippings: T38[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T16[], state?: str }`
|
- `T6` = `{ id: int, number: str, type: str, date: str, lines: T29[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T16[], state?: str }`
|
||||||
- `T7` = `{ quote: T8 }`
|
- `T7` = `{ quote: T8 }`
|
||||||
- `T8` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T16[] }`
|
- `T8` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T16[] }`
|
||||||
- `T9` = `{ delivery: T10 }`
|
- `T9` = `{ delivery: T10 }`
|
||||||
- `T10` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T30[], files: T16[], externalNumber?: str|null, createdAt?: str, ledger?: T52 }`
|
- `T10` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T29[], files: T16[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }`
|
||||||
- `T11` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T12|null }`
|
- `T11` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T12|null }`
|
||||||
- `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }`
|
- `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }`
|
||||||
- `T13` = `{ labeltemplate: T15 }`
|
- `T13` = `{ labeltemplate: T15 }`
|
||||||
@@ -33,46 +33,44 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
- `T15` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T26, thumb?: str|null }`
|
- `T15` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T26, thumb?: str|null }`
|
||||||
- `T16` = `{ 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 }`
|
- `T16` = `{ 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 }`
|
||||||
- `T17` = `{ 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[] }`
|
- `T17` = `{ 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[] }`
|
||||||
- `T18` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T55|null }`
|
- `T18` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }`
|
||||||
- `T19` = `{ vfs: T53, storage: T53, database: T46 }`
|
- `T19` = `{ vfs: T51, storage: T51, database: T44 }`
|
||||||
- `T20` = `{ 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 }`
|
- `T20` = `{ 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 }`
|
||||||
- `T21` = `{ ledger: T22[] }`
|
- `T21` = `{ ledger: T22[] }`
|
||||||
- `T22` = `{ 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 }`
|
- `T22` = `{ 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 }`
|
||||||
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T42[], articleCount?: int }`
|
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
|
||||||
- `T24` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
|
- `T24` = `{ 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 }`
|
||||||
- `T25` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
- `T25` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
||||||
- `T26` = `{ objects: T31[] }`
|
- `T26` = `{ objects: T30[] }`
|
||||||
- `T27` = `{ objects: T32[] }`
|
- `T27` = `{ printer: T32 }`
|
||||||
- `T28` = `{ printer: T34 }`
|
- `T28` = `{ entity: T35 }`
|
||||||
- `T29` = `{ entity: T37 }`
|
- `T29` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||||
- `T30` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||||
- `T31` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
- `T31` = `{ shipping: T36 }`
|
||||||
- `T32` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||||
- `T33` = `{ shipping: T38 }`
|
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||||
- `T34` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T35` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||||
- `T36` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||||
- `T37` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||||
- `T38` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
- `T38` = `{ article: T43 }`
|
||||||
- `T39` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||||
- `T40` = `{ article: T45 }`
|
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||||
- `T41` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||||
- `T42` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||||
- `T43` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||||
- `T44` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||||
- `T45` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||||
- `T46` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||||
- `T47` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T48` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||||
- `T49` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||||
- `T50` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||||
- `T51` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||||
- `T52` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
- `T52` = `{ warehouse: T54 }`
|
||||||
- `T53` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||||
- `T54` = `{ warehouse: T56 }`
|
- `T54` = `{ id: int, name: str, sort: int }`
|
||||||
- `T55` = `{ net: num, taxId: int, taxRate?: num|null }`
|
- `T55` = `{ id: int, categoryIds: int[] }`
|
||||||
- `T56` = `{ id: int, name: str, sort: int }`
|
|
||||||
- `T57` = `{ id: int, categoryIds: int[] }`
|
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -102,7 +100,7 @@ _none_
|
|||||||
|
|
||||||
#### Create article in category — `POST /article`
|
#### Create article in category — `POST /article`
|
||||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||||
- Returns: `201 T40`
|
- Returns: `201 T38`
|
||||||
#### Export articles as ZIP — `GET /article/export.zip`
|
#### Export articles as ZIP — `GET /article/export.zip`
|
||||||
- Params: `query: categoryId?: int`
|
- Params: `query: categoryId?: int`
|
||||||
- Returns: `200 zip`
|
- Returns: `200 zip`
|
||||||
@@ -138,10 +136,10 @@ _none_
|
|||||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||||
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
||||||
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||||
- Returns: `201 T40`
|
- Returns: `201 T38`
|
||||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
||||||
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||||
- Returns: `200 { article: T45, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- Returns: `200 { article: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||||
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||||
- Returns: `200 { success: bool, deleted: bool }`
|
- Returns: `200 { success: bool, deleted: bool }`
|
||||||
@@ -168,7 +166,7 @@ _none_
|
|||||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
||||||
#### 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: `200 T40`
|
- Returns: `200 T38`
|
||||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
||||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||||
- Returns: `200 T3`
|
- Returns: `200 T3`
|
||||||
@@ -219,10 +217,10 @@ _none_
|
|||||||
- 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: T42[] }`
|
- Returns: `200 { values: T40[] }`
|
||||||
#### 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: T42 }`
|
- Returns: `201 { attributevalue: T40 }`
|
||||||
#### 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 }`
|
||||||
@@ -240,28 +238,28 @@ _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: T42 }`
|
- Returns: `200 { attributevalue: T40 }`
|
||||||
#### 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: T49[] } }`
|
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
|
||||||
#### 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: T49[] } }`
|
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
|
||||||
|
|
||||||
### 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: T47 }`
|
- Returns: `200 { message: str, user: T45 }`
|
||||||
#### 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: T47, googleClientId: str }`
|
- Returns: `200 { user: T45, googleClientId: 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: T47 }`
|
- Returns: `200 { user: T45 }`
|
||||||
|
|
||||||
### Section: /backup
|
### Section: /backup
|
||||||
|
|
||||||
@@ -282,7 +280,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: T49[], gsprpinfo?: str|null } }`
|
- Returns: `201 { brand: { id: int, name: str, pics: T47[], 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`
|
||||||
@@ -291,10 +289,10 @@ _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: T49[] } }`
|
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
|
||||||
#### 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: T49[] } }`
|
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
|
||||||
|
|
||||||
### Section: /category
|
### Section: /category
|
||||||
|
|
||||||
@@ -318,7 +316,7 @@ _none_
|
|||||||
- 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`
|
||||||
- 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, brand?: T39|null }[] }`
|
- 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, brand?: T37|null }[] }`
|
||||||
#### 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 }[] }`
|
||||||
@@ -348,7 +346,7 @@ _none_
|
|||||||
- Returns: `200 { companies: T25[] }`
|
- Returns: `200 { companies: T25[] }`
|
||||||
#### 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: T25, user: T47 }`
|
- Returns: `201 { company: T25, user: T45 }`
|
||||||
#### 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`
|
||||||
@@ -387,7 +385,7 @@ _none_
|
|||||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||||
#### Create customer group — `POST /customergroup`
|
#### Create customer group — `POST /customergroup`
|
||||||
- Params: `body: { name: str }`
|
- Params: `body: { name: str }`
|
||||||
- Returns: `201 { customergroup: T56 }`
|
- Returns: `201 { customergroup: T54 }`
|
||||||
#### Reorder customer groups — `PUT /customergroup/order`
|
#### Reorder customer groups — `PUT /customergroup/order`
|
||||||
- Params: `body: { customergroupIds: int[] }`
|
- Params: `body: { customergroupIds: int[] }`
|
||||||
- Returns: `200 { customergroupIds: int[] }`
|
- Returns: `200 { customergroupIds: int[] }`
|
||||||
@@ -396,28 +394,16 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update customer group — `PATCH /customergroup/{id}`
|
#### Update customer group — `PATCH /customergroup/{id}`
|
||||||
- Params: `id: int, body: { name: str }`
|
- Params: `id: int, body: { name: str }`
|
||||||
- Returns: `200 { customergroup: T56 }`
|
- Returns: `200 { customergroup: T54 }`
|
||||||
|
|
||||||
### Section: /delivery
|
### Section: /delivery
|
||||||
|
|
||||||
#### List deliveries — `GET /delivery`
|
#### List deliveries — `GET /delivery`
|
||||||
_none_
|
- 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|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T52 }[] }`
|
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T50 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create delivery — `POST /delivery`
|
#### Create delivery — `POST /delivery`
|
||||||
- Params: `body: { externalNumber: str, date: str, lines: T35[], supplierId?: int|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, lines: T33[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||||
- Returns: `201 T9`
|
- Returns: `201 T9`
|
||||||
#### Parse document (SSE) — `POST /delivery/parse`
|
|
||||||
- Params: `form: { file: str }`
|
|
||||||
- Returns: `200 SSE`
|
|
||||||
|
|
||||||
- Events:
|
|
||||||
- `status`: `{ "message": "…" }`
|
|
||||||
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
|
|
||||||
- `tool_call`: `{ "name": "output_manifest" }`
|
|
||||||
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
|
|
||||||
- `progress`: `ManifestResult`
|
|
||||||
- `result`: `ManifestResult`
|
|
||||||
- `error`: `{ "message": "…" }`
|
|
||||||
#### Delete delivery — `DELETE /delivery/{id}`
|
#### Delete delivery — `DELETE /delivery/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -425,7 +411,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T9`
|
- Returns: `200 T9`
|
||||||
#### Update delivery — `PATCH /delivery/{id}`
|
#### Update delivery — `PATCH /delivery/{id}`
|
||||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T35[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T33[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||||
- Returns: `200 T9`
|
- Returns: `200 T9`
|
||||||
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
|
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
|
||||||
- Params: `id: int, fileId: int`
|
- Params: `id: int, fileId: int`
|
||||||
@@ -441,10 +427,10 @@ _none_
|
|||||||
|
|
||||||
#### List entities — `GET /entity`
|
#### List entities — `GET /entity`
|
||||||
- Params: `query: customergroupId?: int, role?: str`
|
- Params: `query: customergroupId?: int, role?: str`
|
||||||
- Returns: `200 { entities: T37[] }`
|
- Returns: `200 { entities: T35[] }`
|
||||||
#### 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 T29`
|
- Returns: `201 T28`
|
||||||
#### 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 }`
|
||||||
@@ -462,25 +448,25 @@ _none_
|
|||||||
- Returns: `200 { address: T20 }`
|
- Returns: `200 { address: T20 }`
|
||||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||||
- Params: `entityId: int`
|
- Params: `entityId: int`
|
||||||
- Returns: `200 { contacts: T50[] }`
|
- Returns: `200 { contacts: T48[] }`
|
||||||
#### 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: T50 }`
|
- Returns: `201 { contact: T48 }`
|
||||||
#### 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: T50 }`
|
- Returns: `200 { contact: T48 }`
|
||||||
#### 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 T29`
|
- Returns: `200 T28`
|
||||||
#### 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 T29`
|
- Returns: `200 T28`
|
||||||
|
|
||||||
### Section: /events
|
### Section: /events
|
||||||
|
|
||||||
@@ -503,8 +489,6 @@ _none_
|
|||||||
- `modelconversation`: `{}`
|
- `modelconversation`: `{}`
|
||||||
- `modelconversation-<id>`: `{}`
|
- `modelconversation-<id>`: `{}`
|
||||||
- `brand`: `{ sender, brandId }`
|
- `brand`: `{ sender, brandId }`
|
||||||
- `draft`: `{ sender, action, name, content }`
|
|
||||||
- `draft:request`: `{ requestId, action, name, target, sender }`
|
|
||||||
|
|
||||||
### Section: /health
|
### Section: /health
|
||||||
|
|
||||||
@@ -524,7 +508,7 @@ _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: T27 }`
|
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T26 }`
|
||||||
- Returns: `201 T13`
|
- Returns: `201 T13`
|
||||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -533,10 +517,10 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T13`
|
- Returns: `200 T13`
|
||||||
#### 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?: T27 }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
|
||||||
- Returns: `200 T13`
|
- Returns: `200 T13`
|
||||||
#### 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?: T27 }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
|
||||||
- Returns: `200 T13`
|
- Returns: `200 T13`
|
||||||
|
|
||||||
### Section: /ledger
|
### Section: /ledger
|
||||||
@@ -544,9 +528,6 @@ _none_
|
|||||||
#### 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 T21`
|
- Returns: `200 T21`
|
||||||
#### Add ledger entry — `POST /ledger`
|
|
||||||
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
|
||||||
- Returns: `201 { ledger: T22 }`
|
|
||||||
#### 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 T21`
|
- Returns: `200 T21`
|
||||||
@@ -632,7 +613,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: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create order — `POST /order`
|
#### Create order — `POST /order`
|
||||||
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }`
|
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null, externalNumber?: str|null }`
|
||||||
- Returns: `201 T1`
|
- Returns: `201 T1`
|
||||||
#### Delete order — `DELETE /order/{id}`
|
#### Delete order — `DELETE /order/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -641,7 +622,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T1`
|
- Returns: `200 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?: T35[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
|
||||||
- Returns: `200 T1`
|
- Returns: `200 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`
|
||||||
@@ -651,10 +632,10 @@ _none_
|
|||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### List payments of an order — `GET /order/{id}/payment`
|
#### List payments of an order — `GET /order/{id}/payment`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { payments: T44[] }`
|
- Returns: `200 { payments: T42[] }`
|
||||||
#### 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: T44 }`
|
- Returns: `201 { payment: T42 }`
|
||||||
#### 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 T14`
|
- Returns: `201 T14`
|
||||||
@@ -666,10 +647,10 @@ _none_
|
|||||||
- Returns: `201 T5`
|
- Returns: `201 T5`
|
||||||
#### 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: T38[] }`
|
- Returns: `200 { shippings: T36[] }`
|
||||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||||
- Returns: `201 T33`
|
- Returns: `201 T31`
|
||||||
|
|
||||||
### Section: /package
|
### Section: /package
|
||||||
|
|
||||||
@@ -699,25 +680,25 @@ _none_
|
|||||||
- Returns: `200 { posterminal: T17 }`
|
- Returns: `200 { posterminal: T17 }`
|
||||||
#### 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 T57`
|
- Returns: `200 T55`
|
||||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T57`
|
- Returns: `200 T55`
|
||||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||||
- Params: `id: int, categoryId: int`
|
- Params: `id: int, categoryId: int`
|
||||||
- Returns: `200 T57`
|
- Returns: `200 T55`
|
||||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||||
- Params: `id: int, categoryId: int`
|
- Params: `id: int, categoryId: int`
|
||||||
- Returns: `200 T57`
|
- Returns: `200 T55`
|
||||||
|
|
||||||
### Section: /printer
|
### Section: /printer
|
||||||
|
|
||||||
#### List printers — `GET /printer`
|
#### List printers — `GET /printer`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { printers: T34[] }`
|
- Returns: `200 { printers: T32[] }`
|
||||||
#### 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 T28`
|
- Returns: `201 T27`
|
||||||
#### 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[] }`
|
||||||
@@ -726,22 +707,22 @@ _none_
|
|||||||
- 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 T28`
|
- Returns: `200 T27`
|
||||||
#### 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 T28`
|
- Returns: `200 T27`
|
||||||
#### 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 T28`
|
- Returns: `200 T27`
|
||||||
|
|
||||||
### Section: /propertyname
|
### Section: /propertyname
|
||||||
|
|
||||||
#### List property names — `GET /propertyname`
|
#### List property names — `GET /propertyname`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { propertynames: T43[] }`
|
- Returns: `200 { propertynames: T41[] }`
|
||||||
#### 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: T43 }`
|
- Returns: `201 { propertyname: T41 }`
|
||||||
#### 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 }`
|
||||||
@@ -750,19 +731,19 @@ _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: T43 }`
|
- Returns: `200 { propertyname: T41 }`
|
||||||
|
|
||||||
### Section: /public
|
### Section: /public
|
||||||
|
|
||||||
#### Receipt crash dump — `POST /public/telemetry/crash`
|
#### Receipt crash dump — `POST /public/telemetry/crash`
|
||||||
- Params: `body: { content: any }`
|
- Params: `body: { content: str|{} }`
|
||||||
- Returns: `200 { ok: bool, filename: str }`
|
- Returns: `200 { ok: bool, filename: str }`
|
||||||
#### List public articles — `GET /public/{companyId}/articles`
|
#### List public articles — `GET /public/{companyId}/articles`
|
||||||
- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int`
|
- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int`
|
||||||
- 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: {}|null, brand: {}|null, price: {}|null, prices: {}[], categories: {}[], photos: {}[], eans: str[], isParent: bool, parentSku: str|null, variants: {}[] } }`
|
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T39|null, brand: T37|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: T34[], 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 }[] }`
|
||||||
@@ -773,7 +754,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: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
- Returns: `200 { quotes: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create quote — `POST /quote`
|
#### Create quote — `POST /quote`
|
||||||
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }`
|
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null, externalNumber?: str|null }`
|
||||||
- Returns: `201 T7`
|
- Returns: `201 T7`
|
||||||
#### Delete quote — `DELETE /quote/{id}`
|
#### Delete quote — `DELETE /quote/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -782,7 +763,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T7`
|
- Returns: `200 T7`
|
||||||
#### 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?: T35[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
|
||||||
- Returns: `200 T7`
|
- Returns: `200 T7`
|
||||||
#### 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`
|
||||||
@@ -791,7 +772,7 @@ _none_
|
|||||||
- Params: `id: int, fileId: int, query: download?: bool`
|
- Params: `id: int, fileId: int, query: download?: bool`
|
||||||
- Returns: `200 str`
|
- Returns: `200 str`
|
||||||
#### Create order from quote — `POST /quote/{id}/order`
|
#### Create order from quote — `POST /quote/{id}/order`
|
||||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }`
|
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
|
||||||
- Returns: `201 T1`
|
- Returns: `201 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`
|
||||||
@@ -837,10 +818,10 @@ _none_
|
|||||||
- Returns: `201 T14`
|
- Returns: `201 T14`
|
||||||
#### 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: T38[] }`
|
- Returns: `200 { shippings: T36[] }`
|
||||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||||
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||||
- Returns: `201 T33`
|
- Returns: `201 T31`
|
||||||
|
|
||||||
### Section: /shipping
|
### Section: /shipping
|
||||||
|
|
||||||
@@ -849,7 +830,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, carrier?: str, warehouseId?: int }`
|
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||||
- Returns: `200 T33`
|
- Returns: `200 T31`
|
||||||
#### 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 }[] }`
|
||||||
@@ -865,30 +846,36 @@ _none_
|
|||||||
|
|
||||||
### Section: /stock
|
### Section: /stock
|
||||||
|
|
||||||
|
#### Accept delivery manifest item(s) — `POST /stock/accept`
|
||||||
|
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
||||||
|
- 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: T22[] }`
|
- 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: T22[] }`
|
||||||
|
#### Ship shipment manifest item(s) — `POST /stock/ship`
|
||||||
|
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
||||||
|
- Returns: `200`
|
||||||
|
|
||||||
### Section: /supplier
|
### Section: /supplier
|
||||||
|
|
||||||
#### List suppliers — `GET /supplier`
|
#### List suppliers — `GET /supplier`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { suppliers: T51[] }`
|
- Returns: `200 { suppliers: T49[] }`
|
||||||
#### Create supplier — `POST /supplier`
|
#### Create supplier — `POST /supplier`
|
||||||
- Params: `body: { name: str }`
|
- Params: `body: { name: str }`
|
||||||
- Returns: `201 { supplier: T51 }`
|
- Returns: `201 { supplier: T49 }`
|
||||||
#### 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: T51 }`
|
- Returns: `200 { supplier: T49 }`
|
||||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||||
- Params: `id: int, query: skus?: string`
|
- 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?: T55|null }[] }`
|
- 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?: T53|null }[] }`
|
||||||
#### 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?: T55|null }[] }`
|
- 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?: T53|null }[] }`
|
||||||
|
|
||||||
### Section: /thumb
|
### Section: /thumb
|
||||||
|
|
||||||
@@ -903,10 +890,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### List units — `GET /unit`
|
#### List units — `GET /unit`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { units: T41[] }`
|
- Returns: `200 { units: T39[] }`
|
||||||
#### 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: T41 }`
|
- Returns: `200 { unit: T39 }`
|
||||||
|
|
||||||
### Section: /v1
|
### Section: /v1
|
||||||
|
|
||||||
@@ -931,15 +918,18 @@ _none_
|
|||||||
#### List virtual filesystem files — `GET /vfs`
|
#### List virtual filesystem files — `GET /vfs`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
|
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
|
||||||
#### Respond to a frontend draft request — `POST /vfs/drafts/response`
|
#### Restore system default applets — `POST /vfs/applets/restore`
|
||||||
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }`
|
_none_
|
||||||
- Returns: `200 { success: bool }`
|
- Returns: `200 { success: bool, restored: str[], count: int }`
|
||||||
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
||||||
- Params: `body: { path: str }`
|
- Params: `body: { path: str }`
|
||||||
- Returns: `200 { success: bool, dir: str }`
|
- Returns: `200 { success: bool, dir: str }`
|
||||||
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
|
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
|
||||||
- Params: `body: { oldPath: str, newPath: str }`
|
- Params: `body: { oldPath: str, newPath: str }`
|
||||||
- Returns: `200 { success: bool, oldPath: str, newPath: str }`
|
- Returns: `200 { success: bool, oldPath: str, newPath: str }`
|
||||||
|
#### Restore system default skills — `POST /vfs/skills/restore`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { success: bool, restored: str[], count: int }`
|
||||||
#### Upload files to virtual filesystem — `POST /vfs/upload`
|
#### Upload files to virtual filesystem — `POST /vfs/upload`
|
||||||
- Params: `form: { files: str[], targetDir?: str }`
|
- Params: `form: { files: str[], targetDir?: str }`
|
||||||
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
|
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
|
||||||
@@ -960,10 +950,10 @@ _none_
|
|||||||
|
|
||||||
#### List warehouses — `GET /warehouse`
|
#### List warehouses — `GET /warehouse`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { warehouses: T56[] }`
|
- Returns: `200 { warehouses: T54[] }`
|
||||||
#### Create warehouse — `POST /warehouse`
|
#### Create warehouse — `POST /warehouse`
|
||||||
- Params: `body: { name: str, sort?: int }`
|
- Params: `body: { name: str, sort?: int }`
|
||||||
- Returns: `201 T54`
|
- Returns: `201 T52`
|
||||||
#### Reorder warehouses — `PUT /warehouse/order`
|
#### Reorder warehouses — `PUT /warehouse/order`
|
||||||
- Params: `body: { warehouseIds: int[] }`
|
- Params: `body: { warehouseIds: int[] }`
|
||||||
- Returns: `200 { warehouseIds: int[] }`
|
- Returns: `200 { warehouseIds: int[] }`
|
||||||
@@ -972,10 +962,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T54`
|
- Returns: `200 T52`
|
||||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||||
- Returns: `200 T54`
|
- Returns: `200 T52`
|
||||||
#### 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 T54`
|
- Returns: `200 T52`
|
||||||
|
|||||||
@@ -29,20 +29,25 @@ export const article = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/article/history
|
* GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
* History of all articles with paging/sorting/filtering
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||||
|
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||||
*/
|
*/
|
||||||
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
// #region GET /api/article/history — getHistory
|
||||||
|
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (startRow != null) params.set('startRow', String(startRow));
|
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
if (endRow != null) params.set('endRow', String(endRow));
|
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
if (offset != null) params.set('offset', String(offset));
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/article/:sku/history
|
* GET /api/article/:sku/history
|
||||||
|
|||||||
@@ -10,13 +10,35 @@ import { http, clientIdHeaders } from './http.js';
|
|||||||
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
||||||
*/
|
*/
|
||||||
export const delivery = {
|
export const delivery = {
|
||||||
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
|
/**
|
||||||
|
* GET /api/delivery
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number }} [opts]
|
||||||
|
* → { deliveries: DeliverySummary[], total?: number, unfilteredTotal?: number, offset?: number, limit?: number }
|
||||||
|
*/
|
||||||
// #region GET /api/delivery — list
|
// #region GET /api/delivery — list
|
||||||
list() {
|
list(opts) {
|
||||||
|
if (opts && typeof opts === 'object') {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (opts.offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(opts.offset) || 0))));
|
||||||
|
if (opts.limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(opts.limit) || 50))));
|
||||||
|
if (opts.search && String(opts.search).trim()) params.set('search', String(opts.search).trim());
|
||||||
|
if (opts.onlyUnbooked === true || opts.onlyUnbooked === 'true') params.set('onlyUnbooked', 'true');
|
||||||
|
if (opts.supplierId != null) params.set('supplierId', String(opts.supplierId));
|
||||||
|
if (opts.seekMonths != null && Number(opts.seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(opts.seekMonths))));
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return http(`/api/delivery${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
return http('/api/delivery');
|
return http('/api/delivery');
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/** POST /api/delivery/rebuild-summaries → { ok: boolean, companyId: number } */
|
||||||
|
rebuildSummaries() {
|
||||||
|
return http('/api/delivery/rebuild-summaries', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
/** GET /api/delivery/:id → { delivery: Delivery } */
|
/** GET /api/delivery/:id → { delivery: Delivery } */
|
||||||
// #region GET /api/delivery/:id — get
|
// #region GET /api/delivery/:id — get
|
||||||
get(id) {
|
get(id) {
|
||||||
@@ -96,83 +118,4 @@ export const delivery = {
|
|||||||
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
|
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/delivery/parse — multipart/form-data, field "file"
|
|
||||||
* Accepts PDF, TXT or CSV document (max 50 MB)
|
|
||||||
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
|
|
||||||
*/
|
|
||||||
// #region POST /api/delivery/parse — parseSse
|
|
||||||
async *parseSse(file, { signal } = {}) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file, file.name);
|
|
||||||
const res = await fetch('/api/delivery/parse', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { ...clientIdHeaders() },
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = res.statusText;
|
|
||||||
try {
|
|
||||||
const data = await res.json();
|
|
||||||
message = data.error || data.message || message;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
const err = new Error(message || `Request failed (${res.status})`);
|
|
||||||
err.status = res.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buf = '';
|
|
||||||
let currentEvent = 'message';
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
try {
|
|
||||||
reader.cancel();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
};
|
|
||||||
if (signal) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
onAbort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buf += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buf.split('\n');
|
|
||||||
buf = lines.pop();
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event:')) {
|
|
||||||
currentEvent = line.slice(6).trim();
|
|
||||||
} else if (line.startsWith('data:')) {
|
|
||||||
const raw = line.slice(5).trim();
|
|
||||||
let data;
|
|
||||||
try { data = JSON.parse(raw); } catch { data = raw; }
|
|
||||||
yield { event: currentEvent, data };
|
|
||||||
currentEvent = 'message';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (signal?.aborted || err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,20 @@ import { http } from './http.js';
|
|||||||
|
|
||||||
export const globalHistory = {
|
export const globalHistory = {
|
||||||
/**
|
/**
|
||||||
* GET /api/history
|
* GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||||
* Global system/entity history with paging/sorting/filtering
|
* Global system/entity history with chunk paging and search/seek filtering
|
||||||
|
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
|
||||||
|
* → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number }
|
||||||
*/
|
*/
|
||||||
// #region GET /api/history — list
|
// #region GET /api/history — list
|
||||||
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (startRow != null) params.set('startRow', String(startRow));
|
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||||
if (endRow != null) params.set('endRow', String(endRow));
|
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||||
if (offset != null) params.set('offset', String(offset));
|
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||||
if (limit != null) params.set('limit', String(limit));
|
if (seekMonths != null && Number(seekMonths) < 0) {
|
||||||
|
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
|
||||||
|
}
|
||||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
|
|||||||
@@ -6,16 +6,6 @@ import { http } from './http.js';
|
|||||||
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||||
*/
|
*/
|
||||||
export const ledger = {
|
export const ledger = {
|
||||||
/**
|
|
||||||
* POST /api/ledger
|
|
||||||
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
|
|
||||||
* → 201 { ledger: LedgerEntry }
|
|
||||||
*/
|
|
||||||
// #region POST /api/ledger — add
|
|
||||||
add(body) {
|
|
||||||
return http('/api/ledger', { method: 'POST', body });
|
|
||||||
},
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ledger/manifestarticle/:manifestarticleId
|
* GET /api/ledger/manifestarticle/:manifestarticleId
|
||||||
@@ -32,17 +22,15 @@ export const ledger = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ledger
|
* GET /api/ledger
|
||||||
* Query: { manifestarticle_id?, sku?, transaction_type? }
|
* Query: { manifestarticleId?, sku?, transactionType? }
|
||||||
* → 200 { ledger: LedgerEntry[] }
|
* → 200 { ledger: LedgerEntry[] }
|
||||||
*/
|
*/
|
||||||
// #region GET /api/ledger — list
|
// #region GET /api/ledger — list
|
||||||
list(query) {
|
list({ manifestarticleId, sku, transactionType } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
|
if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
|
||||||
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
|
if (sku != null) params.set('sku', String(sku));
|
||||||
if (query?.sku != null) params.set('sku', query.sku);
|
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||||
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
|
|
||||||
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,4 +13,24 @@ export const stock = {
|
|||||||
return http('/api/stock/adjust', { method: 'POST', body });
|
return http('/api/stock/adjust', { method: 'POST', body });
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stock/accept
|
||||||
|
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { deliveryId, warehouseId? }
|
||||||
|
*/
|
||||||
|
// #region POST /api/stock/accept — accept
|
||||||
|
accept(body) {
|
||||||
|
return http('/api/stock/accept', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stock/ship
|
||||||
|
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { shipmentId, warehouseId? }
|
||||||
|
*/
|
||||||
|
// #region POST /api/stock/ship — ship
|
||||||
|
ship(body) {
|
||||||
|
return http('/api/stock/ship', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -144,41 +144,33 @@ export const vfs = {
|
|||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/vfs/drafts/response
|
* POST /api/vfs/skills/restore
|
||||||
* Send response to virtual draft request (action: read or list).
|
* Restores all system default .SKILL files into the company's VFS (overwrites existing files).
|
||||||
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
|
* Requires no request body. Emits vfs SSE event.
|
||||||
|
* → 200 { success: true, restored: string[], count: number }
|
||||||
*/
|
*/
|
||||||
// #region POST /api/vfs/drafts/response — respondDraft
|
// #region POST /api/vfs/skills/restore — restoreSkills
|
||||||
async respondDraft(body) {
|
restoreSkills() {
|
||||||
try {
|
return http('/api/vfs/skills/restore', {
|
||||||
const res = await fetch('/api/vfs/drafts/response', {
|
method: 'POST',
|
||||||
method: 'POST',
|
});
|
||||||
headers: {
|
},
|
||||||
'Content-Type': 'application/json',
|
// #endregion
|
||||||
...clientIdHeaders(),
|
|
||||||
},
|
/**
|
||||||
credentials: 'include',
|
* POST /api/vfs/applets/restore
|
||||||
body: JSON.stringify(body),
|
* Restores all system default .applet.js example applets into the company's VFS (overwrites existing files).
|
||||||
});
|
* Requires no request body. Emits vfs SSE event.
|
||||||
if (!res.ok) {
|
* → 200 { success: true, restored: string[], count: number }
|
||||||
let errText = '';
|
*/
|
||||||
try {
|
// #region POST /api/vfs/applets/restore — restoreApplets
|
||||||
const errData = await res.json();
|
restoreApplets() {
|
||||||
errText = errData.error || errData.message;
|
return http('/api/vfs/applets/restore', {
|
||||||
} catch {
|
method: 'POST',
|
||||||
errText = await res.text().catch(() => '');
|
});
|
||||||
}
|
|
||||||
console.error('[vfs] respondDraft failed:', res.status, errText);
|
|
||||||
return { ok: false, error: errText };
|
|
||||||
}
|
|
||||||
if (res.status === 204) return { ok: true };
|
|
||||||
const text = await res.text();
|
|
||||||
return text ? JSON.parse(text) : { ok: true };
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[vfs] respondDraft error:', err);
|
|
||||||
return { ok: false, error: err.message };
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,45 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file articleHistoryAutomat.js
|
* @file articleHistoryAutomat.js
|
||||||
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for
|
* @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
|
||||||
* infinite-scrolling article history via `react-virtual-aggridautomat`.
|
* article audit logs and history entries with multi-filter caching and LRU eviction.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
* `selectedCompanyId` changes.
|
||||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||||
|
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
import { article } from '../api';
|
import { article } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
export const articleHistoryGridAutomat = createGridAutomat({
|
export const articleHistoryAutomat = createChunkListAutomat({
|
||||||
name: 'articleHistory',
|
name: 'articleHistory',
|
||||||
blockSize: 50,
|
chunkSize: 50,
|
||||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
maxLruChunks: 50,
|
||||||
try {
|
fetchPage: (params) => article.getHistory(params),
|
||||||
const res = await article.getHistory({
|
|
||||||
startRow,
|
|
||||||
endRow,
|
|
||||||
sortModel,
|
|
||||||
filterModel,
|
|
||||||
});
|
|
||||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
|
||||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
|
||||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
|
||||||
return { rows, lastRow };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to fetch article history (backend route pending):', err);
|
|
||||||
return { rows: [], lastRow: 0 };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload on company switch or auth change
|
// Alias for backwards compatibility
|
||||||
authAutomat.subscribe((auth) => {
|
export const articleHistoryGridAutomat = articleHistoryAutomat;
|
||||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
|
||||||
articleHistoryGridAutomat.actions.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default articleHistoryGridAutomat;
|
export default articleHistoryAutomat;
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ const initialState = {
|
|||||||
draftConversation: false,
|
draftConversation: false,
|
||||||
/** Prefilled input text waiting to be consumed by the chat UI. */
|
/** Prefilled input text waiting to be consumed by the chat UI. */
|
||||||
pendingInput: null,
|
pendingInput: null,
|
||||||
|
/** Staged text extraction snippet for data extraction pill. */
|
||||||
|
pendingExtraction: null,
|
||||||
conversationState: 'idle',
|
conversationState: 'idle',
|
||||||
provider: 'openrouter',
|
provider: 'openrouter',
|
||||||
model: DEFAULT_MODEL,
|
model: DEFAULT_MODEL,
|
||||||
@@ -283,6 +285,17 @@ function closeConversationStream() {
|
|||||||
}
|
}
|
||||||
activeStreamConversationId = null;
|
activeStreamConversationId = null;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Checks if a tool call or stream tool event represents an interactive tool
|
||||||
|
* that awaits user intervention (ask_user).
|
||||||
|
* @param {object} item
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInteractiveTool(item) {
|
||||||
|
if (!item) return false;
|
||||||
|
const name = item.name || item.function?.name;
|
||||||
|
return name === 'ask_user';
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/**
|
/**
|
||||||
@@ -327,6 +340,55 @@ const actions = {
|
|||||||
void chatAutomat.actions.loadConversations();
|
void chatAutomat.actions.loadConversations();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill.
|
||||||
|
* @param {{ text: string, label?: string, instruction?: string }} snippet
|
||||||
|
*/
|
||||||
|
insertExtractionSnippet({
|
||||||
|
text = '',
|
||||||
|
label = 'parsed invoice',
|
||||||
|
instruction = "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
} = {}) {
|
||||||
|
const trimmed = String(text || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
chatOpenedThisSession = true;
|
||||||
|
chatAutomat.setState({
|
||||||
|
isOpen: true,
|
||||||
|
sseEstablished: true,
|
||||||
|
pendingExtraction: {
|
||||||
|
text: trimmed,
|
||||||
|
label: label || 'parsed invoice',
|
||||||
|
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ensureCompanyEventsConnected();
|
||||||
|
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
|
||||||
|
chatAutomat.actions.startDraft();
|
||||||
|
}
|
||||||
|
void chatAutomat.actions.loadConversations();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the chat drawer and append text / prompt to the chat composer.
|
||||||
|
* @param {string} prompt
|
||||||
|
*/
|
||||||
|
insertPrompt(prompt = '') {
|
||||||
|
const text = String(prompt || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
chatOpenedThisSession = true;
|
||||||
|
chatAutomat.setState((prev) => ({
|
||||||
|
isOpen: true,
|
||||||
|
sseEstablished: true,
|
||||||
|
pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${text}` : text,
|
||||||
|
}));
|
||||||
|
ensureCompanyEventsConnected();
|
||||||
|
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
|
||||||
|
chatAutomat.actions.startDraft();
|
||||||
|
}
|
||||||
|
void chatAutomat.actions.loadConversations();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start an implicit new conversation with the first preset model. Nothing is
|
* Start an implicit new conversation with the first preset model. Nothing is
|
||||||
* created on the server until the first message is sent.
|
* created on the server until the first message is sent.
|
||||||
@@ -467,13 +529,18 @@ const actions = {
|
|||||||
|
|
||||||
const messages = msgsRes?.messages || [];
|
const messages = msgsRes?.messages || [];
|
||||||
const updatedConv = convRes?.conversation;
|
const updatedConv = convRes?.conversation;
|
||||||
|
const hasPendingInteractive = Boolean(
|
||||||
|
selectPendingAskUser({ messages })
|
||||||
|
);
|
||||||
|
|
||||||
chatAutomat.setState((prev) => ({
|
chatAutomat.setState((prev) => ({
|
||||||
messages,
|
messages,
|
||||||
loadingMessages: false,
|
loadingMessages: false,
|
||||||
conversationState: updatedConv?.state || prev.conversationState,
|
conversationState: hasPendingInteractive ? 'idle' : (updatedConv?.state || prev.conversationState),
|
||||||
|
streamPhase: hasPendingInteractive ? 'idle' : prev.streamPhase,
|
||||||
|
isSending: hasPendingInteractive ? false : prev.isSending,
|
||||||
conversations: updatedConv
|
conversations: updatedConv
|
||||||
? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c))
|
? prev.conversations.map((c) => (c.id === updatedConv.id ? (hasPendingInteractive ? { ...updatedConv, state: 'idle' } : updatedConv) : c))
|
||||||
: prev.conversations,
|
: prev.conversations,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -830,7 +897,7 @@ const actions = {
|
|||||||
const updatedTool = {
|
const updatedTool = {
|
||||||
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||||
state: 'green',
|
state: 'green',
|
||||||
name: data.name || '',
|
name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
|
||||||
arguments: data.arguments,
|
arguments: data.arguments,
|
||||||
content: data.content !== undefined ? data.content : data.arguments || '',
|
content: data.content !== undefined ? data.content : data.arguments || '',
|
||||||
result: data.result,
|
result: data.result,
|
||||||
@@ -844,12 +911,19 @@ const actions = {
|
|||||||
lastTurn.tools = tools;
|
lastTurn.tools = tools;
|
||||||
turns[lastIdx] = lastTurn;
|
turns[lastIdx] = lastTurn;
|
||||||
|
|
||||||
const hasAskUser = data.name === 'ask_user';
|
const isInteractive = isInteractiveTool(data) || isInteractiveTool(updatedTool);
|
||||||
|
const nextConversations = isInteractive
|
||||||
|
? prev.conversations.map((c) =>
|
||||||
|
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
||||||
|
)
|
||||||
|
: prev.conversations;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTurns: turns,
|
activeTurns: turns,
|
||||||
streamPhase: hasAskUser ? 'idle' : 'working',
|
streamPhase: isInteractive ? 'idle' : 'working',
|
||||||
conversationState: hasAskUser ? 'idle' : prev.conversationState,
|
conversationState: isInteractive ? 'idle' : prev.conversationState,
|
||||||
isSending: hasAskUser ? false : prev.isSending,
|
isSending: isInteractive ? false : prev.isSending,
|
||||||
|
conversations: nextConversations,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1146,10 +1220,10 @@ const actions = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
|
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
|
||||||
* Body: { role: 'tool', tool_call_id, content, answer }
|
* Body: { role: 'tool', tool_call_id, name, content, answer }
|
||||||
* @param {{ tool_call_id: string, answer: string }} params
|
* @param {{ tool_call_id: string, answer?: string, name?: string, content?: any }} params
|
||||||
*/
|
*/
|
||||||
async sendToolResponse({ tool_call_id, answer }) {
|
async sendToolResponse({ tool_call_id, answer, name = 'ask_user', content = undefined }) {
|
||||||
const { selectedConversationId, isSending } = chatAutomat.state;
|
const { selectedConversationId, isSending } = chatAutomat.state;
|
||||||
if (!selectedConversationId) {
|
if (!selectedConversationId) {
|
||||||
throw new Error('Keine Unterhaltung ausgewählt');
|
throw new Error('Keine Unterhaltung ausgewählt');
|
||||||
@@ -1158,12 +1232,13 @@ const actions = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toolContent = content !== undefined ? content : answer;
|
||||||
const payload = {
|
const payload = {
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
name: 'ask_user',
|
name,
|
||||||
content: answer,
|
content: toolContent,
|
||||||
answer,
|
...(answer !== undefined ? { answer } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
@@ -1207,7 +1282,7 @@ const actions = {
|
|||||||
if (m.role === 'assistant' && !m.turns) {
|
if (m.role === 'assistant' && !m.turns) {
|
||||||
try {
|
try {
|
||||||
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
|
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
|
||||||
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) {
|
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === name || tc.function?.name === 'ask_user')) {
|
||||||
foundExisting = true;
|
foundExisting = true;
|
||||||
return { ...m, turns: sealedTurns };
|
return { ...m, turns: sealedTurns };
|
||||||
}
|
}
|
||||||
@@ -1316,12 +1391,12 @@ const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAssistant = message.role === 'assistant' || message.role === 'model';
|
const isAssistant = message.role === 'assistant' || message.role === 'model';
|
||||||
let hasAskUser = false;
|
let hasInteractiveTool = false;
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
try {
|
try {
|
||||||
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
|
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
|
||||||
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) {
|
if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
|
||||||
hasAskUser = true;
|
hasInteractiveTool = true;
|
||||||
}
|
}
|
||||||
if (Array.isArray(payload?.tool_calls)) {
|
if (Array.isArray(payload?.tool_calls)) {
|
||||||
for (const tc of payload.tool_calls) {
|
for (const tc of payload.tool_calls) {
|
||||||
@@ -1360,9 +1435,9 @@ const actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
|
||||||
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase;
|
const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
|
||||||
const nextConversations = hasAskUser
|
const nextConversations = hasInteractiveTool
|
||||||
? prev.conversations.map((c) =>
|
? prev.conversations.map((c) =>
|
||||||
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
||||||
)
|
)
|
||||||
@@ -1374,6 +1449,7 @@ const actions = {
|
|||||||
conversationState: nextConversationState,
|
conversationState: nextConversationState,
|
||||||
streamPhase: nextStreamPhase,
|
streamPhase: nextStreamPhase,
|
||||||
conversations: nextConversations,
|
conversations: nextConversations,
|
||||||
|
isSending: hasInteractiveTool ? false : prev.isSending,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1534,6 +1610,7 @@ export function selectPendingAskUser(state = chatAutomat.state) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state));
|
export const selectHasPendingAsk = (state = chatAutomat.state) =>
|
||||||
|
Boolean(selectPendingAskUser(state));
|
||||||
|
|
||||||
export default chatAutomat;
|
export default chatAutomat;
|
||||||
|
|||||||
926
src/automats/createChunkListAutomat.js
Normal file
926
src/automats/createChunkListAutomat.js
Normal file
@@ -0,0 +1,926 @@
|
|||||||
|
/**
|
||||||
|
* @file createChunkListAutomat.js
|
||||||
|
* @description Factory function for company-scoped, chunk-cached double-buffered
|
||||||
|
* list automats with LRU memory caching, debounced prefetching, and multi-filter
|
||||||
|
* retention (like `orderAutomat` and `quoteAutomat`).
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes each created automat to auth state — resets on `'anon'`
|
||||||
|
* or when `selectedCompanyId` changes.
|
||||||
|
* - Consumer UI (e.g. `FillSpace`): Calls `getItems()` and `requireChunk(start, end)`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
|
||||||
|
export const DEFAULT_CHUNK_SIZE = 50;
|
||||||
|
export const DEFAULT_MAX_LRU_CHUNKS = 50;
|
||||||
|
const PREFETCH_CHUNKS = 1;
|
||||||
|
const CHUNK_DEBOUNCE_MS = 130;
|
||||||
|
const MAX_CHUNK_EVENTS = 150;
|
||||||
|
|
||||||
|
export function formatNegativeSeek(months) {
|
||||||
|
if (!months || months >= 0) return '';
|
||||||
|
const total = Math.abs(months);
|
||||||
|
const years = Math.floor(total / 12);
|
||||||
|
const remMonths = total % 12;
|
||||||
|
const parts = [];
|
||||||
|
if (years > 0) {
|
||||||
|
parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`);
|
||||||
|
}
|
||||||
|
if (remMonths > 0) {
|
||||||
|
parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`);
|
||||||
|
}
|
||||||
|
return `-${parts.join(' ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a chunk-paginated, double-buffered automat.
|
||||||
|
*
|
||||||
|
* @param {object} options
|
||||||
|
* @param {string} options.name Name for the Automat instance.
|
||||||
|
* @param {(params: { offset: number, limit: number, search?: string, seekMonths?: number }) => Promise<{ items?: any[], history?: any[], rows?: any[], entries?: any[], total?: number, unfilteredTotal?: number }>} options.fetchPage
|
||||||
|
* @param {number} [options.chunkSize=50]
|
||||||
|
* @param {number} [options.maxLruChunks=50]
|
||||||
|
* @returns {Automat} Configured chunk-list automat.
|
||||||
|
*/
|
||||||
|
export function createChunkListAutomat({
|
||||||
|
name,
|
||||||
|
fetchPage,
|
||||||
|
chunkSize = DEFAULT_CHUNK_SIZE,
|
||||||
|
maxLruChunks = DEFAULT_MAX_LRU_CHUNKS,
|
||||||
|
}) {
|
||||||
|
const initialState = {
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: 0,
|
||||||
|
filter: '',
|
||||||
|
seekMonths: 0,
|
||||||
|
seekYears: 0,
|
||||||
|
negativeSeekMonths: 0,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let automat;
|
||||||
|
let queryEpoch = 0;
|
||||||
|
let filterDebounceTimer = null;
|
||||||
|
let chunkDebounceTimer = null;
|
||||||
|
let currentEventTick = 0;
|
||||||
|
let lastRequireChunkTick = -1;
|
||||||
|
|
||||||
|
const pendingTargetChunks = new Set();
|
||||||
|
let pendingChunkPromise = null;
|
||||||
|
let pendingChunkResolve = null;
|
||||||
|
let pendingChunkReject = null;
|
||||||
|
|
||||||
|
// Multi-Filter Cache & Global LRU Management
|
||||||
|
const filterCaches = new Map();
|
||||||
|
const lruChunks = new Map();
|
||||||
|
const inflightChunks = new Map();
|
||||||
|
|
||||||
|
// Observability & Stats
|
||||||
|
let nextEventId = 0;
|
||||||
|
const chunkEvents = [];
|
||||||
|
const chunkStatsListeners = new Set();
|
||||||
|
|
||||||
|
function makeLruKey(filterKey, chunkIndex) {
|
||||||
|
return `${filterKey}:::${chunkIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLruKey(key) {
|
||||||
|
const [filterKey, chunkStr] = key.split(':::');
|
||||||
|
return { filterKey, chunkIndex: Number(chunkStr) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterParams(state = automat?.state || initialState) {
|
||||||
|
const filter = String(state.filter ?? '').trim();
|
||||||
|
const negativeSeekMonths =
|
||||||
|
state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0
|
||||||
|
? Number(state.negativeSeekMonths)
|
||||||
|
: 0;
|
||||||
|
return { filter, negativeSeekMonths };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilterKey(state = automat?.state || initialState) {
|
||||||
|
const { filter, negativeSeekMonths } = getFilterParams(state);
|
||||||
|
return JSON.stringify({ f: filter, s: negativeSeekMonths });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFilterLabel(filterKeyOrState) {
|
||||||
|
let f, s;
|
||||||
|
if (typeof filterKeyOrState === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(filterKeyOrState);
|
||||||
|
f = parsed.f;
|
||||||
|
s = parsed.s;
|
||||||
|
} catch {
|
||||||
|
return filterKeyOrState;
|
||||||
|
}
|
||||||
|
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
|
||||||
|
if ('f' in filterKeyOrState) {
|
||||||
|
f = filterKeyOrState.f;
|
||||||
|
s = filterKeyOrState.s;
|
||||||
|
} else {
|
||||||
|
const params = getFilterParams(filterKeyOrState);
|
||||||
|
f = params.filter;
|
||||||
|
s = params.negativeSeekMonths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (f) parts.push(`Suche: „${f}“`);
|
||||||
|
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
|
||||||
|
if (parts.length === 0) return 'Standard (Alle Einträge)';
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNonNullItems(items) {
|
||||||
|
if (!Array.isArray(items)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i] != null) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) {
|
||||||
|
let entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) {
|
||||||
|
const params = getFilterParams(filterState);
|
||||||
|
entry = {
|
||||||
|
key: filterKey,
|
||||||
|
label: formatFilterLabel(params),
|
||||||
|
params,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat?.state?.unfilteredTotal || 0,
|
||||||
|
items: null,
|
||||||
|
loadedChunks: new Set(),
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
lastAccessed: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
};
|
||||||
|
filterCaches.set(filterKey, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchChunkLRU(filterKey, chunkIndex) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
const existing = lruChunks.get(key);
|
||||||
|
if (existing) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
lruChunks.set(key, { ...existing, accessedAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerChunkLRU(filterKey, chunkIndex, itemCount = 0) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
if (lruChunks.has(key)) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
lruChunks.set(key, {
|
||||||
|
filterKey,
|
||||||
|
chunkIndex,
|
||||||
|
loadedAt: now,
|
||||||
|
accessedAt: now,
|
||||||
|
itemCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
while (lruChunks.size > maxLruChunks) {
|
||||||
|
const oldestKey = lruChunks.keys().next().value;
|
||||||
|
const oldestInfo = lruChunks.get(oldestKey);
|
||||||
|
lruChunks.delete(oldestKey);
|
||||||
|
if (oldestInfo) {
|
||||||
|
evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') {
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (entry.loadedChunks.has(chunkIndex)) {
|
||||||
|
entry.loadedChunks.delete(chunkIndex);
|
||||||
|
if (Array.isArray(entry.items)) {
|
||||||
|
const offset = chunkIndex * chunkSize;
|
||||||
|
for (let i = offset; i < offset + chunkSize && i < entry.items.length; i++) {
|
||||||
|
entry.items[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
if (filterKey === currentKey) {
|
||||||
|
automat.setState({ items: [...entry.items] });
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: reason === 'lru' ? 'lru_evict' : 'evict',
|
||||||
|
chunkIndex,
|
||||||
|
filterKey,
|
||||||
|
message:
|
||||||
|
reason === 'lru'
|
||||||
|
? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`
|
||||||
|
: `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordChunkEvent(entry) {
|
||||||
|
const event = {
|
||||||
|
id: ++nextEventId,
|
||||||
|
time: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: entry.filterKey ?? getFilterKey(automat?.state),
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
chunkEvents.push(event);
|
||||||
|
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
|
||||||
|
chunkEvents.shift();
|
||||||
|
}
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChunkStats() {
|
||||||
|
if (chunkStatsListeners.size === 0) return;
|
||||||
|
const stats = getChunkStats();
|
||||||
|
for (const listener of chunkStatsListeners) {
|
||||||
|
try {
|
||||||
|
listener(stats);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[${name}] chunkStatsListener error`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChunkStats() {
|
||||||
|
const currentKey = getFilterKey(automat?.state);
|
||||||
|
const activeEntry = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
const total = activeEntry?.total ?? automat?.state?.total ?? 0;
|
||||||
|
const activeLoaded = activeEntry?.loadedChunks ?? new Set();
|
||||||
|
const count = total > 0 ? Math.ceil(total / chunkSize) : 0;
|
||||||
|
|
||||||
|
const activeInflight = [];
|
||||||
|
for (const key of inflightChunks.keys()) {
|
||||||
|
const info = parseLruKey(key);
|
||||||
|
if (info.filterKey === currentKey) {
|
||||||
|
activeInflight.push(info.chunkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = activeEntry?.items ?? automat?.state?.items ?? null;
|
||||||
|
const loadedItemsCount = countNonNullItems(items);
|
||||||
|
|
||||||
|
return {
|
||||||
|
chunkSize,
|
||||||
|
maxLruChunks,
|
||||||
|
lruChunksCount: lruChunks.size,
|
||||||
|
total,
|
||||||
|
unfilteredTotal: activeEntry?.unfilteredTotal ?? automat?.state?.unfilteredTotal ?? total,
|
||||||
|
chunkCount: count,
|
||||||
|
loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b),
|
||||||
|
inflightChunks: activeInflight.sort((a, b) => a - b),
|
||||||
|
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
|
||||||
|
loadedItemsCount,
|
||||||
|
status: automat?.state?.status ?? 'idle',
|
||||||
|
queryEpoch,
|
||||||
|
filter: automat?.state?.filter ?? '',
|
||||||
|
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
|
||||||
|
items,
|
||||||
|
activeFilterKey: currentKey,
|
||||||
|
activeFilterLabel: formatFilterLabel(currentKey),
|
||||||
|
events: [...chunkEvents],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeChunkStats(listener) {
|
||||||
|
chunkStatsListeners.add(listener);
|
||||||
|
try {
|
||||||
|
listener(getChunkStats());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
chunkStatsListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChunkDebounce() {
|
||||||
|
if (chunkDebounceTimer) {
|
||||||
|
clearTimeout(chunkDebounceTimer);
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (pendingTargetChunks.size > 0) {
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
pendingChunkPromise = null;
|
||||||
|
pendingChunkResolve = null;
|
||||||
|
pendingChunkReject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _executeRequiredChunks(
|
||||||
|
chunks,
|
||||||
|
targetEpoch,
|
||||||
|
prefetch = true,
|
||||||
|
targetFilterKey = getFilterKey(automat.state)
|
||||||
|
) {
|
||||||
|
if (targetEpoch !== queryEpoch) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkList = Array.from(chunks).sort((a, b) => a - b);
|
||||||
|
if (chunkList.length === 0) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'execute',
|
||||||
|
chunks: chunkList,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for (const c of chunkList) {
|
||||||
|
promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises).then(() => {
|
||||||
|
if (targetEpoch !== queryEpoch) return automat.state.items;
|
||||||
|
if (prefetch) {
|
||||||
|
const maxChunk = chunkList[chunkList.length - 1];
|
||||||
|
const entry = filterCaches.get(targetFilterKey);
|
||||||
|
const total = entry?.total || automat.state.total || 0;
|
||||||
|
for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
|
||||||
|
if (total > 0 && c * chunkSize >= total) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'prefetch',
|
||||||
|
chunkIndex: c,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`,
|
||||||
|
});
|
||||||
|
void actions.loadChunk(c, targetEpoch, targetFilterKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return automat.state.items;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryParamsFor(filterParams, offset = 0, limit = chunkSize) {
|
||||||
|
const params = { offset, limit };
|
||||||
|
const trimmed = String(filterParams.filter ?? '').trim();
|
||||||
|
if (trimmed) params.search = trimmed;
|
||||||
|
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
|
||||||
|
params.seekMonths = Number(filterParams.negativeSeekMonths);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRecordsPageFor(filterParams, offset = 0, limit = chunkSize) {
|
||||||
|
const params = buildQueryParamsFor(filterParams, offset, limit);
|
||||||
|
const res = await fetchPage(params);
|
||||||
|
const records = Array.isArray(res?.items)
|
||||||
|
? res.items
|
||||||
|
: Array.isArray(res?.history)
|
||||||
|
? res.history
|
||||||
|
: Array.isArray(res?.rows)
|
||||||
|
? res.rows
|
||||||
|
: Array.isArray(res?.entries)
|
||||||
|
? res.entries
|
||||||
|
: Array.isArray(res)
|
||||||
|
? res
|
||||||
|
: [];
|
||||||
|
const total = Number(res?.total ?? res?.totalRows ?? res?.totalCount ?? records.length);
|
||||||
|
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||||
|
return { records, total, unfilteredTotal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFilterChange(immediate = false) {
|
||||||
|
if (filterDebounceTimer) {
|
||||||
|
clearTimeout(filterDebounceTimer);
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
applyFilterReload();
|
||||||
|
} else {
|
||||||
|
filterDebounceTimer = setTimeout(() => {
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
applyFilterReload();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilterReload() {
|
||||||
|
queryEpoch++;
|
||||||
|
clearChunkDebounce();
|
||||||
|
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
const cached = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
if (cached && cached.items !== null) {
|
||||||
|
cached.lastAccessed = Date.now();
|
||||||
|
for (const c of cached.loadedChunks) {
|
||||||
|
touchChunkLRU(currentKey, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: cached.items,
|
||||||
|
total: cached.total,
|
||||||
|
unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: cached.status || 'ready',
|
||||||
|
error: cached.error || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_hit',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Einträge im RAM)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
if (!cached.loadedChunks.has(0)) {
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_miss',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
getItems() {
|
||||||
|
if (automat.state.items === null) {
|
||||||
|
void actions.requireChunk(0, 0, { prefetch: false, immediate: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
requireChunk(chunkIndex, endOrOptions = chunkIndex, maybeOptions = {}) {
|
||||||
|
let start;
|
||||||
|
let end;
|
||||||
|
let options = {};
|
||||||
|
|
||||||
|
if (Array.isArray(chunkIndex)) {
|
||||||
|
start = chunkIndex[0] ?? 0;
|
||||||
|
end = chunkIndex[chunkIndex.length - 1] ?? start;
|
||||||
|
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
|
||||||
|
options = endOrOptions;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
start = Number(chunkIndex) || 0;
|
||||||
|
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
|
||||||
|
end = start;
|
||||||
|
options = endOrOptions;
|
||||||
|
} else {
|
||||||
|
end = Number(endOrOptions ?? start) || start;
|
||||||
|
if (typeof maybeOptions === 'object' && maybeOptions !== null) {
|
||||||
|
options = maybeOptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start = Math.max(0, Math.floor(start));
|
||||||
|
end = Math.max(start, Math.floor(end));
|
||||||
|
const prefetch = options.prefetch !== false;
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
|
||||||
|
if (options.immediate || automat.state.items === null) {
|
||||||
|
clearChunkDebounce();
|
||||||
|
const chunks = new Set();
|
||||||
|
for (let c = start; c <= end; c++) chunks.add(c);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'require_immediate',
|
||||||
|
chunks: Array.from(chunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`,
|
||||||
|
});
|
||||||
|
return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastRequireChunkTick !== currentEventTick) {
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
lastRequireChunkTick = currentEventTick;
|
||||||
|
if (typeof queueMicrotask === 'function') {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
currentEventTick++;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
currentEventTick++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let c = start; c <= end; c++) {
|
||||||
|
pendingTargetChunks.add(c);
|
||||||
|
}
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'pending',
|
||||||
|
chunks: Array.from(pendingTargetChunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
if (chunkDebounceTimer) {
|
||||||
|
clearTimeout(chunkDebounceTimer);
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingChunkPromise) {
|
||||||
|
pendingChunkPromise = new Promise((resolve, reject) => {
|
||||||
|
pendingChunkResolve = resolve;
|
||||||
|
pendingChunkReject = reject;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureEpoch = queryEpoch;
|
||||||
|
chunkDebounceTimer = setTimeout(() => {
|
||||||
|
chunkDebounceTimer = null;
|
||||||
|
const chunksToLoad = new Set(pendingTargetChunks);
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
const resolve = pendingChunkResolve;
|
||||||
|
const reject = pendingChunkReject;
|
||||||
|
pendingChunkPromise = null;
|
||||||
|
pendingChunkResolve = null;
|
||||||
|
pendingChunkReject = null;
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'debounce_fire',
|
||||||
|
chunks: Array.from(chunksToLoad),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
_executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey)
|
||||||
|
.then((res) => resolve?.(res))
|
||||||
|
.catch((err) => reject?.(err));
|
||||||
|
}, CHUNK_DEBOUNCE_MS);
|
||||||
|
|
||||||
|
return pendingChunkPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
|
||||||
|
const index = Math.max(0, Number(chunkIndex) || 0);
|
||||||
|
const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
|
||||||
|
|
||||||
|
if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) {
|
||||||
|
return Promise.resolve(entry.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.loadedChunks.has(index)) {
|
||||||
|
touchChunkLRU(targetFilterKey, index);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`,
|
||||||
|
});
|
||||||
|
return Promise.resolve(
|
||||||
|
targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflightKey = makeLruKey(targetFilterKey, index);
|
||||||
|
if (inflightChunks.has(inflightKey)) {
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'inflight_hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`,
|
||||||
|
});
|
||||||
|
return inflightChunks.get(inflightKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = index * chunkSize;
|
||||||
|
const limit = chunkSize;
|
||||||
|
const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
|
||||||
|
if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
|
||||||
|
entry.status = 'loading';
|
||||||
|
entry.error = null;
|
||||||
|
if (isActiveFilter) {
|
||||||
|
automat.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'start',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterParams = entry.params || getFilterParams(automat.state);
|
||||||
|
const promise = fetchRecordsPageFor(filterParams, offset, limit)
|
||||||
|
.then(({ records, total, unfilteredTotal }) => {
|
||||||
|
const currentTotal = total > 0 ? total : 0;
|
||||||
|
entry.total = currentTotal;
|
||||||
|
entry.unfilteredTotal = unfilteredTotal;
|
||||||
|
|
||||||
|
if (entry.items === null || entry.items.length !== currentTotal) {
|
||||||
|
entry.items = new Array(currentTotal).fill(null);
|
||||||
|
} else {
|
||||||
|
entry.items = [...entry.items];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i++) {
|
||||||
|
const idx = offset + i;
|
||||||
|
if (idx < entry.items.length) {
|
||||||
|
entry.items[idx] = records[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.loadedChunks.add(index);
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
registerChunkLRU(targetFilterKey, index, records.length);
|
||||||
|
|
||||||
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
if (currentActive) {
|
||||||
|
automat.setState({
|
||||||
|
items: entry.items,
|
||||||
|
total: currentTotal,
|
||||||
|
unfilteredTotal,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'loaded',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
count: records.length,
|
||||||
|
duration,
|
||||||
|
message: `Chunk #${index} geladen (${records.length} Einträge in ${duration}ms) für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return currentActive ? automat.state.items : entry.items;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
entry.status = 'failed';
|
||||||
|
entry.error = err;
|
||||||
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
if (currentActive) {
|
||||||
|
if (index === 0 && automat.state.items === null) {
|
||||||
|
automat.setState({ status: 'failed', error: err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error(`[${name}] loadChunk(${index}, ${targetFilterKey}) error:`, err);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'error',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
duration,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`,
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflightChunks.delete(inflightKey);
|
||||||
|
notifyChunkStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
inflightChunks.set(inflightKey, promise);
|
||||||
|
notifyChunkStats();
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
|
||||||
|
filter(query) {
|
||||||
|
automat.setState({ filter: String(query ?? '') });
|
||||||
|
onFilterChange(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
addSeekMonth() {
|
||||||
|
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
|
||||||
|
const seekYears = automat.state.seekYears ?? 0;
|
||||||
|
const totalMonths = -(seekMonths + seekYears * 12);
|
||||||
|
automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
addSeekYear() {
|
||||||
|
const seekMonths = automat.state.seekMonths ?? 0;
|
||||||
|
const seekYears = (automat.state.seekYears ?? 0) + 1;
|
||||||
|
const totalMonths = -(seekMonths + seekYears * 12);
|
||||||
|
automat.setState({ seekYears, negativeSeekMonths: totalMonths });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
addNegativeSeek(months) {
|
||||||
|
if (months === -12) {
|
||||||
|
actions.addSeekYear();
|
||||||
|
} else if (months === -1) {
|
||||||
|
actions.addSeekMonth();
|
||||||
|
} else {
|
||||||
|
const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
|
||||||
|
const next = current + months;
|
||||||
|
automat.setState({ negativeSeekMonths: Math.min(0, next) });
|
||||||
|
onFilterChange(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetNegativeSeek() {
|
||||||
|
automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
automat.setState({
|
||||||
|
filter: '',
|
||||||
|
seekMonths: 0,
|
||||||
|
seekYears: 0,
|
||||||
|
negativeSeekMonths: 0,
|
||||||
|
});
|
||||||
|
onFilterChange(true);
|
||||||
|
},
|
||||||
|
|
||||||
|
load() {
|
||||||
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
},
|
||||||
|
|
||||||
|
ensureLoaded() {
|
||||||
|
if (automat.state.items !== null) {
|
||||||
|
return Promise.resolve(automat.state.items);
|
||||||
|
}
|
||||||
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
},
|
||||||
|
|
||||||
|
async reload(onlyCurrent = false) {
|
||||||
|
queryEpoch++;
|
||||||
|
clearChunkDebounce();
|
||||||
|
|
||||||
|
if (onlyCurrent) {
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
actions.clearFilterCache(currentKey);
|
||||||
|
} else {
|
||||||
|
actions.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
replace(items) {
|
||||||
|
clearChunkDebounce();
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
actions.clearFilterCache(currentKey);
|
||||||
|
|
||||||
|
const count = Array.isArray(items) ? items.length : 0;
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.items = items;
|
||||||
|
entry.total = count;
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
|
||||||
|
const chunkCount = count > 0 ? Math.ceil(count / chunkSize) : 0;
|
||||||
|
for (let c = 0; c < chunkCount; c++) {
|
||||||
|
entry.loadedChunks.add(c);
|
||||||
|
registerChunkLRU(currentKey, c, Math.min(chunkSize, count - c * chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items,
|
||||||
|
total: count,
|
||||||
|
unfilteredTotal: count,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilterCache(filterKey) {
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (entry) {
|
||||||
|
for (const c of entry.loadedChunks) {
|
||||||
|
lruChunks.delete(makeLruKey(filterKey, c));
|
||||||
|
}
|
||||||
|
entry.items = null;
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
|
||||||
|
if (filterKey === getFilterKey(automat.state)) {
|
||||||
|
automat.setState({ items: null, status: 'idle', error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear_filter',
|
||||||
|
filterKey,
|
||||||
|
message: `Filter-Cache "${entry.label}" geleert`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
clearChunkDebounce();
|
||||||
|
lruChunks.clear();
|
||||||
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
for (const entry of filterCaches.values()) {
|
||||||
|
entry.items = null;
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear_all',
|
||||||
|
message: `Gesamter ${name}-Chunk-Cache geleert`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
if (filterDebounceTimer) {
|
||||||
|
clearTimeout(filterDebounceTimer);
|
||||||
|
filterDebounceTimer = null;
|
||||||
|
}
|
||||||
|
clearChunkDebounce();
|
||||||
|
filterCaches.clear();
|
||||||
|
lruChunks.clear();
|
||||||
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'reset',
|
||||||
|
message: `${name}-Automat und sämtliche Caches zurückgesetzt`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
automat.setState(initialState);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(initialState, actions, { name });
|
||||||
|
|
||||||
|
automat.getItems = actions.getItems;
|
||||||
|
automat.requireChunk = actions.requireChunk;
|
||||||
|
automat.CHUNK_SIZE = chunkSize;
|
||||||
|
automat.MAX_LRU_CHUNKS = maxLruChunks;
|
||||||
|
automat.getChunkStats = getChunkStats;
|
||||||
|
automat.subscribeChunkStats = subscribeChunkStats;
|
||||||
|
automat.getFilterKey = getFilterKey;
|
||||||
|
automat.formatFilterLabel = formatFilterLabel;
|
||||||
|
|
||||||
|
// Reset on logout or company change
|
||||||
|
let lastCompanyId = null;
|
||||||
|
authAutomat.subscribe((auth) => {
|
||||||
|
const companyId = auth.user?.selectedCompanyId ?? null;
|
||||||
|
if (auth.status === 'anon') {
|
||||||
|
actions.reset();
|
||||||
|
lastCompanyId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lastCompanyId !== null && companyId !== lastCompanyId) {
|
||||||
|
actions.reset();
|
||||||
|
}
|
||||||
|
if (auth.status === 'authed' && companyId != null) {
|
||||||
|
lastCompanyId = companyId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file draftSync.js
|
|
||||||
* @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery).
|
|
||||||
* Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat)
|
|
||||||
* with backend VFS requests and broadcasts.
|
|
||||||
*
|
|
||||||
* Incoming Signals:
|
|
||||||
* - SSE 'draft:request': Backend requests a draft (read or list).
|
|
||||||
* - SSE 'draft': Backend broadcasts an external update to a draft.
|
|
||||||
*
|
|
||||||
* Outgoing Calls:
|
|
||||||
* - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
|
|
||||||
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
|
|
||||||
import { vfs } from '../api/vfs';
|
|
||||||
import { getStoredClientId } from '../api/http';
|
|
||||||
import { getSseClientId } from './sseAutomat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery').
|
|
||||||
* Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc.
|
|
||||||
*
|
|
||||||
* @param {string} [name]
|
|
||||||
* @returns {string} Normalized key
|
|
||||||
*/
|
|
||||||
export function normalizeDraftName(name) {
|
|
||||||
if (!name) return '';
|
|
||||||
return String(name)
|
|
||||||
.trim()
|
|
||||||
.replace(/^\/?(api\/)?vfs\//, '')
|
|
||||||
.replace(/^drafts\//, '')
|
|
||||||
.replace(/\.json$/, '')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported virtual draft names.
|
|
||||||
*/
|
|
||||||
export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Timestamps of last modifications per draft.
|
|
||||||
* @type {Map<string, string>}
|
|
||||||
*/
|
|
||||||
const draftTimestamps = new Map();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves active draft content for a given draft name from its managing automat.
|
|
||||||
*
|
|
||||||
* @param {string} rawName
|
|
||||||
* @returns {object|null}
|
|
||||||
*/
|
|
||||||
export function getActiveDraftContent(rawName) {
|
|
||||||
const name = normalizeDraftName(rawName);
|
|
||||||
if (name === 'quote') {
|
|
||||||
return quoteDraftAutomat.actions.getManifest();
|
|
||||||
}
|
|
||||||
if (name === 'order') {
|
|
||||||
return orderDraftAutomat.actions.getManifest();
|
|
||||||
}
|
|
||||||
if (name === 'delivery') {
|
|
||||||
return deliveryEditorAutomat.actions.getManifest();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates the list of virtual draft files for the VFS listing.
|
|
||||||
*
|
|
||||||
* @returns {Array<{ name: string, size: number, updated_at: string }>}
|
|
||||||
*/
|
|
||||||
export function getActiveDraftsList() {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
return SUPPORTED_DRAFTS.map((name) => {
|
|
||||||
const content = getActiveDraftContent(name);
|
|
||||||
let size = 0;
|
|
||||||
if (content != null) {
|
|
||||||
try {
|
|
||||||
size = JSON.stringify(content).length;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z');
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
size,
|
|
||||||
updated_at: updatedAt,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies incoming draft content update to the managing automat.
|
|
||||||
*
|
|
||||||
* @param {string} rawName
|
|
||||||
* @param {object|string} rawContent
|
|
||||||
*/
|
|
||||||
export function applyDraftUpdate(rawName, rawContent) {
|
|
||||||
const name = normalizeDraftName(rawName);
|
|
||||||
if (!name) return;
|
|
||||||
|
|
||||||
let content = rawContent;
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
try {
|
|
||||||
content = JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[draft] failed to parse incoming draft string for ${name}:`, err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draftTimestamps.set(name, new Date().toISOString());
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (name === 'quote') {
|
|
||||||
quoteDraftAutomat.actions.setManifest(content);
|
|
||||||
} else if (name === 'order') {
|
|
||||||
orderDraftAutomat.actions.setManifest(content);
|
|
||||||
} else if (name === 'delivery') {
|
|
||||||
const manifest = content?.manifest || content || {};
|
|
||||||
const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? [];
|
|
||||||
const lines = Array.isArray(rawLines)
|
|
||||||
? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l)))
|
|
||||||
: [];
|
|
||||||
const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null;
|
|
||||||
const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null;
|
|
||||||
|
|
||||||
deliveryEditorAutomat.actions.setDelivery({
|
|
||||||
...manifest,
|
|
||||||
partyId,
|
|
||||||
deliveryEntity: partyId,
|
|
||||||
externalNumber,
|
|
||||||
external_number: externalNumber,
|
|
||||||
lines,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`[draft] error applying draft update to ${name} automat:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles incoming 'draft:request' SSE event from backend.
|
|
||||||
*
|
|
||||||
* @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
export async function handleDraftRequest(payload) {
|
|
||||||
if (!payload || !payload.requestId) return;
|
|
||||||
const { requestId, action, name, target } = payload;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (action === 'read') {
|
|
||||||
const draftName = normalizeDraftName(name || target);
|
|
||||||
const content = getActiveDraftContent(draftName);
|
|
||||||
if (content != null) {
|
|
||||||
await vfs.respondDraft({ requestId, content });
|
|
||||||
} else {
|
|
||||||
await vfs.respondDraft({ requestId, error: 'Draft not open' });
|
|
||||||
}
|
|
||||||
} else if (action === 'list') {
|
|
||||||
const files = getActiveDraftsList();
|
|
||||||
await vfs.respondDraft({ requestId, files });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[draft] error handling draft:request:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles incoming 'draft' SSE broadcast from backend.
|
|
||||||
*
|
|
||||||
* @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload
|
|
||||||
*/
|
|
||||||
export function handleDraftEvent(payload) {
|
|
||||||
if (!payload) return;
|
|
||||||
const { sender, action, name, target, content } = payload;
|
|
||||||
|
|
||||||
const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null);
|
|
||||||
if (sender && myClientId && sender === myClientId) {
|
|
||||||
// Ignore self-echo
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (action === 'update') {
|
|
||||||
const draftName = normalizeDraftName(name || target);
|
|
||||||
applyDraftUpdate(draftName, content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file globalHistoryAutomat.js
|
* @file globalHistoryAutomat.js
|
||||||
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for
|
* @description Double-buffered chunk list automat (`globalHistoryAutomat`) for
|
||||||
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`.
|
* global system and change history logs with multi-filter caching and LRU eviction.
|
||||||
*
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
* `selectedCompanyId` changes.
|
||||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
|
||||||
|
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
|
import { createChunkListAutomat } from './createChunkListAutomat';
|
||||||
import { globalHistory } from '../api';
|
import { globalHistory } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
|
||||||
|
|
||||||
export const globalHistoryGridAutomat = createGridAutomat({
|
export const globalHistoryAutomat = createChunkListAutomat({
|
||||||
name: 'globalHistory',
|
name: 'globalHistory',
|
||||||
blockSize: 50,
|
chunkSize: 50,
|
||||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
maxLruChunks: 50,
|
||||||
try {
|
fetchPage: (params) => globalHistory.list(params),
|
||||||
const res = await globalHistory.list({
|
|
||||||
startRow,
|
|
||||||
endRow,
|
|
||||||
sortModel,
|
|
||||||
filterModel,
|
|
||||||
});
|
|
||||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
|
||||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
|
||||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
|
||||||
return { rows, lastRow };
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to fetch global history (backend route pending):', err);
|
|
||||||
return { rows: [], lastRow: 0 };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reload on company switch or auth change
|
// Alias for backwards compatibility
|
||||||
authAutomat.subscribe((auth) => {
|
export const globalHistoryGridAutomat = globalHistoryAutomat;
|
||||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
|
||||||
globalHistoryGridAutomat.actions.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default globalHistoryGridAutomat;
|
export default globalHistoryAutomat;
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
export * from './manifestDraftAutomat';
|
export * from './manifestDraftAutomat';
|
||||||
export * from './draftSync';
|
|
||||||
|
|
||||||
export { default as authAutomat } from './authAutomat';
|
export { default as authAutomat } from './authAutomat';
|
||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
@@ -176,7 +175,7 @@ export {
|
|||||||
} from './categoryViewAutomat';
|
} from './categoryViewAutomat';
|
||||||
|
|
||||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
|
||||||
|
|
||||||
export { default as chatAutomat } from './chatAutomat';
|
export { default as chatAutomat } from './chatAutomat';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import supplierAutomat from './supplierAutomat';
|
|||||||
import orderAutomat from './orderAutomat';
|
import orderAutomat from './orderAutomat';
|
||||||
import quoteAutomat from './quoteAutomat';
|
import quoteAutomat from './quoteAutomat';
|
||||||
import shipmentAutomat from './shipmentAutomat';
|
import shipmentAutomat from './shipmentAutomat';
|
||||||
import { handleDraftRequest, handleDraftEvent } from './draftSync';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,23 +264,8 @@ function connect() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
addTrackedListener(es, 'draft:request', (event) => {
|
|
||||||
try {
|
|
||||||
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
||||||
void handleDraftRequest(payload);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[sse] draft:request handling failed', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'draft', (event) => {
|
|
||||||
try {
|
|
||||||
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
||||||
handleDraftEvent(payload);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[sse] draft event handling failed', err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
addTrackedListener(es, 'open', () => {
|
addTrackedListener(es, 'open', () => {
|
||||||
sseAutomat.setState({ status: 'connected' });
|
sseAutomat.setState({ status: 'connected' });
|
||||||
@@ -290,9 +274,9 @@ function connect() {
|
|||||||
addTrackedListener(es, 'message', () => {});
|
addTrackedListener(es, 'message', () => {});
|
||||||
|
|
||||||
// EventSource reconnects on its own; only surface the state.
|
// EventSource reconnects on its own; only surface the state.
|
||||||
es.onerror = () => {
|
es.onerror = (e) => {
|
||||||
console.error('[sse] event stream error');
|
console.error('[sse] event stream error', e);
|
||||||
recordEvent({ type: 'error', data: '', lastEventId: '' });
|
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
|
||||||
sseAutomat.setState({ status: 'error' });
|
sseAutomat.setState({ status: 'error' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,26 @@ export default class TextInput extends Component {
|
|||||||
? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false)
|
? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false)
|
||||||
: (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly);
|
: (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly);
|
||||||
|
|
||||||
|
const { inputProps: nestedFromInputProps, ...cleanInputProps } = InputProps || {};
|
||||||
|
const { inputProps: nestedFromSlotInput, ...cleanSlotInput } = slotProps?.input || {};
|
||||||
|
|
||||||
|
const rawResolvedInputProps = (InputProps || slotProps?.input) ? {
|
||||||
|
...cleanSlotInput,
|
||||||
|
...cleanInputProps,
|
||||||
|
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
|
||||||
|
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
|
||||||
|
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
|
||||||
|
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
|
||||||
|
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
|
||||||
|
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
|
||||||
|
} : undefined;
|
||||||
|
|
||||||
const htmlInput = isSelect
|
const htmlInput = isSelect
|
||||||
? {
|
? {
|
||||||
readOnly: effectiveReadOnly,
|
readOnly: effectiveReadOnly,
|
||||||
...slotProps?.htmlInput,
|
...slotProps?.htmlInput,
|
||||||
|
...nestedFromSlotInput,
|
||||||
|
...nestedFromInputProps,
|
||||||
...inputProps,
|
...inputProps,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -94,20 +110,12 @@ export default class TextInput extends Component {
|
|||||||
'data-form-type': 'other',
|
'data-form-type': 'other',
|
||||||
readOnly: effectiveReadOnly,
|
readOnly: effectiveReadOnly,
|
||||||
...slotProps?.htmlInput,
|
...slotProps?.htmlInput,
|
||||||
|
...nestedFromSlotInput,
|
||||||
|
...nestedFromInputProps,
|
||||||
...inputProps,
|
...inputProps,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvedInputProps = (InputProps || slotProps?.input) ? {
|
const resolvedInputProps = rawResolvedInputProps;
|
||||||
...slotProps?.input,
|
|
||||||
...InputProps,
|
|
||||||
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
|
|
||||||
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
|
|
||||||
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
|
|
||||||
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
|
|
||||||
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
|
|
||||||
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
|
|
||||||
} : undefined;
|
|
||||||
|
|
||||||
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
||||||
|
|
||||||
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);
|
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ import React, { PureComponent } from 'react';
|
|||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||||
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
||||||
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
||||||
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||||
@@ -202,6 +208,218 @@ export function parseAttachmentTag(attrString) {
|
|||||||
|
|
||||||
export { stripUserEnv };
|
export { stripUserEnv };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all `[EXTRACTION ...]` blocks (or legacy model instruction code blocks)
|
||||||
|
* and return structured extraction objects along with clean non-invoice text.
|
||||||
|
* @param {string} rawText
|
||||||
|
* @returns {{ extractions: Array<{ label: string, text: string }>, cleanText: string }}
|
||||||
|
*/
|
||||||
|
export function parseExtractionsFromText(rawText) {
|
||||||
|
if (typeof rawText !== 'string' || !rawText) {
|
||||||
|
return { extractions: [], cleanText: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractions = [];
|
||||||
|
let cleanText = rawText;
|
||||||
|
|
||||||
|
// 1. Explicit [EXTRACTION label="..."]...[/EXTRACTION] or <extraction label="...">...</extraction>
|
||||||
|
const tagRegex = /(?:\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>)([\s\S]*?)(?:\[\/EXTRACTION\]|<\/extraction>)/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = tagRegex.exec(rawText)) !== null) {
|
||||||
|
const label = match[1] || match[2] || 'parsed invoice';
|
||||||
|
let text = match[3] || '';
|
||||||
|
text = text.replace(/^```[a-z]*\s*\n?|\s*```$/gi, '').trim();
|
||||||
|
extractions.push({ label, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extractions.length > 0) {
|
||||||
|
cleanText = cleanText.replace(tagRegex, '').trim();
|
||||||
|
return { extractions, cleanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Legacy / fallback: message contains "use 'Process Incoming Product and Invoices' SKILL" + code block
|
||||||
|
const hasInvoiceSkill = cleanText.includes("Process Incoming Product and Invoices");
|
||||||
|
if (hasInvoiceSkill) {
|
||||||
|
const codeBlockRegex = /```[a-z]*\s*\n([\s\S]*?)```/g;
|
||||||
|
let cbMatch;
|
||||||
|
while ((cbMatch = codeBlockRegex.exec(cleanText)) !== null) {
|
||||||
|
const code = (cbMatch[1] || '').trim();
|
||||||
|
if (code.length > 20) {
|
||||||
|
extractions.push({ label: 'parsed invoice', text: code });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (extractions.length > 0) {
|
||||||
|
cleanText = cleanText.replace(codeBlockRegex, '').trim();
|
||||||
|
return { extractions, cleanText };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Standalone code block (e.g. ```\n...invoice...```)
|
||||||
|
const soleCodeBlockMatch = cleanText.match(/^\s*```[a-z]*\s*\n([\s\S]{40,})```\s*$/);
|
||||||
|
if (soleCodeBlockMatch) {
|
||||||
|
const code = soleCodeBlockMatch[1].trim();
|
||||||
|
extractions.push({ label: 'parsed invoice', text: code });
|
||||||
|
cleanText = '';
|
||||||
|
return { extractions, cleanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { extractions: [], cleanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clickable pill representing an extracted invoice/data snippet inside a message bubble.
|
||||||
|
* Clicking opens a modal preview to inspect and copy the full text.
|
||||||
|
*/
|
||||||
|
export class ExtractionPill extends PureComponent {
|
||||||
|
state = {
|
||||||
|
openPreview: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = (e) => {
|
||||||
|
e?.preventDefault?.();
|
||||||
|
e?.stopPropagation?.();
|
||||||
|
this.setState({ openPreview: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClose = () => {
|
||||||
|
this.setState({ openPreview: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCopy = () => {
|
||||||
|
const { text } = this.props;
|
||||||
|
if (text && navigator?.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
enqueueSnackbar('Extrahierter Text kopiert', { variant: 'success' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { label = 'parsed invoice', text = '', isUser = false } = this.props;
|
||||||
|
const { openPreview } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleClick}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
my: 0.35,
|
||||||
|
mx: 0.2,
|
||||||
|
pl: 1.1,
|
||||||
|
pr: 1,
|
||||||
|
py: 0.45,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isUser ? 'rgba(255, 255, 255, 0.4)' : 'divider',
|
||||||
|
bgcolor: isUser
|
||||||
|
? 'rgba(255, 255, 255, 0.2)'
|
||||||
|
: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'),
|
||||||
|
color: isUser ? '#fff' : 'text.primary',
|
||||||
|
cursor: 'pointer',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
maxWidth: '100%',
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: isUser ? 'rgba(255, 255, 255, 0.3)' : 'action.hover',
|
||||||
|
borderColor: isUser ? '#fff' : 'primary.main',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DescriptionRoundedIcon sx={{ fontSize: 16, color: isUser ? '#fff' : 'primary.main', flexShrink: 0 }} />
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'inherit',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
lineHeight: 1.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label || 'parsed invoice'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={openPreview}
|
||||||
|
onClose={this.handleClose}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
sx={(theme) => ({
|
||||||
|
zIndex: theme.zIndex.modal + 50,
|
||||||
|
})}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
backgroundImage: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DescriptionRoundedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
|
||||||
|
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
|
||||||
|
{label || 'parsed invoice'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={this.handleClose} aria-label="Schließen">
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
|
||||||
|
onClick={this.handleCopy}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleClose} color="inherit" size="small">
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
|
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
|
||||||
* Also strips <USERENV> tags so route context is never rendered in chat views.
|
* Also strips <USERENV> tags so route context is never rendered in chat views.
|
||||||
|
|||||||
@@ -15,11 +15,17 @@ import SendRoundedIcon from '@mui/icons-material/SendRounded';
|
|||||||
import AddRoundedIcon from '@mui/icons-material/AddRounded';
|
import AddRoundedIcon from '@mui/icons-material/AddRounded';
|
||||||
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||||
|
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||||
|
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
import Divider from '@mui/material/Divider';
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
|
||||||
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
||||||
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
||||||
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
|
||||||
@@ -31,7 +37,9 @@ import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
|
|||||||
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
|
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
|
||||||
|
|
||||||
import { setting as settingApi } from '../../api';
|
import { setting as settingApi } from '../../api';
|
||||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
import chatAutomat, {
|
||||||
|
selectPendingAskUser,
|
||||||
|
} from '../../automats/chatAutomat';
|
||||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||||
import vfsAutomat from '../../automats/vfsAutomat';
|
import vfsAutomat from '../../automats/vfsAutomat';
|
||||||
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
||||||
@@ -46,6 +54,7 @@ import {
|
|||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
const DEFAULT_DRAWER_WIDTH = 460;
|
const DEFAULT_DRAWER_WIDTH = 460;
|
||||||
|
const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL";
|
||||||
|
|
||||||
/** Sort by updated_at desc; subagents (started_by set) directly below their parent. */
|
/** Sort by updated_at desc; subagents (started_by set) directly below their parent. */
|
||||||
function sortConversations(list) {
|
function sortConversations(list) {
|
||||||
@@ -81,8 +90,36 @@ function formatUpdated(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function conversationTitle(c) {
|
function conversationTitle(c) {
|
||||||
const msg = (c.first_message || '').replace(/\s+/g, ' ').trim();
|
let msg = (c.first_message || '').trim();
|
||||||
return msg || '…';
|
if (!msg) return '…';
|
||||||
|
|
||||||
|
const extMatch = msg.match(/\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>/i);
|
||||||
|
const hasInvoiceSkill = msg.includes("Process Incoming Product and Invoices");
|
||||||
|
|
||||||
|
let stripped = msg
|
||||||
|
.replace(/\[EXTRACTION(?:\s+label="[^"]*")?\][\s\S]*?\[\/EXTRACTION\]/gi, '')
|
||||||
|
.replace(/<extraction(?:\s+label="[^"]*")?>[\s\S]*?<\/extraction>/gi, '')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/use 'Process Incoming Product and Invoices' SKILL.*/gi, '')
|
||||||
|
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (extMatch || hasInvoiceSkill) {
|
||||||
|
const label = extMatch?.[1] || extMatch?.[2] || 'parsed invoice';
|
||||||
|
if (stripped) {
|
||||||
|
return `${label}: ${stripped}`;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean = msg
|
||||||
|
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return clean || msg.slice(0, 40) || '…';
|
||||||
}
|
}
|
||||||
const MIN_DRAWER_WIDTH = 320;
|
const MIN_DRAWER_WIDTH = 320;
|
||||||
|
|
||||||
@@ -115,6 +152,17 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
isInputFocused: false,
|
isInputFocused: false,
|
||||||
stagedAttachments: [],
|
stagedAttachments: [],
|
||||||
stagedReferences: [],
|
stagedReferences: [],
|
||||||
|
stagedExtractions: s.pendingExtraction
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `ext-${Date.now()}`,
|
||||||
|
text: s.pendingExtraction.text,
|
||||||
|
label: s.pendingExtraction.label || 'parsed invoice',
|
||||||
|
instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
previewExtraction: null,
|
||||||
isProcessingFiles: false,
|
isProcessingFiles: false,
|
||||||
isDraggingFiles: false,
|
isDraggingFiles: false,
|
||||||
attachmentError: null,
|
attachmentError: null,
|
||||||
@@ -178,6 +226,13 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (chatAutomat.state.pendingExtraction != null) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (chatAutomat.state.pendingExtraction != null) {
|
||||||
|
chatAutomat.setState({ pendingExtraction: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
this.unsub = chatAutomat.subscribe(this, (state) => {
|
this.unsub = chatAutomat.subscribe(this, (state) => {
|
||||||
const wasOpen = this.state.isOpen;
|
const wasOpen = this.state.isOpen;
|
||||||
const willOpen = state.isOpen;
|
const willOpen = state.isOpen;
|
||||||
@@ -199,9 +254,9 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
activeTurns: state.activeTurns || [],
|
activeTurns: state.activeTurns || [],
|
||||||
};
|
};
|
||||||
if (state.pendingInput != null) {
|
if (state.pendingInput != null) {
|
||||||
patch.inputText = state.pendingInput;
|
patch.inputText = this.state.inputText
|
||||||
patch.stagedAttachments = [];
|
? `${this.state.inputText}\n\n${state.pendingInput}`
|
||||||
patch.stagedReferences = [];
|
: state.pendingInput;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (chatAutomat.state.pendingInput != null) {
|
if (chatAutomat.state.pendingInput != null) {
|
||||||
chatAutomat.setState({ pendingInput: null });
|
chatAutomat.setState({ pendingInput: null });
|
||||||
@@ -211,6 +266,22 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
} else if (!wasOpen && willOpen) {
|
} else if (!wasOpen && willOpen) {
|
||||||
this.focusInput(0);
|
this.focusInput(0);
|
||||||
}
|
}
|
||||||
|
if (state.pendingExtraction != null) {
|
||||||
|
const ext = state.pendingExtraction;
|
||||||
|
const newExt = {
|
||||||
|
id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
text: ext.text,
|
||||||
|
label: ext.label || 'parsed invoice',
|
||||||
|
instruction: ext.instruction || EXTRACTION_MODEL_INSTRUCTION,
|
||||||
|
};
|
||||||
|
patch.stagedExtractions = [...(this.state.stagedExtractions || []), newExt];
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (chatAutomat.state.pendingExtraction != null) {
|
||||||
|
chatAutomat.setState({ pendingExtraction: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.focusInput(0);
|
||||||
|
}
|
||||||
return patch;
|
return patch;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +380,29 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
this.focusInput(50);
|
this.focusInput(50);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleRemoveExtraction = (id) => {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
stagedExtractions: (prev.stagedExtractions || []).filter((e) => e.id !== id),
|
||||||
|
}));
|
||||||
|
this.focusInput(50);
|
||||||
|
};
|
||||||
|
|
||||||
|
handlePreviewExtraction = (ext) => {
|
||||||
|
console.log('[ChatDrawer] Clicked extraction pill:', ext);
|
||||||
|
this.setState({ previewExtraction: ext });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseExtractionPreview = () => {
|
||||||
|
this.setState({ previewExtraction: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCopyExtractionText = () => {
|
||||||
|
const text = this.state.previewExtraction?.text;
|
||||||
|
if (text && navigator?.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handleVfsResizerStart = (e) => {
|
handleVfsResizerStart = (e) => {
|
||||||
if (e.button !== undefined && e.button !== 0) return;
|
if (e.button !== undefined && e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -483,9 +577,10 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
const rawText = this.state.inputText.trim();
|
const rawText = this.state.inputText.trim();
|
||||||
const attachments = this.state.stagedAttachments || [];
|
const attachments = this.state.stagedAttachments || [];
|
||||||
const references = this.state.stagedReferences || [];
|
const references = this.state.stagedReferences || [];
|
||||||
|
const extractions = this.state.stagedExtractions || [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!rawText && attachments.length === 0 && references.length === 0) ||
|
(!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) ||
|
||||||
this.state.conversationState === 'busy' ||
|
this.state.conversationState === 'busy' ||
|
||||||
this.state.isSending
|
this.state.isSending
|
||||||
) {
|
) {
|
||||||
@@ -493,6 +588,28 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let text = rawText;
|
let text = rawText;
|
||||||
|
if (extractions.length > 0) {
|
||||||
|
const instructions = extractions
|
||||||
|
.map((e) => e.instruction)
|
||||||
|
.filter(Boolean);
|
||||||
|
const uniqueInstructions = Array.from(new Set(instructions));
|
||||||
|
const instructionText =
|
||||||
|
uniqueInstructions.join('\n\n') || EXTRACTION_MODEL_INSTRUCTION;
|
||||||
|
|
||||||
|
const extBlocks = extractions
|
||||||
|
.map(
|
||||||
|
(ext) =>
|
||||||
|
`[EXTRACTION label="${ext.label || 'parsed invoice'}"]\n\`\`\`\n${ext.text.trim()}\n\`\`\`\n[/EXTRACTION]`
|
||||||
|
)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
text = `${instructionText}\n\n${text}\n\n${extBlocks}`;
|
||||||
|
} else {
|
||||||
|
text = `${instructionText}\n\n${extBlocks}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (references.length > 0) {
|
if (references.length > 0) {
|
||||||
const refMarkdown = references
|
const refMarkdown = references
|
||||||
.map((ref) => {
|
.map((ref) => {
|
||||||
@@ -522,6 +639,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
inputText: '',
|
inputText: '',
|
||||||
stagedAttachments: [],
|
stagedAttachments: [],
|
||||||
stagedReferences: [],
|
stagedReferences: [],
|
||||||
|
stagedExtractions: [],
|
||||||
attachmentError: null,
|
attachmentError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -721,6 +839,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
|
|
||||||
getPendingAskUser = () => selectPendingAskUser(this.state);
|
getPendingAskUser = () => selectPendingAskUser(this.state);
|
||||||
|
|
||||||
|
|
||||||
renderHeader() {
|
renderHeader() {
|
||||||
const {
|
const {
|
||||||
conversations,
|
conversations,
|
||||||
@@ -1265,6 +1384,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
sendError,
|
sendError,
|
||||||
stagedAttachments,
|
stagedAttachments,
|
||||||
stagedReferences,
|
stagedReferences,
|
||||||
|
stagedExtractions = [],
|
||||||
isProcessingFiles,
|
isProcessingFiles,
|
||||||
isDraggingFiles,
|
isDraggingFiles,
|
||||||
attachmentError,
|
attachmentError,
|
||||||
@@ -1273,11 +1393,16 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
const pendingAsk = this.getPendingAskUser();
|
const pendingAsk = this.getPendingAskUser();
|
||||||
const isBusy = conversationState === 'busy';
|
const isBusy = conversationState === 'busy';
|
||||||
const isWorking = isBusy || isSending;
|
const isWorking = isBusy || isSending;
|
||||||
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
|
const canChat =
|
||||||
|
(Boolean(selectedConversationId) || draftConversation) &&
|
||||||
|
!pendingAsk;
|
||||||
const canSend =
|
const canSend =
|
||||||
!isWorking &&
|
!isWorking &&
|
||||||
canChat &&
|
canChat &&
|
||||||
(Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0);
|
(Boolean(inputText.trim()) ||
|
||||||
|
stagedAttachments.length > 0 ||
|
||||||
|
stagedReferences.length > 0 ||
|
||||||
|
stagedExtractions.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -1449,7 +1574,7 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{stagedReferences.length > 0 && (
|
{(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1546,6 +1671,77 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
{stagedExtractions && stagedExtractions.map((ext) => (
|
||||||
|
<Box
|
||||||
|
key={ext.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handlePreviewExtraction(ext);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.6,
|
||||||
|
py: 0.2,
|
||||||
|
pl: 0.7,
|
||||||
|
pr: 0.4,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? 'rgba(255, 255, 255, 0.08)'
|
||||||
|
: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? 'rgba(255, 255, 255, 0.12)'
|
||||||
|
: 'rgba(0, 0, 0, 0.1)',
|
||||||
|
color: 'text.primary',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
userSelect: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.15s ease',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? 'rgba(255, 255, 255, 0.14)'
|
||||||
|
: 'rgba(0, 0, 0, 0.09)',
|
||||||
|
borderColor: 'primary.light',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DescriptionOutlinedIcon sx={{ fontSize: 14, color: 'primary.main' }} />
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
fontSize: '0.74rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '-0.2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ext.label || 'parsed invoice'}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleRemoveExtraction(ext.id);
|
||||||
|
}}
|
||||||
|
aria-label="Entfernen"
|
||||||
|
sx={{
|
||||||
|
p: 0.2,
|
||||||
|
ml: 0.1,
|
||||||
|
color: 'text.secondary',
|
||||||
|
'&:hover': {
|
||||||
|
color: 'error.main',
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 13 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1811,6 +2007,74 @@ export default class ChatDrawer extends PureComponent {
|
|||||||
{this.renderFooter()}
|
{this.renderFooter()}
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Extraction Preview Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(this.state.previewExtraction)}
|
||||||
|
onClose={this.handleCloseExtractionPreview}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
sx={(theme) => ({
|
||||||
|
zIndex: theme.zIndex.modal + 50,
|
||||||
|
})}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
sx: {
|
||||||
|
borderRadius: 2.5,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
backgroundImage: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<DescriptionOutlinedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
|
||||||
|
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
|
||||||
|
{this.state.previewExtraction?.label || 'parsed invoice'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="small" onClick={this.handleCloseExtractionPreview} aria-label="Schließen">
|
||||||
|
<CloseRoundedIcon sx={{ fontSize: 20 }} />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
|
||||||
|
<Box
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
m: 0,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
overflow: 'auto',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{this.state.previewExtraction?.text || ''}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
|
||||||
|
onClick={this.handleCopyExtractionText}
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</Button>
|
||||||
|
<Button onClick={this.handleCloseExtractionPreview} color="inherit" size="small">
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,13 @@ import CalculateIcon from '@mui/icons-material/Calculate';
|
|||||||
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
|
||||||
import DirectionsIcon from '@mui/icons-material/Directions';
|
import DirectionsIcon from '@mui/icons-material/Directions';
|
||||||
import HealingMarkdown from './HealingMarkdown';
|
import HealingMarkdown from './HealingMarkdown';
|
||||||
import { VfsAttachmentLink, parseAttachmentsFromText, stripUserEnv } from './ChatAttachmentItems';
|
import {
|
||||||
|
VfsAttachmentLink,
|
||||||
|
ExtractionPill,
|
||||||
|
parseAttachmentsFromText,
|
||||||
|
parseExtractionsFromText,
|
||||||
|
stripUserEnv,
|
||||||
|
} from './ChatAttachmentItems';
|
||||||
import {
|
import {
|
||||||
CHROMA,
|
CHROMA,
|
||||||
SHADOW_BUBBLE_ASSISTANT,
|
SHADOW_BUBBLE_ASSISTANT,
|
||||||
@@ -878,7 +884,8 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textContent);
|
const { extractions, cleanText: textAfterExtractions } = parseExtractionsFromText(textContent);
|
||||||
|
const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textAfterExtractions);
|
||||||
const cleanText = stripUserEnv(rawCleanText);
|
const cleanText = stripUserEnv(rawCleanText);
|
||||||
|
|
||||||
if (msg.role === 'system') {
|
if (msg.role === 'system') {
|
||||||
@@ -957,6 +964,26 @@ function MessageRow({ msg, showTime = true }) {
|
|||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{extractions.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 0.75,
|
||||||
|
mb: cleanText || vfsAttachments.length > 0 ? 0.75 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extractions.map((ext, idx) => (
|
||||||
|
<ExtractionPill
|
||||||
|
key={ext.id || idx}
|
||||||
|
label={ext.label || 'parsed invoice'}
|
||||||
|
text={ext.text}
|
||||||
|
isUser={isUser}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{vfsAttachments.length > 0 && (
|
{vfsAttachments.length > 0 && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -184,15 +184,46 @@ function getMimeTypeFallback(category, filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger download of an attachment in the browser.
|
* Format bytes into human-readable kb size for data extraction pills (e.g. "2.4 kb" or "12 kb").
|
||||||
* @param {object} attachment
|
* @param {number} bytes
|
||||||
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function downloadAttachment(attachment) {
|
export function formatExtractionSize(bytes) {
|
||||||
if (!attachment) return;
|
const b = Number(bytes) || 0;
|
||||||
const link = document.createElement('a');
|
const kb = b / 1024;
|
||||||
link.href = attachment.dataUrl || (attachment.url ? attachment.url : '');
|
if (kb < 1) {
|
||||||
link.download = attachment.name || 'anhang';
|
return `${Math.max(0.1, kb).toFixed(1)} kb`;
|
||||||
document.body.appendChild(link);
|
}
|
||||||
link.click();
|
return `${kb.toFixed(kb >= 10 ? 0 : 1)} kb`;
|
||||||
document.body.removeChild(link);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses extraction text blocks (e.g. `Text for Data Extraction:\n```\n...```\n` or `Text aus „...“:\n```\n...```\n`)
|
||||||
|
* from a message string, returning the extracted snippets as structured pills and the remaining text.
|
||||||
|
* @param {string} rawText
|
||||||
|
* @returns {{ extractions: Array<{ text: string, size: number, label: string }>, cleanText: string }}
|
||||||
|
*/
|
||||||
|
export function parseExtractionsFromText(rawText) {
|
||||||
|
if (typeof rawText !== 'string' || !rawText) {
|
||||||
|
return { extractions: [], cleanText: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractionRegex = /(?:Text for Data Extraction|Text aus „[^“]+“):\s*```(?:\w+)?\n([\s\S]*?)```/g;
|
||||||
|
const extractions = [];
|
||||||
|
let match;
|
||||||
|
while ((match = extractionRegex.exec(rawText)) !== null) {
|
||||||
|
const content = (match[1] || '').trim();
|
||||||
|
if (content) {
|
||||||
|
const size = new Blob([content]).size;
|
||||||
|
extractions.push({
|
||||||
|
text: content,
|
||||||
|
size,
|
||||||
|
label: `Text for Data Extraction - ${formatExtractionSize(size)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanText = rawText.replace(extractionRegex, '').trim();
|
||||||
|
return { extractions, cleanText };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import SyncIcon from '@mui/icons-material/Sync';
|
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||||
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||||
import SkeleHashImage from '../settings/SkeleHashImage';
|
import SkeleHashImage from '../settings/SkeleHashImage';
|
||||||
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||||
import { notifyErrorMessage } from '../../utils/notify';
|
import chatAutomat from '../../automats/chatAutomat';
|
||||||
import {
|
import {
|
||||||
SCRIM_ACTION,
|
SCRIM_ACTION,
|
||||||
SCRIM_ATTACHMENT,
|
SCRIM_ATTACHMENT,
|
||||||
@@ -61,18 +60,64 @@ export class Attachment extends PureComponent {
|
|||||||
|
|
||||||
handleRescan = (e) => {
|
handleRescan = (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
if (this.props.hasArticles) {
|
|
||||||
notifyErrorMessage(
|
|
||||||
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { attachment, onRescan } = this.props;
|
const { attachment, onRescan } = this.props;
|
||||||
if (attachment && onRescan) {
|
if (attachment && onRescan) {
|
||||||
onRescan(attachment);
|
onRescan(attachment);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleMentionInChat = async (e) => {
|
||||||
|
e?.stopPropagation?.();
|
||||||
|
const { attachment, onMentionInChat } = this.props;
|
||||||
|
console.log('[Attachment] Mention in chat clicked', attachment);
|
||||||
|
if (!attachment) return;
|
||||||
|
|
||||||
|
if (onMentionInChat) {
|
||||||
|
onMentionInChat(attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let textContent = attachment.text || '';
|
||||||
|
if (!textContent && (attachment.file instanceof Blob || attachment.file instanceof File)) {
|
||||||
|
if (attachment.file.size > 0) {
|
||||||
|
try {
|
||||||
|
textContent = await attachment.file.text();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Lesen des Texts:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const docId = this.props.docId ?? attachment.manifestId;
|
||||||
|
const fileId = attachment.fileId ?? attachment.id;
|
||||||
|
const kind = this.props.kind || 'delivery';
|
||||||
|
const prefix = kind === 'delivery' ? 'delivery' : kind === 'quote' ? 'quote' : 'order';
|
||||||
|
if (docId != null && fileId != null) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/${prefix}/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
textContent = await res.text();
|
||||||
|
}
|
||||||
|
} catch (fetchErr) {
|
||||||
|
console.error('Fehler beim Laden der Datei:', fetchErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = (textContent || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
console.warn('[Attachment] No text found to mention in chat', attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatAutomat.actions.insertExtractionSnippet({
|
||||||
|
text: trimmed,
|
||||||
|
label: 'parsed invoice',
|
||||||
|
instruction: "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
handleDelete = (e) => {
|
handleDelete = (e) => {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
const { attachment, onDelete } = this.props;
|
const { attachment, onDelete } = this.props;
|
||||||
@@ -237,51 +282,51 @@ export class Attachment extends PureComponent {
|
|||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{kind === 'delivery' && Boolean(onRescan) && (isPdf || isText) ? (
|
{kind === 'delivery' && Boolean(onRescan) && isPdf ? (
|
||||||
<Tooltip
|
<IconButton
|
||||||
title={
|
size="small"
|
||||||
hasArticles
|
disabled={disabled}
|
||||||
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
|
onClick={this.handleRescan}
|
||||||
: 'Neu analysieren'
|
aria-label="Zu Text"
|
||||||
}
|
sx={{
|
||||||
|
bgcolor: SCRIM_ACTION,
|
||||||
|
color: WHITE,
|
||||||
|
p: 0.5,
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: SCRIM_HEAVY,
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<TextSnippetOutlinedIcon sx={{ fontSize: 16 }} />
|
||||||
style={{ display: 'inline-block' }}
|
</IconButton>
|
||||||
onClick={
|
|
||||||
hasArticles
|
|
||||||
? (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
notifyErrorMessage(
|
|
||||||
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
disabled={disabled || hasArticles}
|
|
||||||
onClick={this.handleRescan}
|
|
||||||
sx={{
|
|
||||||
bgcolor: SCRIM_ACTION,
|
|
||||||
color: hasArticles ? VEIL_DISABLED : WHITE,
|
|
||||||
p: 0.5,
|
|
||||||
backdropFilter: 'blur(2px)',
|
|
||||||
cursor: hasArticles ? 'not-allowed' : 'pointer',
|
|
||||||
'&:hover': {
|
|
||||||
bgcolor: hasArticles ? SCRIM_ACTION : SCRIM_HEAVY,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SyncIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{isText ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this.handleMentionInChat}
|
||||||
|
aria-label="Im Chat erwähnen"
|
||||||
|
sx={{
|
||||||
|
bgcolor: SCRIM_ACTION,
|
||||||
|
color: WHITE,
|
||||||
|
p: 0.5,
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: SCRIM_HEAVY,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatBubbleOutlineRoundedIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={this.handleDelete}
|
onClick={this.handleDelete}
|
||||||
|
aria-label="Löschen"
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: SCRIM_ACTION,
|
bgcolor: SCRIM_ACTION,
|
||||||
color: WHITE,
|
color: WHITE,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Attachment from './Attachment';
|
|||||||
|
|
||||||
export class AttachmentList extends PureComponent {
|
export class AttachmentList extends PureComponent {
|
||||||
render() {
|
render() {
|
||||||
const { attachments, onRescan, onDelete, onClick, disabled, hasArticles, kind, docId } = this.props;
|
const { attachments, onRescan, onDelete, onClick, onMentionInChat, disabled, hasArticles, kind, docId } = this.props;
|
||||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ export class AttachmentList extends PureComponent {
|
|||||||
onRescan={onRescan}
|
onRescan={onRescan}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMentionInChat={onMentionInChat}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
hasArticles={hasArticles}
|
hasArticles={hasArticles}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
let initialAmounts = {};
|
let initialAmounts = {};
|
||||||
if (Array.isArray(props.summary?.lines)) {
|
if (Array.isArray(props.summary?.lines)) {
|
||||||
for (const line of props.summary.lines) {
|
for (const line of props.summary.lines) {
|
||||||
|
if (!line.trackStock) continue;
|
||||||
if (props.prefillLineId != null) {
|
if (props.prefillLineId != null) {
|
||||||
initialAmounts[line.id] =
|
initialAmounts[line.id] =
|
||||||
line.id === props.prefillLineId
|
line.id === props.prefillLineId
|
||||||
@@ -174,6 +175,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
const amounts = {};
|
const amounts = {};
|
||||||
if (Array.isArray(summary?.lines)) {
|
if (Array.isArray(summary?.lines)) {
|
||||||
for (const line of summary.lines) {
|
for (const line of summary.lines) {
|
||||||
|
if (!line.trackStock) continue;
|
||||||
if (prefillLineId != null) {
|
if (prefillLineId != null) {
|
||||||
amounts[line.id] =
|
amounts[line.id] =
|
||||||
line.id === prefillLineId
|
line.id === prefillLineId
|
||||||
@@ -208,6 +210,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
if (!summary?.lines) return;
|
if (!summary?.lines) return;
|
||||||
const amounts = {};
|
const amounts = {};
|
||||||
for (const line of summary.lines) {
|
for (const line of summary.lines) {
|
||||||
|
if (!line.trackStock) continue;
|
||||||
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
||||||
}
|
}
|
||||||
this.setState({ amountsToBook: amounts });
|
this.setState({ amountsToBook: amounts });
|
||||||
@@ -218,6 +221,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
if (!summary?.lines) return;
|
if (!summary?.lines) return;
|
||||||
const amounts = {};
|
const amounts = {};
|
||||||
for (const line of summary.lines) {
|
for (const line of summary.lines) {
|
||||||
|
if (!line.trackStock) continue;
|
||||||
amounts[line.id] = 0;
|
amounts[line.id] = 0;
|
||||||
}
|
}
|
||||||
this.setState({ amountsToBook: amounts });
|
this.setState({ amountsToBook: amounts });
|
||||||
@@ -229,6 +233,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
if (!summary?.lines) return;
|
if (!summary?.lines) return;
|
||||||
|
|
||||||
const linesToBook = summary.lines
|
const linesToBook = summary.lines
|
||||||
|
.filter((line) => line.trackStock)
|
||||||
.map((line) => ({
|
.map((line) => ({
|
||||||
manifestarticleId: line.id,
|
manifestarticleId: line.id,
|
||||||
sku: line.sku,
|
sku: line.sku,
|
||||||
@@ -382,12 +387,12 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
|
|
||||||
renderLinesTable() {
|
renderLinesTable() {
|
||||||
const { summary, amountsToBook, isBooking } = this.state;
|
const { summary, amountsToBook, isBooking } = this.state;
|
||||||
const lines = summary?.lines || [];
|
const lines = (summary?.lines || []).filter((l) => l.trackStock);
|
||||||
|
|
||||||
if (lines.length === 0) {
|
if (lines.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Alert severity="info" sx={{ my: 2 }}>
|
<Alert severity="info" sx={{ my: 2 }}>
|
||||||
Diese Lieferung enthält keine Artikelpositionen zum Einbuchen.
|
Diese Lieferung enthält keine lagergeführten Artikel zum Einbuchen.
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -455,12 +460,10 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
|
onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
|
||||||
disabled={isBooking}
|
disabled={isBooking}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
htmlInput: {
|
||||||
inputProps: {
|
min: 0,
|
||||||
min: 0,
|
step: 1,
|
||||||
step: 1,
|
style: { textAlign: 'right', padding: '4px 8px' },
|
||||||
style: { textAlign: 'right', padding: '4px 8px' },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ width: 90 }}
|
sx={{ width: 90 }}
|
||||||
@@ -490,7 +493,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
warehouseError,
|
warehouseError,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
const lines = summary?.lines || [];
|
const lines = (summary?.lines || []).filter((l) => l.trackStock);
|
||||||
|
const hasTrackStock = lines.length > 0;
|
||||||
const prefillLine =
|
const prefillLine =
|
||||||
this.props.prefillLineId != null
|
this.props.prefillLineId != null
|
||||||
? lines.find((l) => l.id === this.props.prefillLineId)
|
? lines.find((l) => l.id === this.props.prefillLineId)
|
||||||
@@ -502,7 +506,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
const countLinesToBook = lines.filter(
|
const countLinesToBook = lines.filter(
|
||||||
(l) => Number(amountsToBook[l.id]) > 0
|
(l) => Number(amountsToBook[l.id]) > 0
|
||||||
).length;
|
).length;
|
||||||
const isFullyBooked = summary?.status === 'booked';
|
const isFullyBooked = hasTrackStock && summary?.status === 'booked';
|
||||||
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
|
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
|
||||||
const isWarehouseMissing = !selectedWarehouseId;
|
const isWarehouseMissing = !selectedWarehouseId;
|
||||||
|
|
||||||
@@ -554,7 +558,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
{this.renderLinesTable()}
|
{this.renderLinesTable()}
|
||||||
|
|
||||||
{!isFullyBooked && lines.length > 0 && (
|
{!isFullyBooked && hasTrackStock && (
|
||||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
@@ -576,75 +580,77 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{warehouses.length === 0 && !isFullyBooked && (
|
{hasTrackStock && warehouses.length === 0 && !isFullyBooked && (
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
|
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box
|
{hasTrackStock && (
|
||||||
sx={{
|
<Box
|
||||||
display: 'flex',
|
sx={{
|
||||||
gap: 2,
|
display: 'flex',
|
||||||
mb: 2,
|
gap: 2,
|
||||||
flexDirection: { xs: 'column', sm: 'row' },
|
mb: 2,
|
||||||
}}
|
flexDirection: { xs: 'column', sm: 'row' },
|
||||||
>
|
}}
|
||||||
<FormControl
|
|
||||||
size="small"
|
|
||||||
required
|
|
||||||
error={Boolean(warehouseError)}
|
|
||||||
disabled={isBooking || isFullyBooked}
|
|
||||||
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
|
|
||||||
>
|
>
|
||||||
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
|
<FormControl
|
||||||
<Select
|
size="small"
|
||||||
labelId="warehouse-select-label"
|
required
|
||||||
id="warehouse-select"
|
error={Boolean(warehouseError)}
|
||||||
value={selectedWarehouseId ?? ''}
|
disabled={isBooking || isFullyBooked}
|
||||||
label="Ziellager *"
|
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
|
||||||
onChange={this.handleWarehouseChange}
|
|
||||||
startAdornment={
|
|
||||||
<WarehouseIcon
|
|
||||||
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{warehouses.length === 0 ? (
|
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
|
||||||
<MenuItem value="" disabled>
|
<Select
|
||||||
Keine Lager vorhanden
|
labelId="warehouse-select-label"
|
||||||
</MenuItem>
|
id="warehouse-select"
|
||||||
) : (
|
value={selectedWarehouseId ?? ''}
|
||||||
warehouses.map((wh) => (
|
label="Ziellager *"
|
||||||
<MenuItem key={wh.id} value={wh.id}>
|
onChange={this.handleWarehouseChange}
|
||||||
{wh.name}
|
startAdornment={
|
||||||
|
<WarehouseIcon
|
||||||
|
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{warehouses.length === 0 ? (
|
||||||
|
<MenuItem value="" disabled>
|
||||||
|
Keine Lager vorhanden
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))
|
) : (
|
||||||
|
warehouses.map((wh) => (
|
||||||
|
<MenuItem key={wh.id} value={wh.id}>
|
||||||
|
{wh.name}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Select>
|
||||||
|
{warehouseError ? (
|
||||||
|
<FormHelperText>{warehouseError}</FormHelperText>
|
||||||
|
) : (
|
||||||
|
<FormHelperText>Pflichtfeld: Ziellager für die Buchung</FormHelperText>
|
||||||
)}
|
)}
|
||||||
</Select>
|
</FormControl>
|
||||||
{warehouseError ? (
|
|
||||||
<FormHelperText>{warehouseError}</FormHelperText>
|
|
||||||
) : (
|
|
||||||
<FormHelperText>Pflichtfeld: Ziellager für die Buchung</FormHelperText>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
label="Buchungstext (Ledger-Referenz)"
|
label="Buchungstext (Ledger-Referenz)"
|
||||||
value={transactionText}
|
value={transactionText}
|
||||||
onChange={this.handleTransactionTextChange}
|
onChange={this.handleTransactionTextChange}
|
||||||
disabled={isBooking || isFullyBooked}
|
disabled={isBooking || isFullyBooked}
|
||||||
helperText="Wird als Transaktionstext in das Ledger übernommen"
|
helperText="Wird als Transaktionstext in das Ledger übernommen"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{isFullyBooked ? (
|
{isFullyBooked ? (
|
||||||
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
|
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
|
||||||
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
||||||
</Alert>
|
</Alert>
|
||||||
) : totalToBook > 0 ? (
|
) : hasTrackStock && totalToBook > 0 ? (
|
||||||
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
|
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
|
||||||
{selectedWarehouseId ? (
|
{selectedWarehouseId ? (
|
||||||
<>
|
<>
|
||||||
@@ -659,11 +665,11 @@ export default class BookDeliveryDialog extends PureComponent {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : hasTrackStock ? (
|
||||||
<Alert severity="warning">
|
<Alert severity="warning">
|
||||||
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
|
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ class ManifestFormLine extends PureComponent {
|
|||||||
cachedArticle: sku ? getArticle(sku) : null,
|
cachedArticle: sku ? getArticle(sku) : null,
|
||||||
loadingArticle: false,
|
loadingArticle: false,
|
||||||
fallbackSuppliers: [],
|
fallbackSuppliers: [],
|
||||||
|
editingField: null,
|
||||||
};
|
};
|
||||||
this.handleRemove = this.handleRemove.bind(this);
|
this.handleRemove = this.handleRemove.bind(this);
|
||||||
this.handleMoveUp = this.handleMoveUp.bind(this);
|
this.handleMoveUp = this.handleMoveUp.bind(this);
|
||||||
this.handleMoveDown = this.handleMoveDown.bind(this);
|
this.handleMoveDown = this.handleMoveDown.bind(this);
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
this.handleBlur = this.handleBlur.bind(this);
|
||||||
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||||
|
this.handleStartEdit = this.handleStartEdit.bind(this);
|
||||||
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
this.handleCreateArticle = this.handleCreateArticle.bind(this);
|
||||||
this.handleSearchArticle = this.handleSearchArticle.bind(this);
|
this.handleSearchArticle = this.handleSearchArticle.bind(this);
|
||||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||||
@@ -117,7 +120,7 @@ class ManifestFormLine extends PureComponent {
|
|||||||
this.unsubSupplier = supKey.subscribe(this, (item) => ({
|
this.unsubSupplier = supKey.subscribe(this, (item) => ({
|
||||||
fallbackSuppliers: Array.isArray(item?.data) ? item.data : [],
|
fallbackSuppliers: Array.isArray(item?.data) ? item.data : [],
|
||||||
}));
|
}));
|
||||||
void articleSupplierAutomat.load(sku).catch(() => {});
|
void articleSupplierAutomat.load(sku).catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +137,25 @@ class ManifestFormLine extends PureComponent {
|
|||||||
this.props.onMove?.(this.props.line.key, 1);
|
this.props.onMove?.(this.props.line.key, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleStartEdit(columnId) {
|
||||||
|
const { saving } = this.props;
|
||||||
|
const isDisabled = saving || (columnId === 'sku' && Boolean(this.props.isOnShipment));
|
||||||
|
if (isDisabled) return;
|
||||||
|
if (this.state.editingField !== columnId) {
|
||||||
|
this.setState({ editingField: columnId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' || e.key === 'Escape') {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleBlur(e, columnId) {
|
handleBlur(e, columnId) {
|
||||||
|
if (this.state.editingField === columnId) {
|
||||||
|
this.setState({ editingField: null });
|
||||||
|
}
|
||||||
if (columnId === 'sku' && this.props.onSkuBlur) {
|
if (columnId === 'sku' && this.props.onSkuBlur) {
|
||||||
this.props.onSkuBlur(this.props.line.key, e.target.value);
|
this.props.onSkuBlur(this.props.line.key, e.target.value);
|
||||||
}
|
}
|
||||||
@@ -163,7 +184,7 @@ class ManifestFormLine extends PureComponent {
|
|||||||
|
|
||||||
renderLedgerChip() {
|
renderLedgerChip() {
|
||||||
const { ledgerLine, onBookLine } = this.props;
|
const { ledgerLine, onBookLine } = this.props;
|
||||||
if (!ledgerLine) return null;
|
if (!ledgerLine || !ledgerLine.trackStock) return null;
|
||||||
const isBooked = ledgerLine.isBooked;
|
const isBooked = ledgerLine.isBooked;
|
||||||
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
|
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
|
||||||
const isClickable = Boolean(onBookLine);
|
const isClickable = Boolean(onBookLine);
|
||||||
@@ -229,6 +250,8 @@ class ManifestFormLine extends PureComponent {
|
|||||||
renderField(column, colIdx, totalCols) {
|
renderField(column, colIdx, totalCols) {
|
||||||
const { id, align, type } = column;
|
const { id, align, type } = column;
|
||||||
const { line, saving, onChange, columns } = this.props;
|
const { line, saving, onChange, columns } = this.props;
|
||||||
|
const isFirstLine = Boolean(this.props.isFirstLine ?? (this.props.index === 0));
|
||||||
|
const isLastLine = Boolean(this.props.isLastLine);
|
||||||
|
|
||||||
if (id === 'pos') {
|
if (id === 'pos') {
|
||||||
const posVal =
|
const posVal =
|
||||||
@@ -240,8 +263,10 @@ class ManifestFormLine extends PureComponent {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: '100%',
|
height: isFirstLine ? 40 : 41,
|
||||||
minHeight: 38,
|
minHeight: isFirstLine ? 40 : 41,
|
||||||
|
maxHeight: isFirstLine ? 40 : 41,
|
||||||
|
mt: isFirstLine ? 0 : '-1px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontSize: '0.82rem',
|
fontSize: '0.82rem',
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
@@ -297,65 +322,197 @@ class ManifestFormLine extends PureComponent {
|
|||||||
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
|
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
|
||||||
const isLast = colIdx === totalCols - 1;
|
const isLast = colIdx === totalCols - 1;
|
||||||
|
|
||||||
|
const topLeftRadius = isFirstInput && isFirstLine ? 4 : 0;
|
||||||
|
const topRightRadius = isLast && isFirstLine ? 4 : 0;
|
||||||
|
const bottomRightRadius = isLast && isLastLine ? 4 : 0;
|
||||||
|
const bottomLeftRadius = isFirstInput && isLastLine ? 4 : 0;
|
||||||
|
const borderRadius = `${topLeftRadius}px ${topRightRadius}px ${bottomRightRadius}px ${bottomLeftRadius}px`;
|
||||||
|
|
||||||
const { dirtyFields } = this.props;
|
const { dirtyFields } = this.props;
|
||||||
const isFieldDirty = Boolean(
|
const isFieldDirty = Boolean(
|
||||||
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
|
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const isEditing = this.state.editingField === id;
|
||||||
<TextField
|
const isDisabled = saving || (id === 'sku' && Boolean(this.props.isOnShipment));
|
||||||
size="small"
|
const titleText = id === 'sku' && this.props.isOnShipment
|
||||||
fullWidth
|
? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden'
|
||||||
type={type}
|
: undefined;
|
||||||
value={val ?? ''}
|
|
||||||
error={isError}
|
if (isEditing) {
|
||||||
disabled={saving || (id === 'sku' && Boolean(this.props.isOnShipment))}
|
return (
|
||||||
title={id === 'sku' && this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden' : undefined}
|
<TextField
|
||||||
autoComplete="off"
|
autoFocus
|
||||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
size="small"
|
||||||
onBlur={(e) => this.handleBlur(e, id)}
|
type={type}
|
||||||
slotProps={{
|
value={val ?? ''}
|
||||||
htmlInput: {
|
error={isError}
|
||||||
inputMode: type === 'number' ? 'decimal' : 'text',
|
disabled={isDisabled}
|
||||||
style: { textAlign: align },
|
title={titleText}
|
||||||
autoComplete: 'off',
|
autoComplete="off"
|
||||||
step: type === 'number' ? 'any' : undefined,
|
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||||
},
|
onBlur={(e) => this.handleBlur(e, id)}
|
||||||
}}
|
onKeyDown={this.handleKeyDown}
|
||||||
sx={{
|
slotProps={{
|
||||||
'& .MuiOutlinedInput-root': {
|
htmlInput: {
|
||||||
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
|
inputMode: type === 'number' ? 'decimal' : 'text',
|
||||||
|
style: { textAlign: align },
|
||||||
|
autoComplete: 'off',
|
||||||
|
step: type === 'number' ? 'any' : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'relative',
|
||||||
ml: isFirstInput ? 0 : '-1px',
|
ml: isFirstInput ? 0 : '-1px',
|
||||||
...(isFieldDirty && {
|
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
|
||||||
bgcolor: 'rgba(237, 108, 2, 0.08)',
|
mt: isFirstLine ? 0 : '-1px',
|
||||||
}),
|
height: isFirstLine ? 40 : 41,
|
||||||
'&:hover': {
|
zIndex: 3,
|
||||||
zIndex: 1,
|
'& .MuiOutlinedInput-root': {
|
||||||
|
height: isFirstLine ? 40 : 41,
|
||||||
|
borderRadius,
|
||||||
|
width: '100%',
|
||||||
|
...(isFieldDirty && {
|
||||||
|
bgcolor: 'rgba(237, 108, 2, 0.08)',
|
||||||
|
}),
|
||||||
|
'&:hover': {
|
||||||
|
zIndex: 4,
|
||||||
|
},
|
||||||
|
'&.Mui-focused': {
|
||||||
|
zIndex: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'&.Mui-focused': {
|
|
||||||
zIndex: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...(isFieldDirty && {
|
|
||||||
zIndex: 1,
|
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'warning.main',
|
borderWidth: '1px !important',
|
||||||
borderWidth: 2,
|
borderColor: isFieldDirty
|
||||||
|
? 'warning.main'
|
||||||
|
: isError
|
||||||
|
? 'error.main'
|
||||||
|
: 'rgba(0, 0, 0, 0.23)',
|
||||||
|
},
|
||||||
|
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderWidth: '1px !important',
|
||||||
|
borderColor: isFieldDirty
|
||||||
|
? 'warning.main'
|
||||||
|
: isError
|
||||||
|
? 'error.main'
|
||||||
|
: 'primary.main',
|
||||||
},
|
},
|
||||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||||
borderColor: 'warning.main',
|
borderWidth: '1px !important',
|
||||||
|
borderColor: isFieldDirty
|
||||||
|
? 'warning.main'
|
||||||
|
: isError
|
||||||
|
? 'error.main'
|
||||||
|
: 'rgba(0, 0, 0, 0.87)',
|
||||||
},
|
},
|
||||||
|
'& input[type=number]': {
|
||||||
|
MozAppearance: 'textfield',
|
||||||
|
},
|
||||||
|
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
|
||||||
|
{
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasValue = val != null && String(val).trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
tabIndex={isDisabled ? -1 : 0}
|
||||||
|
role={isDisabled ? undefined : 'button'}
|
||||||
|
onClick={() => this.handleStartEdit(id)}
|
||||||
|
onFocus={() => this.handleStartEdit(id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.handleStartEdit(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={titleText}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start',
|
||||||
|
height: isFirstLine ? 40 : 41,
|
||||||
|
minHeight: isFirstLine ? 40 : 41,
|
||||||
|
maxHeight: isFirstLine ? 40 : 41,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
position: 'relative',
|
||||||
|
ml: isFirstInput ? 0 : '-1px',
|
||||||
|
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
|
||||||
|
mt: isFirstLine ? 0 : '-1px',
|
||||||
|
px: '14px',
|
||||||
|
py: 0,
|
||||||
|
borderRadius,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: isFieldDirty
|
||||||
|
? 'warning.main'
|
||||||
|
: isError
|
||||||
|
? 'error.main'
|
||||||
|
: 'rgba(0, 0, 0, 0.23)',
|
||||||
|
cursor: isDisabled ? 'default' : 'text',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
userSelect: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
...(isFieldDirty && {
|
||||||
|
bgcolor: 'rgba(237, 108, 2, 0.08)',
|
||||||
|
zIndex: 2,
|
||||||
}),
|
}),
|
||||||
'& input[type=number]': {
|
...(isError && !isFieldDirty && {
|
||||||
MozAppearance: 'textfield',
|
bgcolor: 'rgba(211, 47, 47, 0.04)',
|
||||||
},
|
borderColor: 'error.main',
|
||||||
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
|
zIndex: 2,
|
||||||
{
|
}),
|
||||||
WebkitAppearance: 'none',
|
'&:hover': isDisabled
|
||||||
margin: 0,
|
? undefined
|
||||||
|
: {
|
||||||
|
bgcolor: isFieldDirty
|
||||||
|
? 'rgba(237, 108, 2, 0.14)'
|
||||||
|
: isError
|
||||||
|
? 'rgba(211, 47, 47, 0.08)'
|
||||||
|
: 'action.hover',
|
||||||
|
borderColor: isFieldDirty
|
||||||
|
? 'warning.main'
|
||||||
|
: isError
|
||||||
|
? 'error.main'
|
||||||
|
: 'rgba(0, 0, 0, 0.87)',
|
||||||
|
zIndex: 3,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '2px solid',
|
||||||
|
outlineColor: 'primary.main',
|
||||||
|
outlineOffset: -2,
|
||||||
|
zIndex: 4,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
textAlign: align,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontWeight: id === 'sku' ? 600 : 400,
|
||||||
|
color: isError
|
||||||
|
? 'error.main'
|
||||||
|
: hasValue
|
||||||
|
? 'text.primary'
|
||||||
|
: 'text.disabled',
|
||||||
|
fontStyle: 'normal',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasValue ? String(val) : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,7 +1136,11 @@ class ManifestFormLine extends PureComponent {
|
|||||||
sx={{
|
sx={{
|
||||||
p: 0,
|
p: 0,
|
||||||
width: col.width,
|
width: col.width,
|
||||||
verticalAlign: 'middle',
|
minWidth: col.width,
|
||||||
|
height: 40,
|
||||||
|
verticalAlign: 'top',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{this.renderField(col, colIdx, columns.length)}
|
{this.renderField(col, colIdx, columns.length)}
|
||||||
@@ -999,32 +1160,28 @@ class ManifestFormLine extends PureComponent {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||||
{this.props.onMove && (
|
{this.props.onMove && (
|
||||||
<>
|
<>
|
||||||
<Tooltip title="Nach oben">
|
<span>
|
||||||
<span>
|
<IconButton
|
||||||
<IconButton
|
size="small"
|
||||||
size="small"
|
aria-label="Nach oben verschieben"
|
||||||
aria-label="Nach oben verschieben"
|
disabled={saving || this.props.isFirstLine}
|
||||||
disabled={saving || this.props.isFirstLine}
|
onClick={this.handleMoveUp}
|
||||||
onClick={this.handleMoveUp}
|
sx={{ p: 0.35 }}
|
||||||
sx={{ p: 0.35 }}
|
>
|
||||||
>
|
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
</IconButton>
|
||||||
</IconButton>
|
</span>
|
||||||
</span>
|
<span>
|
||||||
</Tooltip>
|
<IconButton
|
||||||
<Tooltip title="Nach unten">
|
size="small"
|
||||||
<span>
|
aria-label="Nach unten verschieben"
|
||||||
<IconButton
|
disabled={saving || this.props.isLastLine}
|
||||||
size="small"
|
onClick={this.handleMoveDown}
|
||||||
aria-label="Nach unten verschieben"
|
sx={{ p: 0.35 }}
|
||||||
disabled={saving || this.props.isLastLine}
|
>
|
||||||
onClick={this.handleMoveDown}
|
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
||||||
sx={{ p: 0.35 }}
|
</IconButton>
|
||||||
>
|
</span>
|
||||||
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
onPdfDelete,
|
onPdfDelete,
|
||||||
onPdfClick,
|
onPdfClick,
|
||||||
onAttachmentClick,
|
onAttachmentClick,
|
||||||
|
onMentionInChat,
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onNumberChange,
|
onNumberChange,
|
||||||
onExternalNumberChange,
|
onExternalNumberChange,
|
||||||
@@ -356,7 +357,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
||||||
minWidth: { sm: 160 },
|
minWidth: { sm: 160 },
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => (
|
renderInput={({ inputProps: _unusedInputProps, InputProps: _unusedInputProps2, InputLabelProps: _unusedInputLabelProps, ...params }) => (
|
||||||
<TextField
|
<TextField
|
||||||
{...params}
|
{...params}
|
||||||
inputRef={this.partyInputRef}
|
inputRef={this.partyInputRef}
|
||||||
@@ -456,6 +457,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
onRescan={onPdfRescan}
|
onRescan={onPdfRescan}
|
||||||
onDelete={onPdfDelete}
|
onDelete={onPdfDelete}
|
||||||
onClick={onAttachmentClick ?? onPdfClick}
|
onClick={onAttachmentClick ?? onPdfClick}
|
||||||
|
onMentionInChat={onMentionInChat}
|
||||||
disabled={saving || pdfUploading}
|
disabled={saving || pdfUploading}
|
||||||
hasArticles={hasArticles}
|
hasArticles={hasArticles}
|
||||||
/>
|
/>
|
||||||
@@ -509,7 +511,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
Wiederherstellen
|
Wiederherstellen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{docId != null && kind === 'delivery' && onOpenBookDialog && (
|
{docId != null && kind === 'delivery' && onOpenBookDialog && ledgerSummary?.hasTrackStock && (
|
||||||
<Button
|
<Button
|
||||||
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
||||||
color={
|
color={
|
||||||
|
|||||||
@@ -21,19 +21,28 @@ import TableRow from '@mui/material/TableRow';
|
|||||||
import Tabs from '@mui/material/Tabs';
|
import Tabs from '@mui/material/Tabs';
|
||||||
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 Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
|
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
|
||||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
||||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||||
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
import StorageIcon from '@mui/icons-material/Storage';
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import { vfs } from '../../api';
|
||||||
import backupAutomat from '../../automats/backupAutomat';
|
import backupAutomat from '../../automats/backupAutomat';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import {
|
import {
|
||||||
@@ -92,11 +101,17 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
vfsLoadingByBackup: vfsLoadingByBackup || {},
|
vfsLoadingByBackup: vfsLoadingByBackup || {},
|
||||||
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
||||||
expandedItemName: null,
|
expandedItemName: null,
|
||||||
|
restoringSkills: false,
|
||||||
|
restoringApplets: false,
|
||||||
|
confirmRestore: null, // null | 'skills' | 'applets'
|
||||||
|
lastRestoreResult: null, // null | { type: 'skills' | 'applets', count: number, restored: string[] }
|
||||||
};
|
};
|
||||||
|
|
||||||
this.handleRefresh = this.handleRefresh.bind(this);
|
|
||||||
this.handleTierChange = this.handleTierChange.bind(this);
|
this.handleTierChange = this.handleTierChange.bind(this);
|
||||||
this.handleToggleExpand = this.handleToggleExpand.bind(this);
|
this.handleToggleExpand = this.handleToggleExpand.bind(this);
|
||||||
|
this.handleOpenConfirm = this.handleOpenConfirm.bind(this);
|
||||||
|
this.handleCloseConfirm = this.handleCloseConfirm.bind(this);
|
||||||
|
this.handleExecuteRestore = this.handleExecuteRestore.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
@@ -129,18 +144,6 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
}
|
}
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region handleRefresh
|
|
||||||
// MARK: calls backupAutomat.actions.reload from ./api/index.js
|
|
||||||
async handleRefresh() {
|
|
||||||
try {
|
|
||||||
await backupAutomat.actions.reload();
|
|
||||||
enqueueSnackbar('Backup-Status aktualisiert', { variant: 'info' });
|
|
||||||
} catch (err) {
|
|
||||||
notifyError(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
handleTierChange(_event, newValue) {
|
handleTierChange(_event, newValue) {
|
||||||
this.setState({ tierTab: newValue, expandedItemName: null });
|
this.setState({ tierTab: newValue, expandedItemName: null });
|
||||||
}
|
}
|
||||||
@@ -151,6 +154,65 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleOpenConfirm(type) {
|
||||||
|
this.setState({ confirmRestore: type });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCloseConfirm() {
|
||||||
|
if (this.state.restoringSkills || this.state.restoringApplets) return;
|
||||||
|
this.setState({ confirmRestore: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region handleExecuteRestore
|
||||||
|
// MARK: calls POST /api/vfs/skills/restore or /api/vfs/applets/restore
|
||||||
|
async handleExecuteRestore() {
|
||||||
|
const { confirmRestore } = this.state;
|
||||||
|
if (!confirmRestore) return;
|
||||||
|
|
||||||
|
if (confirmRestore === 'skills') {
|
||||||
|
this.setState({ restoringSkills: true });
|
||||||
|
try {
|
||||||
|
const res = await vfs.restoreSkills();
|
||||||
|
enqueueSnackbar(`${res.count} Standard-Skills (.SKILL) erfolgreich wiederhergestellt.`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
this.setState({
|
||||||
|
confirmRestore: null,
|
||||||
|
restoringSkills: false,
|
||||||
|
lastRestoreResult: {
|
||||||
|
type: 'skills',
|
||||||
|
count: res.count,
|
||||||
|
restored: res.restored || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ restoringSkills: false });
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
} else if (confirmRestore === 'applets') {
|
||||||
|
this.setState({ restoringApplets: true });
|
||||||
|
try {
|
||||||
|
const res = await vfs.restoreApplets();
|
||||||
|
enqueueSnackbar(`${res.count} Beispiel-Applets (.applet.js) erfolgreich wiederhergestellt.`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
this.setState({
|
||||||
|
confirmRestore: null,
|
||||||
|
restoringApplets: false,
|
||||||
|
lastRestoreResult: {
|
||||||
|
type: 'applets',
|
||||||
|
count: res.count,
|
||||||
|
restored: res.restored || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ restoringApplets: false });
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
@@ -161,6 +223,10 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
vfsLoadingByBackup,
|
vfsLoadingByBackup,
|
||||||
tierTab,
|
tierTab,
|
||||||
expandedItemName,
|
expandedItemName,
|
||||||
|
restoringSkills,
|
||||||
|
restoringApplets,
|
||||||
|
confirmRestore,
|
||||||
|
lastRestoreResult,
|
||||||
} = this.state;
|
} = this.state;
|
||||||
|
|
||||||
if (status === 'loading' && !backupStatus) {
|
if (status === 'loading' && !backupStatus) {
|
||||||
@@ -183,7 +249,7 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<RefreshIcon />}
|
startIcon={<RefreshIcon />}
|
||||||
onClick={this.handleRefresh}
|
onClick={() => void this.loadBackups()}
|
||||||
sx={{ alignSelf: 'flex-start' }}
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
>
|
>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
@@ -207,28 +273,15 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
{/* Header Title & Actions */}
|
{/* Header Title */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
<Box>
|
||||||
<Box>
|
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
||||||
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
Datensicherung & Backups
|
||||||
Datensicherung & Backups
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
|
||||||
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
|
</Typography>
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
onClick={this.handleRefresh}
|
|
||||||
disabled={isRunning}
|
|
||||||
>
|
|
||||||
Aktualisieren
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Status Badges Bar */}
|
{/* Status Badges Bar */}
|
||||||
@@ -246,19 +299,11 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
>
|
>
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
icon={backupStatus?.enabled && isRunning ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
||||||
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
label={backupStatus?.enabled && isRunning ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
||||||
color={backupStatus?.enabled ? 'success' : 'default'}
|
color={backupStatus?.enabled && isRunning ? 'success' : 'default'}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
{isRunning && (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
icon={<CircularProgress size={12} color="inherit" />}
|
|
||||||
label="Backup wird ausgeführt..."
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
icon={<StorageIcon />}
|
icon={<StorageIcon />}
|
||||||
@@ -431,6 +476,231 @@ export default class BackupSettingsPanel extends Component {
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* System Defaults Restore Section (VFS Skills & Applets) */}
|
||||||
|
<Box sx={{ border: `1px solid ${BORDER}`, borderRadius: 2, bgcolor: WHITE, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ borderBottom: `1px solid ${BORDER}`, bgcolor: SURFACE_RAISED, px: 2, py: 1.5 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SettingsBackupRestoreIcon sx={{ color: PRIMARY, fontSize: 22 }} />
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
|
||||||
|
System-Standarddateien wiederherstellen (VFS)
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
|
||||||
|
Setzt systemweite Vorlagen im virtuellen Dateisystem (VFS) des Unternehmens auf den Auslieferungszustand zurück. Bereits vorhandene gleichnamige Dateien werden dabei überschrieben.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ p: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Skills Restore Card */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
bgcolor: SURFACE_BASE,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'rgba(15, 118, 110, 0.1)',
|
||||||
|
color: PRIMARY,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PsychologyOutlinedIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
|
||||||
|
Standard-Skills
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip size="small" label=".SKILL" variant="outlined" sx={{ height: 20, fontSize: '0.75rem', fontWeight: 600 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.825rem', lineHeight: 1.4 }}>
|
||||||
|
Stellt alle systemweiten Standard-.SKILL-Dateien für KI-Assistenten und Workflows im VFS wieder her.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${BORDER}` }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.75rem' }}>
|
||||||
|
Überschreibt existierende Dateien
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={restoringSkills ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
|
||||||
|
onClick={() => this.handleOpenConfirm('skills')}
|
||||||
|
disabled={restoringSkills}
|
||||||
|
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Skills wiederherstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Applets Restore Card */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
bgcolor: SURFACE_BASE,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: 'rgba(15, 118, 110, 0.1)',
|
||||||
|
color: PRIMARY,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WidgetsOutlinedIcon fontSize="small" />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
|
||||||
|
Beispiel-Applets
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip size="small" label=".applet.js" variant="outlined" sx={{ height: 20, fontSize: '0.75rem', fontWeight: 600 }} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.825rem', lineHeight: 1.4 }}>
|
||||||
|
Stellt alle systemweiten Standard-.applet.js-Beispiel-Applets für Workflows und Auswertungen im VFS wieder her.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${BORDER}` }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.75rem' }}>
|
||||||
|
Überschreibt existierende Dateien
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="contained"
|
||||||
|
startIcon={restoringApplets ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
|
||||||
|
onClick={() => this.handleOpenConfirm('applets')}
|
||||||
|
disabled={restoringApplets}
|
||||||
|
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Applets wiederherstellen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Last restore results notification / chip display */}
|
||||||
|
{lastRestoreResult && (
|
||||||
|
<Box sx={{ mt: 2, p: 1.5, borderRadius: 1.5, bgcolor: 'rgba(15, 118, 110, 0.08)', border: '1px solid rgba(15, 118, 110, 0.2)' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="caption" fontWeight={600} sx={{ color: PRIMARY }}>
|
||||||
|
Erfolgreich wiederhergestellt ({lastRestoreResult.count} {lastRestoreResult.type === 'skills' ? 'Skills' : 'Applets'}):
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => this.setState({ lastRestoreResult: null })}
|
||||||
|
sx={{ p: 0.25, color: 'text.secondary' }}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
|
||||||
|
{lastRestoreResult.restored.map((name) => (
|
||||||
|
<Chip
|
||||||
|
key={name}
|
||||||
|
size="small"
|
||||||
|
label={name}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
bgcolor: WHITE,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog for System Defaults Restore */}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(confirmRestore)}
|
||||||
|
onClose={this.handleCloseConfirm}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<WarningAmberIcon color="warning" />
|
||||||
|
{confirmRestore === 'skills'
|
||||||
|
? 'Standard-Skills wiederherstellen?'
|
||||||
|
: 'Beispiel-Applets wiederherstellen?'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ color: DARK_2 }}>
|
||||||
|
{confirmRestore === 'skills'
|
||||||
|
? 'Möchten Sie alle systemweiten Standard-.SKILL-Dateien im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .SKILL-Dateien werden dabei überschrieben.'
|
||||||
|
: 'Möchten Sie alle systemweiten Standard-.applet.js-Beispiel-Applets im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .applet.js-Dateien werden dabei überschrieben.'}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleCloseConfirm}
|
||||||
|
disabled={restoringSkills || restoringApplets}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={this.handleExecuteRestore}
|
||||||
|
disabled={restoringSkills || restoringApplets}
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={
|
||||||
|
(confirmRestore === 'skills' ? restoringSkills : restoringApplets) ? (
|
||||||
|
<CircularProgress size={14} color="inherit" />
|
||||||
|
) : (
|
||||||
|
<SettingsBackupRestoreIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{ textTransform: 'none' }}
|
||||||
|
>
|
||||||
|
{(confirmRestore === 'skills' ? restoringSkills : restoringApplets)
|
||||||
|
? 'Wiederherstellen...'
|
||||||
|
: 'Jetzt wiederherstellen'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,448 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
import Typography from '@mui/material/Typography';
|
||||||
import { articleHistoryGridAutomat } from '../../automats';
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import articleHistoryAutomat from '../../automats/articleHistoryAutomat';
|
||||||
import { router } from '../../appRouter';
|
import { router } from '../../appRouter';
|
||||||
import {
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
LINK,
|
import { LINK } from '../../colors';
|
||||||
} from '../../colors';
|
|
||||||
|
|
||||||
const COLUMN_DEFS = [
|
const CHUNK_SIZE = 50;
|
||||||
{
|
|
||||||
headerName: 'ID',
|
function formatDateTime(val) {
|
||||||
field: 'id',
|
if (!val) return '–';
|
||||||
maxWidth: 90,
|
try {
|
||||||
sortable: true,
|
const d = new Date(val);
|
||||||
filter: 'agNumberColumnFilter',
|
if (isNaN(d.getTime())) return String(val);
|
||||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
return d.toLocaleString('de-DE', {
|
||||||
},
|
day: '2-digit',
|
||||||
{
|
month: '2-digit',
|
||||||
headerName: 'Datum',
|
year: 'numeric',
|
||||||
field: 'date',
|
hour: '2-digit',
|
||||||
minWidth: 170,
|
minute: '2-digit',
|
||||||
maxWidth: 200,
|
second: '2-digit',
|
||||||
sortable: true,
|
});
|
||||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
} catch {
|
||||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
return String(val);
|
||||||
},
|
}
|
||||||
{
|
}
|
||||||
headerName: 'Artikel-Nr.',
|
|
||||||
field: 'sku',
|
export function createDummyTestArticleHistory() {
|
||||||
minWidth: 130,
|
return {
|
||||||
maxWidth: 160,
|
id: 999999,
|
||||||
sortable: true,
|
date: '2030-10-30T12:00:00Z',
|
||||||
filter: 'agTextColumnFilter',
|
sku: 'SKU-99999999',
|
||||||
cellRenderer: (p) => {
|
name: 'Musterartikel Name Langform',
|
||||||
if (!p.value) return '';
|
text: 'Preis geändert von 10,00 € auf 12,50 € durch Benutzer',
|
||||||
|
user: 'Max Mustermann',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowGridSx = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns:
|
||||||
|
'minmax(60px, max-content) minmax(150px, max-content) minmax(120px, max-content) minmax(160px, 1.2fr) minmax(240px, 2fr) minmax(120px, max-content)',
|
||||||
|
gridTemplateRows: '0px 1fr',
|
||||||
|
rowGap: 0,
|
||||||
|
columnGap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||||
|
|
||||||
|
const measuringCellSx = {
|
||||||
|
visibility: 'hidden',
|
||||||
|
height: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
textTransform: 'none',
|
||||||
|
letterSpacing: 'normal',
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerCellSx = {
|
||||||
|
color: 'text.secondary',
|
||||||
|
typography: 'caption',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderInvisibleDummyRow(dummy) {
|
||||||
|
const d = dummy || createDummyTestArticleHistory();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.id}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{formatDateTime(d.date)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.sku}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.name}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.text}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.user}</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArticleHistoryRow extends PureComponent {
|
||||||
|
render() {
|
||||||
|
const { item, disabled, headerItem } = this.props;
|
||||||
|
const dummy = headerItem || createDummyTestArticleHistory();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
return (
|
return (
|
||||||
<a
|
<ListItemButton
|
||||||
href={`/artikel/${encodeURIComponent(p.value)}`}
|
disabled
|
||||||
onClick={(e) => {
|
divider
|
||||||
e.preventDefault();
|
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||||
void router.navigate(`/artikel/${encodeURIComponent(p.value)}`);
|
|
||||||
}}
|
|
||||||
style={{ color: LINK, fontWeight: 600, textDecoration: 'none' }}
|
|
||||||
>
|
>
|
||||||
{p.value}
|
{renderInvisibleDummyRow(dummy)}
|
||||||
</a>
|
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="80%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="90%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="85%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="70%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="90%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={20} />
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
},
|
|
||||||
{
|
const userName =
|
||||||
headerName: 'Artikelname',
|
item.user ||
|
||||||
field: 'name',
|
item.employee ||
|
||||||
minWidth: 180,
|
item.companyuser ||
|
||||||
flex: 1,
|
(item.companyuserId ? `User #${item.companyuserId}` : '—');
|
||||||
sortable: true,
|
|
||||||
filter: 'agTextColumnFilter',
|
return (
|
||||||
},
|
<ListItemButton
|
||||||
{
|
divider
|
||||||
headerName: 'Änderung / Ereignis',
|
disabled={disabled}
|
||||||
field: 'text',
|
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||||
minWidth: 300,
|
onClick={
|
||||||
flex: 2,
|
item.sku
|
||||||
sortable: true,
|
? () => {
|
||||||
filter: 'agTextColumnFilter',
|
void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`);
|
||||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
}
|
||||||
autoHeight: true,
|
: undefined
|
||||||
},
|
}
|
||||||
{
|
>
|
||||||
headerName: 'Benutzer',
|
{renderInvisibleDummyRow(dummy)}
|
||||||
field: 'user',
|
|
||||||
maxWidth: 160,
|
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
|
||||||
sortable: true,
|
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
|
||||||
filter: 'agTextColumnFilter',
|
{item.id}
|
||||||
valueGetter: (p) =>
|
</Typography>
|
||||||
p.data?.user ||
|
</Box>
|
||||||
p.data?.employee ||
|
|
||||||
p.data?.companyuser ||
|
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0 }}>
|
||||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
|
||||||
},
|
{formatDateTime(item.date || item.createdAt)}
|
||||||
];
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0 }}>
|
||||||
|
{item.sku ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="a"
|
||||||
|
href={`/artikel/${encodeURIComponent(item.sku)}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
color: LINK,
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
...ellipsisSx,
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.sku}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
–
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, ...ellipsisSx }}>
|
||||||
|
{item.name || item.articlename || '–'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Tooltip title={item.text || item.message || ''} placement="top-start" enterDelay={500}>
|
||||||
|
<Typography variant="body2" sx={ellipsisSx}>
|
||||||
|
{item.text || item.message || '–'}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
|
||||||
|
{userName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArticleHistorySearchField extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const s = articleHistoryAutomat.state;
|
||||||
|
const stats = articleHistoryAutomat.getChunkStats?.();
|
||||||
|
this.state = {
|
||||||
|
input: s.filter ?? '',
|
||||||
|
seekMonths: s.seekMonths ?? 0,
|
||||||
|
seekYears: s.seekYears ?? 0,
|
||||||
|
lruChunksCount: stats?.lruChunksCount || 0,
|
||||||
|
maxLruChunks: stats?.maxLruChunks || 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({
|
||||||
|
input: state.filter ?? '',
|
||||||
|
seekMonths: state.seekMonths ?? 0,
|
||||||
|
seekYears: state.seekYears ?? 0,
|
||||||
|
}));
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
this.unsubStats = articleHistoryAutomat.subscribeChunkStats?.((stats) => {
|
||||||
|
this.setState({
|
||||||
|
lruChunksCount: stats.lruChunksCount,
|
||||||
|
maxLruChunks: stats.maxLruChunks,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
this.unsubStats?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { inputRef, onReload, loading } = this.props;
|
||||||
|
const { input, seekMonths, seekYears } = this.state;
|
||||||
|
const monthActive = seekMonths > 0;
|
||||||
|
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
|
||||||
|
const yearActive = seekYears > 0;
|
||||||
|
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelTileGrid>
|
||||||
|
<Box
|
||||||
|
ref={inputRef}
|
||||||
|
sx={{
|
||||||
|
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.25,
|
||||||
|
pb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: search field */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
size="small"
|
||||||
|
search
|
||||||
|
clear
|
||||||
|
value={input}
|
||||||
|
placeholder="Artikel-Nr., Name, Änderung oder Benutzer suchen…"
|
||||||
|
onChange={(next) => articleHistoryAutomat.actions.filter(next)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={onReload}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={monthActive ? 'contained' : 'outlined'}
|
||||||
|
color={monthActive ? 'primary' : 'inherit'}
|
||||||
|
onClick={() => articleHistoryAutomat.actions.addSeekMonth()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 88,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: monthActive ? 'primary.main' : 'divider',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monthLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={yearActive ? 'contained' : 'outlined'}
|
||||||
|
color={yearActive ? 'primary' : 'inherit'}
|
||||||
|
onClick={() => articleHistoryAutomat.actions.addSeekYear()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 78,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: yearActive ? 'primary.main' : 'divider',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{yearLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
disabled={!monthActive && !yearActive}
|
||||||
|
onClick={() => articleHistoryAutomat.actions.resetNegativeSeek()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 1,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
|
||||||
|
label={
|
||||||
|
this.state.lruChunksCount > 0
|
||||||
|
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
|
||||||
|
: 'Chunk-Cache'
|
||||||
|
}
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class ArticleHistoryPage extends PureComponent {
|
export default class ArticleHistoryPage extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const s = articleHistoryGridAutomat.state;
|
this.headerItem = createDummyTestArticleHistory();
|
||||||
this.state = {
|
this.state = {
|
||||||
loading: s.loading,
|
items: articleHistoryAutomat.getItems(),
|
||||||
error: s.error,
|
unfilteredTotal: articleHistoryAutomat.state.unfilteredTotal ?? 0,
|
||||||
totalRows: s.totalRows,
|
filterKey: articleHistoryAutomat.getFilterKey?.() ?? '',
|
||||||
|
status: articleHistoryAutomat.state.status,
|
||||||
|
error: articleHistoryAutomat.state.error,
|
||||||
};
|
};
|
||||||
|
this.searchRef = createRef();
|
||||||
this.handleReload = this.handleReload.bind(this);
|
this.handleReload = this.handleReload.bind(this);
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.handlePageChange = this.handlePageChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({
|
this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({
|
||||||
loading: state.loading,
|
items: state.items,
|
||||||
|
unfilteredTotal: state.unfilteredTotal ?? 0,
|
||||||
|
filterKey: articleHistoryAutomat.getFilterKey(state),
|
||||||
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
totalRows: state.totalRows,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,47 +450,112 @@ export default class ArticleHistoryPage extends PureComponent {
|
|||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePageChange(page, meta) {
|
||||||
|
const pageSize = meta?.pageSize ?? 10;
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const items = this.state.items;
|
||||||
|
const maxIndex = items ? items.length - 1 : startIndex;
|
||||||
|
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
|
||||||
|
|
||||||
|
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
|
||||||
|
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
|
||||||
|
|
||||||
|
articleHistoryAutomat.requireChunk(startChunk, endChunk);
|
||||||
|
}
|
||||||
|
|
||||||
handleReload() {
|
handleReload() {
|
||||||
articleHistoryGridAutomat.actions.reload();
|
void articleHistoryAutomat.actions.reload().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void articleHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { loading, totalRows } = this.state;
|
const { items, unfilteredTotal, filterKey, status, error } = this.state;
|
||||||
|
const loading = status === 'loading';
|
||||||
|
|
||||||
const actions = (
|
if (items === null && status === 'failed') {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
return (
|
||||||
{loading && <CircularProgress size={18} thickness={5} />}
|
<>
|
||||||
{totalRows >= 0 && (
|
<ArticleHistorySearchField
|
||||||
<Typography variant="body2" color="text.secondary">
|
inputRef={this.searchRef}
|
||||||
{totalRows} Einträge
|
onReload={this.handleReload}
|
||||||
</Typography>
|
loading={loading}
|
||||||
)}
|
/>
|
||||||
<Button
|
<PanelTileGrid>
|
||||||
size="small"
|
<PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
|
||||||
variant="outlined"
|
<BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
|
||||||
startIcon={<RefreshIcon />}
|
</PanelTile>
|
||||||
onClick={this.handleReload}
|
</PanelTileGrid>
|
||||||
disabled={loading}
|
</>
|
||||||
>
|
);
|
||||||
Aktualisieren
|
}
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile title="Artikel-Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
<>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}>
|
<ArticleHistorySearchField
|
||||||
<AutomatGrid
|
inputRef={this.searchRef}
|
||||||
automat={articleHistoryGridAutomat}
|
onReload={this.handleReload}
|
||||||
columnDefs={COLUMN_DEFS}
|
loading={loading}
|
||||||
rowIdField="id"
|
/>
|
||||||
cacheBlockSize={50}
|
<PanelTileGrid>
|
||||||
height="100%"
|
<PanelTile wide={3} minHeight={0} sx={{ pb: 0, position: 'relative' }}>
|
||||||
width="100%"
|
<FillSpace
|
||||||
noRowsOverlayText="Keine Historieneinträge vorhanden"
|
aboveRef={[this.searchRef]}
|
||||||
/>
|
reserveBottom={32}
|
||||||
</Box>
|
minPageSize={8}
|
||||||
</PanelTile>
|
minHeight={0}
|
||||||
|
resetKey={filterKey}
|
||||||
|
items={items || []}
|
||||||
|
totalCount={items ? items.length : 0}
|
||||||
|
unfilteredTotal={unfilteredTotal}
|
||||||
|
headerItem={this.headerItem}
|
||||||
|
renderItem={ArticleHistoryRow}
|
||||||
|
virtualScrollbar
|
||||||
|
header={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...rowGridSx,
|
||||||
|
height: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInvisibleDummyRow(this.headerItem)}
|
||||||
|
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>ID</Box>
|
||||||
|
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Artikel-Nr.</Box>
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Artikelname</Box>
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2, ...headerCellSx }}>Änderung / Ereignis</Box>
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Benutzer</Box>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
onPageChange={this.handlePageChange}
|
||||||
|
/>
|
||||||
|
{items === null && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 52,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 32,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,460 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import Typography from '@mui/material/Typography';
|
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
import Typography from '@mui/material/Typography';
|
||||||
import { globalHistoryGridAutomat } from '../../automats';
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
|
import BackendError from '../../components/BackendError';
|
||||||
|
import globalHistoryAutomat from '../../automats/globalHistoryAutomat';
|
||||||
|
import { router } from '../../appRouter';
|
||||||
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
|
import { LINK } from '../../colors';
|
||||||
|
|
||||||
const COLUMN_DEFS = [
|
const CHUNK_SIZE = 50;
|
||||||
{
|
|
||||||
headerName: 'ID',
|
function formatDateTime(val) {
|
||||||
field: 'id',
|
if (!val) return '–';
|
||||||
maxWidth: 90,
|
try {
|
||||||
sortable: true,
|
const d = new Date(val);
|
||||||
filter: 'agNumberColumnFilter',
|
if (isNaN(d.getTime())) return String(val);
|
||||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
return d.toLocaleString('de-DE', {
|
||||||
},
|
day: '2-digit',
|
||||||
{
|
month: '2-digit',
|
||||||
headerName: 'Datum',
|
year: 'numeric',
|
||||||
field: 'date',
|
hour: '2-digit',
|
||||||
minWidth: 170,
|
minute: '2-digit',
|
||||||
maxWidth: 200,
|
second: '2-digit',
|
||||||
sortable: true,
|
});
|
||||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
} catch {
|
||||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
return String(val);
|
||||||
},
|
}
|
||||||
{
|
}
|
||||||
headerName: 'Typ',
|
|
||||||
field: 'type',
|
export function createDummyTestGlobalHistory() {
|
||||||
minWidth: 120,
|
return {
|
||||||
maxWidth: 160,
|
id: 999999,
|
||||||
sortable: true,
|
date: '2030-10-30T12:00:00Z',
|
||||||
filter: 'agTextColumnFilter',
|
type: 'Bestellung',
|
||||||
},
|
reference: 'BEST-999999',
|
||||||
{
|
text: 'Status geändert auf abgeschlossen durch Benutzer',
|
||||||
headerName: 'Referenz / Key',
|
user: 'Max Mustermann',
|
||||||
field: 'reference',
|
};
|
||||||
minWidth: 140,
|
}
|
||||||
maxWidth: 200,
|
|
||||||
sortable: true,
|
const rowGridSx = {
|
||||||
filter: 'agTextColumnFilter',
|
display: 'grid',
|
||||||
valueGetter: (p) => p.data?.reference || p.data?.p1 || '',
|
gridTemplateColumns:
|
||||||
},
|
'minmax(60px, max-content) minmax(150px, max-content) minmax(110px, max-content) minmax(140px, max-content) minmax(240px, 2fr) minmax(120px, max-content)',
|
||||||
{
|
gridTemplateRows: '0px 1fr',
|
||||||
headerName: 'Meldung / Beschreibung',
|
rowGap: 0,
|
||||||
field: 'text',
|
columnGap: 2,
|
||||||
minWidth: 300,
|
alignItems: 'center',
|
||||||
flex: 2,
|
width: '100%',
|
||||||
sortable: true,
|
boxSizing: 'border-box',
|
||||||
filter: 'agTextColumnFilter',
|
};
|
||||||
valueGetter: (p) => p.data?.text || p.data?.value || '',
|
|
||||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||||
autoHeight: true,
|
|
||||||
},
|
const measuringCellSx = {
|
||||||
{
|
visibility: 'hidden',
|
||||||
headerName: 'Benutzer',
|
height: 0,
|
||||||
field: 'user',
|
boxSizing: 'border-box',
|
||||||
maxWidth: 160,
|
pointerEvents: 'none',
|
||||||
sortable: true,
|
textTransform: 'none',
|
||||||
filter: 'agTextColumnFilter',
|
letterSpacing: 'normal',
|
||||||
valueGetter: (p) =>
|
};
|
||||||
p.data?.user ||
|
|
||||||
p.data?.employee ||
|
const headerCellSx = {
|
||||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
color: 'text.secondary',
|
||||||
},
|
typography: 'caption',
|
||||||
];
|
fontWeight: 600,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
userSelect: 'none',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderInvisibleDummyRow(dummy) {
|
||||||
|
const d = dummy || createDummyTestGlobalHistory();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.id}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{formatDateTime(d.date)}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.type}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.reference}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.text}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
|
||||||
|
<Typography variant="body2">{d.user}</Typography>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class GlobalHistoryRow extends PureComponent {
|
||||||
|
render() {
|
||||||
|
const { item, disabled, headerItem } = this.props;
|
||||||
|
const dummy = headerItem || createDummyTestGlobalHistory();
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
disabled
|
||||||
|
divider
|
||||||
|
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||||
|
>
|
||||||
|
{renderInvisibleDummyRow(dummy)}
|
||||||
|
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="80%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="90%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="rounded" width={70} height={22} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="80%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="90%" height={20} />
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2 }}>
|
||||||
|
<Skeleton variant="text" width="60%" height={20} />
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userName =
|
||||||
|
item.user ||
|
||||||
|
item.employee ||
|
||||||
|
(item.companyuserId ? `User #${item.companyuserId}` : '—');
|
||||||
|
|
||||||
|
const typeStr = item.type || item.entityType || '–';
|
||||||
|
const refStr = item.reference || item.p1 || '–';
|
||||||
|
|
||||||
|
const isArticle = (typeStr === 'article' || typeStr === 'artikel') && refStr && refStr !== '–';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
divider
|
||||||
|
disabled={disabled}
|
||||||
|
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||||
|
onClick={
|
||||||
|
isArticle
|
||||||
|
? () => {
|
||||||
|
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderInvisibleDummyRow(dummy)}
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
|
||||||
|
{item.id}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
|
||||||
|
{formatDateTime(item.date || item.createdAt)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={typeStr}
|
||||||
|
sx={{
|
||||||
|
height: 22,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
bgcolor: 'action.hover',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
|
||||||
|
{isArticle ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="a"
|
||||||
|
href={`/artikel/${encodeURIComponent(refStr)}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
color: LINK,
|
||||||
|
fontWeight: 600,
|
||||||
|
textDecoration: 'none',
|
||||||
|
...ellipsisSx,
|
||||||
|
'&:hover': { textDecoration: 'underline' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{refStr}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
|
||||||
|
{refStr}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}>
|
||||||
|
<Typography variant="body2" sx={ellipsisSx}>
|
||||||
|
{item.text || item.value || '–'}
|
||||||
|
</Typography>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2, minWidth: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
|
||||||
|
{userName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GlobalHistorySearchField extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const s = globalHistoryAutomat.state;
|
||||||
|
const stats = globalHistoryAutomat.getChunkStats?.();
|
||||||
|
this.state = {
|
||||||
|
input: s.filter ?? '',
|
||||||
|
seekMonths: s.seekMonths ?? 0,
|
||||||
|
seekYears: s.seekYears ?? 0,
|
||||||
|
lruChunksCount: stats?.lruChunksCount || 0,
|
||||||
|
maxLruChunks: stats?.maxLruChunks || 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({
|
||||||
|
input: state.filter ?? '',
|
||||||
|
seekMonths: state.seekMonths ?? 0,
|
||||||
|
seekYears: state.seekYears ?? 0,
|
||||||
|
}));
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
this.unsubStats = globalHistoryAutomat.subscribeChunkStats?.((stats) => {
|
||||||
|
this.setState({
|
||||||
|
lruChunksCount: stats.lruChunksCount,
|
||||||
|
maxLruChunks: stats.maxLruChunks,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
this.unsubStats?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { inputRef, onReload, loading } = this.props;
|
||||||
|
const { input, seekMonths, seekYears } = this.state;
|
||||||
|
const monthActive = seekMonths > 0;
|
||||||
|
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
|
||||||
|
const yearActive = seekYears > 0;
|
||||||
|
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelTileGrid>
|
||||||
|
<Box
|
||||||
|
ref={inputRef}
|
||||||
|
sx={{
|
||||||
|
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 1.25,
|
||||||
|
pb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: search field */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
size="small"
|
||||||
|
search
|
||||||
|
clear
|
||||||
|
value={input}
|
||||||
|
placeholder="Referenz, Typ, Meldung oder Benutzer suchen…"
|
||||||
|
onChange={(next) => globalHistoryAutomat.actions.filter(next)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={onReload}
|
||||||
|
disabled={loading}
|
||||||
|
sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.75,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={monthActive ? 'contained' : 'outlined'}
|
||||||
|
color={monthActive ? 'primary' : 'inherit'}
|
||||||
|
onClick={() => globalHistoryAutomat.actions.addSeekMonth()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 88,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: monthActive ? 'primary.main' : 'divider',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monthLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={yearActive ? 'contained' : 'outlined'}
|
||||||
|
color={yearActive ? 'primary' : 'inherit'}
|
||||||
|
onClick={() => globalHistoryAutomat.actions.addSeekYear()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 78,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: yearActive ? 'primary.main' : 'divider',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{yearLabel}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
|
disabled={!monthActive && !yearActive}
|
||||||
|
onClick={() => globalHistoryAutomat.actions.resetNegativeSeek()}
|
||||||
|
sx={{
|
||||||
|
minWidth: 'auto',
|
||||||
|
px: 1,
|
||||||
|
py: 0.25,
|
||||||
|
height: 28,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
|
||||||
|
label={
|
||||||
|
this.state.lruChunksCount > 0
|
||||||
|
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
|
||||||
|
: 'Chunk-Cache'
|
||||||
|
}
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</PanelTileGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class GlobalHistoryPage extends PureComponent {
|
export default class GlobalHistoryPage extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const s = globalHistoryGridAutomat.state;
|
this.headerItem = createDummyTestGlobalHistory();
|
||||||
this.state = {
|
this.state = {
|
||||||
loading: s.loading,
|
items: globalHistoryAutomat.getItems(),
|
||||||
error: s.error,
|
unfilteredTotal: globalHistoryAutomat.state.unfilteredTotal ?? 0,
|
||||||
totalRows: s.totalRows,
|
filterKey: globalHistoryAutomat.getFilterKey?.() ?? '',
|
||||||
|
status: globalHistoryAutomat.state.status,
|
||||||
|
error: globalHistoryAutomat.state.error,
|
||||||
};
|
};
|
||||||
|
this.searchRef = createRef();
|
||||||
this.handleReload = this.handleReload.bind(this);
|
this.handleReload = this.handleReload.bind(this);
|
||||||
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
|
this.handlePageChange = this.handlePageChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({
|
this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({
|
||||||
loading: state.loading,
|
items: state.items,
|
||||||
|
unfilteredTotal: state.unfilteredTotal ?? 0,
|
||||||
|
filterKey: globalHistoryAutomat.getFilterKey(state),
|
||||||
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
totalRows: state.totalRows,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,47 +462,112 @@ export default class GlobalHistoryPage extends PureComponent {
|
|||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePageChange(page, meta) {
|
||||||
|
const pageSize = meta?.pageSize ?? 10;
|
||||||
|
const startIndex = (page - 1) * pageSize;
|
||||||
|
const items = this.state.items;
|
||||||
|
const maxIndex = items ? items.length - 1 : startIndex;
|
||||||
|
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
|
||||||
|
|
||||||
|
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
|
||||||
|
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
|
||||||
|
|
||||||
|
globalHistoryAutomat.requireChunk(startChunk, endChunk);
|
||||||
|
}
|
||||||
|
|
||||||
handleReload() {
|
handleReload() {
|
||||||
globalHistoryGridAutomat.actions.reload();
|
void globalHistoryAutomat.actions.reload().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry() {
|
||||||
|
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { loading, totalRows } = this.state;
|
const { items, unfilteredTotal, filterKey, status, error } = this.state;
|
||||||
|
const loading = status === 'loading';
|
||||||
|
|
||||||
const actions = (
|
if (items === null && status === 'failed') {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
return (
|
||||||
{loading && <CircularProgress size={18} thickness={5} />}
|
<>
|
||||||
{totalRows >= 0 && (
|
<GlobalHistorySearchField
|
||||||
<Typography variant="body2" color="text.secondary">
|
inputRef={this.searchRef}
|
||||||
{totalRows} Einträge
|
onReload={this.handleReload}
|
||||||
</Typography>
|
loading={loading}
|
||||||
)}
|
/>
|
||||||
<Button
|
<PanelTileGrid>
|
||||||
size="small"
|
<PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
|
||||||
variant="outlined"
|
<BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
|
||||||
startIcon={<RefreshIcon />}
|
</PanelTile>
|
||||||
onClick={this.handleReload}
|
</PanelTileGrid>
|
||||||
disabled={loading}
|
</>
|
||||||
>
|
);
|
||||||
Aktualisieren
|
}
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile title="Globale Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
<>
|
||||||
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}>
|
<GlobalHistorySearchField
|
||||||
<AutomatGrid
|
inputRef={this.searchRef}
|
||||||
automat={globalHistoryGridAutomat}
|
onReload={this.handleReload}
|
||||||
columnDefs={COLUMN_DEFS}
|
loading={loading}
|
||||||
rowIdField="id"
|
/>
|
||||||
cacheBlockSize={50}
|
<PanelTileGrid>
|
||||||
height="100%"
|
<PanelTile wide={3} minHeight={0} sx={{ pb: 0, position: 'relative' }}>
|
||||||
width="100%"
|
<FillSpace
|
||||||
noRowsOverlayText="Keine globalen Historieneinträge vorhanden"
|
aboveRef={[this.searchRef]}
|
||||||
/>
|
reserveBottom={32}
|
||||||
</Box>
|
minPageSize={8}
|
||||||
</PanelTile>
|
minHeight={0}
|
||||||
|
resetKey={filterKey}
|
||||||
|
items={items || []}
|
||||||
|
totalCount={items ? items.length : 0}
|
||||||
|
unfilteredTotal={unfilteredTotal}
|
||||||
|
headerItem={this.headerItem}
|
||||||
|
renderItem={GlobalHistoryRow}
|
||||||
|
virtualScrollbar
|
||||||
|
header={
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
...rowGridSx,
|
||||||
|
height: 36,
|
||||||
|
minHeight: 36,
|
||||||
|
px: 2,
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderInvisibleDummyRow(this.headerItem)}
|
||||||
|
<Box sx={{ gridColumn: 1, gridRow: 2, ...headerCellSx }}>ID</Box>
|
||||||
|
<Box sx={{ gridColumn: 2, gridRow: 2, ...headerCellSx }}>Datum</Box>
|
||||||
|
<Box sx={{ gridColumn: 3, gridRow: 2, ...headerCellSx }}>Typ</Box>
|
||||||
|
<Box sx={{ gridColumn: 4, gridRow: 2, ...headerCellSx }}>Referenz / Key</Box>
|
||||||
|
<Box sx={{ gridColumn: 5, gridRow: 2, ...headerCellSx }}>Meldung / Beschreibung</Box>
|
||||||
|
<Box sx={{ gridColumn: 6, gridRow: 2, ...headerCellSx }}>Benutzer</Box>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
onPageChange={this.handlePageChange}
|
||||||
|
/>
|
||||||
|
{items === null && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 52,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 32,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</PanelTile>
|
||||||
|
</PanelTileGrid>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
ArticleSearchDialog,
|
ArticleSearchDialog,
|
||||||
BookDeliveryDialog,
|
BookDeliveryDialog,
|
||||||
} from '../../components/manifest';
|
} from '../../components/manifest';
|
||||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
|
||||||
import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
|
import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
|
||||||
import { article as articleApi, delivery as deliveryApi } from '../../api';
|
import { article as articleApi, delivery as deliveryApi } from '../../api';
|
||||||
import { getCategoryState } from '../../automats/categoryAutomat';
|
import { getCategoryState } from '../../automats/categoryAutomat';
|
||||||
@@ -48,6 +47,7 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
|
|||||||
import { extractPdfText } from '../../utils/pdfText';
|
import { extractPdfText } from '../../utils/pdfText';
|
||||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||||
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
||||||
|
import chatAutomat from '../../automats/chatAutomat';
|
||||||
|
|
||||||
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||||
import {
|
import {
|
||||||
@@ -68,9 +68,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
partiesLoading: true,
|
partiesLoading: true,
|
||||||
saving: false,
|
saving: false,
|
||||||
pdfUploading: false,
|
pdfUploading: false,
|
||||||
pdfDialogOpen: false,
|
|
||||||
pdfDialogData: null,
|
|
||||||
pdfFileName: '',
|
|
||||||
textEditorOpen: false,
|
textEditorOpen: false,
|
||||||
textEditorAttachment: null,
|
textEditorAttachment: null,
|
||||||
viewerPosition: userSettingsAutomat.state.viewerPosition,
|
viewerPosition: userSettingsAutomat.state.viewerPosition,
|
||||||
@@ -105,8 +102,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||||
this.handlePdfDelete = this.handlePdfDelete.bind(this);
|
this.handlePdfDelete = this.handlePdfDelete.bind(this);
|
||||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
this.handleMentionInChat = this.handleMentionInChat.bind(this);
|
||||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
|
||||||
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
|
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
|
||||||
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
|
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
|
||||||
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
|
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
|
||||||
@@ -159,10 +155,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
if (this._parseAbortController) {
|
|
||||||
this._parseAbortController.abort();
|
|
||||||
this._parseAbortController = null;
|
|
||||||
}
|
|
||||||
if (this._skuDebounceTimers) {
|
if (this._skuDebounceTimers) {
|
||||||
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
|
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
|
||||||
this._skuDebounceTimers = null;
|
this._skuDebounceTimers = null;
|
||||||
@@ -301,7 +293,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
|
|
||||||
handleCreateSupplierFromPdf(supplierName) {
|
handleCreateSupplierFromPdf(supplierName) {
|
||||||
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
|
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
|
||||||
this.handleClosePdfDialog();
|
|
||||||
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
|
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
|
||||||
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
|
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
|
||||||
}
|
}
|
||||||
@@ -635,9 +626,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
};
|
};
|
||||||
this.automat.actions.addAttachment(textAttachment);
|
this.automat.actions.addAttachment(textAttachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
|
|
||||||
await this.runParse(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePdfRescan(attachment) {
|
async handlePdfRescan(attachment) {
|
||||||
@@ -697,8 +685,41 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
|
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.runParse(attachment.file);
|
async handleMentionInChat(attachment) {
|
||||||
|
console.log('[neu.jsx] handleMentionInChat called', attachment);
|
||||||
|
if (!attachment) return;
|
||||||
|
let text = attachment.text;
|
||||||
|
if (!text && (!attachment.file || attachment.file.size === 0)) {
|
||||||
|
const file = await this.ensureAttachmentFile(attachment);
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
text = await file.text();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Lesen des Texts:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!text && (attachment.file instanceof Blob || attachment.file instanceof File)) {
|
||||||
|
try {
|
||||||
|
text = await attachment.file.text();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Lesen des Texts:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = (text || '').trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
console.warn('[neu.jsx] handleMentionInChat: text is empty for attachment', attachment);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[neu.jsx] Inserting extraction snippet to chat, length:', trimmed.length);
|
||||||
|
chatAutomat.actions.insertExtractionSnippet({
|
||||||
|
text: trimmed,
|
||||||
|
label: 'parsed invoice',
|
||||||
|
instruction: "use 'Process Incoming Product and Invoices' SKILL",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePdfDelete(attachment) {
|
handlePdfDelete(attachment) {
|
||||||
@@ -706,227 +727,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
this.automat.actions.removeAttachment(attachment.id);
|
this.automat.actions.removeAttachment(attachment.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async runParse(file) {
|
|
||||||
if (this._parseAbortController) {
|
|
||||||
this._parseAbortController.abort();
|
|
||||||
}
|
|
||||||
const abortController = new AbortController();
|
|
||||||
this._parseAbortController = abortController;
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
pdfUploading: true,
|
|
||||||
pdfDialogOpen: true,
|
|
||||||
pdfFileName: file.name,
|
|
||||||
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
const appendMessage = (msg) => {
|
|
||||||
if (this._cancelled || abortController.signal.aborted) return;
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: {
|
|
||||||
...prev.pdfDialogData,
|
|
||||||
messages: [...(prev.pdfDialogData?.messages ?? []), msg],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
for await (const { event, data } of deliveryApi.parseSse(file, { signal: abortController.signal })) {
|
|
||||||
if (this._cancelled || abortController.signal.aborted) return;
|
|
||||||
switch (event) {
|
|
||||||
case 'status':
|
|
||||||
appendMessage({ type: 'status', text: data.message ?? String(data) });
|
|
||||||
break;
|
|
||||||
case 'llm_call':
|
|
||||||
appendMessage({
|
|
||||||
type: 'llm_call',
|
|
||||||
text: `LLM-Aufruf: ${data.model} (${data.promptSize} tokens, ${data.messages} msg)`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'tool_call':
|
|
||||||
appendMessage({ type: 'tool_call', text: `Tool-Aufruf: ${data.name}` });
|
|
||||||
break;
|
|
||||||
case 'usage':
|
|
||||||
appendMessage({
|
|
||||||
type: 'usage',
|
|
||||||
text: `Token-Verbrauch: ${data.input} in / ${data.cached} cached / ${data.output} out — $${(data.cost ?? 0).toFixed(4)}`,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'progress':
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: { ...prev.pdfDialogData, result: data },
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
case 'result':
|
|
||||||
case 'done': {
|
|
||||||
const result = data?.delivery ?? data;
|
|
||||||
const rawItems = Array.isArray(result?.lines)
|
|
||||||
? result.lines
|
|
||||||
: Array.isArray(result?.items?.items)
|
|
||||||
? result.items.items
|
|
||||||
: null;
|
|
||||||
const newLines = Array.isArray(rawItems)
|
|
||||||
? rawItems.map((l, idx) => {
|
|
||||||
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
|
|
||||||
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
|
|
||||||
: '';
|
|
||||||
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
|
|
||||||
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
|
|
||||||
: '';
|
|
||||||
return {
|
|
||||||
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
|
|
||||||
name: l.name ?? l.articleName ?? null,
|
|
||||||
sku: l.sku ?? l.liefArtNr ?? '',
|
|
||||||
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
|
|
||||||
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
|
|
||||||
unit: l.unit ?? '',
|
|
||||||
taxrate: taxVal,
|
|
||||||
tax: taxVal,
|
|
||||||
netprice: netVal,
|
|
||||||
net: netVal,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: { ...prev.pdfDialogData, streaming: false, result },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const docDate = result?.date ?? result?.basedata?.date;
|
|
||||||
let supplierId = result?.supplierId ?? result?.basedata?.kLieferant;
|
|
||||||
const extNum = result?.externalNumber ?? result?.basedata?.manifestNr;
|
|
||||||
const docType = result?.type ?? result?.basedata?.manifestType;
|
|
||||||
const notes = Array.isArray(result?.notes) ? result.notes : [];
|
|
||||||
|
|
||||||
const parsedSupplierName = (
|
|
||||||
result?.supplierName ??
|
|
||||||
result?.basedata?.lieferantName ??
|
|
||||||
result?.lieferantName ??
|
|
||||||
result?.basedata?.supplierName ??
|
|
||||||
''
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
// If supplierId is null, try matching supplier by name from loaded delivery entities
|
|
||||||
if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
|
|
||||||
const nameToMatch = parsedSupplierName.toLowerCase();
|
|
||||||
const matched = this.state.deliveryEntities.find(
|
|
||||||
(e) =>
|
|
||||||
e.name &&
|
|
||||||
(e.name.trim().toLowerCase() === nameToMatch ||
|
|
||||||
e.name.toLowerCase().includes(nameToMatch) ||
|
|
||||||
nameToMatch.includes(e.name.toLowerCase())),
|
|
||||||
);
|
|
||||||
if (matched) supplierId = matched.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
|
|
||||||
this.setState({ unmatchedSupplierName });
|
|
||||||
this.automat.actions.setUnmatchedSupplierName(unmatchedSupplierName);
|
|
||||||
|
|
||||||
|
|
||||||
if (docDate) this.automat.actions.setDate(docDate);
|
|
||||||
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
|
|
||||||
if (extNum != null) {
|
|
||||||
this.automat.actions.setExternalNumber(String(extNum));
|
|
||||||
}
|
|
||||||
if (docType) this.automat.actions.setType(docType);
|
|
||||||
if (newLines && newLines.length > 0) {
|
|
||||||
this.automat.actions.setLineitems(newLines);
|
|
||||||
}
|
|
||||||
if (notes.length > 0) {
|
|
||||||
this.automat.actions.setNotes(notes);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Supply whole article list to GET /supplier/{id}/articles
|
|
||||||
const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
|
|
||||||
const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
|
|
||||||
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
|
|
||||||
this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'error':
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: {
|
|
||||||
...prev.pdfDialogData,
|
|
||||||
streaming: false,
|
|
||||||
error: data.message ?? 'Analyse fehlgeschlagen.',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (this._cancelled) return;
|
|
||||||
if (abortController.signal.aborted || err.name === 'AbortError') {
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: {
|
|
||||||
...prev.pdfDialogData,
|
|
||||||
streaming: false,
|
|
||||||
aborted: true,
|
|
||||||
error: 'Analyse abgebrochen.',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfDialogData: {
|
|
||||||
...prev.pdfDialogData,
|
|
||||||
streaming: false,
|
|
||||||
error: err.message ?? 'Netzwerkfehler bei Analyse.',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
if (this._parseAbortController === abortController) {
|
|
||||||
this._parseAbortController = null;
|
|
||||||
}
|
|
||||||
if (!this._cancelled) {
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfUploading: false,
|
|
||||||
pdfDialogData: prev.pdfDialogData
|
|
||||||
? { ...prev.pdfDialogData, streaming: false }
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runPdfParse(file) {
|
|
||||||
await this.runParse(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTxtParse(file) {
|
|
||||||
await this.runParse(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAbortParse() {
|
|
||||||
if (this._parseAbortController) {
|
|
||||||
this._parseAbortController.abort();
|
|
||||||
this._parseAbortController = null;
|
|
||||||
}
|
|
||||||
this.setState((prev) => ({
|
|
||||||
pdfUploading: false,
|
|
||||||
pdfDialogData: prev.pdfDialogData
|
|
||||||
? {
|
|
||||||
...prev.pdfDialogData,
|
|
||||||
streaming: false,
|
|
||||||
aborted: true,
|
|
||||||
error: 'Analyse abgebrochen.',
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClosePdfDialog() {
|
|
||||||
if (this._parseAbortController) {
|
|
||||||
this._parseAbortController.abort();
|
|
||||||
this._parseAbortController = null;
|
|
||||||
}
|
|
||||||
this.setState({ pdfDialogOpen: false, pdfUploading: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureAttachmentFile(attachment) {
|
async ensureAttachmentFile(attachment) {
|
||||||
if (!attachment) return null;
|
if (!attachment) return null;
|
||||||
const hasContent =
|
const hasContent =
|
||||||
@@ -1127,7 +927,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
isViewerOpen() {
|
isViewerOpen() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
|
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
|
||||||
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
|
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1244,9 +1044,6 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
partiesLoading,
|
partiesLoading,
|
||||||
saving,
|
saving,
|
||||||
pdfUploading,
|
pdfUploading,
|
||||||
pdfDialogOpen,
|
|
||||||
pdfDialogData,
|
|
||||||
pdfFileName,
|
|
||||||
deliveryEntities,
|
deliveryEntities,
|
||||||
date,
|
date,
|
||||||
partyId,
|
partyId,
|
||||||
@@ -1298,6 +1095,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
onPdfRescan={this.handlePdfRescan}
|
onPdfRescan={this.handlePdfRescan}
|
||||||
onPdfDelete={this.handlePdfDelete}
|
onPdfDelete={this.handlePdfDelete}
|
||||||
onAttachmentClick={this.handleAttachmentClick}
|
onAttachmentClick={this.handleAttachmentClick}
|
||||||
|
onMentionInChat={this.handleMentionInChat}
|
||||||
onSave={this.handleSave}
|
onSave={this.handleSave}
|
||||||
onPartyChange={this.handlePartyChange}
|
onPartyChange={this.handlePartyChange}
|
||||||
onDateChange={this.handleDateChange}
|
onDateChange={this.handleDateChange}
|
||||||
@@ -1376,15 +1174,7 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
</PanelTile>
|
</PanelTile>
|
||||||
)}
|
)}
|
||||||
</PanelTileGrid>
|
</PanelTileGrid>
|
||||||
<ManifestPdfDialog
|
|
||||||
open={pdfDialogOpen}
|
|
||||||
filename={pdfFileName}
|
|
||||||
data={pdfDialogData}
|
|
||||||
unmatchedSupplierName={this.state.unmatchedSupplierName}
|
|
||||||
onCreateSupplier={this.handleCreateSupplierFromPdf}
|
|
||||||
onClose={this.handleClosePdfDialog}
|
|
||||||
onAbort={this.handleAbortParse}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ArticleSearchDialog
|
<ArticleSearchDialog
|
||||||
open={this.state.articleSearchDialogOpen}
|
open={this.state.articleSearchDialogOpen}
|
||||||
@@ -1444,6 +1234,8 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
}
|
}
|
||||||
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
|
||||||
|
|
||||||
|
if (!ledgerSummary.hasTrackStock && allEntries.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTile
|
<PanelTile
|
||||||
wide={4}
|
wide={4}
|
||||||
@@ -1456,8 +1248,8 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
status === 'booked'
|
status === 'booked'
|
||||||
? 'success.light'
|
? 'success.light'
|
||||||
: status === 'partial'
|
: status === 'partial'
|
||||||
? 'warning.light'
|
? 'warning.light'
|
||||||
: 'divider',
|
: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
@@ -1538,25 +1330,27 @@ class LieferungNeuPage extends PureComponent {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
{ledgerSummary.hasTrackStock && (
|
||||||
variant={status === 'booked' ? 'outlined' : 'contained'}
|
<Button
|
||||||
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
|
variant={status === 'booked' ? 'outlined' : 'contained'}
|
||||||
startIcon={
|
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
|
||||||
status === 'booked' ? (
|
startIcon={
|
||||||
<CheckCircleOutlineOutlinedIcon />
|
status === 'booked' ? (
|
||||||
) : (
|
<CheckCircleOutlineOutlinedIcon />
|
||||||
<Inventory2OutlinedIcon />
|
) : (
|
||||||
)
|
<Inventory2OutlinedIcon />
|
||||||
}
|
)
|
||||||
onClick={this.handleOpenBookDialog}
|
}
|
||||||
sx={{ fontWeight: 600, textTransform: 'none' }}
|
onClick={this.handleOpenBookDialog}
|
||||||
>
|
sx={{ fontWeight: 600, textTransform: 'none' }}
|
||||||
{status === 'booked'
|
>
|
||||||
? 'Buchungsdetails anzeigen'
|
{status === 'booked'
|
||||||
: status === 'partial'
|
? 'Buchungsdetails anzeigen'
|
||||||
? 'Restliche Mengen einbuchen'
|
: status === 'partial'
|
||||||
: 'Ins Lager buchen'}
|
? 'Restliche Mengen einbuchen'
|
||||||
</Button>
|
: 'Ins Lager buchen'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{allEntries.length > 0 && (
|
{allEntries.length > 0 && (
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi } from '../api';
|
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi, stock as defaultStockApi } from '../api';
|
||||||
import artikelAutomat from '../automats/artikelAutomat';
|
import artikelAutomat, { getArticle } from '../automats/artikelAutomat';
|
||||||
|
import articleIndexAutomat from '../automats/articleIndexAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a delivery line represents an article that tracks stock.
|
||||||
|
*
|
||||||
|
* @param {object} line Delivery line
|
||||||
|
* @param {object|null} [article] Optional pre-resolved article
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isLineStockTracked(line, article = null) {
|
||||||
|
if (!line) return false;
|
||||||
|
if (line.track_stock !== undefined) {
|
||||||
|
return line.track_stock !== false && line.track_stock !== 0 && line.track_stock !== '0';
|
||||||
|
}
|
||||||
|
if (line.trackStock !== undefined) {
|
||||||
|
return Boolean(line.trackStock);
|
||||||
|
}
|
||||||
|
if (line.article?.track_stock !== undefined) {
|
||||||
|
return line.article.track_stock !== false && line.article.track_stock !== 0 && line.article.track_stock !== '0';
|
||||||
|
}
|
||||||
|
const sku = line.sku ? String(line.sku).trim() : null;
|
||||||
|
if (!sku) return false;
|
||||||
|
|
||||||
|
const art = article || getArticle(sku) || articleIndexAutomat.state.items?.find((item) => String(item.sku) === sku);
|
||||||
|
if (art?.track_stock !== undefined) {
|
||||||
|
return art.track_stock !== false && art.track_stock !== 0 && art.track_stock !== '0';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} DeliveryLineLedgerStatus
|
* @typedef {Object} DeliveryLineLedgerStatus
|
||||||
@@ -10,6 +39,8 @@ import artikelAutomat from '../automats/artikelAutomat';
|
|||||||
* @property {number} bookedAmount - Quantity booked in ledger
|
* @property {number} bookedAmount - Quantity booked in ledger
|
||||||
* @property {number} remainingAmount - Quantity remaining to be booked
|
* @property {number} remainingAmount - Quantity remaining to be booked
|
||||||
* @property {boolean} isBooked - Whether this position is fully booked
|
* @property {boolean} isBooked - Whether this position is fully booked
|
||||||
|
* @property {boolean} trackStock - Whether this article tracks inventory
|
||||||
|
* @property {boolean} isBookable - Whether this position can be booked into the warehouse
|
||||||
* @property {Array<Object>} ledgerEntries - Ledger entries for this line
|
* @property {Array<Object>} ledgerEntries - Ledger entries for this line
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,6 +53,7 @@ import artikelAutomat from '../automats/artikelAutomat';
|
|||||||
* @property {number} bookedPositions
|
* @property {number} bookedPositions
|
||||||
* @property {number} totalQuantity
|
* @property {number} totalQuantity
|
||||||
* @property {number} bookedQuantity
|
* @property {number} bookedQuantity
|
||||||
|
* @property {boolean} hasTrackStock
|
||||||
* @property {Array<DeliveryLineLedgerStatus>} lines
|
* @property {Array<DeliveryLineLedgerStatus>} lines
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -48,6 +80,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
bookedPositions: 0,
|
bookedPositions: 0,
|
||||||
totalQuantity: 0,
|
totalQuantity: 0,
|
||||||
bookedQuantity: 0,
|
bookedQuantity: 0,
|
||||||
|
hasTrackStock: false,
|
||||||
lines: [],
|
lines: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,20 +89,43 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
const linesWithLedger = await Promise.all(
|
const linesWithLedger = await Promise.all(
|
||||||
rawLines.map(async (line) => {
|
rawLines.map(async (line) => {
|
||||||
let ledgerEntries = [];
|
let ledgerEntries = [];
|
||||||
try {
|
let article = null;
|
||||||
const ledgerRes = await ledgerApi.listByManifestarticleId(line.id, 'DELIVERY');
|
|
||||||
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
|
const promises = [
|
||||||
} catch (err) {
|
ledgerApi.listByManifestarticleId(line.id).catch((err) => {
|
||||||
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
|
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (line.sku) {
|
||||||
|
const cached = getArticle(line.sku);
|
||||||
|
if (cached) {
|
||||||
|
article = cached;
|
||||||
|
} else {
|
||||||
|
promises.push(
|
||||||
|
artikelAutomat.actions.loadArticle(line.sku).catch(() => null)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deliveryEntries = ledgerEntries.filter(
|
const results = await Promise.all(promises);
|
||||||
(e) => String(e.transaction_type).toUpperCase() === 'DELIVERY'
|
const ledgerRes = results[0];
|
||||||
);
|
if (results[1]) {
|
||||||
|
article = results[1];
|
||||||
|
}
|
||||||
|
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
|
||||||
|
|
||||||
|
const trackStock = isLineStockTracked(line, article);
|
||||||
|
|
||||||
|
const deliveryEntries = ledgerEntries.filter((e) => {
|
||||||
|
const t = String(e.transaction_type).toUpperCase();
|
||||||
|
return t === 'ACCEPTANCE' || t === 'RETURN' || t === 'DELIVERY';
|
||||||
|
});
|
||||||
const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0);
|
const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0);
|
||||||
const targetAmount = Number(line.amount || 0);
|
const targetAmount = Number(line.amount || 0);
|
||||||
const remainingAmount = Math.max(0, targetAmount - bookedAmount);
|
const remainingAmount = trackStock ? Math.max(0, targetAmount - bookedAmount) : 0;
|
||||||
const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0;
|
const isBooked = trackStock ? (targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0) : true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...line,
|
...line,
|
||||||
@@ -77,18 +133,22 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
bookedAmount,
|
bookedAmount,
|
||||||
remainingAmount,
|
remainingAmount,
|
||||||
isBooked,
|
isBooked,
|
||||||
|
trackStock,
|
||||||
|
isBookable: trackStock,
|
||||||
ledgerEntries: deliveryEntries,
|
ledgerEntries: deliveryEntries,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalPositions = linesWithLedger.length;
|
const stockLines = linesWithLedger.filter((l) => l.trackStock);
|
||||||
const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length;
|
const totalPositions = stockLines.length;
|
||||||
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
|
const bookedPositions = stockLines.filter((l) => l.isBooked).length;
|
||||||
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
|
const totalQuantity = stockLines.reduce((sum, l) => sum + l.targetAmount, 0);
|
||||||
|
const bookedQuantity = stockLines.reduce((sum, l) => sum + l.bookedAmount, 0);
|
||||||
|
const hasTrackStock = stockLines.length > 0;
|
||||||
|
|
||||||
let status;
|
let status;
|
||||||
if (totalPositions === 0) {
|
if (!hasTrackStock) {
|
||||||
status = 'empty';
|
status = 'empty';
|
||||||
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
|
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
|
||||||
status = 'booked';
|
status = 'booked';
|
||||||
@@ -106,18 +166,19 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
|||||||
bookedPositions,
|
bookedPositions,
|
||||||
totalQuantity,
|
totalQuantity,
|
||||||
bookedQuantity,
|
bookedQuantity,
|
||||||
|
hasTrackStock,
|
||||||
lines: linesWithLedger,
|
lines: linesWithLedger,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Books specified delivery lines to the ledger as goods receipts (DELIVERY).
|
* Books specified delivery lines to the ledger as goods receipts via /stock/accept.
|
||||||
*
|
*
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
|
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
|
||||||
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
||||||
* @param {number|string} params.warehouseId - Mandatory warehouse ID
|
* @param {number|string} params.warehouseId - Mandatory warehouse ID
|
||||||
* @param {Object} [params.ledgerApi]
|
* @param {Object} [params.stockApi]
|
||||||
* @param {string} [params.transactionText]
|
* @param {string} [params.transactionText]
|
||||||
* @returns {Promise<Array<Object>>}
|
* @returns {Promise<Array<Object>>}
|
||||||
*/
|
*/
|
||||||
@@ -125,7 +186,7 @@ export async function bookDeliveryLines({
|
|||||||
delivery,
|
delivery,
|
||||||
linesToBook,
|
linesToBook,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
ledgerApi = defaultLedgerApi,
|
stockApi = defaultStockApi,
|
||||||
transactionText,
|
transactionText,
|
||||||
}) {
|
}) {
|
||||||
const numericWarehouseId = Number(warehouseId);
|
const numericWarehouseId = Number(warehouseId);
|
||||||
@@ -143,28 +204,25 @@ export async function bookDeliveryLines({
|
|||||||
: `Lieferung ${delivery?.number || delivery?.id || ''}`
|
: `Lieferung ${delivery?.number || delivery?.id || ''}`
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
const results = [];
|
const items = linesToBook
|
||||||
|
.map((item) => ({
|
||||||
|
manifestarticleId: Number(item.manifestarticleId ?? item.id),
|
||||||
|
warehouseId: numericWarehouseId,
|
||||||
|
text: memo,
|
||||||
|
}))
|
||||||
|
.filter((item) => item.manifestarticleId && Number.isInteger(item.manifestarticleId) && item.manifestarticleId > 0);
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await stockApi.accept({ items });
|
||||||
|
|
||||||
for (const item of linesToBook) {
|
for (const item of linesToBook) {
|
||||||
const manifestarticleId = Number(item.manifestarticleId ?? item.id);
|
|
||||||
const amount = Number(item.amount);
|
|
||||||
|
|
||||||
if (!manifestarticleId || !Number.isFinite(amount) || amount <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await ledgerApi.add({
|
|
||||||
manifestarticle_id: manifestarticleId,
|
|
||||||
warehouse_id: numericWarehouseId,
|
|
||||||
transaction_type: 'DELIVERY',
|
|
||||||
amount,
|
|
||||||
transaction_text: memo,
|
|
||||||
});
|
|
||||||
results.push(res?.ledger ?? res);
|
|
||||||
|
|
||||||
if (item.sku) {
|
if (item.sku) {
|
||||||
void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {});
|
void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return Array.isArray(res?.accepted) ? res.accepted : (res ? [res] : []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -505,6 +505,12 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (p === '/api/vfs/upload' && method === 'POST') {
|
if (p === '/api/vfs/upload' && method === 'POST') {
|
||||||
return json(res, 200, { files: [] });
|
return json(res, 200, { files: [] });
|
||||||
}
|
}
|
||||||
|
if (p === '/api/vfs/skills/restore' && method === 'POST') {
|
||||||
|
return json(res, 200, { success: true, restored: ['system.SKILL'], count: 1 });
|
||||||
|
}
|
||||||
|
if (p === '/api/vfs/applets/restore' && method === 'POST') {
|
||||||
|
return json(res, 200, { success: true, restored: ['example.applet.js'], count: 1 });
|
||||||
|
}
|
||||||
if (p.startsWith('/api/vfs/') && method === 'PUT') {
|
if (p.startsWith('/api/vfs/') && method === 'PUT') {
|
||||||
return json(res, 200, { ok: true });
|
return json(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import pc from 'picocolors';
|
import pc from 'picocolors';
|
||||||
|
import qrcode from 'qrcode';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const useHttps = process.env.VITE_HTTPS === '1';
|
const useHttps = process.env.VITE_HTTPS === '1';
|
||||||
@@ -94,6 +95,7 @@ const sharedResolve = {
|
|||||||
|
|
||||||
const sharedServer = {
|
const sharedServer = {
|
||||||
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
|
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
|
||||||
|
host: true,
|
||||||
port: useHttps ? 4308 : 4209,
|
port: useHttps ? 4308 : 4209,
|
||||||
https: httpsOptions(),
|
https: httpsOptions(),
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
@@ -266,6 +268,20 @@ function pidReporterPlugin() {
|
|||||||
server.config.logger.info(
|
server.config.logger.info(
|
||||||
` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`,
|
` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`,
|
||||||
);
|
);
|
||||||
|
const networkUrls = server.resolvedUrls?.network ?? [];
|
||||||
|
const primaryUrl =
|
||||||
|
networkUrls.find((u) => !u.includes('172.17.') && !u.includes('169.254.')) ||
|
||||||
|
networkUrls[0] ||
|
||||||
|
server.resolvedUrls?.local?.[0];
|
||||||
|
if (primaryUrl) {
|
||||||
|
qrcode.toString(primaryUrl, { type: 'terminal', small: true }, (err, qr) => {
|
||||||
|
if (!err && qr) {
|
||||||
|
server.config.logger.info(
|
||||||
|
`\n ${pc.bold('Scan to open on mobile')} ${pc.dim(`(${primaryUrl})`)}:\n` + qr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user