Compare commits

..

16 Commits

Author SHA1 Message Date
seb
5d8ac3a8eb u 2026-10-16 22:27:34 +02:00
seb
0851c31970 u 2026-10-16 22:08:14 +02:00
seb
d8c1d44652 u 2026-10-16 21:04:00 +02:00
seb
23a198b0c1 u 2026-10-16 20:46:26 +02:00
seb
7ad8a3a301 u 2026-10-16 19:36:08 +02:00
seb
4a7a02a0a4 u 2026-10-16 18:05:20 +02:00
seb
8e098b145e u 2026-10-16 17:10:54 +02:00
seb
7a4d452109 u 2026-10-16 16:36:43 +02:00
seb
99d95ee234 u 2026-10-16 16:03:31 +02:00
seb
f5a5cb51e3 u 2026-10-16 15:39:25 +02:00
seb
52411ac45d u 2026-10-16 15:38:39 +02:00
seb
5c57097ad0 u 2026-10-16 14:32:10 +02:00
seb
a592cc30f6 u 2026-10-16 14:17:22 +02:00
seb
764d6f850a u 2026-10-16 13:51:18 +02:00
seb
d851c6e1e9 u 2026-10-16 13:10:58 +02:00
seb
7bc1da4de6 u 2026-10-16 04:12:18 +02:00
31 changed files with 3829 additions and 1384 deletions

View File

@@ -7,7 +7,7 @@ description: >-
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 249 API endpoints across all modules. Overview of all 250 API endpoints across all modules.
### Notation ### Notation
@@ -17,15 +17,15 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types ### Reusable types
- `T1` = `{ order: T2 }` - `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, shipping?: T38|null, shippings?: T38[], payment?: T44|null, payments?: T44[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T6[], files?: T16[] }` - `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], externalNumber?: str|null, createdAt?: str, shipping?: T36|null, shippings?: T36[], payment?: T42|null, payments?: T42[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T6[], files?: T16[] }`
- `T3` = `{ article: T4 }` - `T3` = `{ article: T4 }`
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T41, brand: T39|null, categoryIds: int[], photos: T36[], eans: str[], prices: T48[], suppliers: T18[] }` - `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T18[] }`
- `T5` = `{ shipment: T6 }` - `T5` = `{ shipment: T6 }`
- `T6` = `{ id: int, number: str, type: str, date: str, lines: T30[], shippings: T38[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T16[], state?: str }` - `T6` = `{ id: int, number: str, type: str, date: str, lines: T29[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T16[], state?: str }`
- `T7` = `{ quote: T8 }` - `T7` = `{ quote: T8 }`
- `T8` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T16[] }` - `T8` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T16[] }`
- `T9` = `{ delivery: T10 }` - `T9` = `{ delivery: T10 }`
- `T10` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T30[], files: T16[], externalNumber?: str|null, createdAt?: str, ledger?: T52 }` - `T10` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T29[], files: T16[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }`
- `T11` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T12|null }` - `T11` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T12|null }`
- `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }` - `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }`
- `T13` = `{ labeltemplate: T15 }` - `T13` = `{ labeltemplate: T15 }`
@@ -33,46 +33,44 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- `T15` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T26, thumb?: str|null }` - `T15` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T26, thumb?: str|null }`
- `T16` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }` - `T16` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T17` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }` - `T17` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T18` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T55|null }` - `T18` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }`
- `T19` = `{ vfs: T53, storage: T53, database: T46 }` - `T19` = `{ vfs: T51, storage: T51, database: T44 }`
- `T20` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }` - `T20` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T21` = `{ ledger: T22[] }` - `T21` = `{ ledger: T22[] }`
- `T22` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }` - `T22` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T42[], articleCount?: int }` - `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
- `T24` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }` - `T24` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: int|null, first_message?: str|null }`
- `T25` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }` - `T25` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T26` = `{ objects: T31[] }` - `T26` = `{ objects: T30[] }`
- `T27` = `{ objects: T32[] }` - `T27` = `{ printer: T32 }`
- `T28` = `{ printer: T34 }` - `T28` = `{ entity: T35 }`
- `T29` = `{ entity: T37 }` - `T29` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T30` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }` - `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T31` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }` - `T31` = `{ shipping: T36 }`
- `T32` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }` - `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T33` = `{ shipping: T38 }` - `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T34` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }` - `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }` - `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T36` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }` - `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T37` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }` - `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T38` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }` - `T38` = `{ article: T43 }`
- `T39` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }` - `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T40` = `{ article: T45 }` - `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T41` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }` - `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T42` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }` - `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T43` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }` - `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T44` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }` - `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T45` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }` - `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T46` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }` - `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T47` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }` - `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T48` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }` - `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T49` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }` - `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T50` = `{ id: int, entityId: int, contactType: str, contact: str }` - `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T51` = `{ id: int, name: str, createdAt: str, updatedAt: str }` - `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T52` = `{ totalPositions: int, completelyOnLedgerPositions: int }` - `T52` = `{ warehouse: T54 }`
- `T53` = `{ name: str, sizeBytes: int, sourceExists: bool }` - `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T54` = `{ warehouse: T56 }` - `T54` = `{ id: int, name: str, sort: int }`
- `T55` = `{ net: num, taxId: int, taxRate?: num|null }` - `T55` = `{ id: int, categoryIds: int[] }`
- `T56` = `{ id: int, name: str, sort: int }`
- `T57` = `{ id: int, categoryIds: int[] }`
### Routes ### Routes
@@ -102,7 +100,7 @@ _none_
#### Create article in category — `POST /article` #### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }` - Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T40` - Returns: `201 T38`
#### Export articles as ZIP — `GET /article/export.zip` #### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int` - Params: `query: categoryId?: int`
- Returns: `200 zip` - Returns: `200 zip`
@@ -138,10 +136,10 @@ _none_
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }` - Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children` #### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }` - Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T40` - Returns: `201 T38`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children` #### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }` - Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T45, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }` - Returns: `200 { article: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}` #### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool` - Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }` - Returns: `200 { success: bool, deleted: bool }`
@@ -168,7 +166,7 @@ _none_
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }` - Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent` #### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }` - Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
- Returns: `200 T40` - Returns: `200 T38`
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices` #### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }` - Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T3` - Returns: `200 T3`
@@ -219,10 +217,10 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value` #### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int` - Params: `attributeId: int`
- Returns: `200 { values: T42[] }` - Returns: `200 { values: T40[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value` #### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }` - Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T42 }` - Returns: `201 { attributevalue: T40 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order` #### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }` - Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -240,28 +238,28 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}` #### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }` - Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T42 }` - Returns: `200 { attributevalue: T40 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image` #### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T49[] } }` - Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image` #### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T49[] } }` - Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
### Section: /auth ### Section: /auth
#### Login with Google ID token — `POST /auth/login` #### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }` - Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T47 }` - Returns: `200 { message: str, user: T45 }`
#### Logout — `POST /auth/logout` #### Logout — `POST /auth/logout`
_none_ _none_
- Returns: `200 { message: str }` - Returns: `200 { message: str }`
#### Current user — `GET /auth/me` #### Current user — `GET /auth/me`
_none_ _none_
- Returns: `200 { user: T47, googleClientId: str }` - Returns: `200 { user: T45, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company` #### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }` - Params: `body: { companyId: int }`
- Returns: `200 { user: T47 }` - Returns: `200 { user: T45 }`
### Section: /backup ### Section: /backup
@@ -282,7 +280,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }` - Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand` #### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }` - Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T49[], gsprpinfo?: str|null } }` - Returns: `201 { brand: { id: int, name: str, pics: T47[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}` #### Delete brand — `DELETE /brand/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -291,10 +289,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }` - Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image` #### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T49[] } }` - Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
#### Add or replace brand image — `POST /brand/{id}/image` #### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif` - Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T49[] } }` - Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
### Section: /category ### Section: /category
@@ -318,7 +316,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }` - Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles` #### Articles in category — `GET /category/{id}/articles`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T39|null }[] }` - Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T37|null }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue` #### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }` - Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
@@ -348,7 +346,7 @@ _none_
- Returns: `200 { companies: T25[] }` - Returns: `200 { companies: T25[] }`
#### Create company — `POST /company` #### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }` - Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T25, user: T47 }` - Returns: `201 { company: T25, user: T45 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export` #### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str` - Params: `query: stream?: str`
- Returns: `200 SSE` - Returns: `200 SSE`
@@ -387,7 +385,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }` - Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup` #### Create customer group — `POST /customergroup`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { customergroup: T56 }` - Returns: `201 { customergroup: T54 }`
#### Reorder customer groups — `PUT /customergroup/order` #### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }` - Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }` - Returns: `200 { customergroupIds: int[] }`
@@ -396,28 +394,16 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}` #### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T56 }` - Returns: `200 { customergroup: T54 }`
### Section: /delivery ### Section: /delivery
#### List deliveries — `GET /delivery` #### List deliveries — `GET /delivery`
_none_ - Params: `query: offset?: int, limit?: int, search?: str, onlyUnbooked?: bool, supplierId?: int, seekMonths?: int`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T52 }[] }` - Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T50 }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create delivery — `POST /delivery` #### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T35[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }` - Params: `body: { externalNumber: str, date: str, lines: T33[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T9` - Returns: `201 T9`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
- Events:
- `status`: `{ "message": "…" }`
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
- `tool_call`: `{ "name": "output_manifest" }`
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
- `progress`: `ManifestResult`
- `result`: `ManifestResult`
- `error`: `{ "message": "…" }`
#### Delete delivery — `DELETE /delivery/{id}` #### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
@@ -425,7 +411,7 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T9` - Returns: `200 T9`
#### Update delivery — `PATCH /delivery/{id}` #### Update delivery — `PATCH /delivery/{id}`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T35[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }` - Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T33[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T9` - Returns: `200 T9`
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}` #### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int` - Params: `id: int, fileId: int`
@@ -441,10 +427,10 @@ _none_
#### List entities — `GET /entity` #### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str` - Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T37[] }` - Returns: `200 { entities: T35[] }`
#### Create entity — `POST /entity` #### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }` - Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T29` - Returns: `201 T28`
#### Peek next customer number — `GET /entity/next-number` #### Peek next customer number — `GET /entity/next-number`
_none_ _none_
- Returns: `200 { number: str }` - Returns: `200 { number: str }`
@@ -462,25 +448,25 @@ _none_
- Returns: `200 { address: T20 }` - Returns: `200 { address: T20 }`
#### List entity contacts — `GET /entity/{entityId}/contact` #### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int` - Params: `entityId: int`
- Returns: `200 { contacts: T50[] }` - Returns: `200 { contacts: T48[] }`
#### Create entity contact — `POST /entity/{entityId}/contact` #### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }` - Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T50 }` - Returns: `201 { contact: T48 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}` #### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int` - Params: `entityId: int, id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}` #### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }` - Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T50 }` - Returns: `200 { contact: T48 }`
#### Delete entity — `DELETE /entity/{id}` #### Delete entity — `DELETE /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Get entity — `GET /entity/{id}` #### Get entity — `GET /entity/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T29` - Returns: `200 T28`
#### Update entity — `PATCH /entity/{id}` #### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }` - Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T29` - Returns: `200 T28`
### Section: /events ### Section: /events
@@ -503,8 +489,6 @@ _none_
- `modelconversation`: `{}` - `modelconversation`: `{}`
- `modelconversation-<id>`: `{}` - `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }` - `brand`: `{ sender, brandId }`
- `draft`: `{ sender, action, name, content }`
- `draft:request`: `{ requestId, action, name, target, sender }`
### Section: /health ### Section: /health
@@ -524,7 +508,7 @@ _none_
_none_ _none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }` - Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate` #### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T27 }` - Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T26 }`
- Returns: `201 T13` - Returns: `201 T13`
#### Delete label template — `DELETE /labeltemplate/{id}` #### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int` - Params: `id: int`
@@ -533,10 +517,10 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T13` - Returns: `200 T13`
#### Update label template (partial) — `PATCH /labeltemplate/{id}` #### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T27 }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T13` - Returns: `200 T13`
#### Update label template — `PUT /labeltemplate/{id}` #### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T27 }` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T13` - Returns: `200 T13`
### Section: /ledger ### Section: /ledger
@@ -544,9 +528,6 @@ _none_
#### Query ledger by manifest article ID or SKU — `GET /ledger` #### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str` - Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T21` - Returns: `200 T21`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T22 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}` #### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str` - Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T21` - Returns: `200 T21`
@@ -632,7 +613,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int` - Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }` - Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order` #### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }` - Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null, externalNumber?: str|null }`
- Returns: `201 T1` - Returns: `201 T1`
#### Delete order — `DELETE /order/{id}` #### Delete order — `DELETE /order/{id}`
- Params: `id: int` - Params: `id: int`
@@ -641,7 +622,7 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T1` - Returns: `200 T1`
#### Update order — `PATCH /order/{id}` #### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
- Returns: `200 T1` - Returns: `200 T1`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}` #### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int` - Params: `id: int, fileId: int`
@@ -651,10 +632,10 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment` #### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { payments: T44[] }` - Returns: `200 { payments: T42[] }`
#### Add payment to an order — `POST /order/{id}/payment` #### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }` - Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T44 }` - Returns: `201 { payment: T42 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf` #### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int` - Params: `id: int`
- Returns: `201 T14` - Returns: `201 T14`
@@ -666,10 +647,10 @@ _none_
- Returns: `201 T5` - Returns: `201 T5`
#### List shippings of an order — `GET /order/{id}/shipping` #### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { shippings: T38[] }` - Returns: `200 { shippings: T36[] }`
#### Create shipping for an order — `POST /order/{id}/shipping` #### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }` - Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T33` - Returns: `201 T31`
### Section: /package ### Section: /package
@@ -699,25 +680,25 @@ _none_
- Returns: `200 { posterminal: T17 }` - Returns: `200 { posterminal: T17 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all` #### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T57` - Returns: `200 T55`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all` #### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T57` - Returns: `200 T55`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}` #### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int` - Params: `id: int, categoryId: int`
- Returns: `200 T57` - Returns: `200 T55`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}` #### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int` - Params: `id: int, categoryId: int`
- Returns: `200 T57` - Returns: `200 T55`
### Section: /printer ### Section: /printer
#### List printers — `GET /printer` #### List printers — `GET /printer`
_none_ _none_
- Returns: `200 { printers: T34[] }` - Returns: `200 { printers: T32[] }`
#### Create printer — `POST /printer` #### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T28` - Returns: `201 T27`
#### Reorder printers — `PUT /printer/order` #### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }` - Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }` - Returns: `200 { printerIds: int[] }`
@@ -726,22 +707,22 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}` #### Get printer by ID — `GET /printer/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T28` - Returns: `200 T27`
#### Update printer (partial) — `PATCH /printer/{id}` #### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T28` - Returns: `200 T27`
#### Update printer — `PUT /printer/{id}` #### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }` - Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T28` - Returns: `200 T27`
### Section: /propertyname ### Section: /propertyname
#### List property names — `GET /propertyname` #### List property names — `GET /propertyname`
_none_ _none_
- Returns: `200 { propertynames: T43[] }` - Returns: `200 { propertynames: T41[] }`
#### Create property name — `POST /propertyname` #### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }` - Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T43 }` - Returns: `201 { propertyname: T41 }`
#### Reorder property names — `PUT /propertyname/order` #### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }` - Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
@@ -750,19 +731,19 @@ _none_
- Returns: `200 { ok: bool }` - Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}` #### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }` - Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T43 }` - Returns: `200 { propertyname: T41 }`
### Section: /public ### Section: /public
#### Receipt crash dump — `POST /public/telemetry/crash` #### Receipt crash dump — `POST /public/telemetry/crash`
- Params: `body: { content: any }` - Params: `body: { content: str|{} }`
- Returns: `200 { ok: bool, filename: str }` - Returns: `200 { ok: bool, filename: str }`
#### List public articles — `GET /public/{companyId}/articles` #### List public articles — `GET /public/{companyId}/articles`
- Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int` - Params: `companyId: int, query: categoryId?: int, search?: str, limit?: int, offset?: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }` - Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, net: num|null, taxRate: num|null, gross: num|null, trackStock: bool, brand: { id: int|null, name: str }, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
#### Get public article detail — `GET /public/{companyId}/articles/{sku}` #### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: int, sku: str` - Params: `companyId: int, sku: str`
- Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: {}|null, brand: {}|null, price: {}|null, prices: {}[], categories: {}[], photos: {}[], eans: str[], isParent: bool, parentSku: str|null, variants: {}[] } }` - Returns: `200 { article: { id: int, sku: str, name: str, slug: str, description: str, unitamount: num|null, weight: num|null, trackStock: bool, unit: T39|null, brand: T37|null, price: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }, prices: { minmultiplier: num, net: num|null, taxRate: num|null, gross: num|null }[], categories: { id: int, name: str, slug: str }[], photos: T34[], eans: str[], isParent: bool, parentSku: str|null, variants: { id: int, sku: str, sort: int, name: str, slug: str, attributeValueId: int|null, attributeValue: str|null, unitamount: num|null, weight: num|null, trackStock: bool }[] } }`
#### List public categories — `GET /public/{companyId}/categories` #### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: int` - Params: `companyId: int`
- Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }` - Returns: `200 { categories: { id: int, parentId: int|null, sort: int, name: str, slug: str, fullHash: str|null, thumbW: int|null, thumbH: int|null, thumbHash: str|null }[] }`
@@ -773,7 +754,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int` - Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }` - Returns: `200 { quotes: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote` #### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }` - Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null, externalNumber?: str|null }`
- Returns: `201 T7` - Returns: `201 T7`
#### Delete quote — `DELETE /quote/{id}` #### Delete quote — `DELETE /quote/{id}`
- Params: `id: int` - Params: `id: int`
@@ -782,7 +763,7 @@ _none_
- Params: `id: int` - Params: `id: int`
- Returns: `200 T7` - Returns: `200 T7`
#### Update quote — `PATCH /quote/{id}` #### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
- Returns: `200 T7` - Returns: `200 T7`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}` #### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int` - Params: `id: int, fileId: int`
@@ -791,7 +772,7 @@ _none_
- Params: `id: int, fileId: int, query: download?: bool` - Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str` - Returns: `200 str`
#### Create order from quote — `POST /quote/{id}/order` #### Create order from quote — `POST /quote/{id}/order`
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }` - Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T33[] }`
- Returns: `201 T1` - Returns: `201 T1`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf` #### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int` - Params: `id: int`
@@ -837,10 +818,10 @@ _none_
- Returns: `201 T14` - Returns: `201 T14`
#### List packages of a shipment — `GET /shipment/{id}/shipping` #### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { shippings: T38[] }` - Returns: `200 { shippings: T36[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping` #### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }` - Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T33` - Returns: `201 T31`
### Section: /shipping ### Section: /shipping
@@ -849,7 +830,7 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}` #### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }` - Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T33` - Returns: `200 T31`
#### List packages of a shipping — `GET /shipping/{id}/package` #### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int` - Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }` - Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -865,30 +846,36 @@ _none_
### Section: /stock ### Section: /stock
#### Accept delivery manifest item(s) — `POST /stock/accept`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200`
#### Adjust stock manually — `POST /stock/adjust` #### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }` - Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T22[] }` - Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T22[] }`
#### Ship shipment manifest item(s) — `POST /stock/ship`
- Params: `body: { manifestarticleId?: int, warehouseId?: int, items?: { manifestarticleId: int, warehouseId?: int }[] }`
- Returns: `200`
### Section: /supplier ### Section: /supplier
#### List suppliers — `GET /supplier` #### List suppliers — `GET /supplier`
_none_ _none_
- Returns: `200 { suppliers: T51[] }` - Returns: `200 { suppliers: T49[] }`
#### Create supplier — `POST /supplier` #### Create supplier — `POST /supplier`
- Params: `body: { name: str }` - Params: `body: { name: str }`
- Returns: `201 { supplier: T51 }` - Returns: `201 { supplier: T49 }`
#### Delete supplier — `DELETE /supplier/{id}` #### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}` #### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }` - Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T51 }` - Returns: `200 { supplier: T49 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles` #### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string` - Params: `id: int, query: skus?: str`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T55|null }[] }` - Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles` #### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: int, body?: { skus?: str[] }` - Params: `id: int, body?: { skus?: str[] }`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T55|null }[] }` - Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
### Section: /thumb ### Section: /thumb
@@ -903,10 +890,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### List units — `GET /unit` #### List units — `GET /unit`
_none_ _none_
- Returns: `200 { units: T41[] }` - Returns: `200 { units: T39[] }`
#### Upsert unit — `PUT /unit` #### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }` - Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T41 }` - Returns: `200 { unit: T39 }`
### Section: /v1 ### Section: /v1
@@ -931,15 +918,18 @@ _none_
#### List virtual filesystem files — `GET /vfs` #### List virtual filesystem files — `GET /vfs`
_none_ _none_
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }` - Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
#### Respond to a frontend draft request — `POST /vfs/drafts/response` #### Restore system default applets — `POST /vfs/applets/restore`
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }` _none_
- Returns: `200 { success: bool }` - Returns: `200 { success: bool, restored: str[], count: int }`
#### Create directory in virtual filesystem — `POST /vfs/mkdir` #### Create directory in virtual filesystem — `POST /vfs/mkdir`
- Params: `body: { path: str }` - Params: `body: { path: str }`
- Returns: `200 { success: bool, dir: str }` - Returns: `200 { success: bool, dir: str }`
#### Rename or move a virtual filesystem file or directory — `POST /vfs/rename` #### Rename or move a virtual filesystem file or directory — `POST /vfs/rename`
- Params: `body: { oldPath: str, newPath: str }` - Params: `body: { oldPath: str, newPath: str }`
- Returns: `200 { success: bool, oldPath: str, newPath: str }` - Returns: `200 { success: bool, oldPath: str, newPath: str }`
#### Restore system default skills — `POST /vfs/skills/restore`
_none_
- Returns: `200 { success: bool, restored: str[], count: int }`
#### Upload files to virtual filesystem — `POST /vfs/upload` #### Upload files to virtual filesystem — `POST /vfs/upload`
- Params: `form: { files: str[], targetDir?: str }` - Params: `form: { files: str[], targetDir?: str }`
- Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }` - Returns: `200 { success: bool, uploaded: { name: str, size: int, mime: str }[] }`
@@ -960,10 +950,10 @@ _none_
#### List warehouses — `GET /warehouse` #### List warehouses — `GET /warehouse`
_none_ _none_
- Returns: `200 { warehouses: T56[] }` - Returns: `200 { warehouses: T54[] }`
#### Create warehouse — `POST /warehouse` #### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }` - Params: `body: { name: str, sort?: int }`
- Returns: `201 T54` - Returns: `201 T52`
#### Reorder warehouses — `PUT /warehouse/order` #### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }` - Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }` - Returns: `200 { warehouseIds: int[] }`
@@ -972,10 +962,10 @@ _none_
- Returns: `204 void` - Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}` #### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `200 T54` - Returns: `200 T52`
#### Update warehouse (partial) — `PATCH /warehouse/{id}` #### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T54` - Returns: `200 T52`
#### Update warehouse — `PUT /warehouse/{id}` #### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }` - Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T54` - Returns: `200 T52`

