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)
|
||||
|
||||
Overview of all 249 API endpoints across all modules.
|
||||
Overview of all 250 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -17,15 +17,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
||||
### Reusable types
|
||||
|
||||
- `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 }`
|
||||
- `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 }`
|
||||
- `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 }`
|
||||
- `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 }`
|
||||
- `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 }`
|
||||
- `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }`
|
||||
- `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 }`
|
||||
- `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[] }`
|
||||
- `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 }`
|
||||
- `T19` = `{ vfs: T53, storage: T53, database: T46 }`
|
||||
- `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: 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 }`
|
||||
- `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 }`
|
||||
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T42[], 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 }`
|
||||
- `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?: 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 }`
|
||||
- `T26` = `{ objects: T31[] }`
|
||||
- `T27` = `{ objects: T32[] }`
|
||||
- `T28` = `{ printer: T34 }`
|
||||
- `T29` = `{ entity: T37 }`
|
||||
- `T30` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `T31` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T32` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T33` = `{ shipping: T38 }`
|
||||
- `T34` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T35` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T36` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T37` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T38` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T39` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T40` = `{ article: T45 }`
|
||||
- `T41` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T42` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T43` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T44` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T45` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T46` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||
- `T47` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T48` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T49` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T50` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T51` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T52` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T53` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||
- `T54` = `{ warehouse: T56 }`
|
||||
- `T55` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T56` = `{ id: int, name: str, sort: int }`
|
||||
- `T57` = `{ id: int, categoryIds: int[] }`
|
||||
- `T26` = `{ objects: T30[] }`
|
||||
- `T27` = `{ printer: T32 }`
|
||||
- `T28` = `{ entity: T35 }`
|
||||
- `T29` = `{ 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` = `{ shipping: T36 }`
|
||||
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T38` = `{ article: T43 }`
|
||||
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||
- `T52` = `{ warehouse: T54 }`
|
||||
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T54` = `{ id: int, name: str, sort: int }`
|
||||
- `T55` = `{ id: int, categoryIds: int[] }`
|
||||
|
||||
### Routes
|
||||
|
||||
@@ -102,7 +100,7 @@ _none_
|
||||
|
||||
#### Create article in category — `POST /article`
|
||||
- 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`
|
||||
- Params: `query: categoryId?: int`
|
||||
- Returns: `200 zip`
|
||||
@@ -138,10 +136,10 @@ _none_
|
||||
- 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`
|
||||
- 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`
|
||||
- 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}`
|
||||
- Params: `sku: str, childSku: str, query: deleteArticle?: 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 }[] }`
|
||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||
- 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`
|
||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||
- Returns: `200 T3`
|
||||
@@ -219,10 +217,10 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int`
|
||||
- Returns: `200 { values: T42[] }`
|
||||
- Returns: `200 { values: T40[] }`
|
||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||
- 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`
|
||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -240,28 +238,28 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||
- Returns: `200 { attributevalue: T42 }`
|
||||
- Returns: `200 { attributevalue: T40 }`
|
||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
|
||||
- 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
|
||||
|
||||
#### Login with Google ID token — `POST /auth/login`
|
||||
- Params: `body: { idToken: str }`
|
||||
- Returns: `200 { message: str, user: T47 }`
|
||||
- Returns: `200 { message: str, user: T45 }`
|
||||
#### Logout — `POST /auth/logout`
|
||||
_none_
|
||||
- Returns: `200 { message: str }`
|
||||
#### Current user — `GET /auth/me`
|
||||
_none_
|
||||
- Returns: `200 { user: T47, googleClientId: str }`
|
||||
- Returns: `200 { user: T45, googleClientId: str }`
|
||||
#### Set selected company — `PUT /auth/selected-company`
|
||||
- Params: `body: { companyId: int }`
|
||||
- Returns: `200 { user: T47 }`
|
||||
- Returns: `200 { user: T45 }`
|
||||
|
||||
### 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 }`
|
||||
#### Create brand — `POST /brand`
|
||||
- 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}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -291,10 +289,10 @@ _none_
|
||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
|
||||
- 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
|
||||
|
||||
@@ -318,7 +316,7 @@ _none_
|
||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||
#### Articles in category — `GET /category/{id}/articles`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, 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`
|
||||
- Params: `id: int`
|
||||
- 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[] }`
|
||||
#### Create company — `POST /company`
|
||||
- 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`
|
||||
- Params: `query: stream?: str`
|
||||
- Returns: `200 SSE`
|
||||
@@ -387,7 +385,7 @@ _none_
|
||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||
#### Create customer group — `POST /customergroup`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { customergroup: T56 }`
|
||||
- Returns: `201 { customergroup: T54 }`
|
||||
#### Reorder customer groups — `PUT /customergroup/order`
|
||||
- Params: `body: { customergroupIds: int[] }`
|
||||
- Returns: `200 { customergroupIds: int[] }`
|
||||
@@ -396,28 +394,16 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update customer group — `PATCH /customergroup/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { customergroup: T56 }`
|
||||
- Returns: `200 { customergroup: T54 }`
|
||||
|
||||
### Section: /delivery
|
||||
|
||||
#### List deliveries — `GET /delivery`
|
||||
_none_
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T52 }[] }`
|
||||
- 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?: T50 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### 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`
|
||||
#### 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}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -425,7 +411,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T9`
|
||||
#### 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`
|
||||
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
@@ -441,10 +427,10 @@ _none_
|
||||
|
||||
#### List entities — `GET /entity`
|
||||
- Params: `query: customergroupId?: int, role?: str`
|
||||
- Returns: `200 { entities: T37[] }`
|
||||
- Returns: `200 { entities: T35[] }`
|
||||
#### Create entity — `POST /entity`
|
||||
- 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`
|
||||
_none_
|
||||
- Returns: `200 { number: str }`
|
||||
@@ -462,25 +448,25 @@ _none_
|
||||
- Returns: `200 { address: T20 }`
|
||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { contacts: T50[] }`
|
||||
- Returns: `200 { contacts: T48[] }`
|
||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||
- Returns: `201 { contact: T50 }`
|
||||
- Returns: `201 { contact: T48 }`
|
||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||
- Returns: `200 { contact: T50 }`
|
||||
- Returns: `200 { contact: T48 }`
|
||||
#### Delete entity — `DELETE /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get entity — `GET /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T29`
|
||||
- Returns: `200 T28`
|
||||
#### Update entity — `PATCH /entity/{id}`
|
||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||
- Returns: `200 T29`
|
||||
- Returns: `200 T28`
|
||||
|
||||
### Section: /events
|
||||
|
||||
@@ -503,8 +489,6 @@ _none_
|
||||
- `modelconversation`: `{}`
|
||||
- `modelconversation-<id>`: `{}`
|
||||
- `brand`: `{ sender, brandId }`
|
||||
- `draft`: `{ sender, action, name, content }`
|
||||
- `draft:request`: `{ requestId, action, name, target, sender }`
|
||||
|
||||
### Section: /health
|
||||
|
||||
@@ -524,7 +508,7 @@ _none_
|
||||
_none_
|
||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||
#### Create label template — `POST /labeltemplate`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T27 }`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T26 }`
|
||||
- Returns: `201 T13`
|
||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -533,10 +517,10 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T13`
|
||||
#### 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`
|
||||
#### 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`
|
||||
|
||||
### Section: /ledger
|
||||
@@ -544,9 +528,6 @@ _none_
|
||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- 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}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 T21`
|
||||
@@ -632,7 +613,7 @@ _none_
|
||||
- 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 }`
|
||||
#### 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`
|
||||
#### Delete order — `DELETE /order/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -641,7 +622,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T1`
|
||||
#### 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`
|
||||
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
@@ -651,10 +632,10 @@ _none_
|
||||
- Returns: `200 str`
|
||||
#### List payments of an order — `GET /order/{id}/payment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { payments: T44[] }`
|
||||
- Returns: `200 { payments: T42[] }`
|
||||
#### Add payment to an order — `POST /order/{id}/payment`
|
||||
- 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`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 T14`
|
||||
@@ -666,10 +647,10 @@ _none_
|
||||
- Returns: `201 T5`
|
||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T38[] }`
|
||||
- Returns: `200 { shippings: T36[] }`
|
||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||
- Returns: `201 T33`
|
||||
- Returns: `201 T31`
|
||||
|
||||
### Section: /package
|
||||
|
||||
@@ -699,25 +680,25 @@ _none_
|
||||
- Returns: `200 { posterminal: T17 }`
|
||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T57`
|
||||
- Returns: `200 T55`
|
||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T57`
|
||||
- Returns: `200 T55`
|
||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T57`
|
||||
- Returns: `200 T55`
|
||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T57`
|
||||
- Returns: `200 T55`
|
||||
|
||||
### Section: /printer
|
||||
|
||||
#### List printers — `GET /printer`
|
||||
_none_
|
||||
- Returns: `200 { printers: T34[] }`
|
||||
- Returns: `200 { printers: T32[] }`
|
||||
#### Create printer — `POST /printer`
|
||||
- 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`
|
||||
- Params: `body: { printerIds: int[] }`
|
||||
- Returns: `200 { printerIds: int[] }`
|
||||
@@ -726,22 +707,22 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get printer by ID — `GET /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T28`
|
||||
- Returns: `200 T27`
|
||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T28`
|
||||
- Returns: `200 T27`
|
||||
#### Update printer — `PUT /printer/{id}`
|
||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `200 T28`
|
||||
- Returns: `200 T27`
|
||||
|
||||
### Section: /propertyname
|
||||
|
||||
#### List property names — `GET /propertyname`
|
||||
_none_
|
||||
- Returns: `200 { propertynames: T43[] }`
|
||||
- Returns: `200 { propertynames: T41[] }`
|
||||
#### Create property name — `POST /propertyname`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { propertyname: T43 }`
|
||||
- Returns: `201 { propertyname: T41 }`
|
||||
#### Reorder property names — `PUT /propertyname/order`
|
||||
- Params: `body: { propertynameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -750,19 +731,19 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update property name — `PATCH /propertyname/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `200 { propertyname: T43 }`
|
||||
- Returns: `200 { propertyname: T41 }`
|
||||
|
||||
### Section: /public
|
||||
|
||||
#### Receipt crash dump — `POST /public/telemetry/crash`
|
||||
- Params: `body: { content: any }`
|
||||
- Params: `body: { content: str|{} }`
|
||||
- Returns: `200 { ok: bool, filename: str }`
|
||||
#### List public articles — `GET /public/{companyId}/articles`
|
||||
- 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 }[] }`
|
||||
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
|
||||
- Params: `companyId: int, sku: str`
|
||||
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: {}|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`
|
||||
- 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 }[] }`
|
||||
@@ -773,7 +754,7 @@ _none_
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||
- Returns: `200 { quotes: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### 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`
|
||||
#### Delete quote — `DELETE /quote/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -782,7 +763,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T7`
|
||||
#### 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`
|
||||
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
@@ -791,7 +772,7 @@ _none_
|
||||
- Params: `id: int, fileId: int, query: download?: bool`
|
||||
- Returns: `200 str`
|
||||
#### Create order from quote — `POST /quote/{id}/order`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
|
||||
- Returns: `201 T1`
|
||||
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
@@ -837,10 +818,10 @@ _none_
|
||||
- Returns: `201 T14`
|
||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T38[] }`
|
||||
- Returns: `200 { shippings: T36[] }`
|
||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||
- Returns: `201 T33`
|
||||
- Returns: `201 T31`
|
||||
|
||||
### Section: /shipping
|
||||
|
||||
@@ -849,7 +830,7 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update shipping — `PATCH /shipping/{id}`
|
||||
- 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`
|
||||
- Params: `id: int`
|
||||
- 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
|
||||
|
||||
#### 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`
|
||||
- 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[] }`
|
||||
#### Ship shipment manifest item(s) — `POST /stock/ship`
|
||||
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
|
||||
- Returns: `200`
|
||||
|
||||
### Section: /supplier
|
||||
|
||||
#### List suppliers — `GET /supplier`
|
||||
_none_
|
||||
- Returns: `200 { suppliers: T51[] }`
|
||||
- Returns: `200 { suppliers: T49[] }`
|
||||
#### Create supplier — `POST /supplier`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { supplier: T51 }`
|
||||
- Returns: `201 { supplier: T49 }`
|
||||
#### Delete supplier — `DELETE /supplier/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Update supplier — `PATCH /supplier/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { supplier: T51 }`
|
||||
- Returns: `200 { supplier: T49 }`
|
||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||
- Params: `id: int, query: skus?: string`
|
||||
- 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 }[] }`
|
||||
- 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?: T53|null }[] }`
|
||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||
- Params: `id: int, body?: { skus?: str[] }`
|
||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: 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
|
||||
|
||||
@@ -903,10 +890,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### List units — `GET /unit`
|
||||
_none_
|
||||
- Returns: `200 { units: T41[] }`
|
||||
- Returns: `200 { units: T39[] }`
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: T41 }`
|
||||
- Returns: `200 { unit: T39 }`
|
||||
|
||||
### Section: /v1
|
||||
|
||||
@@ -931,15 +918,18 @@ _none_
|
||||
#### List virtual filesystem files — `GET /vfs`
|
||||
_none_
|
||||
- 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`
|
||||
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }`
|
||||
- Returns: `200 { success: bool }`
|
||||
#### Restore system default applets — `POST /vfs/applets/restore`
|
||||
_none_
|
||||
- Returns: `200 { success: bool, restored: str[], count: int }`
|
||||
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
||||
- Params: `body: { path: str }`
|
||||
- Returns: `200 { success: bool, dir: str }`
|
||||
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
|
||||
- Params: `body: { 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`
|
||||
- Params: `form: { files: str[], targetDir?: str }`
|
||||
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
|
||||
@@ -960,10 +950,10 @@ _none_
|
||||
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T56[] }`
|
||||
- Returns: `200 { warehouses: T54[] }`
|
||||
#### Create warehouse — `POST /warehouse`
|
||||
- Params: `body: { name: str, sort?: int }`
|
||||
- Returns: `201 T54`
|
||||
- Returns: `201 T52`
|
||||
#### Reorder warehouses — `PUT /warehouse/order`
|
||||
- Params: `body: { warehouseIds: int[] }`
|
||||
- Returns: `200 { warehouseIds: int[] }`
|
||||
@@ -972,10 +962,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T54`
|
||||
- Returns: `200 T52`
|
||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T54`
|
||||
- Returns: `200 T52`
|
||||
#### Update warehouse — `PUT /warehouse/{id}`
|
||||
- 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
|
||||
* History of all articles with paging/sorting/filtering
|
||||
* GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* @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();
|
||||
if (startRow != null) params.set('startRow', String(startRow));
|
||||
if (endRow != null) params.set('endRow', String(endRow));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
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 (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/history
|
||||
|
||||
@@ -10,13 +10,35 @@ import { http, clientIdHeaders } from './http.js';
|
||||
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
|
||||
*/
|
||||
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
|
||||
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');
|
||||
},
|
||||
// #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 } */
|
||||
// #region GET /api/delivery/:id — get
|
||||
get(id) {
|
||||
@@ -96,83 +118,4 @@ export const delivery = {
|
||||
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #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 = {
|
||||
/**
|
||||
* GET /api/history
|
||||
* Global system/entity history with paging/sorting/filtering
|
||||
* GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
|
||||
* 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
|
||||
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
||||
list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (startRow != null) params.set('startRow', String(startRow));
|
||||
if (endRow != null) params.set('endRow', String(endRow));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
|
||||
params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
|
||||
if (search && String(search).trim()) params.set('search', String(search).trim());
|
||||
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 (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
|
||||
@@ -6,16 +6,6 @@ import { http } from './http.js';
|
||||
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
|
||||
*/
|
||||
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
|
||||
@@ -32,17 +22,15 @@ export const ledger = {
|
||||
|
||||
/**
|
||||
* GET /api/ledger
|
||||
* Query: { manifestarticle_id?, sku?, transaction_type? }
|
||||
* Query: { manifestarticleId?, sku?, transactionType? }
|
||||
* → 200 { ledger: LedgerEntry[] }
|
||||
*/
|
||||
// #region GET /api/ledger — list
|
||||
list(query) {
|
||||
list({ manifestarticleId, sku, transactionType } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
|
||||
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
|
||||
if (query?.sku != null) params.set('sku', query.sku);
|
||||
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
|
||||
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
|
||||
if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
|
||||
if (sku != null) params.set('sku', String(sku));
|
||||
if (transactionType != null) params.set('transaction_type', String(transactionType));
|
||||
const qs = params.toString();
|
||||
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
@@ -13,4 +13,24 @@ export const stock = {
|
||||
return http('/api/stock/adjust', { method: 'POST', body });
|
||||
},
|
||||
// #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
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* POST /api/vfs/drafts/response
|
||||
* Send response to virtual draft request (action: read or list).
|
||||
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
|
||||
* POST /api/vfs/skills/restore
|
||||
* Restores all system default .SKILL files into the company's VFS (overwrites existing files).
|
||||
* Requires no request body. Emits vfs SSE event.
|
||||
* → 200 { success: true, restored: string[], count: number }
|
||||
*/
|
||||
// #region POST /api/vfs/drafts/response — respondDraft
|
||||
async respondDraft(body) {
|
||||
try {
|
||||
const res = await fetch('/api/vfs/drafts/response', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...clientIdHeaders(),
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errText = '';
|
||||
try {
|
||||
const errData = await res.json();
|
||||
errText = errData.error || errData.message;
|
||||
} catch {
|
||||
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 };
|
||||
}
|
||||
// #region POST /api/vfs/skills/restore — restoreSkills
|
||||
restoreSkills() {
|
||||
return http('/api/vfs/skills/restore', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/vfs/applets/restore
|
||||
* Restores all system default .applet.js example applets into the company's VFS (overwrites existing files).
|
||||
* Requires no request body. Emits vfs SSE event.
|
||||
* → 200 { success: true, restored: string[], count: number }
|
||||
*/
|
||||
// #region POST /api/vfs/applets/restore — restoreApplets
|
||||
restoreApplets() {
|
||||
return http('/api/vfs/applets/restore', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
/**
|
||||
* @file articleHistoryAutomat.js
|
||||
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for
|
||||
* infinite-scrolling article history via `react-virtual-aggridautomat`.
|
||||
* @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
|
||||
* article audit logs and history entries with multi-filter caching and LRU eviction.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
||||
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||
* `selectedCompanyId` 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 authAutomat from './authAutomat';
|
||||
|
||||
export const articleHistoryGridAutomat = createGridAutomat({
|
||||
export const articleHistoryAutomat = createChunkListAutomat({
|
||||
name: 'articleHistory',
|
||||
blockSize: 50,
|
||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
chunkSize: 50,
|
||||
maxLruChunks: 50,
|
||||
fetchPage: (params) => article.getHistory(params),
|
||||
});
|
||||
|
||||
// Reload on company switch or auth change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
articleHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
});
|
||||
// Alias for backwards compatibility
|
||||
export const articleHistoryGridAutomat = articleHistoryAutomat;
|
||||
|
||||
export default articleHistoryGridAutomat;
|
||||
export default articleHistoryAutomat;
|
||||
|
||||
@@ -87,6 +87,8 @@ const initialState = {
|
||||
draftConversation: false,
|
||||
/** Prefilled input text waiting to be consumed by the chat UI. */
|
||||
pendingInput: null,
|
||||
/** Staged text extraction snippet for data extraction pill. */
|
||||
pendingExtraction: null,
|
||||
conversationState: 'idle',
|
||||
provider: 'openrouter',
|
||||
model: DEFAULT_MODEL,
|
||||
@@ -283,6 +285,17 @@ function closeConversationStream() {
|
||||
}
|
||||
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 = {
|
||||
/**
|
||||
@@ -327,6 +340,55 @@ const actions = {
|
||||
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
|
||||
* created on the server until the first message is sent.
|
||||
@@ -467,13 +529,18 @@ const actions = {
|
||||
|
||||
const messages = msgsRes?.messages || [];
|
||||
const updatedConv = convRes?.conversation;
|
||||
const hasPendingInteractive = Boolean(
|
||||
selectPendingAskUser({ messages })
|
||||
);
|
||||
|
||||
chatAutomat.setState((prev) => ({
|
||||
messages,
|
||||
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
|
||||
? 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,
|
||||
}));
|
||||
} catch (err) {
|
||||
@@ -830,7 +897,7 @@ const actions = {
|
||||
const updatedTool = {
|
||||
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
state: 'green',
|
||||
name: data.name || '',
|
||||
name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
|
||||
arguments: data.arguments,
|
||||
content: data.content !== undefined ? data.content : data.arguments || '',
|
||||
result: data.result,
|
||||
@@ -844,12 +911,19 @@ const actions = {
|
||||
lastTurn.tools = tools;
|
||||
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 {
|
||||
activeTurns: turns,
|
||||
streamPhase: hasAskUser ? 'idle' : 'working',
|
||||
conversationState: hasAskUser ? 'idle' : prev.conversationState,
|
||||
isSending: hasAskUser ? false : prev.isSending,
|
||||
streamPhase: isInteractive ? 'idle' : 'working',
|
||||
conversationState: isInteractive ? 'idle' : prev.conversationState,
|
||||
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.
|
||||
* Body: { role: 'tool', tool_call_id, content, answer }
|
||||
* @param {{ tool_call_id: string, answer: string }} params
|
||||
* Body: { role: 'tool', tool_call_id, name, content, answer }
|
||||
* @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;
|
||||
if (!selectedConversationId) {
|
||||
throw new Error('Keine Unterhaltung ausgewählt');
|
||||
@@ -1158,12 +1232,13 @@ const actions = {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolContent = content !== undefined ? content : answer;
|
||||
const payload = {
|
||||
role: 'tool',
|
||||
tool_call_id,
|
||||
name: 'ask_user',
|
||||
content: answer,
|
||||
answer,
|
||||
name,
|
||||
content: toolContent,
|
||||
...(answer !== undefined ? { answer } : {}),
|
||||
};
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
@@ -1207,7 +1282,7 @@ const actions = {
|
||||
if (m.role === 'assistant' && !m.turns) {
|
||||
try {
|
||||
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;
|
||||
return { ...m, turns: sealedTurns };
|
||||
}
|
||||
@@ -1316,12 +1391,12 @@ const actions = {
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant' || message.role === 'model';
|
||||
let hasAskUser = false;
|
||||
let hasInteractiveTool = false;
|
||||
if (isAssistant) {
|
||||
try {
|
||||
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
|
||||
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) {
|
||||
hasAskUser = true;
|
||||
if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
|
||||
hasInteractiveTool = true;
|
||||
}
|
||||
if (Array.isArray(payload?.tool_calls)) {
|
||||
for (const tc of payload.tool_calls) {
|
||||
@@ -1360,9 +1435,9 @@ const actions = {
|
||||
}
|
||||
}
|
||||
|
||||
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
|
||||
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase;
|
||||
const nextConversations = hasAskUser
|
||||
const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
|
||||
const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
|
||||
const nextConversations = hasInteractiveTool
|
||||
? prev.conversations.map((c) =>
|
||||
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
|
||||
)
|
||||
@@ -1374,6 +1449,7 @@ const actions = {
|
||||
conversationState: nextConversationState,
|
||||
streamPhase: nextStreamPhase,
|
||||
conversations: nextConversations,
|
||||
isSending: hasInteractiveTool ? false : prev.isSending,
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -1534,6 +1610,7 @@ export function selectPendingAskUser(state = chatAutomat.state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state));
|
||||
export const selectHasPendingAsk = (state = chatAutomat.state) =>
|
||||
Boolean(selectPendingAskUser(state));
|
||||
|
||||
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
|
||||
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for
|
||||
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`.
|
||||
* @description Double-buffered chunk list automat (`globalHistoryAutomat`) for
|
||||
* global system and change history logs with multi-filter caching and LRU eviction.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
|
||||
* status reaches `'authed'` with a `selectedCompanyId`.
|
||||
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
|
||||
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
|
||||
* `selectedCompanyId` 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 authAutomat from './authAutomat';
|
||||
|
||||
export const globalHistoryGridAutomat = createGridAutomat({
|
||||
export const globalHistoryAutomat = createChunkListAutomat({
|
||||
name: 'globalHistory',
|
||||
blockSize: 50,
|
||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
},
|
||||
chunkSize: 50,
|
||||
maxLruChunks: 50,
|
||||
fetchPage: (params) => globalHistory.list(params),
|
||||
});
|
||||
|
||||
// Reload on company switch or auth change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
globalHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
});
|
||||
// Alias for backwards compatibility
|
||||
export const globalHistoryGridAutomat = globalHistoryAutomat;
|
||||
|
||||
export default globalHistoryGridAutomat;
|
||||
export default globalHistoryAutomat;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
export * from './deliveryEditorAutomat';
|
||||
export * from './manifestDraftAutomat';
|
||||
export * from './draftSync';
|
||||
|
||||
export { default as authAutomat } from './authAutomat';
|
||||
export * from './authAutomat';
|
||||
@@ -176,7 +175,7 @@ export {
|
||||
} from './categoryViewAutomat';
|
||||
|
||||
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 {
|
||||
|
||||
@@ -35,7 +35,6 @@ import supplierAutomat from './supplierAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
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', () => {
|
||||
sseAutomat.setState({ status: 'connected' });
|
||||
@@ -290,9 +274,9 @@ function connect() {
|
||||
addTrackedListener(es, 'message', () => {});
|
||||
|
||||
// EventSource reconnects on its own; only surface the state.
|
||||
es.onerror = () => {
|
||||
console.error('[sse] event stream error');
|
||||
recordEvent({ type: 'error', data: '', lastEventId: '' });
|
||||
es.onerror = (e) => {
|
||||
console.error('[sse] event stream error', e);
|
||||
recordEvent(e || { type: 'error', data: '', lastEventId: '' });
|
||||
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 ?? 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
|
||||
? {
|
||||
readOnly: effectiveReadOnly,
|
||||
...slotProps?.htmlInput,
|
||||
...nestedFromSlotInput,
|
||||
...nestedFromInputProps,
|
||||
...inputProps,
|
||||
}
|
||||
: {
|
||||
@@ -94,20 +110,12 @@ export default class TextInput extends Component {
|
||||
'data-form-type': 'other',
|
||||
readOnly: effectiveReadOnly,
|
||||
...slotProps?.htmlInput,
|
||||
...nestedFromSlotInput,
|
||||
...nestedFromInputProps,
|
||||
...inputProps,
|
||||
};
|
||||
|
||||
const resolvedInputProps = (InputProps || slotProps?.input) ? {
|
||||
...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 resolvedInputProps = rawResolvedInputProps;
|
||||
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
|
||||
|
||||
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);
|
||||
|
||||
@@ -8,8 +8,14 @@ import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
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 CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
||||
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
|
||||
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
|
||||
import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||
@@ -202,6 +208,218 @@ export function parseAttachmentTag(attrString) {
|
||||
|
||||
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.
|
||||
* 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 AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Badge from '@mui/material/Badge';
|
||||
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 ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||
import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
|
||||
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
|
||||
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 { setting as settingApi } from '../../api';
|
||||
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat';
|
||||
import chatAutomat, {
|
||||
selectPendingAskUser,
|
||||
} from '../../automats/chatAutomat';
|
||||
import userSettingsAutomat from '../../automats/userSettingsAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
import { VfsEditorPanel, VfsFileIcon } from '../vfs';
|
||||
@@ -46,6 +54,7 @@ import {
|
||||
} from '../../colors';
|
||||
|
||||
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. */
|
||||
function sortConversations(list) {
|
||||
@@ -81,8 +90,36 @@ function formatUpdated(value) {
|
||||
}
|
||||
|
||||
function conversationTitle(c) {
|
||||
const msg = (c.first_message || '').replace(/\s+/g, ' ').trim();
|
||||
return msg || '…';
|
||||
let msg = (c.first_message || '').trim();
|
||||
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;
|
||||
|
||||
@@ -115,6 +152,17 @@ export default class ChatDrawer extends PureComponent {
|
||||
isInputFocused: false,
|
||||
stagedAttachments: [],
|
||||
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,
|
||||
isDraggingFiles: false,
|
||||
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) => {
|
||||
const wasOpen = this.state.isOpen;
|
||||
const willOpen = state.isOpen;
|
||||
@@ -199,9 +254,9 @@ export default class ChatDrawer extends PureComponent {
|
||||
activeTurns: state.activeTurns || [],
|
||||
};
|
||||
if (state.pendingInput != null) {
|
||||
patch.inputText = state.pendingInput;
|
||||
patch.stagedAttachments = [];
|
||||
patch.stagedReferences = [];
|
||||
patch.inputText = this.state.inputText
|
||||
? `${this.state.inputText}\n\n${state.pendingInput}`
|
||||
: state.pendingInput;
|
||||
queueMicrotask(() => {
|
||||
if (chatAutomat.state.pendingInput != null) {
|
||||
chatAutomat.setState({ pendingInput: null });
|
||||
@@ -211,6 +266,22 @@ export default class ChatDrawer extends PureComponent {
|
||||
} else if (!wasOpen && willOpen) {
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -309,6 +380,29 @@ export default class ChatDrawer extends PureComponent {
|
||||
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) => {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -483,9 +577,10 @@ export default class ChatDrawer extends PureComponent {
|
||||
const rawText = this.state.inputText.trim();
|
||||
const attachments = this.state.stagedAttachments || [];
|
||||
const references = this.state.stagedReferences || [];
|
||||
const extractions = this.state.stagedExtractions || [];
|
||||
|
||||
if (
|
||||
(!rawText && attachments.length === 0 && references.length === 0) ||
|
||||
(!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) ||
|
||||
this.state.conversationState === 'busy' ||
|
||||
this.state.isSending
|
||||
) {
|
||||
@@ -493,6 +588,28 @@ export default class ChatDrawer extends PureComponent {
|
||||
}
|
||||
|
||||
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) {
|
||||
const refMarkdown = references
|
||||
.map((ref) => {
|
||||
@@ -522,6 +639,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
inputText: '',
|
||||
stagedAttachments: [],
|
||||
stagedReferences: [],
|
||||
stagedExtractions: [],
|
||||
attachmentError: null,
|
||||
});
|
||||
|
||||
@@ -721,6 +839,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
|
||||
getPendingAskUser = () => selectPendingAskUser(this.state);
|
||||
|
||||
|
||||
renderHeader() {
|
||||
const {
|
||||
conversations,
|
||||
@@ -1265,6 +1384,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
sendError,
|
||||
stagedAttachments,
|
||||
stagedReferences,
|
||||
stagedExtractions = [],
|
||||
isProcessingFiles,
|
||||
isDraggingFiles,
|
||||
attachmentError,
|
||||
@@ -1273,11 +1393,16 @@ export default class ChatDrawer extends PureComponent {
|
||||
const pendingAsk = this.getPendingAskUser();
|
||||
const isBusy = conversationState === 'busy';
|
||||
const isWorking = isBusy || isSending;
|
||||
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk;
|
||||
const canChat =
|
||||
(Boolean(selectedConversationId) || draftConversation) &&
|
||||
!pendingAsk;
|
||||
const canSend =
|
||||
!isWorking &&
|
||||
canChat &&
|
||||
(Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0);
|
||||
(Boolean(inputText.trim()) ||
|
||||
stagedAttachments.length > 0 ||
|
||||
stagedReferences.length > 0 ||
|
||||
stagedExtractions.length > 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -1449,7 +1574,7 @@ export default class ChatDrawer extends PureComponent {
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{stagedReferences.length > 0 && (
|
||||
{(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -1546,6 +1671,77 @@ export default class ChatDrawer extends PureComponent {
|
||||
</IconButton>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1811,6 +2007,74 @@ export default class ChatDrawer extends PureComponent {
|
||||
{this.renderFooter()}
|
||||
</Box>
|
||||
</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 DirectionsIcon from '@mui/icons-material/Directions';
|
||||
import HealingMarkdown from './HealingMarkdown';
|
||||
import { VfsAttachmentLink, parseAttachmentsFromText, stripUserEnv } from './ChatAttachmentItems';
|
||||
import {
|
||||
VfsAttachmentLink,
|
||||
ExtractionPill,
|
||||
parseAttachmentsFromText,
|
||||
parseExtractionsFromText,
|
||||
stripUserEnv,
|
||||
} from './ChatAttachmentItems';
|
||||
import {
|
||||
CHROMA,
|
||||
SHADOW_BUBBLE_ASSISTANT,
|
||||
@@ -878,7 +884,8 @@ function MessageRow({ msg, showTime = true }) {
|
||||
} 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);
|
||||
|
||||
if (msg.role === 'system') {
|
||||
@@ -957,6 +964,26 @@ function MessageRow({ msg, showTime = true }) {
|
||||
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 && (
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -184,15 +184,46 @@ function getMimeTypeFallback(category, filename) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger download of an attachment in the browser.
|
||||
* @param {object} attachment
|
||||
* Format bytes into human-readable kb size for data extraction pills (e.g. "2.4 kb" or "12 kb").
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function downloadAttachment(attachment) {
|
||||
if (!attachment) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = attachment.dataUrl || (attachment.url ? attachment.url : '');
|
||||
link.download = attachment.name || 'anhang';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
export function formatExtractionSize(bytes) {
|
||||
const b = Number(bytes) || 0;
|
||||
const kb = b / 1024;
|
||||
if (kb < 1) {
|
||||
return `${Math.max(0.1, kb).toFixed(1)} kb`;
|
||||
}
|
||||
return `${kb.toFixed(kb >= 10 ? 0 : 1)} kb`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
|
||||
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 { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
|
||||
import SkeleHashImage from '../settings/SkeleHashImage';
|
||||
import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||
import { notifyErrorMessage } from '../../utils/notify';
|
||||
import chatAutomat from '../../automats/chatAutomat';
|
||||
import {
|
||||
SCRIM_ACTION,
|
||||
SCRIM_ATTACHMENT,
|
||||
@@ -61,18 +60,64 @@ export class Attachment extends PureComponent {
|
||||
|
||||
handleRescan = (e) => {
|
||||
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;
|
||||
if (attachment && onRescan) {
|
||||
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) => {
|
||||
e?.stopPropagation?.();
|
||||
const { attachment, onDelete } = this.props;
|
||||
@@ -237,51 +282,51 @@ export class Attachment extends PureComponent {
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{kind === 'delivery' && Boolean(onRescan) && (isPdf || isText) ? (
|
||||
<Tooltip
|
||||
title={
|
||||
hasArticles
|
||||
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
|
||||
: 'Neu analysieren'
|
||||
}
|
||||
{kind === 'delivery' && Boolean(onRescan) && isPdf ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={this.handleRescan}
|
||||
aria-label="Zu Text"
|
||||
sx={{
|
||||
bgcolor: SCRIM_ACTION,
|
||||
color: WHITE,
|
||||
p: 0.5,
|
||||
backdropFilter: 'blur(2px)',
|
||||
'&:hover': {
|
||||
bgcolor: SCRIM_HEAVY,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'inline-block' }}
|
||||
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>
|
||||
<TextSnippetOutlinedIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
) : 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
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={this.handleDelete}
|
||||
aria-label="Löschen"
|
||||
sx={{
|
||||
bgcolor: SCRIM_ACTION,
|
||||
color: WHITE,
|
||||
|
||||
@@ -6,7 +6,7 @@ import Attachment from './Attachment';
|
||||
|
||||
export class AttachmentList extends PureComponent {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export class AttachmentList extends PureComponent {
|
||||
onRescan={onRescan}
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onMentionInChat={onMentionInChat}
|
||||
disabled={disabled}
|
||||
hasArticles={hasArticles}
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
let initialAmounts = {};
|
||||
if (Array.isArray(props.summary?.lines)) {
|
||||
for (const line of props.summary.lines) {
|
||||
if (!line.trackStock) continue;
|
||||
if (props.prefillLineId != null) {
|
||||
initialAmounts[line.id] =
|
||||
line.id === props.prefillLineId
|
||||
@@ -174,6 +175,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
const amounts = {};
|
||||
if (Array.isArray(summary?.lines)) {
|
||||
for (const line of summary.lines) {
|
||||
if (!line.trackStock) continue;
|
||||
if (prefillLineId != null) {
|
||||
amounts[line.id] =
|
||||
line.id === prefillLineId
|
||||
@@ -208,6 +210,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
if (!summary?.lines) return;
|
||||
const amounts = {};
|
||||
for (const line of summary.lines) {
|
||||
if (!line.trackStock) continue;
|
||||
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
|
||||
}
|
||||
this.setState({ amountsToBook: amounts });
|
||||
@@ -218,6 +221,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
if (!summary?.lines) return;
|
||||
const amounts = {};
|
||||
for (const line of summary.lines) {
|
||||
if (!line.trackStock) continue;
|
||||
amounts[line.id] = 0;
|
||||
}
|
||||
this.setState({ amountsToBook: amounts });
|
||||
@@ -229,6 +233,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
if (!summary?.lines) return;
|
||||
|
||||
const linesToBook = summary.lines
|
||||
.filter((line) => line.trackStock)
|
||||
.map((line) => ({
|
||||
manifestarticleId: line.id,
|
||||
sku: line.sku,
|
||||
@@ -382,12 +387,12 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
|
||||
renderLinesTable() {
|
||||
const { summary, amountsToBook, isBooking } = this.state;
|
||||
const lines = summary?.lines || [];
|
||||
const lines = (summary?.lines || []).filter((l) => l.trackStock);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -455,12 +460,10 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
|
||||
disabled={isBooking}
|
||||
slotProps={{
|
||||
input: {
|
||||
inputProps: {
|
||||
min: 0,
|
||||
step: 1,
|
||||
style: { textAlign: 'right', padding: '4px 8px' },
|
||||
},
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
step: 1,
|
||||
style: { textAlign: 'right', padding: '4px 8px' },
|
||||
},
|
||||
}}
|
||||
sx={{ width: 90 }}
|
||||
@@ -490,7 +493,8 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
warehouseError,
|
||||
} = this.state;
|
||||
|
||||
const lines = summary?.lines || [];
|
||||
const lines = (summary?.lines || []).filter((l) => l.trackStock);
|
||||
const hasTrackStock = lines.length > 0;
|
||||
const prefillLine =
|
||||
this.props.prefillLineId != null
|
||||
? lines.find((l) => l.id === this.props.prefillLineId)
|
||||
@@ -502,7 +506,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
const countLinesToBook = lines.filter(
|
||||
(l) => Number(amountsToBook[l.id]) > 0
|
||||
).length;
|
||||
const isFullyBooked = summary?.status === 'booked';
|
||||
const isFullyBooked = hasTrackStock && summary?.status === 'booked';
|
||||
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
|
||||
const isWarehouseMissing = !selectedWarehouseId;
|
||||
|
||||
@@ -554,7 +558,7 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
)}
|
||||
{this.renderLinesTable()}
|
||||
|
||||
{!isFullyBooked && lines.length > 0 && (
|
||||
{!isFullyBooked && hasTrackStock && (
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -576,75 +580,77 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{warehouses.length === 0 && !isFullyBooked && (
|
||||
{hasTrackStock && warehouses.length === 0 && !isFullyBooked && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
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' } }}
|
||||
{hasTrackStock && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
mb: 2,
|
||||
flexDirection: { xs: 'column', sm: 'row' },
|
||||
}}
|
||||
>
|
||||
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
|
||||
<Select
|
||||
labelId="warehouse-select-label"
|
||||
id="warehouse-select"
|
||||
value={selectedWarehouseId ?? ''}
|
||||
label="Ziellager *"
|
||||
onChange={this.handleWarehouseChange}
|
||||
startAdornment={
|
||||
<WarehouseIcon
|
||||
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
|
||||
/>
|
||||
}
|
||||
<FormControl
|
||||
size="small"
|
||||
required
|
||||
error={Boolean(warehouseError)}
|
||||
disabled={isBooking || isFullyBooked}
|
||||
sx={{ minWidth: { sm: 240 }, flex: { sm: '0 0 auto' } }}
|
||||
>
|
||||
{warehouses.length === 0 ? (
|
||||
<MenuItem value="" disabled>
|
||||
Keine Lager vorhanden
|
||||
</MenuItem>
|
||||
) : (
|
||||
warehouses.map((wh) => (
|
||||
<MenuItem key={wh.id} value={wh.id}>
|
||||
{wh.name}
|
||||
<InputLabel id="warehouse-select-label">Ziellager *</InputLabel>
|
||||
<Select
|
||||
labelId="warehouse-select-label"
|
||||
id="warehouse-select"
|
||||
value={selectedWarehouseId ?? ''}
|
||||
label="Ziellager *"
|
||||
onChange={this.handleWarehouseChange}
|
||||
startAdornment={
|
||||
<WarehouseIcon
|
||||
sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{warehouses.length === 0 ? (
|
||||
<MenuItem value="" disabled>
|
||||
Keine Lager vorhanden
|
||||
</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>
|
||||
{warehouseError ? (
|
||||
<FormHelperText>{warehouseError}</FormHelperText>
|
||||
) : (
|
||||
<FormHelperText>Pflichtfeld: Ziellager für die Buchung</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Buchungstext (Ledger-Referenz)"
|
||||
value={transactionText}
|
||||
onChange={this.handleTransactionTextChange}
|
||||
disabled={isBooking || isFullyBooked}
|
||||
helperText="Wird als Transaktionstext in das Ledger übernommen"
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
label="Buchungstext (Ledger-Referenz)"
|
||||
value={transactionText}
|
||||
onChange={this.handleTransactionTextChange}
|
||||
disabled={isBooking || isFullyBooked}
|
||||
helperText="Wird als Transaktionstext in das Ledger übernommen"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isFullyBooked ? (
|
||||
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
|
||||
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
|
||||
</Alert>
|
||||
) : totalToBook > 0 ? (
|
||||
) : hasTrackStock && totalToBook > 0 ? (
|
||||
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
|
||||
{selectedWarehouseId ? (
|
||||
<>
|
||||
@@ -659,11 +665,11 @@ export default class BookDeliveryDialog extends PureComponent {
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
) : (
|
||||
) : hasTrackStock ? (
|
||||
<Alert severity="warning">
|
||||
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
|
||||
</Alert>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -40,11 +40,14 @@ class ManifestFormLine extends PureComponent {
|
||||
cachedArticle: sku ? getArticle(sku) : null,
|
||||
loadingArticle: false,
|
||||
fallbackSuppliers: [],
|
||||
editingField: null,
|
||||
};
|
||||
this.handleRemove = this.handleRemove.bind(this);
|
||||
this.handleMoveUp = this.handleMoveUp.bind(this);
|
||||
this.handleMoveDown = this.handleMoveDown.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.handleSearchArticle = this.handleSearchArticle.bind(this);
|
||||
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
|
||||
@@ -117,7 +120,7 @@ class ManifestFormLine extends PureComponent {
|
||||
this.unsubSupplier = supKey.subscribe(this, (item) => ({
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (this.state.editingField === columnId) {
|
||||
this.setState({ editingField: null });
|
||||
}
|
||||
if (columnId === 'sku' && this.props.onSkuBlur) {
|
||||
this.props.onSkuBlur(this.props.line.key, e.target.value);
|
||||
}
|
||||
@@ -163,7 +184,7 @@ class ManifestFormLine extends PureComponent {
|
||||
|
||||
renderLedgerChip() {
|
||||
const { ledgerLine, onBookLine } = this.props;
|
||||
if (!ledgerLine) return null;
|
||||
if (!ledgerLine || !ledgerLine.trackStock) return null;
|
||||
const isBooked = ledgerLine.isBooked;
|
||||
const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
|
||||
const isClickable = Boolean(onBookLine);
|
||||
@@ -229,6 +250,8 @@ class ManifestFormLine extends PureComponent {
|
||||
renderField(column, colIdx, totalCols) {
|
||||
const { id, align, type } = column;
|
||||
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') {
|
||||
const posVal =
|
||||
@@ -240,8 +263,10 @@ class ManifestFormLine extends PureComponent {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
minHeight: 38,
|
||||
height: isFirstLine ? 40 : 41,
|
||||
minHeight: isFirstLine ? 40 : 41,
|
||||
maxHeight: isFirstLine ? 40 : 41,
|
||||
mt: isFirstLine ? 0 : '-1px',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.82rem',
|
||||
color: 'text.secondary',
|
||||
@@ -297,65 +322,197 @@ class ManifestFormLine extends PureComponent {
|
||||
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
|
||||
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 isFieldDirty = Boolean(
|
||||
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
|
||||
);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
type={type}
|
||||
value={val ?? ''}
|
||||
error={isError}
|
||||
disabled={saving || (id === 'sku' && Boolean(this.props.isOnShipment))}
|
||||
title={id === 'sku' && this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden' : undefined}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||
onBlur={(e) => this.handleBlur(e, id)}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: type === 'number' ? 'decimal' : 'text',
|
||||
style: { textAlign: align },
|
||||
autoComplete: 'off',
|
||||
step: type === 'number' ? 'any' : undefined,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
|
||||
const isEditing = this.state.editingField === id;
|
||||
const isDisabled = saving || (id === 'sku' && Boolean(this.props.isOnShipment));
|
||||
const titleText = id === 'sku' && this.props.isOnShipment
|
||||
? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden'
|
||||
: undefined;
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<TextField
|
||||
autoFocus
|
||||
size="small"
|
||||
type={type}
|
||||
value={val ?? ''}
|
||||
error={isError}
|
||||
disabled={isDisabled}
|
||||
title={titleText}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||
onBlur={(e) => this.handleBlur(e, id)}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
inputMode: type === 'number' ? 'decimal' : 'text',
|
||||
style: { textAlign: align },
|
||||
autoComplete: 'off',
|
||||
step: type === 'number' ? 'any' : undefined,
|
||||
},
|
||||
}}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
ml: isFirstInput ? 0 : '-1px',
|
||||
...(isFieldDirty && {
|
||||
bgcolor: 'rgba(237, 108, 2, 0.08)',
|
||||
}),
|
||||
'&:hover': {
|
||||
zIndex: 1,
|
||||
width: isFirstInput ? '100%' : 'calc(100% + 1px)',
|
||||
mt: isFirstLine ? 0 : '-1px',
|
||||
height: isFirstLine ? 40 : 41,
|
||||
zIndex: 3,
|
||||
'& .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': {
|
||||
borderColor: 'warning.main',
|
||||
borderWidth: 2,
|
||||
borderWidth: '1px !important',
|
||||
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': {
|
||||
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]': {
|
||||
MozAppearance: 'textfield',
|
||||
},
|
||||
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
|
||||
{
|
||||
WebkitAppearance: 'none',
|
||||
margin: 0,
|
||||
...(isError && !isFieldDirty && {
|
||||
bgcolor: 'rgba(211, 47, 47, 0.04)',
|
||||
borderColor: 'error.main',
|
||||
zIndex: 2,
|
||||
}),
|
||||
'&:hover': isDisabled
|
||||
? 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={{
|
||||
p: 0,
|
||||
width: col.width,
|
||||
verticalAlign: 'middle',
|
||||
minWidth: col.width,
|
||||
height: 40,
|
||||
verticalAlign: 'top',
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{this.renderField(col, colIdx, columns.length)}
|
||||
@@ -999,32 +1160,28 @@ class ManifestFormLine extends PureComponent {
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
|
||||
{this.props.onMove && (
|
||||
<>
|
||||
<Tooltip title="Nach oben">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Nach oben verschieben"
|
||||
disabled={saving || this.props.isFirstLine}
|
||||
onClick={this.handleMoveUp}
|
||||
sx={{ p: 0.35 }}
|
||||
>
|
||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Nach unten">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Nach unten verschieben"
|
||||
disabled={saving || this.props.isLastLine}
|
||||
onClick={this.handleMoveDown}
|
||||
sx={{ p: 0.35 }}
|
||||
>
|
||||
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Nach oben verschieben"
|
||||
disabled={saving || this.props.isFirstLine}
|
||||
onClick={this.handleMoveUp}
|
||||
sx={{ p: 0.35 }}
|
||||
>
|
||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Nach unten verschieben"
|
||||
disabled={saving || this.props.isLastLine}
|
||||
onClick={this.handleMoveDown}
|
||||
sx={{ p: 0.35 }}
|
||||
>
|
||||
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
|
||||
@@ -243,6 +243,7 @@ class ManifestHeader extends PureComponent {
|
||||
onPdfDelete,
|
||||
onPdfClick,
|
||||
onAttachmentClick,
|
||||
onMentionInChat,
|
||||
onDateChange,
|
||||
onNumberChange,
|
||||
onExternalNumberChange,
|
||||
@@ -356,7 +357,7 @@ class ManifestHeader extends PureComponent {
|
||||
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
||||
minWidth: { sm: 160 },
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
renderInput={({ inputProps: _unusedInputProps, InputProps: _unusedInputProps2, InputLabelProps: _unusedInputLabelProps, ...params }) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={this.partyInputRef}
|
||||
@@ -456,6 +457,7 @@ class ManifestHeader extends PureComponent {
|
||||
onRescan={onPdfRescan}
|
||||
onDelete={onPdfDelete}
|
||||
onClick={onAttachmentClick ?? onPdfClick}
|
||||
onMentionInChat={onMentionInChat}
|
||||
disabled={saving || pdfUploading}
|
||||
hasArticles={hasArticles}
|
||||
/>
|
||||
@@ -509,7 +511,7 @@ class ManifestHeader extends PureComponent {
|
||||
Wiederherstellen
|
||||
</Button>
|
||||
)}
|
||||
{docId != null && kind === 'delivery' && onOpenBookDialog && (
|
||||
{docId != null && kind === 'delivery' && onOpenBookDialog && ledgerSummary?.hasTrackStock && (
|
||||
<Button
|
||||
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
|
||||
color={
|
||||
|
||||
@@ -21,19 +21,28 @@ import TableRow from '@mui/material/TableRow';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import TextField from '@mui/material/TextField';
|
||||
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 CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
||||
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { vfs } from '../../api';
|
||||
import backupAutomat from '../../automats/backupAutomat';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import {
|
||||
@@ -92,11 +101,17 @@ export default class BackupSettingsPanel extends Component {
|
||||
vfsLoadingByBackup: vfsLoadingByBackup || {},
|
||||
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
||||
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.handleToggleExpand = this.handleToggleExpand.bind(this);
|
||||
this.handleOpenConfirm = this.handleOpenConfirm.bind(this);
|
||||
this.handleCloseConfirm = this.handleCloseConfirm.bind(this);
|
||||
this.handleExecuteRestore = this.handleExecuteRestore.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -129,18 +144,6 @@ export default class BackupSettingsPanel extends Component {
|
||||
}
|
||||
// #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) {
|
||||
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() {
|
||||
const {
|
||||
status,
|
||||
@@ -161,6 +223,10 @@ export default class BackupSettingsPanel extends Component {
|
||||
vfsLoadingByBackup,
|
||||
tierTab,
|
||||
expandedItemName,
|
||||
restoringSkills,
|
||||
restoringApplets,
|
||||
confirmRestore,
|
||||
lastRestoreResult,
|
||||
} = this.state;
|
||||
|
||||
if (status === 'loading' && !backupStatus) {
|
||||
@@ -183,7 +249,7 @@ export default class BackupSettingsPanel extends Component {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleRefresh}
|
||||
onClick={() => void this.loadBackups()}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Erneut versuchen
|
||||
@@ -207,28 +273,15 @@ export default class BackupSettingsPanel extends Component {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Header Title & Actions */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
||||
Datensicherung & Backups
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
|
||||
</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>
|
||||
{/* Header Title */}
|
||||
<Box>
|
||||
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
||||
Datensicherung & Backups
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Status Badges Bar */}
|
||||
@@ -246,19 +299,11 @@ export default class BackupSettingsPanel extends Component {
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
||||
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
||||
color={backupStatus?.enabled ? 'success' : 'default'}
|
||||
icon={backupStatus?.enabled && isRunning ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
||||
label={backupStatus?.enabled && isRunning ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
||||
color={backupStatus?.enabled && isRunning ? 'success' : 'default'}
|
||||
variant="outlined"
|
||||
/>
|
||||
{isRunning && (
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<CircularProgress size={12} color="inherit" />}
|
||||
label="Backup wird ausgeführt..."
|
||||
color="primary"
|
||||
/>
|
||||
)}
|
||||
<Chip
|
||||
size="small"
|
||||
icon={<StorageIcon />}
|
||||
@@ -431,6 +476,231 @@ export default class BackupSettingsPanel extends Component {
|
||||
</TableContainer>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +1,448 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
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 { PanelTile } from 'dashgui/panels';
|
||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
||||
import { articleHistoryGridAutomat } from '../../automats';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import 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 {
|
||||
LINK,
|
||||
} from '../../colors';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { LINK } from '../../colors';
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{
|
||||
headerName: 'ID',
|
||||
field: 'id',
|
||||
maxWidth: 90,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'date',
|
||||
minWidth: 170,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Artikel-Nr.',
|
||||
field: 'sku',
|
||||
minWidth: 130,
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
cellRenderer: (p) => {
|
||||
if (!p.value) return '';
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
function formatDateTime(val) {
|
||||
if (!val) return '–';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return String(val);
|
||||
return d.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDummyTestArticleHistory() {
|
||||
return {
|
||||
id: 999999,
|
||||
date: '2030-10-30T12:00:00Z',
|
||||
sku: 'SKU-99999999',
|
||||
name: 'Musterartikel Name Langform',
|
||||
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 (
|
||||
<a
|
||||
href={`/artikel/${encodeURIComponent(p.value)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void router.navigate(`/artikel/${encodeURIComponent(p.value)}`);
|
||||
}}
|
||||
style={{ color: LINK, fontWeight: 600, textDecoration: 'none' }}
|
||||
<ListItemButton
|
||||
disabled
|
||||
divider
|
||||
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||
>
|
||||
{p.value}
|
||||
</a>
|
||||
{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="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>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: 'Artikelname',
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
headerName: 'Änderung / Ereignis',
|
||||
field: 'text',
|
||||
minWidth: 300,
|
||||
flex: 2,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
||||
autoHeight: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Benutzer',
|
||||
field: 'user',
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) =>
|
||||
p.data?.user ||
|
||||
p.data?.employee ||
|
||||
p.data?.companyuser ||
|
||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const userName =
|
||||
item.user ||
|
||||
item.employee ||
|
||||
item.companyuser ||
|
||||
(item.companyuserId ? `User #${item.companyuserId}` : '—');
|
||||
|
||||
return (
|
||||
<ListItemButton
|
||||
divider
|
||||
disabled={disabled}
|
||||
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
|
||||
onClick={
|
||||
item.sku
|
||||
? () => {
|
||||
void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`);
|
||||
}
|
||||
: 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 }}>
|
||||
{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 {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const s = articleHistoryGridAutomat.state;
|
||||
this.headerItem = createDummyTestArticleHistory();
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
totalRows: s.totalRows,
|
||||
items: articleHistoryAutomat.getItems(),
|
||||
unfilteredTotal: articleHistoryAutomat.state.unfilteredTotal ?? 0,
|
||||
filterKey: articleHistoryAutomat.getFilterKey?.() ?? '',
|
||||
status: articleHistoryAutomat.state.status,
|
||||
error: articleHistoryAutomat.state.error,
|
||||
};
|
||||
this.searchRef = createRef();
|
||||
this.handleReload = this.handleReload.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handlePageChange = this.handlePageChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({
|
||||
loading: state.loading,
|
||||
this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({
|
||||
items: state.items,
|
||||
unfilteredTotal: state.unfilteredTotal ?? 0,
|
||||
filterKey: articleHistoryAutomat.getFilterKey(state),
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
totalRows: state.totalRows,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -109,47 +450,112 @@ export default class ArticleHistoryPage extends PureComponent {
|
||||
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() {
|
||||
articleHistoryGridAutomat.actions.reload();
|
||||
void articleHistoryAutomat.actions.reload().catch(() => {});
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void articleHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, totalRows } = this.state;
|
||||
const { items, unfilteredTotal, filterKey, status, error } = this.state;
|
||||
const loading = status === 'loading';
|
||||
|
||||
const actions = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{loading && <CircularProgress size={18} thickness={5} />}
|
||||
{totalRows >= 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalRows} Einträge
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
disabled={loading}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
if (items === null && status === 'failed') {
|
||||
return (
|
||||
<>
|
||||
<ArticleHistorySearchField
|
||||
inputRef={this.searchRef}
|
||||
onReload={this.handleReload}
|
||||
loading={loading}
|
||||
/>
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
|
||||
<BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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%' }}>
|
||||
<AutomatGrid
|
||||
automat={articleHistoryGridAutomat}
|
||||
columnDefs={COLUMN_DEFS}
|
||||
rowIdField="id"
|
||||
cacheBlockSize={50}
|
||||
height="100%"
|
||||
width="100%"
|
||||
noRowsOverlayText="Keine Historieneinträge vorhanden"
|
||||
/>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
<>
|
||||
<ArticleHistorySearchField
|
||||
inputRef={this.searchRef}
|
||||
onReload={this.handleReload}
|
||||
loading={loading}
|
||||
/>
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={0} sx={{ pb: 0, position: 'relative' }}>
|
||||
<FillSpace
|
||||
aboveRef={[this.searchRef]}
|
||||
reserveBottom={32}
|
||||
minPageSize={8}
|
||||
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 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 { PanelTile } from 'dashgui/panels';
|
||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
||||
import { globalHistoryGridAutomat } from '../../automats';
|
||||
import ListItemButton from '@mui/material/ListItemButton';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import 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 = [
|
||||
{
|
||||
headerName: 'ID',
|
||||
field: 'id',
|
||||
maxWidth: 90,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'date',
|
||||
minWidth: 170,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Typ',
|
||||
field: 'type',
|
||||
minWidth: 120,
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
headerName: 'Referenz / Key',
|
||||
field: 'reference',
|
||||
minWidth: 140,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) => p.data?.reference || p.data?.p1 || '',
|
||||
},
|
||||
{
|
||||
headerName: 'Meldung / Beschreibung',
|
||||
field: 'text',
|
||||
minWidth: 300,
|
||||
flex: 2,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) => p.data?.text || p.data?.value || '',
|
||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
||||
autoHeight: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Benutzer',
|
||||
field: 'user',
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) =>
|
||||
p.data?.user ||
|
||||
p.data?.employee ||
|
||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
||||
},
|
||||
];
|
||||
const CHUNK_SIZE = 50;
|
||||
|
||||
function formatDateTime(val) {
|
||||
if (!val) return '–';
|
||||
try {
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return String(val);
|
||||
return d.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDummyTestGlobalHistory() {
|
||||
return {
|
||||
id: 999999,
|
||||
date: '2030-10-30T12:00:00Z',
|
||||
type: 'Bestellung',
|
||||
reference: 'BEST-999999',
|
||||
text: 'Status geändert auf abgeschlossen durch Benutzer',
|
||||
user: 'Max Mustermann',
|
||||
};
|
||||
}
|
||||
|
||||
const rowGridSx = {
|
||||
display: 'grid',
|
||||
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',
|
||||
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 || 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 {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const s = globalHistoryGridAutomat.state;
|
||||
this.headerItem = createDummyTestGlobalHistory();
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
totalRows: s.totalRows,
|
||||
items: globalHistoryAutomat.getItems(),
|
||||
unfilteredTotal: globalHistoryAutomat.state.unfilteredTotal ?? 0,
|
||||
filterKey: globalHistoryAutomat.getFilterKey?.() ?? '',
|
||||
status: globalHistoryAutomat.state.status,
|
||||
error: globalHistoryAutomat.state.error,
|
||||
};
|
||||
this.searchRef = createRef();
|
||||
this.handleReload = this.handleReload.bind(this);
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handlePageChange = this.handlePageChange.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({
|
||||
loading: state.loading,
|
||||
this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({
|
||||
items: state.items,
|
||||
unfilteredTotal: state.unfilteredTotal ?? 0,
|
||||
filterKey: globalHistoryAutomat.getFilterKey(state),
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
totalRows: state.totalRows,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -91,47 +462,112 @@ export default class GlobalHistoryPage extends PureComponent {
|
||||
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() {
|
||||
globalHistoryGridAutomat.actions.reload();
|
||||
void globalHistoryAutomat.actions.reload().catch(() => {});
|
||||
}
|
||||
|
||||
handleRetry() {
|
||||
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, totalRows } = this.state;
|
||||
const { items, unfilteredTotal, filterKey, status, error } = this.state;
|
||||
const loading = status === 'loading';
|
||||
|
||||
const actions = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{loading && <CircularProgress size={18} thickness={5} />}
|
||||
{totalRows >= 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalRows} Einträge
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
disabled={loading}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
if (items === null && status === 'failed') {
|
||||
return (
|
||||
<>
|
||||
<GlobalHistorySearchField
|
||||
inputRef={this.searchRef}
|
||||
onReload={this.handleReload}
|
||||
loading={loading}
|
||||
/>
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
|
||||
<BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
|
||||
</PanelTile>
|
||||
</PanelTileGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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%' }}>
|
||||
<AutomatGrid
|
||||
automat={globalHistoryGridAutomat}
|
||||
columnDefs={COLUMN_DEFS}
|
||||
rowIdField="id"
|
||||
cacheBlockSize={50}
|
||||
height="100%"
|
||||
width="100%"
|
||||
noRowsOverlayText="Keine globalen Historieneinträge vorhanden"
|
||||
/>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
<>
|
||||
<GlobalHistorySearchField
|
||||
inputRef={this.searchRef}
|
||||
onReload={this.handleReload}
|
||||
loading={loading}
|
||||
/>
|
||||
<PanelTileGrid>
|
||||
<PanelTile wide={3} minHeight={0} sx={{ pb: 0, position: 'relative' }}>
|
||||
<FillSpace
|
||||
aboveRef={[this.searchRef]}
|
||||
reserveBottom={32}
|
||||
minPageSize={8}
|
||||
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,
|
||||
BookDeliveryDialog,
|
||||
} from '../../components/manifest';
|
||||
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
|
||||
import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
|
||||
import { article as articleApi, delivery as deliveryApi } from '../../api';
|
||||
import { getCategoryState } from '../../automats/categoryAutomat';
|
||||
@@ -48,6 +47,7 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
|
||||
import { extractPdfText } from '../../utils/pdfText';
|
||||
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
|
||||
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
|
||||
import chatAutomat from '../../automats/chatAutomat';
|
||||
|
||||
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
|
||||
import {
|
||||
@@ -68,9 +68,6 @@ class LieferungNeuPage extends PureComponent {
|
||||
partiesLoading: true,
|
||||
saving: false,
|
||||
pdfUploading: false,
|
||||
pdfDialogOpen: false,
|
||||
pdfDialogData: null,
|
||||
pdfFileName: '',
|
||||
textEditorOpen: false,
|
||||
textEditorAttachment: null,
|
||||
viewerPosition: userSettingsAutomat.state.viewerPosition,
|
||||
@@ -105,8 +102,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
this.handlePdfUpload = this.handlePdfUpload.bind(this);
|
||||
this.handlePdfRescan = this.handlePdfRescan.bind(this);
|
||||
this.handlePdfDelete = this.handlePdfDelete.bind(this);
|
||||
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
|
||||
this.handleAbortParse = this.handleAbortParse.bind(this);
|
||||
this.handleMentionInChat = this.handleMentionInChat.bind(this);
|
||||
this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
|
||||
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
|
||||
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
|
||||
@@ -159,10 +155,6 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelled = true;
|
||||
if (this._parseAbortController) {
|
||||
this._parseAbortController.abort();
|
||||
this._parseAbortController = null;
|
||||
}
|
||||
if (this._skuDebounceTimers) {
|
||||
Object.values(this._skuDebounceTimers).forEach(clearTimeout);
|
||||
this._skuDebounceTimers = null;
|
||||
@@ -301,7 +293,6 @@ class LieferungNeuPage extends PureComponent {
|
||||
|
||||
handleCreateSupplierFromPdf(supplierName) {
|
||||
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
|
||||
this.handleClosePdfDialog();
|
||||
const target = `/lieferung/lieferanten?name=${encodeURIComponent(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);
|
||||
}
|
||||
|
||||
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
|
||||
await this.runParse(file);
|
||||
}
|
||||
|
||||
async handlePdfRescan(attachment) {
|
||||
@@ -697,8 +685,41 @@ class LieferungNeuPage extends PureComponent {
|
||||
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) {
|
||||
@@ -706,227 +727,6 @@ class LieferungNeuPage extends PureComponent {
|
||||
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) {
|
||||
if (!attachment) return null;
|
||||
const hasContent =
|
||||
@@ -1127,7 +927,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
isViewerOpen() {
|
||||
return Boolean(
|
||||
(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,
|
||||
saving,
|
||||
pdfUploading,
|
||||
pdfDialogOpen,
|
||||
pdfDialogData,
|
||||
pdfFileName,
|
||||
deliveryEntities,
|
||||
date,
|
||||
partyId,
|
||||
@@ -1298,6 +1095,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
onPdfRescan={this.handlePdfRescan}
|
||||
onPdfDelete={this.handlePdfDelete}
|
||||
onAttachmentClick={this.handleAttachmentClick}
|
||||
onMentionInChat={this.handleMentionInChat}
|
||||
onSave={this.handleSave}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
@@ -1376,15 +1174,7 @@ class LieferungNeuPage extends PureComponent {
|
||||
</PanelTile>
|
||||
)}
|
||||
</PanelTileGrid>
|
||||
<ManifestPdfDialog
|
||||
open={pdfDialogOpen}
|
||||
filename={pdfFileName}
|
||||
data={pdfDialogData}
|
||||
unmatchedSupplierName={this.state.unmatchedSupplierName}
|
||||
onCreateSupplier={this.handleCreateSupplierFromPdf}
|
||||
onClose={this.handleClosePdfDialog}
|
||||
onAbort={this.handleAbortParse}
|
||||
/>
|
||||
|
||||
|
||||
<ArticleSearchDialog
|
||||
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 || '')));
|
||||
|
||||
if (!ledgerSummary.hasTrackStock && allEntries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<PanelTile
|
||||
wide={4}
|
||||
@@ -1456,8 +1248,8 @@ class LieferungNeuPage extends PureComponent {
|
||||
status === 'booked'
|
||||
? 'success.light'
|
||||
: status === 'partial'
|
||||
? 'warning.light'
|
||||
: 'divider',
|
||||
? 'warning.light'
|
||||
: 'divider',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
@@ -1538,25 +1330,27 @@ class LieferungNeuPage extends PureComponent {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant={status === 'booked' ? 'outlined' : 'contained'}
|
||||
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
|
||||
startIcon={
|
||||
status === 'booked' ? (
|
||||
<CheckCircleOutlineOutlinedIcon />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon />
|
||||
)
|
||||
}
|
||||
onClick={this.handleOpenBookDialog}
|
||||
sx={{ fontWeight: 600, textTransform: 'none' }}
|
||||
>
|
||||
{status === 'booked'
|
||||
? 'Buchungsdetails anzeigen'
|
||||
: status === 'partial'
|
||||
? 'Restliche Mengen einbuchen'
|
||||
: 'Ins Lager buchen'}
|
||||
</Button>
|
||||
{ledgerSummary.hasTrackStock && (
|
||||
<Button
|
||||
variant={status === 'booked' ? 'outlined' : 'contained'}
|
||||
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
|
||||
startIcon={
|
||||
status === 'booked' ? (
|
||||
<CheckCircleOutlineOutlinedIcon />
|
||||
) : (
|
||||
<Inventory2OutlinedIcon />
|
||||
)
|
||||
}
|
||||
onClick={this.handleOpenBookDialog}
|
||||
sx={{ fontWeight: 600, textTransform: 'none' }}
|
||||
>
|
||||
{status === 'booked'
|
||||
? 'Buchungsdetails anzeigen'
|
||||
: status === 'partial'
|
||||
? 'Restliche Mengen einbuchen'
|
||||
: 'Ins Lager buchen'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{allEntries.length > 0 && (
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi } from '../api';
|
||||
import artikelAutomat from '../automats/artikelAutomat';
|
||||
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi, stock as defaultStockApi } from '../api';
|
||||
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
|
||||
@@ -10,6 +39,8 @@ import artikelAutomat from '../automats/artikelAutomat';
|
||||
* @property {number} bookedAmount - Quantity booked in ledger
|
||||
* @property {number} remainingAmount - Quantity remaining to be 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
|
||||
*/
|
||||
|
||||
@@ -22,6 +53,7 @@ import artikelAutomat from '../automats/artikelAutomat';
|
||||
* @property {number} bookedPositions
|
||||
* @property {number} totalQuantity
|
||||
* @property {number} bookedQuantity
|
||||
* @property {boolean} hasTrackStock
|
||||
* @property {Array<DeliveryLineLedgerStatus>} lines
|
||||
*/
|
||||
|
||||
@@ -48,6 +80,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
||||
bookedPositions: 0,
|
||||
totalQuantity: 0,
|
||||
bookedQuantity: 0,
|
||||
hasTrackStock: false,
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
@@ -56,20 +89,43 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
||||
const linesWithLedger = await Promise.all(
|
||||
rawLines.map(async (line) => {
|
||||
let ledgerEntries = [];
|
||||
try {
|
||||
const ledgerRes = await ledgerApi.listByManifestarticleId(line.id, 'DELIVERY');
|
||||
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
|
||||
} catch (err) {
|
||||
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
|
||||
let article = null;
|
||||
|
||||
const promises = [
|
||||
ledgerApi.listByManifestarticleId(line.id).catch((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(
|
||||
(e) => String(e.transaction_type).toUpperCase() === 'DELIVERY'
|
||||
);
|
||||
const results = await Promise.all(promises);
|
||||
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 targetAmount = Number(line.amount || 0);
|
||||
const remainingAmount = Math.max(0, targetAmount - bookedAmount);
|
||||
const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0;
|
||||
const remainingAmount = trackStock ? Math.max(0, targetAmount - bookedAmount) : 0;
|
||||
const isBooked = trackStock ? (targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0) : true;
|
||||
|
||||
return {
|
||||
...line,
|
||||
@@ -77,18 +133,22 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
||||
bookedAmount,
|
||||
remainingAmount,
|
||||
isBooked,
|
||||
trackStock,
|
||||
isBookable: trackStock,
|
||||
ledgerEntries: deliveryEntries,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const totalPositions = linesWithLedger.length;
|
||||
const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length;
|
||||
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
|
||||
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
|
||||
const stockLines = linesWithLedger.filter((l) => l.trackStock);
|
||||
const totalPositions = stockLines.length;
|
||||
const bookedPositions = stockLines.filter((l) => l.isBooked).length;
|
||||
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;
|
||||
if (totalPositions === 0) {
|
||||
if (!hasTrackStock) {
|
||||
status = 'empty';
|
||||
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
|
||||
status = 'booked';
|
||||
@@ -106,18 +166,19 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
||||
bookedPositions,
|
||||
totalQuantity,
|
||||
bookedQuantity,
|
||||
hasTrackStock,
|
||||
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.delivery - The delivery object (used for transaction memo and fallback)
|
||||
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
|
||||
* @param {number|string} params.warehouseId - Mandatory warehouse ID
|
||||
* @param {Object} [params.ledgerApi]
|
||||
* @param {Object} [params.stockApi]
|
||||
* @param {string} [params.transactionText]
|
||||
* @returns {Promise<Array<Object>>}
|
||||
*/
|
||||
@@ -125,7 +186,7 @@ export async function bookDeliveryLines({
|
||||
delivery,
|
||||
linesToBook,
|
||||
warehouseId,
|
||||
ledgerApi = defaultLedgerApi,
|
||||
stockApi = defaultStockApi,
|
||||
transactionText,
|
||||
}) {
|
||||
const numericWarehouseId = Number(warehouseId);
|
||||
@@ -143,28 +204,25 @@ export async function bookDeliveryLines({
|
||||
: `Lieferung ${delivery?.number || delivery?.id || ''}`
|
||||
).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) {
|
||||
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) {
|
||||
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') {
|
||||
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') {
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import pc from 'picocolors';
|
||||
import qrcode from 'qrcode';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const useHttps = process.env.VITE_HTTPS === '1';
|
||||
@@ -94,6 +95,7 @@ const sharedResolve = {
|
||||
|
||||
const sharedServer = {
|
||||
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
|
||||
host: true,
|
||||
port: useHttps ? 4308 : 4209,
|
||||
https: httpsOptions(),
|
||||
allowedHosts: true,
|
||||
@@ -266,6 +268,20 @@ function pidReporterPlugin() {
|
||||
server.config.logger.info(
|
||||
` ${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