View File

@@ -29,20 +29,25 @@ export const article = {
}, },
/** /**
* GET /api/article/history * GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
* History of all articles with paging/sorting/filtering * @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number }
*/ */
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { // #region GET /api/article/history — getHistory
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow)); params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (endRow != null) params.set('endRow', String(endRow)); params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (offset != null) params.set('offset', String(offset)); if (search && String(search).trim()) params.set('search', String(search).trim());
if (limit != null) params.set('limit', String(limit)); if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString(); const qs = params.toString();
return http(`/api/article/history${qs ? `?${qs}` : ''}`); return http(`/api/article/history${qs ? `?${qs}` : ''}`);
}, },
// #endregion
/** /**
* GET /api/article/:sku/history * GET /api/article/:sku/history

View File

@@ -10,13 +10,35 @@ import { http, clientIdHeaders } from './http.js';
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? } * DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
*/ */
export const delivery = { export const delivery = {
/** GET /api/delivery → { deliveries: DeliverySummary[] } */ /**
* GET /api/delivery
* @param {{ offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number }} [opts]
* → { deliveries: DeliverySummary[], total?: number, unfilteredTotal?: number, offset?: number, limit?: number }
*/
// #region GET /api/delivery — list // #region GET /api/delivery — list
list() { list(opts) {
if (opts && typeof opts === 'object') {
const params = new URLSearchParams();
if (opts.offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(opts.offset) || 0))));
if (opts.limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(opts.limit) || 50))));
if (opts.search && String(opts.search).trim()) params.set('search', String(opts.search).trim());
if (opts.onlyUnbooked === true || opts.onlyUnbooked === 'true') params.set('onlyUnbooked', 'true');
if (opts.supplierId != null) params.set('supplierId', String(opts.supplierId));
if (opts.seekMonths != null && Number(opts.seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(opts.seekMonths))));
}
const qs = params.toString();
return http(`/api/delivery${qs ? `?${qs}` : ''}`);
}
return http('/api/delivery'); return http('/api/delivery');
}, },
// #endregion // #endregion
/** POST /api/delivery/rebuild-summaries → { ok: boolean, companyId: number } */
rebuildSummaries() {
return http('/api/delivery/rebuild-summaries', { method: 'POST' });
},
/** GET /api/delivery/:id → { delivery: Delivery } */ /** GET /api/delivery/:id → { delivery: Delivery } */
// #region GET /api/delivery/:id — get // #region GET /api/delivery/:id — get
get(id) { get(id) {
@@ -96,83 +118,4 @@ export const delivery = {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' }); return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
}, },
// #endregion // #endregion
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
*/
// #region POST /api/delivery/parse — parseSse
async *parseSse(file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
},
// #endregion
}; };

View File

@@ -2,16 +2,20 @@ import { http } from './http.js';
export const globalHistory = { export const globalHistory = {
/** /**
* GET /api/history * GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
* Global system/entity history with paging/sorting/filtering * Global system/entity history with chunk paging and search/seek filtering
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
* → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number }
*/ */
// #region GET /api/history — list // #region GET /api/history — list
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) { list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow)); params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (endRow != null) params.set('endRow', String(endRow)); params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (offset != null) params.set('offset', String(offset)); if (search && String(search).trim()) params.set('search', String(search).trim());
if (limit != null) params.set('limit', String(limit)); if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel)); if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel)); if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString(); const qs = params.toString();

View File

@@ -6,16 +6,6 @@ import { http } from './http.js';
* Input = { manifestarticle_id, transaction_type, amount, transaction_text } * Input = { manifestarticle_id, transaction_type, amount, transaction_text }
*/ */
export const ledger = { export const ledger = {
/**
* POST /api/ledger
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
* → 201 { ledger: LedgerEntry }
*/
// #region POST /api/ledger — add
add(body) {
return http('/api/ledger', { method: 'POST', body });
},
// #endregion
/** /**
* GET /api/ledger/manifestarticle/:manifestarticleId * GET /api/ledger/manifestarticle/:manifestarticleId
@@ -32,17 +22,15 @@ export const ledger = {
/** /**
* GET /api/ledger * GET /api/ledger
* Query: { manifestarticle_id?, sku?, transaction_type? } * Query: { manifestarticleId?, sku?, transactionType? }
* → 200 { ledger: LedgerEntry[] } * → 200 { ledger: LedgerEntry[] }
*/ */
// #region GET /api/ledger — list // #region GET /api/ledger — list
list(query) { list({ manifestarticleId, sku, transactionType } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id); if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId); if (sku != null) params.set('sku', String(sku));
if (query?.sku != null) params.set('sku', query.sku); if (transactionType != null) params.set('transaction_type', String(transactionType));
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
const qs = params.toString(); const qs = params.toString();
return http(`/api/ledger${qs ? `?${qs}` : ''}`); return http(`/api/ledger${qs ? `?${qs}` : ''}`);
}, },

View File

@@ -13,4 +13,24 @@ export const stock = {
return http('/api/stock/adjust', { method: 'POST', body }); return http('/api/stock/adjust', { method: 'POST', body });
}, },
// #endregion // #endregion
/**
* POST /api/stock/accept
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { deliveryId, warehouseId? }
*/
// #region POST /api/stock/accept — accept
accept(body) {
return http('/api/stock/accept', { method: 'POST', body });
},
// #endregion
/**
* POST /api/stock/ship
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { shipmentId, warehouseId? }
*/
// #region POST /api/stock/ship — ship
ship(body) {
return http('/api/stock/ship', { method: 'POST', body });
},
// #endregion
}; };

View File

@@ -144,41 +144,33 @@ export const vfs = {
}, },
// #endregion // #endregion
/** /**
* POST /api/vfs/drafts/response * POST /api/vfs/skills/restore
* Send response to virtual draft request (action: read or list). * Restores all system default .SKILL files into the company's VFS (overwrites existing files).
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body * Requires no request body. Emits vfs SSE event.
* → 200 { success: true, restored: string[], count: number }
*/ */
// #region POST /api/vfs/drafts/response — respondDraft // #region POST /api/vfs/skills/restore — restoreSkills
async respondDraft(body) { restoreSkills() {
try { return http('/api/vfs/skills/restore', {
const res = await fetch('/api/vfs/drafts/response', { method: 'POST',
});
},
// #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', 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 };
}
}, },
// #endregion // #endregion
}; };

View File

@@ -1,45 +1,26 @@
/** /**
* @file articleHistoryAutomat.js * @file articleHistoryAutomat.js
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for * @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
* infinite-scrolling article history via `react-virtual-aggridautomat`. * article audit logs and history entries with multi-filter caching and LRU eviction.
* *
* Upstream Triggers: * Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when * - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* status reaches `'authed'` with a `selectedCompanyId`. * `selectedCompanyId` changes.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes. * - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/ */
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat'; import { createChunkListAutomat } from './createChunkListAutomat';
import { article } from '../api'; import { article } from '../api';
import authAutomat from './authAutomat';
export const articleHistoryGridAutomat = createGridAutomat({ export const articleHistoryAutomat = createChunkListAutomat({
name: 'articleHistory', name: 'articleHistory',
blockSize: 50, chunkSize: 50,
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => { maxLruChunks: 50,
try { fetchPage: (params) => article.getHistory(params),
const res = await article.getHistory({
startRow,
endRow,
sortModel,
filterModel,
});
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
return { rows, lastRow };
} catch (err) {
console.warn('Failed to fetch article history (backend route pending):', err);
return { rows: [], lastRow: 0 };
}
},
}); });
// Reload on company switch or auth change // Alias for backwards compatibility
authAutomat.subscribe((auth) => { export const articleHistoryGridAutomat = articleHistoryAutomat;
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
articleHistoryGridAutomat.actions.reload();
}
});
export default articleHistoryGridAutomat; export default articleHistoryAutomat;

View File

@@ -87,6 +87,8 @@ const initialState = {
draftConversation: false, draftConversation: false,
/** Prefilled input text waiting to be consumed by the chat UI. */ /** Prefilled input text waiting to be consumed by the chat UI. */
pendingInput: null, pendingInput: null,
/** Staged text extraction snippet for data extraction pill. */
pendingExtraction: null,
conversationState: 'idle', conversationState: 'idle',
provider: 'openrouter', provider: 'openrouter',
model: DEFAULT_MODEL, model: DEFAULT_MODEL,
@@ -283,6 +285,17 @@ function closeConversationStream() {
} }
activeStreamConversationId = null; activeStreamConversationId = null;
} }
/**
* Checks if a tool call or stream tool event represents an interactive tool
* that awaits user intervention (ask_user).
* @param {object} item
* @returns {boolean}
*/
export function isInteractiveTool(item) {
if (!item) return false;
const name = item.name || item.function?.name;
return name === 'ask_user';
}
const actions = { const actions = {
/** /**
@@ -327,6 +340,55 @@ const actions = {
void chatAutomat.actions.loadConversations(); void chatAutomat.actions.loadConversations();
}, },
/**
* Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill.
* @param {{ text: string, label?: string, instruction?: string }} snippet
*/
insertExtractionSnippet({
text = '',
label = 'parsed invoice',
instruction = "use 'Process Incoming Product and Invoices' SKILL",
} = {}) {
const trimmed = String(text || '').trim();
if (!trimmed) return;
chatOpenedThisSession = true;
chatAutomat.setState({
isOpen: true,
sseEstablished: true,
pendingExtraction: {
text: trimmed,
label: label || 'parsed invoice',
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL",
},
});
ensureCompanyEventsConnected();
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
chatAutomat.actions.startDraft();
}
void chatAutomat.actions.loadConversations();
},
/**
* Open the chat drawer and append text / prompt to the chat composer.
* @param {string} prompt
*/
insertPrompt(prompt = '') {
const text = String(prompt || '').trim();
if (!text) return;
chatOpenedThisSession = true;
chatAutomat.setState((prev) => ({
isOpen: true,
sseEstablished: true,
pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${text}` : text,
}));
ensureCompanyEventsConnected();
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
chatAutomat.actions.startDraft();
}
void chatAutomat.actions.loadConversations();
},
/** /**
* Start an implicit new conversation with the first preset model. Nothing is * Start an implicit new conversation with the first preset model. Nothing is
* created on the server until the first message is sent. * created on the server until the first message is sent.
@@ -467,13 +529,18 @@ const actions = {
const messages = msgsRes?.messages || []; const messages = msgsRes?.messages || [];
const updatedConv = convRes?.conversation; const updatedConv = convRes?.conversation;
const hasPendingInteractive = Boolean(
selectPendingAskUser({ messages })
);
chatAutomat.setState((prev) => ({ chatAutomat.setState((prev) => ({
messages, messages,
loadingMessages: false, loadingMessages: false,
conversationState: updatedConv?.state || prev.conversationState, conversationState: hasPendingInteractive ? 'idle' : (updatedConv?.state || prev.conversationState),
streamPhase: hasPendingInteractive ? 'idle' : prev.streamPhase,
isSending: hasPendingInteractive ? false : prev.isSending,
conversations: updatedConv conversations: updatedConv
? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c)) ? prev.conversations.map((c) => (c.id === updatedConv.id ? (hasPendingInteractive ? { ...updatedConv, state: 'idle' } : updatedConv) : c))
: prev.conversations, : prev.conversations,
})); }));
} catch (err) { } catch (err) {
@@ -830,7 +897,7 @@ const actions = {
const updatedTool = { const updatedTool = {
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
state: 'green', state: 'green',
name: data.name || '', name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
arguments: data.arguments, arguments: data.arguments,
content: data.content !== undefined ? data.content : data.arguments || '', content: data.content !== undefined ? data.content : data.arguments || '',
result: data.result, result: data.result,
@@ -844,12 +911,19 @@ const actions = {
lastTurn.tools = tools; lastTurn.tools = tools;
turns[lastIdx] = lastTurn; turns[lastIdx] = lastTurn;
const hasAskUser = data.name === 'ask_user'; const isInteractive = isInteractiveTool(data) || isInteractiveTool(updatedTool);
const nextConversations = isInteractive
? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
)
: prev.conversations;
return { return {
activeTurns: turns, activeTurns: turns,
streamPhase: hasAskUser ? 'idle' : 'working', streamPhase: isInteractive ? 'idle' : 'working',
conversationState: hasAskUser ? 'idle' : prev.conversationState, conversationState: isInteractive ? 'idle' : prev.conversationState,
isSending: hasAskUser ? false : prev.isSending, isSending: isInteractive ? false : prev.isSending,
conversations: nextConversations,
}; };
}); });
}, },
@@ -1146,10 +1220,10 @@ const actions = {
/** /**
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message. * Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
* Body: { role: 'tool', tool_call_id, content, answer } * Body: { role: 'tool', tool_call_id, name, content, answer }
* @param {{ tool_call_id: string, answer: string }} params * @param {{ tool_call_id: string, answer?: string, name?: string, content?: any }} params
*/ */
async sendToolResponse({ tool_call_id, answer }) { async sendToolResponse({ tool_call_id, answer, name = 'ask_user', content = undefined }) {
const { selectedConversationId, isSending } = chatAutomat.state; const { selectedConversationId, isSending } = chatAutomat.state;
if (!selectedConversationId) { if (!selectedConversationId) {
throw new Error('Keine Unterhaltung ausgewählt'); throw new Error('Keine Unterhaltung ausgewählt');
@@ -1158,12 +1232,13 @@ const actions = {
return; return;
} }
const toolContent = content !== undefined ? content : answer;
const payload = { const payload = {
role: 'tool', role: 'tool',
tool_call_id, tool_call_id,
name: 'ask_user', name,
content: answer, content: toolContent,
answer, ...(answer !== undefined ? { answer } : {}),
}; };
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
@@ -1207,7 +1282,7 @@ const actions = {
if (m.role === 'assistant' && !m.turns) { if (m.role === 'assistant' && !m.turns) {
try { try {
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) { if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === name || tc.function?.name === 'ask_user')) {
foundExisting = true; foundExisting = true;
return { ...m, turns: sealedTurns }; return { ...m, turns: sealedTurns };
} }
@@ -1316,12 +1391,12 @@ const actions = {
} }
const isAssistant = message.role === 'assistant' || message.role === 'model'; const isAssistant = message.role === 'assistant' || message.role === 'model';
let hasAskUser = false; let hasInteractiveTool = false;
if (isAssistant) { if (isAssistant) {
try { try {
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message; const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) { if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
hasAskUser = true; hasInteractiveTool = true;
} }
if (Array.isArray(payload?.tool_calls)) { if (Array.isArray(payload?.tool_calls)) {
for (const tc of payload.tool_calls) { for (const tc of payload.tool_calls) {
@@ -1360,9 +1435,9 @@ const actions = {
} }
} }
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState; const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase; const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
const nextConversations = hasAskUser const nextConversations = hasInteractiveTool
? prev.conversations.map((c) => ? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
) )
@@ -1374,6 +1449,7 @@ const actions = {
conversationState: nextConversationState, conversationState: nextConversationState,
streamPhase: nextStreamPhase, streamPhase: nextStreamPhase,
conversations: nextConversations, conversations: nextConversations,
isSending: hasInteractiveTool ? false : prev.isSending,
}; };
}); });
}, },
@@ -1534,6 +1610,7 @@ export function selectPendingAskUser(state = chatAutomat.state) {
return null; return null;
} }
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state)); export const selectHasPendingAsk = (state = chatAutomat.state) =>
Boolean(selectPendingAskUser(state));
export default chatAutomat; export default chatAutomat;

View 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;
}

View File

@@ -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);
}
}

View File

@@ -1,45 +1,26 @@
/** /**
* @file globalHistoryAutomat.js * @file globalHistoryAutomat.js
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for * @description Double-buffered chunk list automat (`globalHistoryAutomat`) for
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`. * global system and change history logs with multi-filter caching and LRU eviction.
* *
* Upstream Triggers: * Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when * - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* status reaches `'authed'` with a `selectedCompanyId`. * `selectedCompanyId` changes.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes. * - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/ */
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat'; import { createChunkListAutomat } from './createChunkListAutomat';
import { globalHistory } from '../api'; import { globalHistory } from '../api';
import authAutomat from './authAutomat';
export const globalHistoryGridAutomat = createGridAutomat({ export const globalHistoryAutomat = createChunkListAutomat({
name: 'globalHistory', name: 'globalHistory',
blockSize: 50, chunkSize: 50,
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => { maxLruChunks: 50,
try { fetchPage: (params) => globalHistory.list(params),
const res = await globalHistory.list({
startRow,
endRow,
sortModel,
filterModel,
});
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
return { rows, lastRow };
} catch (err) {
console.warn('Failed to fetch global history (backend route pending):', err);
return { rows: [], lastRow: 0 };
}
},
}); });
// Reload on company switch or auth change // Alias for backwards compatibility
authAutomat.subscribe((auth) => { export const globalHistoryGridAutomat = globalHistoryAutomat;
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
globalHistoryGridAutomat.actions.reload();
}
});
export default globalHistoryGridAutomat; export default globalHistoryAutomat;

View File

@@ -6,7 +6,6 @@
export * from './deliveryEditorAutomat'; export * from './deliveryEditorAutomat';
export * from './manifestDraftAutomat'; export * from './manifestDraftAutomat';
export * from './draftSync';
export { default as authAutomat } from './authAutomat'; export { default as authAutomat } from './authAutomat';
export * from './authAutomat'; export * from './authAutomat';
@@ -176,7 +175,7 @@ export {
} from './categoryViewAutomat'; } from './categoryViewAutomat';
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat'; export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat'; export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as chatAutomat } from './chatAutomat'; export { default as chatAutomat } from './chatAutomat';
export { export {

View File

@@ -35,7 +35,6 @@ import supplierAutomat from './supplierAutomat';
import orderAutomat from './orderAutomat'; import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat'; import quoteAutomat from './quoteAutomat';
import shipmentAutomat from './shipmentAutomat'; import shipmentAutomat from './shipmentAutomat';
import { handleDraftRequest, handleDraftEvent } from './draftSync';
/** /**
@@ -265,23 +264,8 @@ function connect() {
}); });
}); });
addTrackedListener(es, 'draft:request', (event) => {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
void handleDraftRequest(payload);
} catch (err) {
console.error('[sse] draft:request handling failed', err);
}
});
addTrackedListener(es, 'draft', (event) => {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
handleDraftEvent(payload);
} catch (err) {
console.error('[sse] draft event handling failed', err);
}
});
addTrackedListener(es, 'open', () => { addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' }); sseAutomat.setState({ status: 'connected' });
@@ -290,9 +274,9 @@ function connect() {
addTrackedListener(es, 'message', () => {}); addTrackedListener(es, 'message', () => {});
// EventSource reconnects on its own; only surface the state. // EventSource reconnects on its own; only surface the state.
es.onerror = () => { es.onerror = (e) => {
console.error('[sse] event stream error'); console.error('[sse] event stream error', e);
recordEvent({ type: 'error', data: '', lastEventId: '' }); recordEvent(e || { type: 'error', data: '', lastEventId: '' });
sseAutomat.setState({ status: 'error' }); sseAutomat.setState({ status: 'error' });
}; };

View File

@@ -76,10 +76,26 @@ export default class TextInput extends Component {
? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false) ? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false)
: (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly); : (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly);
const { inputProps: nestedFromInputProps, ...cleanInputProps } = InputProps || {};
const { inputProps: nestedFromSlotInput, ...cleanSlotInput } = slotProps?.input || {};
const rawResolvedInputProps = (InputProps || slotProps?.input) ? {
...cleanSlotInput,
...cleanInputProps,
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
} : undefined;
const htmlInput = isSelect const htmlInput = isSelect
? { ? {
readOnly: effectiveReadOnly, readOnly: effectiveReadOnly,
...slotProps?.htmlInput, ...slotProps?.htmlInput,
...nestedFromSlotInput,
...nestedFromInputProps,
...inputProps, ...inputProps,
} }
: { : {
@@ -94,20 +110,12 @@ export default class TextInput extends Component {
'data-form-type': 'other', 'data-form-type': 'other',
readOnly: effectiveReadOnly, readOnly: effectiveReadOnly,
...slotProps?.htmlInput, ...slotProps?.htmlInput,
...nestedFromSlotInput,
...nestedFromInputProps,
...inputProps, ...inputProps,
}; };
const resolvedInputProps = (InputProps || slotProps?.input) ? { const resolvedInputProps = rawResolvedInputProps;
...slotProps?.input,
...InputProps,
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
} : undefined;
const { input: _unusedInput, ...restSlotProps } = (slotProps || {}); const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel); const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);

View File

@@ -8,8 +8,14 @@ import React, { PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded'; import PictureAsPdfRoundedIcon from '@mui/icons-material/PictureAsPdfRounded';
import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded'; import TableChartRoundedIcon from '@mui/icons-material/TableChartRounded';
import CodeRoundedIcon from '@mui/icons-material/CodeRounded'; import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
@@ -202,6 +208,218 @@ export function parseAttachmentTag(attrString) {
export { stripUserEnv }; export { stripUserEnv };
/**
* Extract all `[EXTRACTION ...]` blocks (or legacy model instruction code blocks)
* and return structured extraction objects along with clean non-invoice text.
* @param {string} rawText
* @returns {{ extractions: Array<{ label: string, text: string }>, cleanText: string }}
*/
export function parseExtractionsFromText(rawText) {
if (typeof rawText !== 'string' || !rawText) {
return { extractions: [], cleanText: '' };
}
const extractions = [];
let cleanText = rawText;
// 1. Explicit [EXTRACTION label="..."]...[/EXTRACTION] or <extraction label="...">...</extraction>
const tagRegex = /(?:\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>)([\s\S]*?)(?:\[\/EXTRACTION\]|<\/extraction>)/gi;
let match;
while ((match = tagRegex.exec(rawText)) !== null) {
const label = match[1] || match[2] || 'parsed invoice';
let text = match[3] || '';
text = text.replace(/^```[a-z]*\s*\n?|\s*```$/gi, '').trim();
extractions.push({ label, text });
}
if (extractions.length > 0) {
cleanText = cleanText.replace(tagRegex, '').trim();
return { extractions, cleanText };
}
// 2. Legacy / fallback: message contains "use 'Process Incoming Product and Invoices' SKILL" + code block
const hasInvoiceSkill = cleanText.includes("Process Incoming Product and Invoices");
if (hasInvoiceSkill) {
const codeBlockRegex = /```[a-z]*\s*\n([\s\S]*?)```/g;
let cbMatch;
while ((cbMatch = codeBlockRegex.exec(cleanText)) !== null) {
const code = (cbMatch[1] || '').trim();
if (code.length > 20) {
extractions.push({ label: 'parsed invoice', text: code });
}
}
if (extractions.length > 0) {
cleanText = cleanText.replace(codeBlockRegex, '').trim();
return { extractions, cleanText };
}
}
// 3. Standalone code block (e.g. ```\n...invoice...```)
const soleCodeBlockMatch = cleanText.match(/^\s*```[a-z]*\s*\n([\s\S]{40,})```\s*$/);
if (soleCodeBlockMatch) {
const code = soleCodeBlockMatch[1].trim();
extractions.push({ label: 'parsed invoice', text: code });
cleanText = '';
return { extractions, cleanText };
}
return { extractions: [], cleanText };
}
/**
* Clickable pill representing an extracted invoice/data snippet inside a message bubble.
* Clicking opens a modal preview to inspect and copy the full text.
*/
export class ExtractionPill extends PureComponent {
state = {
openPreview: false,
};
handleClick = (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
this.setState({ openPreview: true });
};
handleClose = () => {
this.setState({ openPreview: false });
};
handleCopy = () => {
const { text } = this.props;
if (text && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text);
enqueueSnackbar('Extrahierter Text kopiert', { variant: 'success' });
}
};
render() {
const { label = 'parsed invoice', text = '', isUser = false } = this.props;
const { openPreview } = this.state;
return (
<>
<Box
component="button"
type="button"
onClick={this.handleClick}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
my: 0.35,
mx: 0.2,
pl: 1.1,
pr: 1,
py: 0.45,
borderRadius: 2,
border: '1px solid',
borderColor: isUser ? 'rgba(255, 255, 255, 0.4)' : 'divider',
bgcolor: isUser
? 'rgba(255, 255, 255, 0.2)'
: (theme) => (theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)'),
color: isUser ? '#fff' : 'text.primary',
cursor: 'pointer',
verticalAlign: 'middle',
maxWidth: '100%',
outline: 'none',
fontFamily: 'inherit',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: isUser ? 'rgba(255, 255, 255, 0.3)' : 'action.hover',
borderColor: isUser ? '#fff' : 'primary.main',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
},
}}
>
<DescriptionRoundedIcon sx={{ fontSize: 16, color: isUser ? '#fff' : 'primary.main', flexShrink: 0 }} />
<Typography
component="span"
sx={{
fontSize: '0.8rem',
fontWeight: 600,
color: 'inherit',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1.25,
}}
>
{label || 'parsed invoice'}
</Typography>
</Box>
<Dialog
open={openPreview}
onClose={this.handleClose}
maxWidth="md"
fullWidth
sx={(theme) => ({
zIndex: theme.zIndex.modal + 50,
})}
slotProps={{
paper: {
sx: {
borderRadius: 2.5,
bgcolor: 'background.paper',
backgroundImage: 'none',
},
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionRoundedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
{label || 'parsed invoice'}
</Typography>
</Box>
<IconButton size="small" onClick={this.handleClose} aria-label="Schließen">
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1.5,
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
maxHeight: '60vh',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
{text}
</Box>
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Button
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
onClick={this.handleCopy}
color="primary"
variant="outlined"
size="small"
>
Kopieren
</Button>
<Button onClick={this.handleClose} color="inherit" size="small">
Schließen
</Button>
</DialogActions>
</Dialog>
</>
);
}
}
/** /**
* Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata. * Extract all `[ATTACHMENT ...]` markers and return clean text plus attachment metadata.
* Also strips <USERENV> tags so route context is never rendered in chat views. * Also strips <USERENV> tags so route context is never rendered in chat views.

View File

@@ -15,11 +15,17 @@ import SendRoundedIcon from '@mui/icons-material/SendRounded';
import AddRoundedIcon from '@mui/icons-material/AddRounded'; import AddRoundedIcon from '@mui/icons-material/AddRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded'; import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded'; import AttachFileRoundedIcon from '@mui/icons-material/AttachFileRounded';
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
import TuneRoundedIcon from '@mui/icons-material/TuneRounded'; import TuneRoundedIcon from '@mui/icons-material/TuneRounded';
import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded'; import ElderlyRoundedIcon from '@mui/icons-material/ElderlyRounded';
import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded'; import DirectionsWalkRoundedIcon from '@mui/icons-material/DirectionsWalkRounded';
@@ -31,7 +37,9 @@ import LightModeRoundedIcon from '@mui/icons-material/LightModeRounded';
import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded'; import CycloneRoundedIcon from '@mui/icons-material/CycloneRounded';
import { setting as settingApi } from '../../api'; import { setting as settingApi } from '../../api';
import chatAutomat, { selectPendingAskUser } from '../../automats/chatAutomat'; import chatAutomat, {
selectPendingAskUser,
} from '../../automats/chatAutomat';
import userSettingsAutomat from '../../automats/userSettingsAutomat'; import userSettingsAutomat from '../../automats/userSettingsAutomat';
import vfsAutomat from '../../automats/vfsAutomat'; import vfsAutomat from '../../automats/vfsAutomat';
import { VfsEditorPanel, VfsFileIcon } from '../vfs'; import { VfsEditorPanel, VfsFileIcon } from '../vfs';
@@ -46,6 +54,7 @@ import {
} from '../../colors'; } from '../../colors';
const DEFAULT_DRAWER_WIDTH = 460; const DEFAULT_DRAWER_WIDTH = 460;
const EXTRACTION_MODEL_INSTRUCTION = "use 'Process Incoming Product and Invoices' SKILL";
/** Sort by updated_at desc; subagents (started_by set) directly below their parent. */ /** Sort by updated_at desc; subagents (started_by set) directly below their parent. */
function sortConversations(list) { function sortConversations(list) {
@@ -81,8 +90,36 @@ function formatUpdated(value) {
} }
function conversationTitle(c) { function conversationTitle(c) {
const msg = (c.first_message || '').replace(/\s+/g, ' ').trim(); let msg = (c.first_message || '').trim();
return msg || '…'; if (!msg) return '…';
const extMatch = msg.match(/\[EXTRACTION(?:\s+label="([^"]*)")?\]|<extraction(?:\s+label="([^"]*)")?>/i);
const hasInvoiceSkill = msg.includes("Process Incoming Product and Invoices");
let stripped = msg
.replace(/\[EXTRACTION(?:\s+label="[^"]*")?\][\s\S]*?\[\/EXTRACTION\]/gi, '')
.replace(/<extraction(?:\s+label="[^"]*")?>[\s\S]*?<\/extraction>/gi, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/use 'Process Incoming Product and Invoices' SKILL.*/gi, '')
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (extMatch || hasInvoiceSkill) {
const label = extMatch?.[1] || extMatch?.[2] || 'parsed invoice';
if (stripped) {
return `${label}: ${stripped}`;
}
return label;
}
let clean = msg
.replace(/<USERENV>[\s\S]*?<\/USERENV>/gi, '')
.replace(/```[\s\S]*?```/g, '')
.replace(/\s+/g, ' ')
.trim();
return clean || msg.slice(0, 40) || '…';
} }
const MIN_DRAWER_WIDTH = 320; const MIN_DRAWER_WIDTH = 320;
@@ -115,6 +152,17 @@ export default class ChatDrawer extends PureComponent {
isInputFocused: false, isInputFocused: false,
stagedAttachments: [], stagedAttachments: [],
stagedReferences: [], stagedReferences: [],
stagedExtractions: s.pendingExtraction
? [
{
id: `ext-${Date.now()}`,
text: s.pendingExtraction.text,
label: s.pendingExtraction.label || 'parsed invoice',
instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION,
},
]
: [],
previewExtraction: null,
isProcessingFiles: false, isProcessingFiles: false,
isDraggingFiles: false, isDraggingFiles: false,
attachmentError: null, attachmentError: null,
@@ -178,6 +226,13 @@ export default class ChatDrawer extends PureComponent {
} }
}); });
} }
if (chatAutomat.state.pendingExtraction != null) {
queueMicrotask(() => {
if (chatAutomat.state.pendingExtraction != null) {
chatAutomat.setState({ pendingExtraction: null });
}
});
}
this.unsub = chatAutomat.subscribe(this, (state) => { this.unsub = chatAutomat.subscribe(this, (state) => {
const wasOpen = this.state.isOpen; const wasOpen = this.state.isOpen;
const willOpen = state.isOpen; const willOpen = state.isOpen;
@@ -199,9 +254,9 @@ export default class ChatDrawer extends PureComponent {
activeTurns: state.activeTurns || [], activeTurns: state.activeTurns || [],
}; };
if (state.pendingInput != null) { if (state.pendingInput != null) {
patch.inputText = state.pendingInput; patch.inputText = this.state.inputText
patch.stagedAttachments = []; ? `${this.state.inputText}\n\n${state.pendingInput}`
patch.stagedReferences = []; : state.pendingInput;
queueMicrotask(() => { queueMicrotask(() => {
if (chatAutomat.state.pendingInput != null) { if (chatAutomat.state.pendingInput != null) {
chatAutomat.setState({ pendingInput: null }); chatAutomat.setState({ pendingInput: null });
@@ -211,6 +266,22 @@ export default class ChatDrawer extends PureComponent {
} else if (!wasOpen && willOpen) { } else if (!wasOpen && willOpen) {
this.focusInput(0); this.focusInput(0);
} }
if (state.pendingExtraction != null) {
const ext = state.pendingExtraction;
const newExt = {
id: `ext-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
text: ext.text,
label: ext.label || 'parsed invoice',
instruction: ext.instruction || EXTRACTION_MODEL_INSTRUCTION,
};
patch.stagedExtractions = [...(this.state.stagedExtractions || []), newExt];
queueMicrotask(() => {
if (chatAutomat.state.pendingExtraction != null) {
chatAutomat.setState({ pendingExtraction: null });
}
});
this.focusInput(0);
}
return patch; return patch;
}); });
@@ -309,6 +380,29 @@ export default class ChatDrawer extends PureComponent {
this.focusInput(50); this.focusInput(50);
}; };
handleRemoveExtraction = (id) => {
this.setState((prev) => ({
stagedExtractions: (prev.stagedExtractions || []).filter((e) => e.id !== id),
}));
this.focusInput(50);
};
handlePreviewExtraction = (ext) => {
console.log('[ChatDrawer] Clicked extraction pill:', ext);
this.setState({ previewExtraction: ext });
};
handleCloseExtractionPreview = () => {
this.setState({ previewExtraction: null });
};
handleCopyExtractionText = () => {
const text = this.state.previewExtraction?.text;
if (text && navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(text);
}
};
handleVfsResizerStart = (e) => { handleVfsResizerStart = (e) => {
if (e.button !== undefined && e.button !== 0) return; if (e.button !== undefined && e.button !== 0) return;
e.preventDefault(); e.preventDefault();
@@ -483,9 +577,10 @@ export default class ChatDrawer extends PureComponent {
const rawText = this.state.inputText.trim(); const rawText = this.state.inputText.trim();
const attachments = this.state.stagedAttachments || []; const attachments = this.state.stagedAttachments || [];
const references = this.state.stagedReferences || []; const references = this.state.stagedReferences || [];
const extractions = this.state.stagedExtractions || [];
if ( if (
(!rawText && attachments.length === 0 && references.length === 0) || (!rawText && attachments.length === 0 && references.length === 0 && extractions.length === 0) ||
this.state.conversationState === 'busy' || this.state.conversationState === 'busy' ||
this.state.isSending this.state.isSending
) { ) {
@@ -493,6 +588,28 @@ export default class ChatDrawer extends PureComponent {
} }
let text = rawText; let text = rawText;
if (extractions.length > 0) {
const instructions = extractions
.map((e) => e.instruction)
.filter(Boolean);
const uniqueInstructions = Array.from(new Set(instructions));
const instructionText =
uniqueInstructions.join('\n\n') || EXTRACTION_MODEL_INSTRUCTION;
const extBlocks = extractions
.map(
(ext) =>
`[EXTRACTION label="${ext.label || 'parsed invoice'}"]\n\`\`\`\n${ext.text.trim()}\n\`\`\`\n[/EXTRACTION]`
)
.join('\n\n');
if (text) {
text = `${instructionText}\n\n${text}\n\n${extBlocks}`;
} else {
text = `${instructionText}\n\n${extBlocks}`;
}
}
if (references.length > 0) { if (references.length > 0) {
const refMarkdown = references const refMarkdown = references
.map((ref) => { .map((ref) => {
@@ -522,6 +639,7 @@ export default class ChatDrawer extends PureComponent {
inputText: '', inputText: '',
stagedAttachments: [], stagedAttachments: [],
stagedReferences: [], stagedReferences: [],
stagedExtractions: [],
attachmentError: null, attachmentError: null,
}); });
@@ -721,6 +839,7 @@ export default class ChatDrawer extends PureComponent {
getPendingAskUser = () => selectPendingAskUser(this.state); getPendingAskUser = () => selectPendingAskUser(this.state);
renderHeader() { renderHeader() {
const { const {
conversations, conversations,
@@ -1265,6 +1384,7 @@ export default class ChatDrawer extends PureComponent {
sendError, sendError,
stagedAttachments, stagedAttachments,
stagedReferences, stagedReferences,
stagedExtractions = [],
isProcessingFiles, isProcessingFiles,
isDraggingFiles, isDraggingFiles,
attachmentError, attachmentError,
@@ -1273,11 +1393,16 @@ export default class ChatDrawer extends PureComponent {
const pendingAsk = this.getPendingAskUser(); const pendingAsk = this.getPendingAskUser();
const isBusy = conversationState === 'busy'; const isBusy = conversationState === 'busy';
const isWorking = isBusy || isSending; const isWorking = isBusy || isSending;
const canChat = (Boolean(selectedConversationId) || draftConversation) && !pendingAsk; const canChat =
(Boolean(selectedConversationId) || draftConversation) &&
!pendingAsk;
const canSend = const canSend =
!isWorking && !isWorking &&
canChat && canChat &&
(Boolean(inputText.trim()) || stagedAttachments.length > 0 || stagedReferences.length > 0); (Boolean(inputText.trim()) ||
stagedAttachments.length > 0 ||
stagedReferences.length > 0 ||
stagedExtractions.length > 0);
return ( return (
<Box <Box
@@ -1449,7 +1574,7 @@ export default class ChatDrawer extends PureComponent {
boxSizing: 'border-box', boxSizing: 'border-box',
}} }}
> >
{stagedReferences.length > 0 && ( {(stagedReferences.length > 0 || (stagedExtractions && stagedExtractions.length > 0)) && (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -1546,6 +1671,77 @@ export default class ChatDrawer extends PureComponent {
</IconButton> </IconButton>
</Box> </Box>
))} ))}
{stagedExtractions && stagedExtractions.map((ext) => (
<Box
key={ext.id}
onClick={(e) => {
e.stopPropagation();
this.handlePreviewExtraction(ext);
}}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.6,
py: 0.2,
pl: 0.7,
pr: 0.4,
borderRadius: 1.5,
bgcolor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.05)',
border: '1px solid',
borderColor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.1)',
color: 'text.primary',
fontSize: '0.75rem',
lineHeight: 1.2,
userSelect: 'none',
cursor: 'pointer',
transition: 'all 0.15s ease',
'&:hover': {
bgcolor: (theme) =>
theme.palette.mode === 'dark'
? 'rgba(255, 255, 255, 0.14)'
: 'rgba(0, 0, 0, 0.09)',
borderColor: 'primary.light',
},
}}
>
<DescriptionOutlinedIcon sx={{ fontSize: 14, color: 'primary.main' }} />
<Typography
component="span"
sx={{
fontSize: '0.74rem',
fontWeight: 600,
letterSpacing: '-0.2px',
}}
>
{ext.label || 'parsed invoice'}
</Typography>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
this.handleRemoveExtraction(ext.id);
}}
aria-label="Entfernen"
sx={{
p: 0.2,
ml: 0.1,
color: 'text.secondary',
'&:hover': {
color: 'error.main',
bgcolor: 'action.hover',
},
}}
>
<CloseRoundedIcon sx={{ fontSize: 13 }} />
</IconButton>
</Box>
))}
</Box> </Box>
)} )}
@@ -1811,6 +2007,74 @@ export default class ChatDrawer extends PureComponent {
{this.renderFooter()} {this.renderFooter()}
</Box> </Box>
</Drawer> </Drawer>
{/* Extraction Preview Dialog */}
<Dialog
open={Boolean(this.state.previewExtraction)}
onClose={this.handleCloseExtractionPreview}
maxWidth="md"
fullWidth
sx={(theme) => ({
zIndex: theme.zIndex.modal + 50,
})}
slotProps={{
paper: {
sx: {
borderRadius: 2.5,
bgcolor: 'background.paper',
backgroundImage: 'none',
},
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<DescriptionOutlinedIcon sx={{ color: 'primary.main', fontSize: 22 }} />
<Typography variant="h6" component="span" sx={{ fontSize: '1.05rem', fontWeight: 600 }}>
{this.state.previewExtraction?.label || 'parsed invoice'}
</Typography>
</Box>
<IconButton size="small" onClick={this.handleCloseExtractionPreview} aria-label="Schließen">
<CloseRoundedIcon sx={{ fontSize: 20 }} />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2, bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.2)' : 'rgba(0,0,0,0.02)' }}>
<Box
component="pre"
sx={{
m: 0,
p: 1.5,
borderRadius: 1.5,
fontFamily: 'monospace',
fontSize: '0.8rem',
lineHeight: 1.5,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overflow: 'auto',
maxHeight: '60vh',
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
{this.state.previewExtraction?.text || ''}
</Box>
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Button
startIcon={<ContentCopyRoundedIcon sx={{ fontSize: 18 }} />}
onClick={this.handleCopyExtractionText}
color="primary"
variant="outlined"
size="small"
>
Kopieren
</Button>
<Button onClick={this.handleCloseExtractionPreview} color="inherit" size="small">
Schließen
</Button>
</DialogActions>
</Dialog>
</> </>
); );
} }

View File

@@ -27,7 +27,13 @@ import CalculateIcon from '@mui/icons-material/Calculate';
import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner';
import DirectionsIcon from '@mui/icons-material/Directions'; import DirectionsIcon from '@mui/icons-material/Directions';
import HealingMarkdown from './HealingMarkdown'; import HealingMarkdown from './HealingMarkdown';
import { VfsAttachmentLink, parseAttachmentsFromText, stripUserEnv } from './ChatAttachmentItems'; import {
VfsAttachmentLink,
ExtractionPill,
parseAttachmentsFromText,
parseExtractionsFromText,
stripUserEnv,
} from './ChatAttachmentItems';
import { import {
CHROMA, CHROMA,
SHADOW_BUBBLE_ASSISTANT, SHADOW_BUBBLE_ASSISTANT,
@@ -878,7 +884,8 @@ function MessageRow({ msg, showTime = true }) {
} catch { } } catch { }
} }
const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textContent); const { extractions, cleanText: textAfterExtractions } = parseExtractionsFromText(textContent);
const { attachments: vfsAttachments, cleanText: rawCleanText } = parseAttachmentsFromText(textAfterExtractions);
const cleanText = stripUserEnv(rawCleanText); const cleanText = stripUserEnv(rawCleanText);
if (msg.role === 'system') { if (msg.role === 'system') {
@@ -957,6 +964,26 @@ function MessageRow({ msg, showTime = true }) {
lineHeight: 1.5, lineHeight: 1.5,
})} })}
> >
{extractions.length > 0 && (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.75,
mb: cleanText || vfsAttachments.length > 0 ? 0.75 : 0,
}}
>
{extractions.map((ext, idx) => (
<ExtractionPill
key={ext.id || idx}
label={ext.label || 'parsed invoice'}
text={ext.text}
isUser={isUser}
/>
))}
</Box>
)}
{vfsAttachments.length > 0 && ( {vfsAttachments.length > 0 && (
<Box <Box
sx={{ sx={{

View File

@@ -184,15 +184,46 @@ function getMimeTypeFallback(category, filename) {
} }
/** /**
* Trigger download of an attachment in the browser. * Format bytes into human-readable kb size for data extraction pills (e.g. "2.4 kb" or "12 kb").
* @param {object} attachment * @param {number} bytes
* @returns {string}
*/ */
export function downloadAttachment(attachment) { export function formatExtractionSize(bytes) {
if (!attachment) return; const b = Number(bytes) || 0;
const link = document.createElement('a'); const kb = b / 1024;
link.href = attachment.dataUrl || (attachment.url ? attachment.url : ''); if (kb < 1) {
link.download = attachment.name || 'anhang'; return `${Math.max(0.1, kb).toFixed(1)} kb`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} }
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 };
}

View File

@@ -1,16 +1,15 @@
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined'; import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync'; import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles'; import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import SkeleHashImage from '../settings/SkeleHashImage'; import SkeleHashImage from '../settings/SkeleHashImage';
import { generateTextThumbnail } from '../../utils/textThumbnail'; import { generateTextThumbnail } from '../../utils/textThumbnail';
import { notifyErrorMessage } from '../../utils/notify'; import chatAutomat from '../../automats/chatAutomat';
import { import {
SCRIM_ACTION, SCRIM_ACTION,
SCRIM_ATTACHMENT, SCRIM_ATTACHMENT,
@@ -61,18 +60,64 @@ export class Attachment extends PureComponent {
handleRescan = (e) => { handleRescan = (e) => {
e?.stopPropagation?.(); e?.stopPropagation?.();
if (this.props.hasArticles) {
notifyErrorMessage(
'Neu analysieren nicht möglich, solange Artikel vorhanden sind. Bitte entfernen Sie zuerst alle Zeilen.',
);
return;
}
const { attachment, onRescan } = this.props; const { attachment, onRescan } = this.props;
if (attachment && onRescan) { if (attachment && onRescan) {
onRescan(attachment); onRescan(attachment);
} }
}; };
handleMentionInChat = async (e) => {
e?.stopPropagation?.();
const { attachment, onMentionInChat } = this.props;
console.log('[Attachment] Mention in chat clicked', attachment);
if (!attachment) return;
if (onMentionInChat) {
onMentionInChat(attachment);
return;
}
let textContent = attachment.text || '';
if (!textContent && (attachment.file instanceof Blob || attachment.file instanceof File)) {
if (attachment.file.size > 0) {
try {
textContent = await attachment.file.text();
} catch (err) {
console.error('Fehler beim Lesen des Texts:', err);
}
} else {
const docId = this.props.docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
const kind = this.props.kind || 'delivery';
const prefix = kind === 'delivery' ? 'delivery' : kind === 'quote' ? 'quote' : 'order';
if (docId != null && fileId != null) {
try {
const res = await fetch(`/api/${prefix}/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`, {
credentials: 'include',
});
if (res.ok) {
textContent = await res.text();
}
} catch (fetchErr) {
console.error('Fehler beim Laden der Datei:', fetchErr);
}
}
}
}
const trimmed = (textContent || '').trim();
if (!trimmed) {
console.warn('[Attachment] No text found to mention in chat', attachment);
return;
}
chatAutomat.actions.insertExtractionSnippet({
text: trimmed,
label: 'parsed invoice',
instruction: "use 'Process Incoming Product and Invoices' SKILL",
});
};
handleDelete = (e) => { handleDelete = (e) => {
e?.stopPropagation?.(); e?.stopPropagation?.();
const { attachment, onDelete } = this.props; const { attachment, onDelete } = this.props;
@@ -237,51 +282,51 @@ export class Attachment extends PureComponent {
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{kind === 'delivery' && Boolean(onRescan) && (isPdf || isText) ? ( {kind === 'delivery' && Boolean(onRescan) && isPdf ? (
<Tooltip
title={
hasArticles
? 'Neu analysieren nicht möglich, solange Artikel vorhanden sind'
: 'Neu analysieren'
}
>
<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 <IconButton
size="small" size="small"
disabled={disabled || hasArticles} disabled={disabled}
onClick={this.handleRescan} onClick={this.handleRescan}
aria-label="Zu Text"
sx={{ sx={{
bgcolor: SCRIM_ACTION, bgcolor: SCRIM_ACTION,
color: hasArticles ? VEIL_DISABLED : WHITE, color: WHITE,
p: 0.5, p: 0.5,
backdropFilter: 'blur(2px)', backdropFilter: 'blur(2px)',
cursor: hasArticles ? 'not-allowed' : 'pointer',
'&:hover': { '&:hover': {
bgcolor: hasArticles ? SCRIM_ACTION : SCRIM_HEAVY, bgcolor: SCRIM_HEAVY,
}, },
}} }}
> >
<SyncIcon sx={{ fontSize: 16 }} /> <TextSnippetOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</span>
</Tooltip>
) : null} ) : null}
{isText ? (
<IconButton
size="small"
disabled={disabled}
onClick={this.handleMentionInChat}
aria-label="Im Chat erwähnen"
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': {
bgcolor: SCRIM_HEAVY,
},
}}
>
<ChatBubbleOutlineRoundedIcon sx={{ fontSize: 16 }} />
</IconButton>
) : null}
<IconButton <IconButton
size="small" size="small"
disabled={disabled} disabled={disabled}
onClick={this.handleDelete} onClick={this.handleDelete}
aria-label="Löschen"
sx={{ sx={{
bgcolor: SCRIM_ACTION, bgcolor: SCRIM_ACTION,
color: WHITE, color: WHITE,

View File

@@ -6,7 +6,7 @@ import Attachment from './Attachment';
export class AttachmentList extends PureComponent { export class AttachmentList extends PureComponent {
render() { render() {
const { attachments, onRescan, onDelete, onClick, disabled, hasArticles, kind, docId } = this.props; const { attachments, onRescan, onDelete, onClick, onMentionInChat, disabled, hasArticles, kind, docId } = this.props;
if (!Array.isArray(attachments) || attachments.length === 0) { if (!Array.isArray(attachments) || attachments.length === 0) {
return null; return null;
} }
@@ -31,6 +31,7 @@ export class AttachmentList extends PureComponent {
onRescan={onRescan} onRescan={onRescan}
onDelete={onDelete} onDelete={onDelete}
onClick={onClick} onClick={onClick}
onMentionInChat={onMentionInChat}
disabled={disabled} disabled={disabled}
hasArticles={hasArticles} hasArticles={hasArticles}
/> />

View File

@@ -47,6 +47,7 @@ export default class BookDeliveryDialog extends PureComponent {
let initialAmounts = {}; let initialAmounts = {};
if (Array.isArray(props.summary?.lines)) { if (Array.isArray(props.summary?.lines)) {
for (const line of props.summary.lines) { for (const line of props.summary.lines) {
if (!line.trackStock) continue;
if (props.prefillLineId != null) { if (props.prefillLineId != null) {
initialAmounts[line.id] = initialAmounts[line.id] =
line.id === props.prefillLineId line.id === props.prefillLineId
@@ -174,6 +175,7 @@ export default class BookDeliveryDialog extends PureComponent {
const amounts = {}; const amounts = {};
if (Array.isArray(summary?.lines)) { if (Array.isArray(summary?.lines)) {
for (const line of summary.lines) { for (const line of summary.lines) {
if (!line.trackStock) continue;
if (prefillLineId != null) { if (prefillLineId != null) {
amounts[line.id] = amounts[line.id] =
line.id === prefillLineId line.id === prefillLineId
@@ -208,6 +210,7 @@ export default class BookDeliveryDialog extends PureComponent {
if (!summary?.lines) return; if (!summary?.lines) return;
const amounts = {}; const amounts = {};
for (const line of summary.lines) { for (const line of summary.lines) {
if (!line.trackStock) continue;
amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0; amounts[line.id] = line.remainingAmount > 0 ? line.remainingAmount : 0;
} }
this.setState({ amountsToBook: amounts }); this.setState({ amountsToBook: amounts });
@@ -218,6 +221,7 @@ export default class BookDeliveryDialog extends PureComponent {
if (!summary?.lines) return; if (!summary?.lines) return;
const amounts = {}; const amounts = {};
for (const line of summary.lines) { for (const line of summary.lines) {
if (!line.trackStock) continue;
amounts[line.id] = 0; amounts[line.id] = 0;
} }
this.setState({ amountsToBook: amounts }); this.setState({ amountsToBook: amounts });
@@ -229,6 +233,7 @@ export default class BookDeliveryDialog extends PureComponent {
if (!summary?.lines) return; if (!summary?.lines) return;
const linesToBook = summary.lines const linesToBook = summary.lines
.filter((line) => line.trackStock)
.map((line) => ({ .map((line) => ({
manifestarticleId: line.id, manifestarticleId: line.id,
sku: line.sku, sku: line.sku,
@@ -382,12 +387,12 @@ export default class BookDeliveryDialog extends PureComponent {
renderLinesTable() { renderLinesTable() {
const { summary, amountsToBook, isBooking } = this.state; const { summary, amountsToBook, isBooking } = this.state;
const lines = summary?.lines || []; const lines = (summary?.lines || []).filter((l) => l.trackStock);
if (lines.length === 0) { if (lines.length === 0) {
return ( return (
<Alert severity="info" sx={{ my: 2 }}> <Alert severity="info" sx={{ my: 2 }}>
Diese Lieferung enthält keine Artikelpositionen zum Einbuchen. Diese Lieferung enthält keine lagergeführten Artikel zum Einbuchen.
</Alert> </Alert>
); );
} }
@@ -455,13 +460,11 @@ export default class BookDeliveryDialog extends PureComponent {
onChange={(e) => this.handleAmountChange(line.id, e.target.value)} onChange={(e) => this.handleAmountChange(line.id, e.target.value)}
disabled={isBooking} disabled={isBooking}
slotProps={{ slotProps={{
input: { htmlInput: {
inputProps: {
min: 0, min: 0,
step: 1, step: 1,
style: { textAlign: 'right', padding: '4px 8px' }, style: { textAlign: 'right', padding: '4px 8px' },
}, },
},
}} }}
sx={{ width: 90 }} sx={{ width: 90 }}
/> />
@@ -490,7 +493,8 @@ export default class BookDeliveryDialog extends PureComponent {
warehouseError, warehouseError,
} = this.state; } = this.state;
const lines = summary?.lines || []; const lines = (summary?.lines || []).filter((l) => l.trackStock);
const hasTrackStock = lines.length > 0;
const prefillLine = const prefillLine =
this.props.prefillLineId != null this.props.prefillLineId != null
? lines.find((l) => l.id === this.props.prefillLineId) ? lines.find((l) => l.id === this.props.prefillLineId)
@@ -502,7 +506,7 @@ export default class BookDeliveryDialog extends PureComponent {
const countLinesToBook = lines.filter( const countLinesToBook = lines.filter(
(l) => Number(amountsToBook[l.id]) > 0 (l) => Number(amountsToBook[l.id]) > 0
).length; ).length;
const isFullyBooked = summary?.status === 'booked'; const isFullyBooked = hasTrackStock && summary?.status === 'booked';
const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId); const selectedWarehouse = warehouses.find((w) => w.id === selectedWarehouseId);
const isWarehouseMissing = !selectedWarehouseId; const isWarehouseMissing = !selectedWarehouseId;
@@ -554,7 +558,7 @@ export default class BookDeliveryDialog extends PureComponent {
)} )}
{this.renderLinesTable()} {this.renderLinesTable()}
{!isFullyBooked && lines.length > 0 && ( {!isFullyBooked && hasTrackStock && (
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Button <Button
size="small" size="small"
@@ -576,12 +580,13 @@ export default class BookDeliveryDialog extends PureComponent {
</Box> </Box>
)} )}
{warehouses.length === 0 && !isFullyBooked && ( {hasTrackStock && warehouses.length === 0 && !isFullyBooked && (
<Alert severity="error" sx={{ mb: 2 }}> <Alert severity="error" sx={{ mb: 2 }}>
Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an. Es ist noch kein Lager eingerichtet. Bitte legen Sie unter „Artikel → Lager“ zuerst ein Lager an.
</Alert> </Alert>
)} )}
{hasTrackStock && (
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@@ -639,12 +644,13 @@ export default class BookDeliveryDialog extends PureComponent {
helperText="Wird als Transaktionstext in das Ledger übernommen" helperText="Wird als Transaktionstext in das Ledger übernommen"
/> />
</Box> </Box>
)}
{isFullyBooked ? ( {isFullyBooked ? (
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}> <Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />}>
Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht. Alle Artikel dieser Lieferung sind bereits vollständig im Lager verbucht.
</Alert> </Alert>
) : totalToBook > 0 ? ( ) : hasTrackStock && totalToBook > 0 ? (
<Alert severity={selectedWarehouseId ? 'info' : 'warning'}> <Alert severity={selectedWarehouseId ? 'info' : 'warning'}>
{selectedWarehouseId ? ( {selectedWarehouseId ? (
<> <>
@@ -659,11 +665,11 @@ export default class BookDeliveryDialog extends PureComponent {
</> </>
)} )}
</Alert> </Alert>
) : ( ) : hasTrackStock ? (
<Alert severity="warning"> <Alert severity="warning">
Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen. Wählen Sie mindestens eine Menge größer als 0 aus, um den Lagerzugang zu buchen.
</Alert> </Alert>
)} ) : null}
</> </>
)} )}
</DialogContent> </DialogContent>

View File

@@ -40,11 +40,14 @@ class ManifestFormLine extends PureComponent {
cachedArticle: sku ? getArticle(sku) : null, cachedArticle: sku ? getArticle(sku) : null,
loadingArticle: false, loadingArticle: false,
fallbackSuppliers: [], fallbackSuppliers: [],
editingField: null,
}; };
this.handleRemove = this.handleRemove.bind(this); this.handleRemove = this.handleRemove.bind(this);
this.handleMoveUp = this.handleMoveUp.bind(this); this.handleMoveUp = this.handleMoveUp.bind(this);
this.handleMoveDown = this.handleMoveDown.bind(this); this.handleMoveDown = this.handleMoveDown.bind(this);
this.handleBlur = this.handleBlur.bind(this); this.handleBlur = this.handleBlur.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleStartEdit = this.handleStartEdit.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this); this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this); this.handleSearchArticle = this.handleSearchArticle.bind(this);
this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this); this.handleRemoveSupplierArticle = this.handleRemoveSupplierArticle.bind(this);
@@ -134,7 +137,25 @@ class ManifestFormLine extends PureComponent {
this.props.onMove?.(this.props.line.key, 1); this.props.onMove?.(this.props.line.key, 1);
} }
handleStartEdit(columnId) {
const { saving } = this.props;
const isDisabled = saving || (columnId === 'sku' && Boolean(this.props.isOnShipment));
if (isDisabled) return;
if (this.state.editingField !== columnId) {
this.setState({ editingField: columnId });
}
}
handleKeyDown(e) {
if (e.key === 'Enter' || e.key === 'Escape') {
e.target.blur();
}
}
handleBlur(e, columnId) { handleBlur(e, columnId) {
if (this.state.editingField === columnId) {
this.setState({ editingField: null });
}
if (columnId === 'sku' && this.props.onSkuBlur) { if (columnId === 'sku' && this.props.onSkuBlur) {
this.props.onSkuBlur(this.props.line.key, e.target.value); this.props.onSkuBlur(this.props.line.key, e.target.value);
} }
@@ -163,7 +184,7 @@ class ManifestFormLine extends PureComponent {
renderLedgerChip() { renderLedgerChip() {
const { ledgerLine, onBookLine } = this.props; const { ledgerLine, onBookLine } = this.props;
if (!ledgerLine) return null; if (!ledgerLine || !ledgerLine.trackStock) return null;
const isBooked = ledgerLine.isBooked; const isBooked = ledgerLine.isBooked;
const isPartial = !isBooked && ledgerLine.bookedAmount > 0; const isPartial = !isBooked && ledgerLine.bookedAmount > 0;
const isClickable = Boolean(onBookLine); const isClickable = Boolean(onBookLine);
@@ -229,6 +250,8 @@ class ManifestFormLine extends PureComponent {
renderField(column, colIdx, totalCols) { renderField(column, colIdx, totalCols) {
const { id, align, type } = column; const { id, align, type } = column;
const { line, saving, onChange, columns } = this.props; const { line, saving, onChange, columns } = this.props;
const isFirstLine = Boolean(this.props.isFirstLine ?? (this.props.index === 0));
const isLastLine = Boolean(this.props.isLastLine);
if (id === 'pos') { if (id === 'pos') {
const posVal = const posVal =
@@ -240,8 +263,10 @@ class ManifestFormLine extends PureComponent {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
height: '100%', height: isFirstLine ? 40 : 41,
minHeight: 38, minHeight: isFirstLine ? 40 : 41,
maxHeight: isFirstLine ? 40 : 41,
mt: isFirstLine ? 0 : '-1px',
fontWeight: 700, fontWeight: 700,
fontSize: '0.82rem', fontSize: '0.82rem',
color: 'text.secondary', color: 'text.secondary',
@@ -297,23 +322,37 @@ class ManifestFormLine extends PureComponent {
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0; const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
const isLast = colIdx === totalCols - 1; const isLast = colIdx === totalCols - 1;
const topLeftRadius = isFirstInput && isFirstLine ? 4 : 0;
const topRightRadius = isLast && isFirstLine ? 4 : 0;
const bottomRightRadius = isLast && isLastLine ? 4 : 0;
const bottomLeftRadius = isFirstInput && isLastLine ? 4 : 0;
const borderRadius = `${topLeftRadius}px ${topRightRadius}px ${bottomRightRadius}px ${bottomLeftRadius}px`;
const { dirtyFields } = this.props; const { dirtyFields } = this.props;
const isFieldDirty = Boolean( const isFieldDirty = Boolean(
dirtyFields && (dirtyFields.__all__ || dirtyFields[id]) dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
); );
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 ( return (
<TextField <TextField
autoFocus
size="small" size="small"
fullWidth
type={type} type={type}
value={val ?? ''} value={val ?? ''}
error={isError} error={isError}
disabled={saving || (id === 'sku' && Boolean(this.props.isOnShipment))} disabled={isDisabled}
title={id === 'sku' && this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden' : undefined} title={titleText}
autoComplete="off" autoComplete="off"
onChange={(e) => onChange(line.key, id, e.target.value)} onChange={(e) => onChange(line.key, id, e.target.value)}
onBlur={(e) => this.handleBlur(e, id)} onBlur={(e) => this.handleBlur(e, id)}
onKeyDown={this.handleKeyDown}
slotProps={{ slotProps={{
htmlInput: { htmlInput: {
inputMode: type === 'number' ? 'decimal' : 'text', inputMode: type === 'number' ? 'decimal' : 'text',
@@ -323,29 +362,50 @@ class ManifestFormLine extends PureComponent {
}, },
}} }}
sx={{ sx={{
'& .MuiOutlinedInput-root': { position: 'relative',
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
ml: isFirstInput ? 0 : '-1px', ml: isFirstInput ? 0 : '-1px',
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 && { ...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)', bgcolor: 'rgba(237, 108, 2, 0.08)',
}), }),
'&:hover': { '&:hover': {
zIndex: 1, zIndex: 4,
}, },
'&.Mui-focused': { '&.Mui-focused': {
zIndex: 2, zIndex: 5,
}, },
}, },
...(isFieldDirty && {
zIndex: 1,
'& .MuiOutlinedInput-notchedOutline': { '& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main', borderWidth: '1px !important',
borderWidth: 2, borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.23)',
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'primary.main',
}, },
'&:hover .MuiOutlinedInput-notchedOutline': { '&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main', borderWidth: '1px !important',
borderColor: isFieldDirty
? 'warning.main'
: isError
? 'error.main'
: 'rgba(0, 0, 0, 0.87)',
}, },
}),
'& input[type=number]': { '& input[type=number]': {
MozAppearance: 'textfield', MozAppearance: 'textfield',
}, },
@@ -359,6 +419,103 @@ class ManifestFormLine extends PureComponent {
); );
} }
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,
}),
...(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>
);
}
renderSecondLine() { renderSecondLine() {
const { kind = 'delivery' } = this.props; const { kind = 'delivery' } = this.props;
const rawSku = this.props.line?.sku != null ? String(this.props.line.sku).trim() : ''; const rawSku = this.props.line?.sku != null ? String(this.props.line.sku).trim() : '';
@@ -979,7 +1136,11 @@ class ManifestFormLine extends PureComponent {
sx={{ sx={{
p: 0, p: 0,
width: col.width, width: col.width,
verticalAlign: 'middle', minWidth: col.width,
height: 40,
verticalAlign: 'top',
position: 'relative',
overflow: 'visible',
}} }}
> >
{this.renderField(col, colIdx, columns.length)} {this.renderField(col, colIdx, columns.length)}
@@ -999,7 +1160,6 @@ class ManifestFormLine extends PureComponent {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{this.props.onMove && ( {this.props.onMove && (
<> <>
<Tooltip title="Nach oben">
<span> <span>
<IconButton <IconButton
size="small" size="small"
@@ -1011,8 +1171,6 @@ class ManifestFormLine extends PureComponent {
<ArrowUpwardIcon sx={{ fontSize: 16 }} /> <ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</span> </span>
</Tooltip>
<Tooltip title="Nach unten">
<span> <span>
<IconButton <IconButton
size="small" size="small"
@@ -1024,7 +1182,6 @@ class ManifestFormLine extends PureComponent {
<ArrowDownwardIcon sx={{ fontSize: 16 }} /> <ArrowDownwardIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</span> </span>
</Tooltip>
</> </>
)} )}
<IconButton <IconButton

View File

@@ -243,6 +243,7 @@ class ManifestHeader extends PureComponent {
onPdfDelete, onPdfDelete,
onPdfClick, onPdfClick,
onAttachmentClick, onAttachmentClick,
onMentionInChat,
onDateChange, onDateChange,
onNumberChange, onNumberChange,
onExternalNumberChange, onExternalNumberChange,
@@ -356,7 +357,7 @@ class ManifestHeader extends PureComponent {
flex: { xs: '1 1 100%', sm: '2 1 200px' }, flex: { xs: '1 1 100%', sm: '2 1 200px' },
minWidth: { sm: 160 }, minWidth: { sm: 160 },
}} }}
renderInput={(params) => ( renderInput={({ inputProps: _unusedInputProps, InputProps: _unusedInputProps2, InputLabelProps: _unusedInputLabelProps, ...params }) => (
<TextField <TextField
{...params} {...params}
inputRef={this.partyInputRef} inputRef={this.partyInputRef}
@@ -456,6 +457,7 @@ class ManifestHeader extends PureComponent {
onRescan={onPdfRescan} onRescan={onPdfRescan}
onDelete={onPdfDelete} onDelete={onPdfDelete}
onClick={onAttachmentClick ?? onPdfClick} onClick={onAttachmentClick ?? onPdfClick}
onMentionInChat={onMentionInChat}
disabled={saving || pdfUploading} disabled={saving || pdfUploading}
hasArticles={hasArticles} hasArticles={hasArticles}
/> />
@@ -509,7 +511,7 @@ class ManifestHeader extends PureComponent {
Wiederherstellen Wiederherstellen
</Button> </Button>
)} )}
{docId != null && kind === 'delivery' && onOpenBookDialog && ( {docId != null && kind === 'delivery' && onOpenBookDialog && ledgerSummary?.hasTrackStock && (
<Button <Button
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'} variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
color={ color={

View File

@@ -21,19 +21,28 @@ import TableRow from '@mui/material/TableRow';
import Tabs from '@mui/material/Tabs'; import Tabs from '@mui/material/Tabs';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogActions from '@mui/material/DialogActions';
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined'; import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined'; import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
import CloseIcon from '@mui/icons-material/Close';
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined'; import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined'; import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined'; import InsertDriveFileOutlinedIcon from '@mui/icons-material/InsertDriveFileOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import PsychologyOutlinedIcon from '@mui/icons-material/PsychologyOutlined';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import StorageIcon from '@mui/icons-material/Storage'; import StorageIcon from '@mui/icons-material/Storage';
import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import WidgetsOutlinedIcon from '@mui/icons-material/WidgetsOutlined';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { vfs } from '../../api';
import backupAutomat from '../../automats/backupAutomat'; import backupAutomat from '../../automats/backupAutomat';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { import {
@@ -92,11 +101,17 @@ export default class BackupSettingsPanel extends Component {
vfsLoadingByBackup: vfsLoadingByBackup || {}, vfsLoadingByBackup: vfsLoadingByBackup || {},
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly' tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
expandedItemName: null, expandedItemName: null,
restoringSkills: false,
restoringApplets: false,
confirmRestore: null, // null | 'skills' | 'applets'
lastRestoreResult: null, // null | { type: 'skills' | 'applets', count: number, restored: string[] }
}; };
this.handleRefresh = this.handleRefresh.bind(this);
this.handleTierChange = this.handleTierChange.bind(this); this.handleTierChange = this.handleTierChange.bind(this);
this.handleToggleExpand = this.handleToggleExpand.bind(this); this.handleToggleExpand = this.handleToggleExpand.bind(this);
this.handleOpenConfirm = this.handleOpenConfirm.bind(this);
this.handleCloseConfirm = this.handleCloseConfirm.bind(this);
this.handleExecuteRestore = this.handleExecuteRestore.bind(this);
} }
componentDidMount() { componentDidMount() {
@@ -129,18 +144,6 @@ export default class BackupSettingsPanel extends Component {
} }
// #endregion // #endregion
// #region handleRefresh
// MARK: calls backupAutomat.actions.reload from ./api/index.js
async handleRefresh() {
try {
await backupAutomat.actions.reload();
enqueueSnackbar('Backup-Status aktualisiert', { variant: 'info' });
} catch (err) {
notifyError(err);
}
}
// #endregion
handleTierChange(_event, newValue) { handleTierChange(_event, newValue) {
this.setState({ tierTab: newValue, expandedItemName: null }); this.setState({ tierTab: newValue, expandedItemName: null });
} }
@@ -151,6 +154,65 @@ export default class BackupSettingsPanel extends Component {
})); }));
} }
handleOpenConfirm(type) {
this.setState({ confirmRestore: type });
}
handleCloseConfirm() {
if (this.state.restoringSkills || this.state.restoringApplets) return;
this.setState({ confirmRestore: null });
}
// #region handleExecuteRestore
// MARK: calls POST /api/vfs/skills/restore or /api/vfs/applets/restore
async handleExecuteRestore() {
const { confirmRestore } = this.state;
if (!confirmRestore) return;
if (confirmRestore === 'skills') {
this.setState({ restoringSkills: true });
try {
const res = await vfs.restoreSkills();
enqueueSnackbar(`${res.count} Standard-Skills (.SKILL) erfolgreich wiederhergestellt.`, {
variant: 'success',
});
this.setState({
confirmRestore: null,
restoringSkills: false,
lastRestoreResult: {
type: 'skills',
count: res.count,
restored: res.restored || [],
},
});
} catch (err) {
this.setState({ restoringSkills: false });
notifyError(err);
}
} else if (confirmRestore === 'applets') {
this.setState({ restoringApplets: true });
try {
const res = await vfs.restoreApplets();
enqueueSnackbar(`${res.count} Beispiel-Applets (.applet.js) erfolgreich wiederhergestellt.`, {
variant: 'success',
});
this.setState({
confirmRestore: null,
restoringApplets: false,
lastRestoreResult: {
type: 'applets',
count: res.count,
restored: res.restored || [],
},
});
} catch (err) {
this.setState({ restoringApplets: false });
notifyError(err);
}
}
}
// #endregion
render() { render() {
const { const {
status, status,
@@ -161,6 +223,10 @@ export default class BackupSettingsPanel extends Component {
vfsLoadingByBackup, vfsLoadingByBackup,
tierTab, tierTab,
expandedItemName, expandedItemName,
restoringSkills,
restoringApplets,
confirmRestore,
lastRestoreResult,
} = this.state; } = this.state;
if (status === 'loading' && !backupStatus) { if (status === 'loading' && !backupStatus) {
@@ -183,7 +249,7 @@ export default class BackupSettingsPanel extends Component {
<Button <Button
variant="outlined" variant="outlined"
startIcon={<RefreshIcon />} startIcon={<RefreshIcon />}
onClick={this.handleRefresh} onClick={() => void this.loadBackups()}
sx={{ alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >
Erneut versuchen Erneut versuchen
@@ -207,8 +273,7 @@ export default class BackupSettingsPanel extends Component {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Header Title & Actions */} {/* Header Title */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Box> <Box>
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}> <Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
<BackupOutlinedIcon sx={{ color: PRIMARY }} /> <BackupOutlinedIcon sx={{ color: PRIMARY }} />
@@ -218,18 +283,6 @@ export default class BackupSettingsPanel extends Component {
Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher. Automatische Sicherung von Datenbank, virtuellem Dateisystem (VFS) und Dokumentenspeicher.
</Typography> </Typography>
</Box> </Box>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Button
variant="outlined"
size="small"
startIcon={<RefreshIcon />}
onClick={this.handleRefresh}
disabled={isRunning}
>
Aktualisieren
</Button>
</Stack>
</Box>
{/* Status Badges Bar */} {/* Status Badges Bar */}
<Box <Box
@@ -246,19 +299,11 @@ export default class BackupSettingsPanel extends Component {
> >
<Chip <Chip
size="small" size="small"
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />} icon={backupStatus?.enabled && isRunning ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'} label={backupStatus?.enabled && isRunning ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
color={backupStatus?.enabled ? 'success' : 'default'} color={backupStatus?.enabled && isRunning ? 'success' : 'default'}
variant="outlined" variant="outlined"
/> />
{isRunning && (
<Chip
size="small"
icon={<CircularProgress size={12} color="inherit" />}
label="Backup wird ausgeführt..."
color="primary"
/>
)}
<Chip <Chip
size="small" size="small"
icon={<StorageIcon />} icon={<StorageIcon />}
@@ -431,6 +476,231 @@ export default class BackupSettingsPanel extends Component {
</TableContainer> </TableContainer>
)} )}
</Box> </Box>
{/* System Defaults Restore Section (VFS Skills & Applets) */}
<Box sx={{ border: `1px solid ${BORDER}`, borderRadius: 2, bgcolor: WHITE, overflow: 'hidden' }}>
<Box sx={{ borderBottom: `1px solid ${BORDER}`, bgcolor: SURFACE_RAISED, px: 2, py: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SettingsBackupRestoreIcon sx={{ color: PRIMARY, fontSize: 22 }} />
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
System-Standarddateien wiederherstellen (VFS)
</Typography>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
Setzt systemweite Vorlagen im virtuellen Dateisystem (VFS) des Unternehmens auf den Auslieferungszustand zurück. Bereits vorhandene gleichnamige Dateien werden dabei überschrieben.
</Typography>
</Box>
<Box sx={{ p: 2 }}>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
gap: 2,
}}
>
{/* Skills Restore Card */}
<Box
sx={{
p: 2,
borderRadius: 2,
border: `1px solid ${BORDER}`,
bgcolor: SURFACE_BASE,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1.5,
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: 'rgba(15, 118, 110, 0.1)',
color: PRIMARY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PsychologyOutlinedIcon fontSize="small" />
</Box>
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
Standard-Skills
</Typography>
</Box>
<Chip size="small" label=".SKILL" variant="outlined" sx={{ height: 20, fontSize: '0.75rem', fontWeight: 600 }} />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.825rem', lineHeight: 1.4 }}>
Stellt alle systemweiten Standard-.SKILL-Dateien für KI-Assistenten und Workflows im VFS wieder her.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${BORDER}` }}>
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.75rem' }}>
Überschreibt existierende Dateien
</Typography>
<Button
size="small"
variant="contained"
startIcon={restoringSkills ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={() => this.handleOpenConfirm('skills')}
disabled={restoringSkills}
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
>
Skills wiederherstellen
</Button>
</Box>
</Box>
{/* Applets Restore Card */}
<Box
sx={{
p: 2,
borderRadius: 2,
border: `1px solid ${BORDER}`,
bgcolor: SURFACE_BASE,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1.5,
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
bgcolor: 'rgba(15, 118, 110, 0.1)',
color: PRIMARY,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<WidgetsOutlinedIcon fontSize="small" />
</Box>
<Typography variant="subtitle2" fontWeight={700} sx={{ color: DARK_4 }}>
Beispiel-Applets
</Typography>
</Box>
<Chip size="small" label=".applet.js" variant="outlined" sx={{ height: 20, fontSize: '0.75rem', fontWeight: 600 }} />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '0.825rem', lineHeight: 1.4 }}>
Stellt alle systemweiten Standard-.applet.js-Beispiel-Applets für Workflows und Auswertungen im VFS wieder her.
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${BORDER}` }}>
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic', fontSize: '0.75rem' }}>
Überschreibt existierende Dateien
</Typography>
<Button
size="small"
variant="contained"
startIcon={restoringApplets ? <CircularProgress size={14} color="inherit" /> : <SettingsBackupRestoreIcon fontSize="small" />}
onClick={() => this.handleOpenConfirm('applets')}
disabled={restoringApplets}
sx={{ textTransform: 'none', minHeight: 30, fontSize: '0.8rem', fontWeight: 600 }}
>
Applets wiederherstellen
</Button>
</Box>
</Box>
</Box>
{/* Last restore results notification / chip display */}
{lastRestoreResult && (
<Box sx={{ mt: 2, p: 1.5, borderRadius: 1.5, bgcolor: 'rgba(15, 118, 110, 0.08)', border: '1px solid rgba(15, 118, 110, 0.2)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="caption" fontWeight={600} sx={{ color: PRIMARY }}>
Erfolgreich wiederhergestellt ({lastRestoreResult.count} {lastRestoreResult.type === 'skills' ? 'Skills' : 'Applets'}):
</Typography>
<IconButton
size="small"
onClick={() => this.setState({ lastRestoreResult: null })}
sx={{ p: 0.25, color: 'text.secondary' }}
>
<CloseIcon fontSize="small" sx={{ fontSize: 16 }} />
</IconButton>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75 }}>
{lastRestoreResult.restored.map((name) => (
<Chip
key={name}
size="small"
label={name}
variant="outlined"
sx={{
height: 22,
fontSize: '0.75rem',
fontFamily: 'monospace',
bgcolor: WHITE,
}}
/>
))}
</Box>
</Box>
)}
</Box>
</Box>
{/* Confirmation Dialog for System Defaults Restore */}
<Dialog
open={Boolean(confirmRestore)}
onClose={this.handleCloseConfirm}
maxWidth="xs"
fullWidth
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningAmberIcon color="warning" />
{confirmRestore === 'skills'
? 'Standard-Skills wiederherstellen?'
: 'Beispiel-Applets wiederherstellen?'}
</DialogTitle>
<DialogContent>
<DialogContentText sx={{ color: DARK_2 }}>
{confirmRestore === 'skills'
? 'Möchten Sie alle systemweiten Standard-.SKILL-Dateien im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .SKILL-Dateien werden dabei überschrieben.'
: 'Möchten Sie alle systemweiten Standard-.applet.js-Beispiel-Applets im VFS des aktuellen Unternehmens wiederherstellen? Bestehende .applet.js-Dateien werden dabei überschrieben.'}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button
onClick={this.handleCloseConfirm}
disabled={restoringSkills || restoringApplets}
variant="outlined"
sx={{ textTransform: 'none' }}
>
Abbrechen
</Button>
<Button
onClick={this.handleExecuteRestore}
disabled={restoringSkills || restoringApplets}
variant="contained"
color="primary"
startIcon={
(confirmRestore === 'skills' ? restoringSkills : restoringApplets) ? (
<CircularProgress size={14} color="inherit" />
) : (
<SettingsBackupRestoreIcon />
)
}
sx={{ textTransform: 'none' }}
>
{(confirmRestore === 'skills' ? restoringSkills : restoringApplets)
? 'Wiederherstellen...'
: 'Jetzt wiederherstellen'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
); );
} }

View File

@@ -1,107 +1,448 @@
import { PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import RefreshIcon from '@mui/icons-material/Refresh';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { PanelTile } from 'dashgui/panels'; import ListItemButton from '@mui/material/ListItemButton';
import { AutomatGrid } from 'react-virtual-aggridautomat'; import Typography from '@mui/material/Typography';
import { articleHistoryGridAutomat } from '../../automats'; import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import Chip from '@mui/material/Chip';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../../components/BackendError';
import articleHistoryAutomat from '../../automats/articleHistoryAutomat';
import { router } from '../../appRouter'; import { router } from '../../appRouter';
import { import { backendErrorFrom } from '../../utils/backendError';
LINK, import { LINK } from '../../colors';
} from '../../colors';
const COLUMN_DEFS = [ const CHUNK_SIZE = 50;
{
headerName: 'ID', function formatDateTime(val) {
field: 'id', if (!val) return '–';
maxWidth: 90, try {
sortable: true, const d = new Date(val);
filter: 'agNumberColumnFilter', if (isNaN(d.getTime())) return String(val);
cellStyle: { fontVariantNumeric: 'tabular-nums' }, return d.toLocaleString('de-DE', {
}, day: '2-digit',
{ month: '2-digit',
headerName: 'Datum', year: 'numeric',
field: 'date', hour: '2-digit',
minWidth: 170, minute: '2-digit',
maxWidth: 200, second: '2-digit',
sortable: true, });
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''), } catch {
cellStyle: { fontVariantNumeric: 'tabular-nums' }, return String(val);
}, }
{ }
headerName: 'Artikel-Nr.',
field: 'sku', export function createDummyTestArticleHistory() {
minWidth: 130, return {
maxWidth: 160, id: 999999,
sortable: true, date: '2030-10-30T12:00:00Z',
filter: 'agTextColumnFilter', sku: 'SKU-99999999',
cellRenderer: (p) => { name: 'Musterartikel Name Langform',
if (!p.value) return ''; text: 'Preis geändert von 10,00 € auf 12,50 € durch Benutzer',
user: 'Max Mustermann',
};
}
const rowGridSx = {
display: 'grid',
gridTemplateColumns:
'minmax(60px, max-content) minmax(150px, max-content) minmax(120px, max-content) minmax(160px, 1.2fr) minmax(240px, 2fr) minmax(120px, max-content)',
gridTemplateRows: '0px 1fr',
rowGap: 0,
columnGap: 2,
alignItems: 'center',
width: '100%',
boxSizing: 'border-box',
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
const measuringCellSx = {
visibility: 'hidden',
height: 0,
boxSizing: 'border-box',
pointerEvents: 'none',
textTransform: 'none',
letterSpacing: 'normal',
};
const headerCellSx = {
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestArticleHistory();
return ( return (
<a <>
href={`/artikel/${encodeURIComponent(p.value)}`} <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 (
<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="text" width="85%" height={20} />
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
<Skeleton variant="text" width="70%" height={20} />
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
</ListItemButton>
);
}
const userName =
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) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
void router.navigate(`/artikel/${encodeURIComponent(p.value)}`); e.stopPropagation();
void router.navigate(`/artikel/${encodeURIComponent(item.sku)}`);
}}
sx={{
color: LINK,
fontWeight: 600,
textDecoration: 'none',
...ellipsisSx,
'&:hover': { textDecoration: 'underline' },
}} }}
style={{ color: LINK, fontWeight: 600, textDecoration: 'none' }}
> >
{p.value} {item.sku}
</a> </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>
); );
}, }
}, }
{
headerName: 'Artikelname', class ArticleHistorySearchField extends PureComponent {
field: 'name', constructor(props) {
minWidth: 180, super(props);
flex: 1, const s = articleHistoryAutomat.state;
sortable: true, const stats = articleHistoryAutomat.getChunkStats?.();
filter: 'agTextColumnFilter', this.state = {
}, input: s.filter ?? '',
{ seekMonths: s.seekMonths ?? 0,
headerName: 'Änderung / Ereignis', seekYears: s.seekYears ?? 0,
field: 'text', lruChunksCount: stats?.lruChunksCount || 0,
minWidth: 300, maxLruChunks: stats?.maxLruChunks || 50,
flex: 2, };
sortable: true, }
filter: 'agTextColumnFilter',
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' }, componentDidMount() {
autoHeight: true, this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({
}, input: state.filter ?? '',
{ seekMonths: state.seekMonths ?? 0,
headerName: 'Benutzer', seekYears: state.seekYears ?? 0,
field: 'user', }));
maxWidth: 160, if (import.meta.env.DEV) {
sortable: true, this.unsubStats = articleHistoryAutomat.subscribeChunkStats?.((stats) => {
filter: 'agTextColumnFilter', this.setState({
valueGetter: (p) => lruChunksCount: stats.lruChunksCount,
p.data?.user || maxLruChunks: stats.maxLruChunks,
p.data?.employee || });
p.data?.companyuser || });
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'), }
}, }
];
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const { inputRef, onReload, loading } = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
placeholder="Artikel-Nr., Name, Änderung oder Benutzer suchen…"
onChange={(next) => articleHistoryAutomat.actions.filter(next)}
/>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={onReload}
disabled={loading}
sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
>
Aktualisieren
</Button>
</Box>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => articleHistoryAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => articleHistoryAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => articleHistoryAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
label={
this.state.lruChunksCount > 0
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
: 'Chunk-Cache'
}
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
)}
</Box>
</Box>
</PanelTileGrid>
);
}
}
export default class ArticleHistoryPage extends PureComponent { export default class ArticleHistoryPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = articleHistoryGridAutomat.state; this.headerItem = createDummyTestArticleHistory();
this.state = { this.state = {
loading: s.loading, items: articleHistoryAutomat.getItems(),
error: s.error, unfilteredTotal: articleHistoryAutomat.state.unfilteredTotal ?? 0,
totalRows: s.totalRows, filterKey: articleHistoryAutomat.getFilterKey?.() ?? '',
status: articleHistoryAutomat.state.status,
error: articleHistoryAutomat.state.error,
}; };
this.searchRef = createRef();
this.handleReload = this.handleReload.bind(this); this.handleReload = this.handleReload.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
} }
componentDidMount() { componentDidMount() {
this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({ this.unsub = articleHistoryAutomat.subscribe(this, (state) => ({
loading: state.loading, items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: articleHistoryAutomat.getFilterKey(state),
status: state.status,
error: state.error, error: state.error,
totalRows: state.totalRows,
})); }));
} }
@@ -109,47 +450,112 @@ export default class ArticleHistoryPage extends PureComponent {
this.unsub?.(); this.unsub?.();
} }
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
articleHistoryAutomat.requireChunk(startChunk, endChunk);
}
handleReload() { handleReload() {
articleHistoryGridAutomat.actions.reload(); void articleHistoryAutomat.actions.reload().catch(() => {});
}
handleRetry() {
void articleHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
} }
render() { render() {
const { loading, totalRows } = this.state; const { items, unfilteredTotal, filterKey, status, error } = this.state;
const loading = status === 'loading';
const actions = ( if (items === null && status === 'failed') {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> return (
{loading && <CircularProgress size={18} thickness={5} />} <>
{totalRows >= 0 && ( <ArticleHistorySearchField
<Typography variant="body2" color="text.secondary"> inputRef={this.searchRef}
{totalRows} Einträge onReload={this.handleReload}
</Typography> loading={loading}
)} />
<Button <PanelTileGrid>
size="small" <PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
variant="outlined" <BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
startIcon={<RefreshIcon />} </PanelTile>
onClick={this.handleReload} </PanelTileGrid>
disabled={loading} </>
>
Aktualisieren
</Button>
</Box>
); );
}
return ( return (
<PanelTile title="Artikel-Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}> <>
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}> <ArticleHistorySearchField
<AutomatGrid inputRef={this.searchRef}
automat={articleHistoryGridAutomat} onReload={this.handleReload}
columnDefs={COLUMN_DEFS} loading={loading}
rowIdField="id"
cacheBlockSize={50}
height="100%"
width="100%"
noRowsOverlayText="Keine Historieneinträge vorhanden"
/> />
<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> </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> </PanelTile>
</PanelTileGrid>
</>
); );
} }
} }

View File

@@ -1,89 +1,460 @@
import { PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import RefreshIcon from '@mui/icons-material/Refresh';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { PanelTile } from 'dashgui/panels'; import ListItemButton from '@mui/material/ListItemButton';
import { AutomatGrid } from 'react-virtual-aggridautomat'; import Typography from '@mui/material/Typography';
import { globalHistoryGridAutomat } from '../../automats'; import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import RefreshIcon from '@mui/icons-material/Refresh';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import Chip from '@mui/material/Chip';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../../components/BackendError';
import globalHistoryAutomat from '../../automats/globalHistoryAutomat';
import { router } from '../../appRouter';
import { backendErrorFrom } from '../../utils/backendError';
import { LINK } from '../../colors';
const COLUMN_DEFS = [ const CHUNK_SIZE = 50;
{
headerName: 'ID', function formatDateTime(val) {
field: 'id', if (!val) return '–';
maxWidth: 90, try {
sortable: true, const d = new Date(val);
filter: 'agNumberColumnFilter', if (isNaN(d.getTime())) return String(val);
cellStyle: { fontVariantNumeric: 'tabular-nums' }, return d.toLocaleString('de-DE', {
}, day: '2-digit',
{ month: '2-digit',
headerName: 'Datum', year: 'numeric',
field: 'date', hour: '2-digit',
minWidth: 170, minute: '2-digit',
maxWidth: 200, second: '2-digit',
sortable: true, });
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''), } catch {
cellStyle: { fontVariantNumeric: 'tabular-nums' }, return String(val);
}, }
{ }
headerName: 'Typ',
field: 'type', export function createDummyTestGlobalHistory() {
minWidth: 120, return {
maxWidth: 160, id: 999999,
sortable: true, date: '2030-10-30T12:00:00Z',
filter: 'agTextColumnFilter', type: 'Bestellung',
}, reference: 'BEST-999999',
{ text: 'Status geändert auf abgeschlossen durch Benutzer',
headerName: 'Referenz / Key', user: 'Max Mustermann',
field: 'reference', };
minWidth: 140, }
maxWidth: 200,
sortable: true, const rowGridSx = {
filter: 'agTextColumnFilter', display: 'grid',
valueGetter: (p) => p.data?.reference || p.data?.p1 || '', gridTemplateColumns:
}, 'minmax(60px, max-content) minmax(150px, max-content) minmax(110px, max-content) minmax(140px, max-content) minmax(240px, 2fr) minmax(120px, max-content)',
{ gridTemplateRows: '0px 1fr',
headerName: 'Meldung / Beschreibung', rowGap: 0,
field: 'text', columnGap: 2,
minWidth: 300, alignItems: 'center',
flex: 2, width: '100%',
sortable: true, boxSizing: 'border-box',
filter: 'agTextColumnFilter', };
valueGetter: (p) => p.data?.text || p.data?.value || '',
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' }, const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
autoHeight: true,
}, const measuringCellSx = {
{ visibility: 'hidden',
headerName: 'Benutzer', height: 0,
field: 'user', boxSizing: 'border-box',
maxWidth: 160, pointerEvents: 'none',
sortable: true, textTransform: 'none',
filter: 'agTextColumnFilter', letterSpacing: 'normal',
valueGetter: (p) => };
p.data?.user ||
p.data?.employee || const headerCellSx = {
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'), color: 'text.secondary',
}, typography: 'caption',
]; fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
};
function renderInvisibleDummyRow(dummy) {
const d = dummy || createDummyTestGlobalHistory();
return (
<>
<Box aria-hidden sx={{ gridColumn: 1, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.id}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 2, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{formatDateTime(d.date)}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 3, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.type}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 4, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.reference}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 5, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.text}</Typography>
</Box>
<Box aria-hidden sx={{ gridColumn: 6, gridRow: 1, whiteSpace: 'nowrap', ...measuringCellSx }}>
<Typography variant="body2">{d.user}</Typography>
</Box>
</>
);
}
class GlobalHistoryRow extends PureComponent {
render() {
const { item, disabled, headerItem } = this.props;
const dummy = headerItem || createDummyTestGlobalHistory();
if (!item) {
return (
<ListItemButton
disabled
divider
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2 }}>
<Skeleton variant="text" width="80%" height={20} />
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2 }}>
<Skeleton variant="rounded" width={70} height={22} />
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2 }}>
<Skeleton variant="text" width="80%" height={20} />
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2 }}>
<Skeleton variant="text" width="90%" height={20} />
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
</ListItemButton>
);
}
const userName =
item.user ||
item.employee ||
(item.companyuserId ? `User #${item.companyuserId}` : '—');
const typeStr = item.type || item.entityType || '–';
const refStr = item.reference || item.p1 || '–';
const isArticle = (typeStr === 'article' || typeStr === 'artikel') && refStr && refStr !== '–';
return (
<ListItemButton
divider
disabled={disabled}
sx={{ ...rowGridSx, height: 56, minHeight: 56, maxHeight: 56, px: 2, py: 0 }}
onClick={
isArticle
? () => {
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
}
: undefined
}
>
{renderInvisibleDummyRow(dummy)}
<Box sx={{ gridColumn: 1, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{item.id}
</Typography>
</Box>
<Box sx={{ gridColumn: 2, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{formatDateTime(item.date || item.createdAt)}
</Typography>
</Box>
<Box sx={{ gridColumn: 3, gridRow: 2, minWidth: 0 }}>
<Chip
size="small"
label={typeStr}
sx={{
height: 22,
fontSize: '0.75rem',
fontWeight: 500,
bgcolor: 'action.hover',
textTransform: 'capitalize',
}}
/>
</Box>
<Box sx={{ gridColumn: 4, gridRow: 2, minWidth: 0 }}>
{isArticle ? (
<Typography
variant="body2"
component="a"
href={`/artikel/${encodeURIComponent(refStr)}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void router.navigate(`/artikel/${encodeURIComponent(refStr)}`);
}}
sx={{
color: LINK,
fontWeight: 600,
textDecoration: 'none',
...ellipsisSx,
'&:hover': { textDecoration: 'underline' },
}}
>
{refStr}
</Typography>
) : (
<Typography variant="body2" sx={{ fontWeight: 500, fontVariantNumeric: 'tabular-nums', ...ellipsisSx }}>
{refStr}
</Typography>
)}
</Box>
<Box sx={{ gridColumn: 5, gridRow: 2, minWidth: 0 }}>
<Tooltip title={item.text || item.value || ''} placement="top-start" enterDelay={500}>
<Typography variant="body2" sx={ellipsisSx}>
{item.text || item.value || '–'}
</Typography>
</Tooltip>
</Box>
<Box sx={{ gridColumn: 6, gridRow: 2, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{userName}
</Typography>
</Box>
</ListItemButton>
);
}
}
class GlobalHistorySearchField extends PureComponent {
constructor(props) {
super(props);
const s = globalHistoryAutomat.state;
const stats = globalHistoryAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 0,
lruChunksCount: stats?.lruChunksCount || 0,
maxLruChunks: stats?.maxLruChunks || 50,
};
}
componentDidMount() {
this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = globalHistoryAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const { inputRef, onReload, loading } = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
placeholder="Referenz, Typ, Meldung oder Benutzer suchen…"
onChange={(next) => globalHistoryAutomat.actions.filter(next)}
/>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
onClick={onReload}
disabled={loading}
sx={{ height: 40, px: 2, whiteSpace: 'nowrap' }}
>
Aktualisieren
</Button>
</Box>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => globalHistoryAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => globalHistoryAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => globalHistoryAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<Chip
size="small"
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
label={
this.state.lruChunksCount > 0
? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})`
: 'Chunk-Cache'
}
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
)}
</Box>
</Box>
</PanelTileGrid>
);
}
}
export default class GlobalHistoryPage extends PureComponent { export default class GlobalHistoryPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
const s = globalHistoryGridAutomat.state; this.headerItem = createDummyTestGlobalHistory();
this.state = { this.state = {
loading: s.loading, items: globalHistoryAutomat.getItems(),
error: s.error, unfilteredTotal: globalHistoryAutomat.state.unfilteredTotal ?? 0,
totalRows: s.totalRows, filterKey: globalHistoryAutomat.getFilterKey?.() ?? '',
status: globalHistoryAutomat.state.status,
error: globalHistoryAutomat.state.error,
}; };
this.searchRef = createRef();
this.handleReload = this.handleReload.bind(this); this.handleReload = this.handleReload.bind(this);
this.handleRetry = this.handleRetry.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
} }
componentDidMount() { componentDidMount() {
this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({ this.unsub = globalHistoryAutomat.subscribe(this, (state) => ({
loading: state.loading, items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: globalHistoryAutomat.getFilterKey(state),
status: state.status,
error: state.error, error: state.error,
totalRows: state.totalRows,
})); }));
} }
@@ -91,47 +462,112 @@ export default class GlobalHistoryPage extends PureComponent {
this.unsub?.(); this.unsub?.();
} }
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
globalHistoryAutomat.requireChunk(startChunk, endChunk);
}
handleReload() { handleReload() {
globalHistoryGridAutomat.actions.reload(); void globalHistoryAutomat.actions.reload().catch(() => {});
}
handleRetry() {
void globalHistoryAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
} }
render() { render() {
const { loading, totalRows } = this.state; const { items, unfilteredTotal, filterKey, status, error } = this.state;
const loading = status === 'loading';
const actions = ( if (items === null && status === 'failed') {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}> return (
{loading && <CircularProgress size={18} thickness={5} />} <>
{totalRows >= 0 && ( <GlobalHistorySearchField
<Typography variant="body2" color="text.secondary"> inputRef={this.searchRef}
{totalRows} Einträge onReload={this.handleReload}
</Typography> loading={loading}
)} />
<Button <PanelTileGrid>
size="small" <PanelTile wide={3} minHeight={0} sx={{ p: 2 }}>
variant="outlined" <BackendError error={backendErrorFrom(error)} onRetry={this.handleRetry} />
startIcon={<RefreshIcon />} </PanelTile>
onClick={this.handleReload} </PanelTileGrid>
disabled={loading} </>
>
Aktualisieren
</Button>
</Box>
); );
}
return ( return (
<PanelTile title="Globale Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}> <>
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}> <GlobalHistorySearchField
<AutomatGrid inputRef={this.searchRef}
automat={globalHistoryGridAutomat} onReload={this.handleReload}
columnDefs={COLUMN_DEFS} loading={loading}
rowIdField="id"
cacheBlockSize={50}
height="100%"
width="100%"
noRowsOverlayText="Keine globalen Historieneinträge vorhanden"
/> />
<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> </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> </PanelTile>
</PanelTileGrid>
</>
); );
} }
} }

View File

@@ -36,7 +36,6 @@ import {
ArticleSearchDialog, ArticleSearchDialog,
BookDeliveryDialog, BookDeliveryDialog,
} from '../../components/manifest'; } from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
import { loadParties, apiFor } from '../../components/manifest/manifestConfig'; import { loadParties, apiFor } from '../../components/manifest/manifestConfig';
import { article as articleApi, delivery as deliveryApi } from '../../api'; import { article as articleApi, delivery as deliveryApi } from '../../api';
import { getCategoryState } from '../../automats/categoryAutomat'; import { getCategoryState } from '../../automats/categoryAutomat';
@@ -48,6 +47,7 @@ import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText'; import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats'; import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import chatAutomat from '../../automats/chatAutomat';
import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger'; import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger';
import { import {
@@ -68,9 +68,6 @@ class LieferungNeuPage extends PureComponent {
partiesLoading: true, partiesLoading: true,
saving: false, saving: false,
pdfUploading: false, pdfUploading: false,
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
textEditorOpen: false, textEditorOpen: false,
textEditorAttachment: null, textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition, viewerPosition: userSettingsAutomat.state.viewerPosition,
@@ -105,8 +102,7 @@ class LieferungNeuPage extends PureComponent {
this.handlePdfUpload = this.handlePdfUpload.bind(this); this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handlePdfRescan = this.handlePdfRescan.bind(this); this.handlePdfRescan = this.handlePdfRescan.bind(this);
this.handlePdfDelete = this.handlePdfDelete.bind(this); this.handlePdfDelete = this.handlePdfDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this); this.handleMentionInChat = this.handleMentionInChat.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this); this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this); this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this); this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
@@ -159,10 +155,6 @@ class LieferungNeuPage extends PureComponent {
componentWillUnmount() { componentWillUnmount() {
this._cancelled = true; this._cancelled = true;
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
if (this._skuDebounceTimers) { if (this._skuDebounceTimers) {
Object.values(this._skuDebounceTimers).forEach(clearTimeout); Object.values(this._skuDebounceTimers).forEach(clearTimeout);
this._skuDebounceTimers = null; this._skuDebounceTimers = null;
@@ -301,7 +293,6 @@ class LieferungNeuPage extends PureComponent {
handleCreateSupplierFromPdf(supplierName) { handleCreateSupplierFromPdf(supplierName) {
const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim(); const name = (supplierName ?? this.state.unmatchedSupplierName ?? '').trim();
this.handleClosePdfDialog();
const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`; const target = `/lieferung/lieferanten?name=${encodeURIComponent(name)}&returnTo=/lieferung/neu`;
this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } }); this.props.navigate(target, { state: { name, returnTo: '/lieferung/neu' } });
} }
@@ -635,9 +626,6 @@ class LieferungNeuPage extends PureComponent {
}; };
this.automat.actions.addAttachment(textAttachment); this.automat.actions.addAttachment(textAttachment);
} }
// 3. Run SSE parsing with backend (POST /api/delivery/parse)
await this.runParse(file);
} }
async handlePdfRescan(attachment) { async handlePdfRescan(attachment) {
@@ -697,8 +685,41 @@ class LieferungNeuPage extends PureComponent {
console.error('Fehler beim Aktualisieren der Textdatei:', txtErr); console.error('Fehler beim Aktualisieren der Textdatei:', txtErr);
} }
} }
}
await this.runParse(attachment.file); async handleMentionInChat(attachment) {
console.log('[neu.jsx] handleMentionInChat called', attachment);
if (!attachment) return;
let text = attachment.text;
if (!text && (!attachment.file || attachment.file.size === 0)) {
const file = await this.ensureAttachmentFile(attachment);
if (file) {
try {
text = await file.text();
} catch (e) {
console.error('Fehler beim Lesen des Texts:', e);
}
}
} else if (!text && (attachment.file instanceof Blob || attachment.file instanceof File)) {
try {
text = await attachment.file.text();
} catch (e) {
console.error('Fehler beim Lesen des Texts:', e);
}
}
const trimmed = (text || '').trim();
if (!trimmed) {
console.warn('[neu.jsx] handleMentionInChat: text is empty for attachment', attachment);
return;
}
console.log('[neu.jsx] Inserting extraction snippet to chat, length:', trimmed.length);
chatAutomat.actions.insertExtractionSnippet({
text: trimmed,
label: 'parsed invoice',
instruction: "use 'Process Incoming Product and Invoices' SKILL",
});
} }
handlePdfDelete(attachment) { handlePdfDelete(attachment) {
@@ -706,227 +727,6 @@ class LieferungNeuPage extends PureComponent {
this.automat.actions.removeAttachment(attachment.id); this.automat.actions.removeAttachment(attachment.id);
} }
async runParse(file) {
if (this._parseAbortController) {
this._parseAbortController.abort();
}
const abortController = new AbortController();
this._parseAbortController = abortController;
this.setState({
pdfUploading: true,
pdfDialogOpen: true,
pdfFileName: file.name,
pdfDialogData: { streaming: true, messages: [], result: null, error: null, aborted: false },
});
const appendMessage = (msg) => {
if (this._cancelled || abortController.signal.aborted) return;
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
messages: [...(prev.pdfDialogData?.messages ?? []), msg],
},
}));
};
try {
for await (const { event, data } of deliveryApi.parseSse(file, { signal: abortController.signal })) {
if (this._cancelled || abortController.signal.aborted) return;
switch (event) {
case 'status':
appendMessage({ type: 'status', text: data.message ?? String(data) });
break;
case 'llm_call':
appendMessage({
type: 'llm_call',
text: `LLM-Aufruf: ${data.model} (${data.promptSize} tokens, ${data.messages} msg)`,
});
break;
case 'tool_call':
appendMessage({ type: 'tool_call', text: `Tool-Aufruf: ${data.name}` });
break;
case 'usage':
appendMessage({
type: 'usage',
text: `Token-Verbrauch: ${data.input} in / ${data.cached} cached / ${data.output} out — $${(data.cost ?? 0).toFixed(4)}`,
});
break;
case 'progress':
this.setState((prev) => ({
pdfDialogData: { ...prev.pdfDialogData, result: data },
}));
break;
case 'result':
case 'done': {
const result = data?.delivery ?? data;
const rawItems = Array.isArray(result?.lines)
? result.lines
: Array.isArray(result?.items?.items)
? result.items.items
: null;
const newLines = Array.isArray(rawItems)
? rawItems.map((l, idx) => {
const taxVal = (l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst) != null
? String(l.taxrate ?? l.tax ?? l.taxRate ?? l.mwst)
: '';
const netVal = (l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : null)) != null
? String(l.netprice ?? l.net ?? l.netPrice ?? (l.eknetto != null ? (l.eknetto / 100).toFixed(2) : ''))
: '';
return {
key: l.key ?? `${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 8)}`,
name: l.name ?? l.articleName ?? null,
sku: l.sku ?? l.liefArtNr ?? '',
amount: (l.amount ?? l.count) != null ? String(l.amount ?? l.count) : '',
pu: (l.pu ?? l.vpe) != null ? String(l.pu ?? l.vpe) : '',
unit: l.unit ?? '',
taxrate: taxVal,
tax: taxVal,
netprice: netVal,
net: netVal,
};
})
: null;
this.setState((prev) => ({
pdfDialogData: { ...prev.pdfDialogData, streaming: false, result },
}));
const docDate = result?.date ?? result?.basedata?.date;
let supplierId = result?.supplierId ?? result?.basedata?.kLieferant;
const extNum = result?.externalNumber ?? result?.basedata?.manifestNr;
const docType = result?.type ?? result?.basedata?.manifestType;
const notes = Array.isArray(result?.notes) ? result.notes : [];
const parsedSupplierName = (
result?.supplierName ??
result?.basedata?.lieferantName ??
result?.lieferantName ??
result?.basedata?.supplierName ??
''
).trim();
// If supplierId is null, try matching supplier by name from loaded delivery entities
if (supplierId == null && parsedSupplierName && Array.isArray(this.state.deliveryEntities)) {
const nameToMatch = parsedSupplierName.toLowerCase();
const matched = this.state.deliveryEntities.find(
(e) =>
e.name &&
(e.name.trim().toLowerCase() === nameToMatch ||
e.name.toLowerCase().includes(nameToMatch) ||
nameToMatch.includes(e.name.toLowerCase())),
);
if (matched) supplierId = matched.id;
}
const unmatchedSupplierName = supplierId == null && parsedSupplierName ? parsedSupplierName : null;
this.setState({ unmatchedSupplierName });
this.automat.actions.setUnmatchedSupplierName(unmatchedSupplierName);
if (docDate) this.automat.actions.setDate(docDate);
if (supplierId != null) this.automat.actions.setDeliveryEntity(supplierId);
if (extNum != null) {
this.automat.actions.setExternalNumber(String(extNum));
}
if (docType) this.automat.actions.setType(docType);
if (newLines && newLines.length > 0) {
this.automat.actions.setLineitems(newLines);
}
if (notes.length > 0) {
this.automat.actions.setNotes(notes);
}
// Supply whole article list to GET /supplier/{id}/articles
const effectiveSupplierId = supplierId ?? this.automat.state.deliveryEntity ?? this.automat.state.partyId;
const effectiveLines = (newLines && newLines.length > 0) ? newLines : this.automat.state.lines;
if (effectiveSupplierId != null && effectiveLines && effectiveLines.length > 0) {
this.automat.actions.fetchSupplierArticles(effectiveSupplierId, effectiveLines);
}
break;
}
case 'error':
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
error: data.message ?? 'Analyse fehlgeschlagen.',
},
}));
break;
default:
break;
}
}
} catch (err) {
if (this._cancelled) return;
if (abortController.signal.aborted || err.name === 'AbortError') {
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
},
}));
return;
}
this.setState((prev) => ({
pdfDialogData: {
...prev.pdfDialogData,
streaming: false,
error: err.message ?? 'Netzwerkfehler bei Analyse.',
},
}));
} finally {
if (this._parseAbortController === abortController) {
this._parseAbortController = null;
}
if (!this._cancelled) {
this.setState((prev) => ({
pdfUploading: false,
pdfDialogData: prev.pdfDialogData
? { ...prev.pdfDialogData, streaming: false }
: null,
}));
}
}
}
async runPdfParse(file) {
await this.runParse(file);
}
async runTxtParse(file) {
await this.runParse(file);
}
handleAbortParse() {
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState((prev) => ({
pdfUploading: false,
pdfDialogData: prev.pdfDialogData
? {
...prev.pdfDialogData,
streaming: false,
aborted: true,
error: 'Analyse abgebrochen.',
}
: null,
}));
}
handleClosePdfDialog() {
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
}
this.setState({ pdfDialogOpen: false, pdfUploading: false });
}
async ensureAttachmentFile(attachment) { async ensureAttachmentFile(attachment) {
if (!attachment) return null; if (!attachment) return null;
const hasContent = const hasContent =
@@ -1244,9 +1044,6 @@ class LieferungNeuPage extends PureComponent {
partiesLoading, partiesLoading,
saving, saving,
pdfUploading, pdfUploading,
pdfDialogOpen,
pdfDialogData,
pdfFileName,
deliveryEntities, deliveryEntities,
date, date,
partyId, partyId,
@@ -1298,6 +1095,7 @@ class LieferungNeuPage extends PureComponent {
onPdfRescan={this.handlePdfRescan} onPdfRescan={this.handlePdfRescan}
onPdfDelete={this.handlePdfDelete} onPdfDelete={this.handlePdfDelete}
onAttachmentClick={this.handleAttachmentClick} onAttachmentClick={this.handleAttachmentClick}
onMentionInChat={this.handleMentionInChat}
onSave={this.handleSave} onSave={this.handleSave}
onPartyChange={this.handlePartyChange} onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange} onDateChange={this.handleDateChange}
@@ -1376,15 +1174,7 @@ class LieferungNeuPage extends PureComponent {
</PanelTile> </PanelTile>
)} )}
</PanelTileGrid> </PanelTileGrid>
<ManifestPdfDialog
open={pdfDialogOpen}
filename={pdfFileName}
data={pdfDialogData}
unmatchedSupplierName={this.state.unmatchedSupplierName}
onCreateSupplier={this.handleCreateSupplierFromPdf}
onClose={this.handleClosePdfDialog}
onAbort={this.handleAbortParse}
/>
<ArticleSearchDialog <ArticleSearchDialog
open={this.state.articleSearchDialogOpen} open={this.state.articleSearchDialogOpen}
@@ -1444,6 +1234,8 @@ class LieferungNeuPage extends PureComponent {
} }
allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || ''))); allEntries.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
if (!ledgerSummary.hasTrackStock && allEntries.length === 0) return null;
return ( return (
<PanelTile <PanelTile
wide={4} wide={4}
@@ -1538,6 +1330,7 @@ class LieferungNeuPage extends PureComponent {
)} )}
</Box> </Box>
{ledgerSummary.hasTrackStock && (
<Button <Button
variant={status === 'booked' ? 'outlined' : 'contained'} variant={status === 'booked' ? 'outlined' : 'contained'}
color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'} color={status === 'booked' ? 'success' : status === 'partial' ? 'warning' : 'primary'}
@@ -1557,6 +1350,7 @@ class LieferungNeuPage extends PureComponent {
? 'Restliche Mengen einbuchen' ? 'Restliche Mengen einbuchen'
: 'Ins Lager buchen'} : 'Ins Lager buchen'}
</Button> </Button>
)}
</Box> </Box>
{allEntries.length > 0 && ( {allEntries.length > 0 && (

View File

@@ -1,5 +1,34 @@
import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi } from '../api'; import { delivery as defaultDeliveryApi, ledger as defaultLedgerApi, stock as defaultStockApi } from '../api';
import artikelAutomat from '../automats/artikelAutomat'; import artikelAutomat, { getArticle } from '../automats/artikelAutomat';
import articleIndexAutomat from '../automats/articleIndexAutomat';
/**
* Determines whether a delivery line represents an article that tracks stock.
*
* @param {object} line Delivery line
* @param {object|null} [article] Optional pre-resolved article
* @returns {boolean}
*/
export function isLineStockTracked(line, article = null) {
if (!line) return false;
if (line.track_stock !== undefined) {
return line.track_stock !== false && line.track_stock !== 0 && line.track_stock !== '0';
}
if (line.trackStock !== undefined) {
return Boolean(line.trackStock);
}
if (line.article?.track_stock !== undefined) {
return line.article.track_stock !== false && line.article.track_stock !== 0 && line.article.track_stock !== '0';
}
const sku = line.sku ? String(line.sku).trim() : null;
if (!sku) return false;
const art = article || getArticle(sku) || articleIndexAutomat.state.items?.find((item) => String(item.sku) === sku);
if (art?.track_stock !== undefined) {
return art.track_stock !== false && art.track_stock !== 0 && art.track_stock !== '0';
}
return false;
}
/** /**
* @typedef {Object} DeliveryLineLedgerStatus * @typedef {Object} DeliveryLineLedgerStatus
@@ -10,6 +39,8 @@ import artikelAutomat from '../automats/artikelAutomat';
* @property {number} bookedAmount - Quantity booked in ledger * @property {number} bookedAmount - Quantity booked in ledger
* @property {number} remainingAmount - Quantity remaining to be booked * @property {number} remainingAmount - Quantity remaining to be booked
* @property {boolean} isBooked - Whether this position is fully booked * @property {boolean} isBooked - Whether this position is fully booked
* @property {boolean} trackStock - Whether this article tracks inventory
* @property {boolean} isBookable - Whether this position can be booked into the warehouse
* @property {Array<Object>} ledgerEntries - Ledger entries for this line * @property {Array<Object>} ledgerEntries - Ledger entries for this line
*/ */
@@ -22,6 +53,7 @@ import artikelAutomat from '../automats/artikelAutomat';
* @property {number} bookedPositions * @property {number} bookedPositions
* @property {number} totalQuantity * @property {number} totalQuantity
* @property {number} bookedQuantity * @property {number} bookedQuantity
* @property {boolean} hasTrackStock
* @property {Array<DeliveryLineLedgerStatus>} lines * @property {Array<DeliveryLineLedgerStatus>} lines
*/ */
@@ -48,6 +80,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
bookedPositions: 0, bookedPositions: 0,
totalQuantity: 0, totalQuantity: 0,
bookedQuantity: 0, bookedQuantity: 0,
hasTrackStock: false,
lines: [], lines: [],
}; };
} }
@@ -56,20 +89,43 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
const linesWithLedger = await Promise.all( const linesWithLedger = await Promise.all(
rawLines.map(async (line) => { rawLines.map(async (line) => {
let ledgerEntries = []; let ledgerEntries = [];
try { let article = null;
const ledgerRes = await ledgerApi.listByManifestarticleId(line.id, 'DELIVERY');
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : []; const promises = [
} catch (err) { ledgerApi.listByManifestarticleId(line.id).catch((err) => {
console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err); console.error(`[deliveryLedger] Failed to fetch ledger for line ${line.id}:`, err);
return null;
}),
];
if (line.sku) {
const cached = getArticle(line.sku);
if (cached) {
article = cached;
} else {
promises.push(
artikelAutomat.actions.loadArticle(line.sku).catch(() => null)
);
}
} }
const deliveryEntries = ledgerEntries.filter( const results = await Promise.all(promises);
(e) => String(e.transaction_type).toUpperCase() === 'DELIVERY' const ledgerRes = results[0];
); if (results[1]) {
article = results[1];
}
ledgerEntries = Array.isArray(ledgerRes?.ledger) ? ledgerRes.ledger : [];
const trackStock = isLineStockTracked(line, article);
const deliveryEntries = ledgerEntries.filter((e) => {
const t = String(e.transaction_type).toUpperCase();
return t === 'ACCEPTANCE' || t === 'RETURN' || t === 'DELIVERY';
});
const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0); const bookedAmount = deliveryEntries.reduce((sum, e) => sum + Number(e.amount || 0), 0);
const targetAmount = Number(line.amount || 0); const targetAmount = Number(line.amount || 0);
const remainingAmount = Math.max(0, targetAmount - bookedAmount); const remainingAmount = trackStock ? Math.max(0, targetAmount - bookedAmount) : 0;
const isBooked = targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0; const isBooked = trackStock ? (targetAmount > 0 ? bookedAmount >= targetAmount : bookedAmount > 0) : true;
return { return {
...line, ...line,
@@ -77,18 +133,22 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
bookedAmount, bookedAmount,
remainingAmount, remainingAmount,
isBooked, isBooked,
trackStock,
isBookable: trackStock,
ledgerEntries: deliveryEntries, ledgerEntries: deliveryEntries,
}; };
}) })
); );
const totalPositions = linesWithLedger.length; const stockLines = linesWithLedger.filter((l) => l.trackStock);
const bookedPositions = linesWithLedger.filter((l) => l.isBooked).length; const totalPositions = stockLines.length;
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0); const bookedPositions = stockLines.filter((l) => l.isBooked).length;
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0); const totalQuantity = stockLines.reduce((sum, l) => sum + l.targetAmount, 0);
const bookedQuantity = stockLines.reduce((sum, l) => sum + l.bookedAmount, 0);
const hasTrackStock = stockLines.length > 0;
let status; let status;
if (totalPositions === 0) { if (!hasTrackStock) {
status = 'empty'; status = 'empty';
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) { } else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
status = 'booked'; status = 'booked';
@@ -106,18 +166,19 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
bookedPositions, bookedPositions,
totalQuantity, totalQuantity,
bookedQuantity, bookedQuantity,
hasTrackStock,
lines: linesWithLedger, lines: linesWithLedger,
}; };
} }
/** /**
* Books specified delivery lines to the ledger as goods receipts (DELIVERY). * Books specified delivery lines to the ledger as goods receipts via /stock/accept.
* *
* @param {Object} params * @param {Object} params
* @param {Object} params.delivery - The delivery object (used for transaction memo and fallback) * @param {Object} params.delivery - The delivery object (used for transaction memo and fallback)
* @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook * @param {Array<{ manifestarticleId?: number, id?: number, amount: number, sku?: string }>} params.linesToBook
* @param {number|string} params.warehouseId - Mandatory warehouse ID * @param {number|string} params.warehouseId - Mandatory warehouse ID
* @param {Object} [params.ledgerApi] * @param {Object} [params.stockApi]
* @param {string} [params.transactionText] * @param {string} [params.transactionText]
* @returns {Promise<Array<Object>>} * @returns {Promise<Array<Object>>}
*/ */
@@ -125,7 +186,7 @@ export async function bookDeliveryLines({
delivery, delivery,
linesToBook, linesToBook,
warehouseId, warehouseId,
ledgerApi = defaultLedgerApi, stockApi = defaultStockApi,
transactionText, transactionText,
}) { }) {
const numericWarehouseId = Number(warehouseId); const numericWarehouseId = Number(warehouseId);
@@ -143,28 +204,25 @@ export async function bookDeliveryLines({
: `Lieferung ${delivery?.number || delivery?.id || ''}` : `Lieferung ${delivery?.number || delivery?.id || ''}`
).trim(); ).trim();
const results = []; const items = linesToBook
for (const item of linesToBook) { .map((item) => ({
const manifestarticleId = Number(item.manifestarticleId ?? item.id); manifestarticleId: Number(item.manifestarticleId ?? item.id),
const amount = Number(item.amount); warehouseId: numericWarehouseId,
text: memo,
}))
.filter((item) => item.manifestarticleId && Number.isInteger(item.manifestarticleId) && item.manifestarticleId > 0);
if (!manifestarticleId || !Number.isFinite(amount) || amount <= 0) { if (items.length === 0) {
continue; return [];
} }
const res = await ledgerApi.add({ const res = await stockApi.accept({ items });
manifestarticle_id: manifestarticleId,
warehouse_id: numericWarehouseId,
transaction_type: 'DELIVERY',
amount,
transaction_text: memo,
});
results.push(res?.ledger ?? res);
for (const item of linesToBook) {
if (item.sku) { if (item.sku) {
void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {}); void artikelAutomat.actions.reloadArticle(item.sku).catch(() => {});
} }
} }
return results; return Array.isArray(res?.accepted) ? res.accepted : (res ? [res] : []);
} }

View File

@@ -505,6 +505,12 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/vfs/upload' && method === 'POST') { if (p === '/api/vfs/upload' && method === 'POST') {
return json(res, 200, { files: [] }); return json(res, 200, { files: [] });
} }
if (p === '/api/vfs/skills/restore' && method === 'POST') {
return json(res, 200, { success: true, restored: ['system.SKILL'], count: 1 });
}
if (p === '/api/vfs/applets/restore' && method === 'POST') {
return json(res, 200, { success: true, restored: ['example.applet.js'], count: 1 });
}
if (p.startsWith('/api/vfs/') && method === 'PUT') { if (p.startsWith('/api/vfs/') && method === 'PUT') {
return json(res, 200, { ok: true }); return json(res, 200, { ok: true });
} }

View File

@@ -6,6 +6,7 @@ import { defineConfig, searchForWorkspaceRoot, createLogger } from 'vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from 'vite-plugin-pwa';
import pc from 'picocolors'; import pc from 'picocolors';
import qrcode from 'qrcode';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const useHttps = process.env.VITE_HTTPS === '1'; const useHttps = process.env.VITE_HTTPS === '1';
@@ -94,6 +95,7 @@ const sharedResolve = {
const sharedServer = { const sharedServer = {
// HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https) // HTTP :4209 — HTTPS :4308 (self-signed; see npm run cert / dev:https)
host: true,
port: useHttps ? 4308 : 4209, port: useHttps ? 4308 : 4209,
https: httpsOptions(), https: httpsOptions(),
allowedHosts: true, allowedHosts: true,
@@ -266,6 +268,20 @@ function pidReporterPlugin() {
server.config.logger.info( server.config.logger.info(
` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`, ` ${pc.green('➜')} ${pc.bold('PID')}: ${pc.cyan(process.pid)}${pmId}`,
); );
const networkUrls = server.resolvedUrls?.network ?? [];
const primaryUrl =
networkUrls.find((u) => !u.includes('172.17.') && !u.includes('169.254.')) ||
networkUrls[0] ||
server.resolvedUrls?.local?.[0];
if (primaryUrl) {
qrcode.toString(primaryUrl, { type: 'terminal', small: true }, (err, qr) => {
if (!err && qr) {
server.config.logger.info(
`\n ${pc.bold('Scan to open on mobile')} ${pc.dim(`(${primaryUrl})`)}:\n` + qr,
);
}
});
}
}; };
}; };