Compare commits
3 Commits
be111e8f7c
...
0d0c5dfdfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0c5dfdfb | ||
|
|
c79ece307c | ||
|
|
bc4a1e0468 |
@@ -7,7 +7,7 @@ description: >-
|
||||
|
||||
# Dash API Routes Overview
|
||||
|
||||
Overview of all 141 API endpoints across all modules.
|
||||
Overview of all 167 API endpoints across all modules.
|
||||
|
||||
### Parameter Notation
|
||||
|
||||
@@ -20,26 +20,28 @@ Overview of all 141 API endpoints across all modules.
|
||||
| Method | Route | Params | Return Type | Summary |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category |
|
||||
| `GET` | `/article/export.zip` | `query: categoryId?:integer` | `200 zip` | Export articles as ZIP |
|
||||
| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU |
|
||||
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Get article by SKU |
|
||||
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Update article by SKU |
|
||||
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Get article by SKU |
|
||||
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Update article by SKU |
|
||||
| `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article |
|
||||
| `PUT` | `/article/{sku}/articlelist` | `sku:string, body: { items: { articleId?, sku?: string, count?, sort? }[] }` | `200 { items: object[] }` | Set Stückliste (bundle items) for an article |
|
||||
| `GET` | `/article/{sku}/attributevalue` | `sku:string` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List article attribute values |
|
||||
| `PUT` | `/article/{sku}/attributevalue` | `sku:string, body: { attributevalues: any[] }` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set article attribute values |
|
||||
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set or clear article brand by SKU |
|
||||
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article categories by SKU |
|
||||
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set or clear article brand by SKU |
|
||||
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article categories by SKU |
|
||||
| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article |
|
||||
| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article |
|
||||
| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article |
|
||||
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace article EANs by SKU |
|
||||
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article EANs by SKU |
|
||||
| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article |
|
||||
| `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU |
|
||||
| `POST` | `/article/{sku}/image` | `sku:string, image/avif` | `201 { image: { picId, sort, full, thumb, hash, thumbHash } }` | Add article image |
|
||||
| `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image |
|
||||
| `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images |
|
||||
| `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article |
|
||||
| `PUT` | `/article/{sku}/optionslist` | `sku:string, body: { options: { articleId?, sku?: string, count?, sort?, mandated? }[] }` | `200 { options: object[] }` | Set options list for an article |
|
||||
| `PUT` | `/article/{sku}/prices` | `sku:string, body: { prices: { price, tax, minmultiplier? }[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Replace POS prices by SKU |
|
||||
| `PUT` | `/article/{sku}/prices` | `sku:string, body: { prices: { price, tax, minmultiplier? }[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace POS prices by SKU |
|
||||
| `POST` | `/article/{sku}/print` | `sku:string, body: { templateId, printerId, quantity }` | `200` | Print article label |
|
||||
| `GET` | `/article/{sku}/property` | `sku:string` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | List article properties |
|
||||
| `PUT` | `/article/{sku}/property` | `sku:string, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | Bulk set article properties |
|
||||
@@ -49,8 +51,8 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `PUT` | `/article/{sku}/supplier/order` | `sku:string, body: { ids: integer[] }` | `200 { ids: integer[] }` | Reorder article suppliers |
|
||||
| `DELETE` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer` | `204 void` | Remove supplier assignment from article |
|
||||
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
|
||||
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set article track_stock flag by SKU |
|
||||
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: object[], eans: string[], prices: object[], suppliers: object[] } }` | Set article weight by SKU |
|
||||
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
|
||||
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
|
||||
| `GET` | `/articleIndex` | — | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[] }` | List article search index |
|
||||
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
|
||||
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
|
||||
@@ -64,10 +66,13 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `PATCH` | `/attributevalue/{id}` | `id:integer, body: { value?, sort? }` | `200 { attributevalue: { id, attributeId, value, sort } }` | Update attribute value |
|
||||
| `POST` | `/auth/login` | `body: { idToken: string }` | `200 { message, user }` | Login with Google ID token |
|
||||
| `POST` | `/auth/logout` | — | `200 { message: string }` | Logout |
|
||||
| `GET` | `/auth/me` | — | `200 { user, googleClientId }` | Current user |
|
||||
| `PUT` | `/auth/selected-company` | `body: { companyId: integer }` | `200 { user: { id, email, selectedCompanyId, picture } }` | Set selected company |
|
||||
| `GET` | `/brand` | — | `200 { brands: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] }[] }` | List brands |
|
||||
| `PUT` | `/brand` | `body: { name, gsprpinfo? }` | `200 { brand: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] } }` | Upsert brand |
|
||||
| `DELETE` | `/brand/{id}` | `id:integer` | `204 void` | Delete brand |
|
||||
| `POST` | `/category` | `body: { parentId, name }` | `201 { category: { id, parentId, sort, name } }` | Create category |
|
||||
| `GET` | `/category/export.zip` | — | `200 zip` | Export categories as ZIP |
|
||||
| `PUT` | `/category/order` | `body: { parentId, categoryIds: integer[] }` | `200 { categoryIds: integer[] }` | Reorder sibling categories |
|
||||
| `GET` | `/category/tree` | — | `200 { languageId, tree: object[], articles: object[] }` | Category tree |
|
||||
| `DELETE` | `/category/{id}` | `id:integer` | `204 void` | Delete category |
|
||||
@@ -76,13 +81,16 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `GET` | `/category/{id}/attributevalue` | `id:integer` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List category attribute values |
|
||||
| `PUT` | `/category/{id}/attributevalue` | `id:integer, body: { attributevalues: any[] }` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set category attribute values |
|
||||
| `DELETE` | `/category/{id}/image` | `id:integer` | `200 { category: { id, thumbW, thumbH, thumbHash } }` | Delete category image |
|
||||
| `POST` | `/category/{id}/image` | `id:integer, image/avif` | `201 { category: { id, thumbW, thumbH, thumbHash } }` | Add category image |
|
||||
| `GET` | `/category/{id}/property` | `id:integer` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | List category properties |
|
||||
| `PUT` | `/category/{id}/property` | `id:integer, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | Bulk set category properties |
|
||||
| `DELETE` | `/category/{id}/property/{propertyId}` | `id:integer, propertyId:integer` | `200` | Delete category property |
|
||||
| `GET` | `/company` | — | `200 { companies: { id, iso639, name, taxrate, reducedtaxrate }[] }` | List companies |
|
||||
| `POST` | `/company` | `body: { iso639, name, taxrate, reducedtaxrate }` | `201 { company, user }` | Create company |
|
||||
| `GET` | `/company/setting` | — | `200 { settings: { key, value }[] }` | List company settings |
|
||||
| `GET` | `/company/setting/{key}` | `key:string` | `200 { setting: { key, value } }` | Get company setting by key |
|
||||
| `PUT` | `/company/setting/{key}` | `key:string, body: { value, frontend? }` | `200 { setting: { key, value } }` | Upsert company setting |
|
||||
| `DELETE` | `/company/{id}` | `id:integer` | `204 void` | Delete company |
|
||||
| `PUT` | `/company/{id}` | `id:integer, body: { iso639, name, taxrate, reducedtaxrate }` | `200 { company: { id, iso639, name, taxrate, reducedtaxrate } }` | Update company |
|
||||
| `GET` | `/customergroup` | — | `200 { customergroups: { id, name, sort, editable }[] }` | List customer groups |
|
||||
| `POST` | `/customergroup` | `body: { name: string }` | `201 { customergroup: { id, name, sort } }` | Create customer group |
|
||||
@@ -91,6 +99,7 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `PATCH` | `/customergroup/{id}` | `id:integer, body: { name: string }` | `200 { customergroup: { id, name, sort } }` | Update customer group |
|
||||
| `GET` | `/delivery` | — | `200 { deliveries: { id, number, type, supplierId, externalNumber, date, createdAt, ledger }[] }` | List deliveries |
|
||||
| `POST` | `/delivery` | `body: { externalNumber, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], supplierId?, files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `201 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Create delivery |
|
||||
| `POST` | `/delivery/parse` | `form: { file: string }` | `200 SSE` | Parse document (SSE) |
|
||||
| `DELETE` | `/delivery/{id}` | `id:integer` | `204 void` | Delete delivery |
|
||||
| `GET` | `/delivery/{id}` | `id:integer` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Get delivery |
|
||||
| `PATCH` | `/delivery/{id}` | `id:integer, body: { externalNumber?, date?, supplierId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Update delivery |
|
||||
@@ -109,6 +118,9 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `DELETE` | `/entity/{id}` | `id:integer` | `204 void` | Delete entity |
|
||||
| `GET` | `/entity/{id}` | `id:integer` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Get entity |
|
||||
| `PATCH` | `/entity/{id}` | `id:integer, body: { name?, firstName?, number? }` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Update entity |
|
||||
| `GET` | `/events` | — | `200 SSE` | Subscribe to update signals (SSE) |
|
||||
| `GET` | `/health` | — | `200 { ok: boolean }` | Database health check |
|
||||
| `GET` | `/image/{fullHash}` | `fullHash:string` | `200 avif` | Full image by hash |
|
||||
| `GET` | `/labeltemplate` | — | `200 { labeltemplates: { id, name, width, height, dpi, type, content, thumb }[] }` | List label templates |
|
||||
| `POST` | `/labeltemplate` | `body: { name, width, height, dpi, type, content }` | `201 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Create label template |
|
||||
| `DELETE` | `/labeltemplate/{id}` | `id:integer` | `204 void` | Delete label template |
|
||||
@@ -126,6 +138,13 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `DELETE` | `/order/{id}` | `id:integer` | `204 void` | Delete order |
|
||||
| `GET` | `/order/{id}` | `id:integer` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get order |
|
||||
| `PATCH` | `/order/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update order |
|
||||
| `GET` | `/posterminal` | — | `200 { posterminals: { id, name, pairingpin, categoryIds: integer[] }[] }` | List POS terminals |
|
||||
| `PUT` | `/posterminal` | `body: { name: string }` | `200 { posterminal: { id, name, pairingpin, categoryIds: integer[] } }` | Create POS terminal |
|
||||
| `DELETE` | `/posterminal/{id}` | `id:integer` | `204 void` | Delete POS terminal |
|
||||
| `DELETE` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Disable all categories on terminal |
|
||||
| `PUT` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Enable all categories on terminal |
|
||||
| `DELETE` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Disable category on terminal |
|
||||
| `PUT` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Enable category on terminal |
|
||||
| `GET` | `/printer` | — | `200 { printers: { id, company_id, address, type, model, serial, sort, desc }[] }` | List printers |
|
||||
| `POST` | `/printer` | `body: { address, type, model?, serial?, sort?, desc? }` | `201 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Create printer |
|
||||
| `PUT` | `/printer/order` | `body: { printerIds: integer[] }` | `200 { printerIds: integer[] }` | Reorder printers |
|
||||
@@ -143,6 +162,8 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `DELETE` | `/quote/{id}` | `id:integer` | `204 void` | Delete quote |
|
||||
| `GET` | `/quote/{id}` | `id:integer` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get quote |
|
||||
| `PATCH` | `/quote/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update quote |
|
||||
| `POST` | `/serper/image` | `body: { url: string }` | `200 avif` | Crawl image from URL, crop white/transparency borders, and return AVIF |
|
||||
| `POST` | `/serper/images` | `body: { q, gl?, hl?, num?, preferSource? }` | `200 SSE` | Search images via Serper.dev and stream WebP thumbnails (SSE) |
|
||||
| `POST` | `/stock/adjust` | `body: { sku?: string, warehouseId?, amount?, text?, lines?: { sku: string, amount, warehouseId?, text? }[] }` | `201 { manifest, lines: object[], ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Adjust stock manually |
|
||||
| `GET` | `/supplier` | — | `200 { suppliers: { id, name, createdAt, updatedAt }[] }` | List suppliers |
|
||||
| `POST` | `/supplier` | `body: { name: string }` | `201 { supplier: { id, name, createdAt, updatedAt } }` | Create supplier |
|
||||
@@ -150,9 +171,14 @@ Overview of all 141 API endpoints across all modules.
|
||||
| `PATCH` | `/supplier/{id}` | `id:integer, body: { name: string }` | `200 { supplier: { id, name, createdAt, updatedAt } }` | Update supplier |
|
||||
| `GET` | `/supplier/{id}/articles` | `id:integer, query: skus?` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | List supplier articles by SKUs |
|
||||
| `POST` | `/supplier/{id}/articles` | `id:integer, body?: { skus?: string[] }` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | Query supplier articles by SKUs (bulk) |
|
||||
| `GET` | `/thumb/{fullHash}` | `fullHash:string` | `200 avif` | Thumb image by hash |
|
||||
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
|
||||
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
|
||||
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
|
||||
| `GET` | `/v1/category` | `query: lastChangedCategory?:string, limit?:integer` | `200 object[]` | JTL-POS category synchronization |
|
||||
| `GET` | `/v1/client` | `query: authCode:string, name?:string` | `200 { authCode, authToken, certificateFingerprint, certificateSerialNumber, mandantId, mandantName, mandantDatabase, serverFingerprint, name, serverTimestamp }` | POS discovery and pairing |
|
||||
| `GET` | `/v1/init` | `query: lastChangedProduct?:integer, lastChangedCategory?:integer, lastChangedCustomer?:integer, lastChangedCustomerGroup?:integer, lastChangedCompositeProduct?:integer, lastChangedDeletedEntity?:integer` | `200 { version, product_count, category_count, customer_count, customerGroup_count, compositeProduct_count, configurationGroup_count, configurationItem_count, deletedEntity_count, max_orderId_count }` | POS sync initialization counts |
|
||||
| `GET` | `/v1/product` | `query: lastChangedProduct?:string, limit?:integer` | `200 object[]` | JTL-POS product catalog synchronization |
|
||||
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
|
||||
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
|
||||
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
|
||||
|
||||
71
package-lock.json
generated
71
package-lock.json
generated
@@ -15,6 +15,8 @@
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"ag-grid-community": "^36.2.0",
|
||||
"ag-grid-react": "^36.2.0",
|
||||
"dashgui": "file:../dashgui",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"notistack": "^3.0.2",
|
||||
@@ -24,6 +26,7 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
||||
"sortablejs": "^1.15.7",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
@@ -79,6 +82,34 @@
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"../react-virtual-aggridautomat": {
|
||||
"version": "0.0.0",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"ag-grid-community": "^36.2.0",
|
||||
"ag-grid-react": "^36.2.0",
|
||||
"marked": "^18.0.14",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.2.8",
|
||||
"react-automat": "github:sebseb7/react-automat",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"eslint": "^10.10.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.6",
|
||||
"globals": "^17.12.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.69.0",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -3848,6 +3879,42 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-charts-types": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-14.2.0.tgz",
|
||||
"integrity": "sha512-4pDV6/4zbzPN7t607Gr1o6DO9RhmPafQGr+hIpTJBaerbaMoC+WOO3v/wkxFbvCNrHKOexEKSfNTH7PaAR4UzA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ag-grid-community": {
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-36.2.0.tgz",
|
||||
"integrity": "sha512-tK0Toj7t0fV/OMLPnDc0xlYKAqDbnCT3iJodYSn2TJ541caU8jPdYwWhMdW+AP2+pUNwFiJVgc04eJNfvBPsWw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-charts-types": "14.2.0",
|
||||
"ag-stack": "36.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-grid-react": {
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-36.2.0.tgz",
|
||||
"integrity": "sha512-7dsaR4Ga8/bJAOov7XJqxsDc1JAjUU5Bmh9+s4U9+/TKIdSsaVo53SuZb2yEFSB1elv3eh6aZP8a1fqGZehXwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ag-grid-community": "36.2.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ag-stack": {
|
||||
"version": "36.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ag-stack/-/ag-stack-36.2.0.tgz",
|
||||
"integrity": "sha512-f3XT2htFs9dUPcHni2z9ak69QNta5ODEpooHuuNvbjN98JkokpEqRQ9YLQHQJFdCcgi9Ybs/j3zkkvmNRamIPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
@@ -7412,6 +7479,10 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtual-aggridautomat": {
|
||||
"resolved": "../react-virtual-aggridautomat",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"@mui/icons-material": "^9.4.0",
|
||||
"@mui/material": "^9.4.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"ag-grid-community": "^36.2.0",
|
||||
"ag-grid-react": "^36.2.0",
|
||||
"dashgui": "file:../dashgui",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"notistack": "^3.0.2",
|
||||
@@ -31,6 +33,7 @@
|
||||
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
|
||||
"sortablejs": "^1.15.7",
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
|
||||
@@ -215,6 +215,32 @@ export const article = {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/history
|
||||
* History of all articles with paging/sorting/filtering
|
||||
*/
|
||||
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (startRow != null) params.set('startRow', String(startRow));
|
||||
if (endRow != null) params.set('endRow', String(endRow));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* GET /api/article/:sku/history
|
||||
*/
|
||||
getBySkuHistory(sku, { limit } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
const qs = params.toString();
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
|
||||
/** POST /api/article → 201 { article } */
|
||||
create({ parentId, name, track_stock }) {
|
||||
return http('/api/article', {
|
||||
@@ -1797,6 +1823,24 @@ export const labeltemplate = {
|
||||
// thumb is returned inline as a base64 string in list/get responses
|
||||
};
|
||||
|
||||
export const globalHistory = {
|
||||
/**
|
||||
* GET /api/history
|
||||
* Global system/entity history with paging/sorting/filtering
|
||||
*/
|
||||
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (startRow != null) params.set('startRow', String(startRow));
|
||||
if (endRow != null) params.set('endRow', String(endRow));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
if (limit != null) params.set('limit', String(limit));
|
||||
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
|
||||
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
|
||||
const qs = params.toString();
|
||||
return http(`/api/history${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
auth,
|
||||
article,
|
||||
@@ -1822,4 +1866,5 @@ export default {
|
||||
unit,
|
||||
warehouse,
|
||||
stock,
|
||||
globalHistory,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,11 @@ import KundeListePage from './routes/kunde';
|
||||
import KundeNeuPage from './routes/kunde/neu';
|
||||
import KundeGruppenPage from './routes/kunde/gruppen';
|
||||
import KundeEditPage from './routes/kunde/_entityId';
|
||||
import InfosPage from './routes/infos';
|
||||
import {
|
||||
ArticleHistoryPage,
|
||||
GlobalHistoryPage,
|
||||
StatisticsPage,
|
||||
} from './routes/infos';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -42,7 +46,16 @@ const routes = [
|
||||
{ path: ':deliveryId', element: <LieferungEditPage /> },
|
||||
],
|
||||
},
|
||||
{ path: 'infos', element: <InfosPage /> },
|
||||
{
|
||||
path: 'infos',
|
||||
element: <SectionLayout sectionPath="/infos" />,
|
||||
children: [
|
||||
{ index: true, element: <ArticleHistoryPage /> },
|
||||
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
|
||||
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
|
||||
{ path: 'statistics', element: <StatisticsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'kategorien/*',
|
||||
element: <SectionLayout sectionPath="/artikel" />,
|
||||
|
||||
34
src/automats/articleHistoryAutomat.js
Normal file
34
src/automats/articleHistoryAutomat.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createGridAutomat } from 'react-virtual-aggridautomat';
|
||||
import { article } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
export const articleHistoryGridAutomat = createGridAutomat({
|
||||
name: 'articleHistory',
|
||||
blockSize: 50,
|
||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
||||
try {
|
||||
const res = await article.getHistory({
|
||||
startRow,
|
||||
endRow,
|
||||
sortModel,
|
||||
filterModel,
|
||||
});
|
||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
||||
return { rows, lastRow };
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch article history (backend route pending):', err);
|
||||
return { rows: [], lastRow: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Reload on company switch or auth change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
articleHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
});
|
||||
|
||||
export default articleHistoryGridAutomat;
|
||||
34
src/automats/globalHistoryAutomat.js
Normal file
34
src/automats/globalHistoryAutomat.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createGridAutomat } from 'react-virtual-aggridautomat';
|
||||
import { globalHistory } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
export const globalHistoryGridAutomat = createGridAutomat({
|
||||
name: 'globalHistory',
|
||||
blockSize: 50,
|
||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
||||
try {
|
||||
const res = await globalHistory.list({
|
||||
startRow,
|
||||
endRow,
|
||||
sortModel,
|
||||
filterModel,
|
||||
});
|
||||
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
|
||||
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
|
||||
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
|
||||
return { rows, lastRow };
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch global history (backend route pending):', err);
|
||||
return { rows: [], lastRow: 0 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Reload on company switch or auth change
|
||||
authAutomat.subscribe((auth) => {
|
||||
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
|
||||
globalHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
});
|
||||
|
||||
export default globalHistoryGridAutomat;
|
||||
@@ -70,3 +70,6 @@ export {
|
||||
invalidateAllCategoryViews,
|
||||
} from './categoryAutomat';
|
||||
|
||||
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
|
||||
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
|
||||
|
||||
|
||||
@@ -65,13 +65,13 @@ class ArticlePhoto extends Component {
|
||||
fill || objectFit === 'cover'
|
||||
? { position: 'absolute', inset: 0 }
|
||||
: {
|
||||
position: 'absolute',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
aspectRatio,
|
||||
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
||||
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
||||
}
|
||||
position: 'absolute',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
aspectRatio,
|
||||
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
||||
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
||||
}
|
||||
}
|
||||
>
|
||||
<ThumbHashImage thumbHash={photo.thumbHash} />
|
||||
@@ -87,21 +87,21 @@ class ArticlePhoto extends Component {
|
||||
sx={
|
||||
fill || objectFit === 'cover'
|
||||
? {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
: {
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -121,13 +121,13 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = trimmed
|
||||
? bookableArticles.filter((a) => {
|
||||
const skuMatch = a.sku?.toLowerCase().includes(trimmed);
|
||||
const nameMatch = a.name?.toLowerCase().includes(trimmed);
|
||||
const eanMatch = Array.isArray(a.eans)
|
||||
? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
|
||||
: false;
|
||||
return skuMatch || nameMatch || eanMatch;
|
||||
})
|
||||
const skuMatch = a.sku?.toLowerCase().includes(trimmed);
|
||||
const nameMatch = a.name?.toLowerCase().includes(trimmed);
|
||||
const eanMatch = Array.isArray(a.eans)
|
||||
? a.eans.some((e) => String(e).toLowerCase().includes(trimmed))
|
||||
: false;
|
||||
return skuMatch || nameMatch || eanMatch;
|
||||
})
|
||||
: bookableArticles.slice(0, 50);
|
||||
|
||||
const rawSku = line?.sku != null ? String(line.sku).trim() : '';
|
||||
@@ -270,8 +270,6 @@ export default class ArticleSearchDialog extends PureComponent {
|
||||
<ArticlePhoto
|
||||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||
size={34}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
|
||||
@@ -67,7 +67,7 @@ class ManifestFormLine extends PureComponent {
|
||||
const current = getArticle(sku);
|
||||
this.setState({ cachedArticle: current });
|
||||
if (!current) {
|
||||
artikelAutomat.actions.loadArticle(sku).catch(() => {});
|
||||
artikelAutomat.actions.loadArticle(sku).catch(() => { });
|
||||
}
|
||||
this.unsubArticle = artikelAutomat.subscribe(this, (state) => ({
|
||||
cachedArticle: createArticleSelector(sku)(state),
|
||||
@@ -118,8 +118,8 @@ class ManifestFormLine extends PureComponent {
|
||||
isBooked
|
||||
? `Vollständig im Lager gebucht: ${ledgerLine.bookedAmount} Stück (Klicken für Details)`
|
||||
: isPartial
|
||||
? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
|
||||
: `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
|
||||
? `Teilweise im Lager gebucht: ${ledgerLine.bookedAmount} von ${ledgerLine.targetAmount} Stück (Klicken zum Einbuchen dieser Position)`
|
||||
: `Noch nicht im Lager gebucht (${ledgerLine.targetAmount} Stück – Klicken zum Einbuchen dieser Position)`
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
@@ -128,9 +128,9 @@ class ManifestFormLine extends PureComponent {
|
||||
onClick={
|
||||
isClickable
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
onBookLine(ledgerLine);
|
||||
}
|
||||
e.stopPropagation();
|
||||
onBookLine(ledgerLine);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
@@ -144,8 +144,8 @@ class ManifestFormLine extends PureComponent {
|
||||
isBooked
|
||||
? `Lager: ${ledgerLine.bookedAmount} Stk. gebucht`
|
||||
: isPartial
|
||||
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
|
||||
: 'Lager: offen'
|
||||
? `Lager: ${ledgerLine.bookedAmount}/${ledgerLine.targetAmount} Stk.`
|
||||
: 'Lager: offen'
|
||||
}
|
||||
color={isBooked ? 'success' : isPartial ? 'warning' : 'default'}
|
||||
variant="outlined"
|
||||
@@ -157,12 +157,12 @@ class ManifestFormLine extends PureComponent {
|
||||
bgcolor: isBooked ? 'success.50' : undefined,
|
||||
'&:hover': isClickable
|
||||
? {
|
||||
bgcolor: isBooked
|
||||
? 'success.100'
|
||||
: isPartial
|
||||
bgcolor: isBooked
|
||||
? 'success.100'
|
||||
: isPartial
|
||||
? 'warning.100'
|
||||
: 'action.hover',
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
@@ -239,10 +239,10 @@ class ManifestFormLine extends PureComponent {
|
||||
MozAppearance: 'textfield',
|
||||
},
|
||||
'& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button':
|
||||
{
|
||||
WebkitAppearance: 'none',
|
||||
margin: 0,
|
||||
},
|
||||
{
|
||||
WebkitAppearance: 'none',
|
||||
margin: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -282,9 +282,9 @@ class ManifestFormLine extends PureComponent {
|
||||
const vkNetto = vkPrice?.net;
|
||||
const vkBrutto = vkPrice
|
||||
? priceBrutto(vkPrice) ??
|
||||
(vkPrice.net != null && vkPrice.taxrate != null
|
||||
? vkPrice.net * (1 + vkPrice.taxrate / 100)
|
||||
: null)
|
||||
(vkPrice.net != null && vkPrice.taxrate != null
|
||||
? vkPrice.net * (1 + vkPrice.taxrate / 100)
|
||||
: null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -335,8 +335,6 @@ class ManifestFormLine extends PureComponent {
|
||||
<ArticlePhoto
|
||||
photo={{ fullHash, thumbHash, thumbW, thumbH }}
|
||||
size={28}
|
||||
fill
|
||||
objectFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<Inventory2OutlinedIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
@@ -5,8 +6,205 @@ import Typography from '@mui/material/Typography';
|
||||
import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../article/articleRoute';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { decodeThumbHash } from '../article/ThumbHashImage';
|
||||
import { BrandLabel } from '../brand';
|
||||
import { thumb } from '../../api';
|
||||
|
||||
const MIN_EDGE_STRETCH = 10;
|
||||
|
||||
function drawContainedWithStretchedEdges(
|
||||
ctx,
|
||||
source,
|
||||
srcW,
|
||||
srcH,
|
||||
dstW,
|
||||
dstH,
|
||||
minStretch = MIN_EDGE_STRETCH,
|
||||
) {
|
||||
ctx.clearRect(0, 0, dstW, dstH);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
const stretch = Math.min(minStretch, Math.max(0, Math.floor(Math.min(dstW, dstH) / 4)));
|
||||
const innerW = dstW - stretch * 2;
|
||||
const innerH = dstH - stretch * 2;
|
||||
const scale = Math.min(innerW / srcW, innerH / srcH);
|
||||
const fitW = srcW * scale;
|
||||
const fitH = srcH * scale;
|
||||
const offsetX = (dstW - fitW) / 2;
|
||||
const offsetY = (dstH - fitH) / 2;
|
||||
|
||||
const leftW = Math.ceil(offsetX);
|
||||
const rightX = Math.floor(offsetX + fitW);
|
||||
const rightW = dstW - rightX;
|
||||
const topH = Math.ceil(offsetY);
|
||||
const botY = Math.floor(offsetY + fitH);
|
||||
const botH = dstH - botY;
|
||||
|
||||
// Edge fill: a strongly blurred layer, crossfaded smoothly into a lightly
|
||||
// blurred layer near the image (masked by a blurred rect, no visible seams).
|
||||
// Rects overdraw past the canvas and under the center so blur never fades
|
||||
// into transparency.
|
||||
const maxBlur = Math.max(2, stretch * 0.6);
|
||||
const drawEdges = (c, blur) => {
|
||||
const pad = Math.ceil(blur * 3);
|
||||
c.filter = `blur(${blur}px)`;
|
||||
|
||||
// 1. Four corners (1x1 source border pixels stretched)
|
||||
c.drawImage(source, 0, 0, 1, 1, -pad, -pad, leftW + pad * 2, topH + pad * 2);
|
||||
c.drawImage(source, srcW - 1, 0, 1, 1, rightX - pad, -pad, rightW + pad * 2, topH + pad * 2);
|
||||
c.drawImage(source, 0, srcH - 1, 1, 1, -pad, botY - pad, leftW + pad * 2, botH + pad * 2);
|
||||
c.drawImage(
|
||||
source, srcW - 1, srcH - 1, 1, 1,
|
||||
rightX - pad, botY - pad, rightW + pad * 2, botH + pad * 2,
|
||||
);
|
||||
|
||||
// 2. Four edge bars (stretched 1px border rows/columns)
|
||||
c.drawImage(source, 0, 0, srcW, 1, offsetX, -pad, fitW, topH + pad * 2);
|
||||
c.drawImage(source, 0, srcH - 1, srcW, 1, offsetX, botY - pad, fitW, botH + pad * 2);
|
||||
c.drawImage(source, 0, 0, 1, srcH, -pad, offsetY, leftW + pad * 2, fitH);
|
||||
c.drawImage(source, srcW - 1, 0, 1, srcH, rightX - pad, offsetY, rightW + pad * 2, fitH);
|
||||
|
||||
c.filter = 'none';
|
||||
};
|
||||
|
||||
drawEdges(ctx, maxBlur);
|
||||
|
||||
const light = document.createElement('canvas');
|
||||
light.width = dstW;
|
||||
light.height = dstH;
|
||||
const lctx = light.getContext('2d');
|
||||
if (lctx) {
|
||||
drawEdges(lctx, Math.max(0.5, maxBlur * 0.15));
|
||||
// Mask: blurred rect around the image -> opaque near it, fading outward.
|
||||
const grow = maxBlur * 0.5;
|
||||
lctx.globalCompositeOperation = 'destination-in';
|
||||
lctx.filter = `blur(${maxBlur}px)`;
|
||||
lctx.fillStyle = '#000';
|
||||
lctx.fillRect(
|
||||
offsetX - grow,
|
||||
offsetY - grow,
|
||||
fitW + grow * 2,
|
||||
fitH + grow * 2,
|
||||
);
|
||||
lctx.filter = 'none';
|
||||
ctx.drawImage(light, 0, 0);
|
||||
}
|
||||
|
||||
// 3. Center image (aspect ratio preserved / contained)
|
||||
ctx.drawImage(source, 0, 0, srcW, srcH, offsetX, offsetY, fitW, fitH);
|
||||
}
|
||||
|
||||
class ArticleTileBackdrop extends PureComponent {
|
||||
canvasRef = null;
|
||||
img = null;
|
||||
observer = null;
|
||||
|
||||
componentDidMount() {
|
||||
this.loadImage();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.fullHash !== this.props.fullHash) {
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.observer?.disconnect();
|
||||
this.img = null;
|
||||
}
|
||||
|
||||
loadImage() {
|
||||
const { fullHash } = this.props;
|
||||
if (fullHash) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (this.img === img) {
|
||||
this.paint();
|
||||
}
|
||||
};
|
||||
img.src = thumb.url(fullHash);
|
||||
this.img = img;
|
||||
if (img.complete && img.naturalWidth) {
|
||||
this.paint();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.paint();
|
||||
}
|
||||
|
||||
setCanvas = (canvas) => {
|
||||
this.canvasRef = canvas;
|
||||
if (canvas && typeof ResizeObserver !== 'undefined') {
|
||||
this.observer?.disconnect();
|
||||
this.observer = new ResizeObserver(() => this.paint());
|
||||
this.observer.observe(canvas);
|
||||
}
|
||||
this.paint();
|
||||
};
|
||||
|
||||
paint = () => {
|
||||
const canvas = this.canvasRef;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dstW = Math.round(rect.width);
|
||||
const dstH = Math.round(rect.height);
|
||||
if (!dstW || !dstH) return;
|
||||
|
||||
canvas.width = dstW;
|
||||
canvas.height = dstH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const img = this.img;
|
||||
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
|
||||
drawContainedWithStretchedEdges(
|
||||
ctx,
|
||||
img,
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
dstW,
|
||||
dstH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { thumbHash } = this.props;
|
||||
if (thumbHash) {
|
||||
const decoded = decodeThumbHash(thumbHash);
|
||||
if (decoded) {
|
||||
const { w, h, rgba } = decoded;
|
||||
const temp = document.createElement('canvas');
|
||||
temp.width = w;
|
||||
temp.height = h;
|
||||
temp.getContext('2d').putImageData(
|
||||
new ImageData(new Uint8ClampedArray(rgba), w, h),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Box
|
||||
component="canvas"
|
||||
ref={this.setCanvas}
|
||||
aria-hidden
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ArticleTile extends Tile {
|
||||
hasPhoto() {
|
||||
@@ -81,10 +279,7 @@ export class ArticleTile extends Tile {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hasPhoto()) {
|
||||
return super.render();
|
||||
}
|
||||
|
||||
const hasPhoto = this.hasPhoto();
|
||||
const { article, label, sx, disabled } = this.props;
|
||||
const sku = articleSku(article);
|
||||
|
||||
@@ -104,15 +299,25 @@ export class ArticleTile extends Tile {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', inset: 0 }}>
|
||||
<ArticlePhoto
|
||||
fill
|
||||
photo={{
|
||||
fullHash: article.fullHash,
|
||||
thumbW: article.thumbW,
|
||||
thumbH: article.thumbH,
|
||||
thumbHash: article.thumbHash,
|
||||
}}
|
||||
/>
|
||||
{hasPhoto ? (
|
||||
<ArticleTileBackdrop
|
||||
fullHash={article.fullHash}
|
||||
thumbHash={article.thumbHash}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pb: 4,
|
||||
}}
|
||||
>
|
||||
{this.renderIcon()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{this.renderExtra()}
|
||||
<Box
|
||||
@@ -125,6 +330,8 @@ export class ArticleTile extends Tile {
|
||||
py: 0.75,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.65))',
|
||||
textAlign: 'left',
|
||||
color: '#fff',
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||
}}
|
||||
>
|
||||
{article?.brand?.name ? (
|
||||
@@ -136,7 +343,13 @@ export class ArticleTile extends Tile {
|
||||
typographyProps={{
|
||||
variant: 'caption',
|
||||
noWrap: true,
|
||||
sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 },
|
||||
sx: {
|
||||
color: '#b3e5fc',
|
||||
opacity: 1,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.1,
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||
},
|
||||
}}
|
||||
sx={{ mb: 0.25 }}
|
||||
/>
|
||||
@@ -146,16 +359,40 @@ export class ArticleTile extends Tile {
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ color: '#fff', lineHeight: 1.2 }}
|
||||
sx={{
|
||||
color: '#111',
|
||||
lineHeight: 1.2,
|
||||
textShadow: '0 0 3px rgba(255, 255, 255, 1), 0 0 6px rgba(255, 255, 255, 0.9), 0 1px 2px rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
{sku ? (
|
||||
<Typography variant="caption" noWrap sx={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
noWrap
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||
}}
|
||||
>
|
||||
{sku}
|
||||
</Typography>
|
||||
) : null}
|
||||
{article.net != null && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
fontWeight={600}
|
||||
component="div"
|
||||
sx={{
|
||||
color: '#b9f6ca',
|
||||
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
||||
}}
|
||||
>
|
||||
{formatPrice(article.net)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
);
|
||||
|
||||
@@ -66,8 +66,8 @@ function TopBand({ onSettingsClick, settingsTab, terminalCount, onPlacementClick
|
||||
color: isDeleteMode
|
||||
? 'error.main'
|
||||
: isTerminalMode || isPhotoMode
|
||||
? 'primary.main'
|
||||
: 'common.white',
|
||||
? 'primary.main'
|
||||
: 'common.white',
|
||||
}}
|
||||
>
|
||||
{isTerminalMode ? (
|
||||
@@ -114,10 +114,10 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
|
||||
px: 1,
|
||||
...(isActionTab
|
||||
? {
|
||||
'&:hover': {
|
||||
bgcolor: settingsTab === -3 ? 'rgba(211, 47, 47, 0.12)' : 'grey.400',
|
||||
},
|
||||
}
|
||||
'&:hover': {
|
||||
bgcolor: settingsTab === -3 ? 'rgba(211, 47, 47, 0.12)' : 'grey.400',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
@@ -10,16 +9,27 @@ import TextField from '@mui/material/TextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import BugReportIcon from '@mui/icons-material/BugReport';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { notifyError } from '../utils/notify';
|
||||
import AutomatEntry from './inspector/AutomatEntry';
|
||||
import ActionLogPanel from './inspector/ActionLogPanel';
|
||||
import {
|
||||
actionLog,
|
||||
callListeners,
|
||||
formatTime,
|
||||
getRegistry,
|
||||
installActionLogging,
|
||||
logListeners,
|
||||
notifyLog,
|
||||
watchRegistry,
|
||||
} from './inspector/actionLog';
|
||||
import { formatState } from './inspector/serialize';
|
||||
|
||||
export { formatState };
|
||||
|
||||
/**
|
||||
* Dev-only overlay that lists every Automat registered in the
|
||||
@@ -32,295 +42,6 @@ import { notifyError } from '../utils/notify';
|
||||
/** Registry polling interval in ms; lazy automats (`delivery-<id>`) appear over time. */
|
||||
const REGISTRY_POLL_MS = 2000;
|
||||
|
||||
/** Serialization limits so huge states stay renderable. */
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_STRING = 1500;
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
/**
|
||||
* Live registry view. Named automats register themselves in the constructor,
|
||||
* so the map may be absent until the first one is created.
|
||||
*
|
||||
* @returns {Map<string, import('react-automat').Automat>} Registered automats.
|
||||
*/
|
||||
function getRegistry() {
|
||||
const registry = window.__AUTOMATS__;
|
||||
return registry instanceof Map ? registry : new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes a string and truncates oversized values.
|
||||
*
|
||||
* @param {string} text Value to quote.
|
||||
* @returns {string} Quoted, possibly truncated string.
|
||||
*/
|
||||
function quote(text) {
|
||||
if (text.length <= MAX_STRING) return JSON.stringify(text);
|
||||
const rest = text.length - MAX_STRING;
|
||||
return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `entries` as an indented block.
|
||||
*
|
||||
* @param {string} open Opening bracket.
|
||||
* @param {string} close Closing bracket.
|
||||
* @param {Array<string>} entries Already serialized entries.
|
||||
* @param {string} pad Indentation of the enclosing level.
|
||||
* @returns {string} Indented block.
|
||||
*/
|
||||
function block(open, close, entries, pad) {
|
||||
if (entries.length === 0) return `${open}${close}`;
|
||||
const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
|
||||
return `${open}\n${body}\n${pad}${close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an arbitrary state value for display. Handles functions, blobs,
|
||||
* DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
|
||||
* would drop or throw on.
|
||||
*
|
||||
* @param {*} value Value to serialize.
|
||||
* @param {number} depth Current nesting depth.
|
||||
* @param {WeakSet<object>} ancestors Objects on the current path, for cycle detection.
|
||||
* @param {string} pad Indentation of the current level.
|
||||
* @returns {string} Human-readable representation.
|
||||
*/
|
||||
function serialize(value, depth, ancestors, pad) {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
|
||||
const type = typeof value;
|
||||
if (type === 'string') return quote(value);
|
||||
if (type === 'number' || type === 'boolean') return String(value);
|
||||
if (type === 'bigint') return `${value}n`;
|
||||
if (type === 'function') return `[Function ${value.name || 'anonym'}]`;
|
||||
if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`;
|
||||
if (type !== 'object') return String(value);
|
||||
|
||||
if (value instanceof Date) return `[Date ${value.toISOString()}]`;
|
||||
if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`;
|
||||
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`;
|
||||
if (value instanceof Promise) return '[Promise]';
|
||||
if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`;
|
||||
|
||||
if (ancestors.has(value)) return '[Circular]';
|
||||
if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]';
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (value instanceof Map) {
|
||||
const shown = Array.from(value.entries()).slice(0, MAX_ITEMS);
|
||||
const entries = shown.map(
|
||||
([k, v]) =>
|
||||
`${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`,
|
||||
);
|
||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||
return block(`Map(${value.size}) {`, '}', entries, pad);
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
const shown = Array.from(value.values()).slice(0, MAX_ITEMS);
|
||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||
return block(`Set(${value.size}) {`, '}', entries, pad);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, MAX_ITEMS);
|
||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||
if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`);
|
||||
return block('[', ']', entries, pad);
|
||||
}
|
||||
|
||||
const keys = Object.keys(value);
|
||||
const shown = keys.slice(0, MAX_ITEMS);
|
||||
const entries = shown.map(
|
||||
(key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`,
|
||||
);
|
||||
if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`);
|
||||
|
||||
const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : '';
|
||||
return block(`${label}{`, '}', entries, pad);
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an automat state for the overlay. Serialization failures are surfaced
|
||||
* instead of being swallowed.
|
||||
*
|
||||
* @param {*} value Automat state.
|
||||
* @returns {string} Display text.
|
||||
*/
|
||||
export function formatState(value) {
|
||||
try {
|
||||
return serialize(value, 0, new WeakSet(), '');
|
||||
} catch (err) {
|
||||
return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum number of retained action log entries (ring buffer). */
|
||||
const MAX_LOG = 300;
|
||||
|
||||
/** Marks action functions that already carry the logging proxy. */
|
||||
const LOGGED = Symbol('automatInspectorLogged');
|
||||
|
||||
/** Compact single-line rendering limit for args/results in the log. */
|
||||
const LOG_VALUE_LIMIT = 240;
|
||||
|
||||
/**
|
||||
* Ring buffer of captured action calls, oldest first. Kept at module scope so
|
||||
* entries survive component re-renders (and are shared by every inspector).
|
||||
*
|
||||
* @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
|
||||
*/
|
||||
const actionLog = [];
|
||||
|
||||
/** Monotonic id source for log entries. */
|
||||
let logSeq = 0;
|
||||
|
||||
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
|
||||
const logListeners = new Set();
|
||||
|
||||
/** Notifies every log listener, swallowing individual errors. */
|
||||
function notifyLog() {
|
||||
for (const listener of logListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* listeners are dev-only; never let one break logging */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an entry to the ring buffer and notifies listeners.
|
||||
*
|
||||
* @param {object} entry Log entry.
|
||||
*/
|
||||
function pushLog(entry) {
|
||||
actionLog.push(entry);
|
||||
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
|
||||
notifyLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a value as a compact single-line string for the log.
|
||||
*
|
||||
* @param {*} value Value to summarize.
|
||||
* @param {number} [limit] Maximum length before truncation.
|
||||
* @returns {string} Compact representation.
|
||||
*/
|
||||
function compact(value, limit = LOG_VALUE_LIMIT) {
|
||||
const text = formatState(value).replace(/\s+/g, ' ').trim();
|
||||
return text.length > limit ? `${text.slice(0, limit)} …` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as `HH:MM:SS.mmm`.
|
||||
*
|
||||
* @param {Date} date Timestamp.
|
||||
* @returns {string} Formatted time.
|
||||
*/
|
||||
function formatTime(date) {
|
||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps every action of an automat with a logging proxy that records call
|
||||
* arguments, duration, return value and errors. Idempotent: already wrapped
|
||||
* actions are left untouched, and the original function is always invoked with
|
||||
* the caller's `this` and arguments.
|
||||
*
|
||||
* @param {import('react-automat').Automat} automat Automat instance.
|
||||
*/
|
||||
function installActionLogging(automat) {
|
||||
const actions = automat.actions;
|
||||
if (!actions) return;
|
||||
for (const key of Object.keys(actions)) {
|
||||
const original = actions[key];
|
||||
if (typeof original !== 'function' || original[LOGGED]) continue;
|
||||
|
||||
const wrapped = function loggedAction(...args) {
|
||||
const started = performance.now();
|
||||
const entry = {
|
||||
id: ++logSeq,
|
||||
time: new Date(),
|
||||
automat: automat.name ?? '(anonym)',
|
||||
action: key,
|
||||
args: compact(args),
|
||||
status: 'running',
|
||||
duration: null,
|
||||
result: '',
|
||||
error: null,
|
||||
};
|
||||
pushLog(entry);
|
||||
|
||||
const settle = (status, payload, err) => {
|
||||
entry.status = status;
|
||||
entry.duration = performance.now() - started;
|
||||
if (status === 'error') entry.error = err?.message ?? String(err);
|
||||
else entry.result = compact(payload);
|
||||
notifyLog();
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = original.apply(this, args);
|
||||
} catch (err) {
|
||||
settle('error', undefined, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.then(
|
||||
(value) => {
|
||||
settle('ok', value);
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
settle('error', undefined, err);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
settle('ok', result);
|
||||
return result;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(wrapped, 'name', { value: key });
|
||||
} catch {
|
||||
/* name is cosmetic */
|
||||
}
|
||||
wrapped[LOGGED] = true;
|
||||
actions[key] = wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the registry Map once its `set` has been wrapped. */
|
||||
const WATCHED = Symbol('automatInspectorWatched');
|
||||
|
||||
/**
|
||||
* Wraps the live registry's `set` so automats created after mount get their
|
||||
* actions instrumented immediately instead of on the next poll tick.
|
||||
*/
|
||||
function watchRegistry() {
|
||||
const registry = window.__AUTOMATS__;
|
||||
if (!(registry instanceof Map) || registry[WATCHED]) return;
|
||||
const originalSet = registry.set.bind(registry);
|
||||
registry.set = (name, automat) => {
|
||||
const result = originalSet(name, automat);
|
||||
installActionLogging(automat);
|
||||
return result;
|
||||
};
|
||||
registry[WATCHED] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating dev button plus a docked side panel listing all registered Automats
|
||||
@@ -343,7 +64,10 @@ class AutomatInspector extends PureComponent {
|
||||
revision: 0,
|
||||
logOpen: false,
|
||||
log: [],
|
||||
flash: {},
|
||||
};
|
||||
this.handleActionCall = this.handleActionCall.bind(this);
|
||||
this.handleRunAction = this.handleRunAction.bind(this);
|
||||
|
||||
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
|
||||
this.unsubs = new Map();
|
||||
@@ -372,13 +96,42 @@ class AutomatInspector extends PureComponent {
|
||||
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
logListeners.add(this.scheduleLogUpdate);
|
||||
callListeners.add(this.handleActionCall);
|
||||
this.scheduleLogUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumps the flash counter of an action so its chip replays the flash animation.
|
||||
*
|
||||
* @param {string} automat Automat name.
|
||||
* @param {string} action Action name.
|
||||
*/
|
||||
handleActionCall(automat, action) {
|
||||
if (!this.state.open) return;
|
||||
const id = `${automat}.${action}`;
|
||||
this.setState((prev) => ({ flash: { ...prev.flash, [id]: (prev.flash[id] ?? 0) + 1 } }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers a parameterless action manually.
|
||||
*
|
||||
* @param {string} name Automat name.
|
||||
* @param {string} action Action name.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleRunAction(name, action) {
|
||||
try {
|
||||
await getRegistry().get(name)?.actions?.[action]?.();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.registryTimer);
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
logListeners.delete(this.scheduleLogUpdate);
|
||||
callListeners.delete(this.handleActionCall);
|
||||
this.unsubscribeAll();
|
||||
if (this.frame) cancelAnimationFrame(this.frame);
|
||||
if (this.logFrame) cancelAnimationFrame(this.logFrame);
|
||||
@@ -561,221 +314,10 @@ class AutomatInspector extends PureComponent {
|
||||
* @param {boolean} isExpanded Whether the details are visible.
|
||||
* @returns {React.ReactElement} Entry element.
|
||||
*/
|
||||
renderEntry(automat, isExpanded) {
|
||||
const name = automat.name;
|
||||
const actionNames = Object.keys(automat.actions ?? {});
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={name}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => this.handleToggleEntry(name)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontWeight: 600, flexGrow: 1, minWidth: 0 }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={`subs ${automat.subscriberCount}`} />
|
||||
<Chip
|
||||
size="small"
|
||||
color={automat.isReady ? 'success' : 'default'}
|
||||
variant={automat.isReady ? 'filled' : 'outlined'}
|
||||
label={automat.isReady ? 'ready' : 'unready'}
|
||||
/>
|
||||
{automat.isDirty ? <Chip size="small" color="warning" label="dirty" /> : null}
|
||||
</ButtonBase>
|
||||
|
||||
{isExpanded ? (
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
|
||||
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
|
||||
{automat.error ? (
|
||||
<Chip size="small" color="error" label={`error: ${automat.error?.message ?? automat.error}`} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||
Aktionen ({actionNames.length})
|
||||
</Typography>
|
||||
<Tooltip title="State als Text kopieren">
|
||||
<IconButton size="small" onClick={() => this.handleCopy(name)}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||
{actionNames.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
keine
|
||||
</Typography>
|
||||
) : (
|
||||
actionNames.map((action) => <Chip key={action} size="small" label={action} />)
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
State
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{formatState(automat.state)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one captured action call as a single log line.
|
||||
*
|
||||
* @param {object} entry Log entry.
|
||||
* @returns {React.ReactElement} Log line.
|
||||
*/
|
||||
renderLogLine(entry) {
|
||||
const color =
|
||||
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
|
||||
return (
|
||||
<Box key={entry.id} component="div">
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
{formatTime(entry.time)}{' '}
|
||||
</Box>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>
|
||||
{entry.automat}.{entry.action}
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
({entry.args})
|
||||
</Box>
|
||||
{entry.status === 'error' ? (
|
||||
<Box component="span" sx={{ color: 'error.main' }}>
|
||||
{' '}
|
||||
✗ {entry.error}
|
||||
</Box>
|
||||
) : entry.status === 'ok' ? (
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
{' '}
|
||||
→ {entry.result}
|
||||
</Box>
|
||||
) : null}{' '}
|
||||
<Box component="span" sx={{ color, ml: 0.5 }}>
|
||||
{entry.status === 'running'
|
||||
? '…'
|
||||
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bottom-docked, collapsible log of every captured automat action call. */
|
||||
renderLog() {
|
||||
const { logOpen, log } = this.state;
|
||||
const errorCount = log.filter((entry) => entry.status === 'error').length;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={this.handleToggleLog}
|
||||
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
|
||||
>
|
||||
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
Action-Log
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={log.length} />
|
||||
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
|
||||
</ButtonBase>
|
||||
<Tooltip title="Log als Text kopieren">
|
||||
<span>
|
||||
<IconButton size="small" onClick={this.handleCopyLog} disabled={log.length === 0}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Log leeren">
|
||||
<span>
|
||||
<IconButton size="small" onClick={this.handleClearLog} disabled={log.length === 0}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{logOpen ? (
|
||||
<Box
|
||||
ref={this.logRef}
|
||||
component="div"
|
||||
sx={{
|
||||
m: 0,
|
||||
mx: 1.5,
|
||||
mb: 1,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
maxHeight: '40vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{log.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Noch keine Action-Aufrufe protokolliert.
|
||||
</Typography>
|
||||
) : (
|
||||
log.map((entry) => this.renderLogLine(entry))
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Docked side panel with the automat list, filter and action log. */
|
||||
renderPanel() {
|
||||
const { names, expanded, query } = this.state;
|
||||
const { names, expanded, query, flash, logOpen, log } = this.state;
|
||||
const registry = getRegistry();
|
||||
const needle = query.trim().toLowerCase();
|
||||
const entries = names
|
||||
@@ -848,12 +390,29 @@ class AutomatInspector extends PureComponent {
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
|
||||
{entries.map((automat) => (
|
||||
<AutomatEntry
|
||||
key={automat.name}
|
||||
automat={automat}
|
||||
isExpanded={Boolean(expanded[automat.name])}
|
||||
flash={flash}
|
||||
onToggle={this.handleToggleEntry}
|
||||
onCopy={this.handleCopy}
|
||||
onRunAction={this.handleRunAction}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{this.renderLog()}
|
||||
<ActionLogPanel
|
||||
logOpen={logOpen}
|
||||
log={log}
|
||||
logRef={this.logRef}
|
||||
onToggle={this.handleToggleLog}
|
||||
onCopy={this.handleCopyLog}
|
||||
onClear={this.handleClearLog}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -892,3 +451,4 @@ class AutomatInspector extends PureComponent {
|
||||
}
|
||||
|
||||
export default AutomatInspector;
|
||||
|
||||
|
||||
129
src/devtools/inspector/ActionLogPanel.jsx
Normal file
129
src/devtools/inspector/ActionLogPanel.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { formatTime } from './actionLog';
|
||||
|
||||
/**
|
||||
* Renders one captured action call as a single log line.
|
||||
*
|
||||
* @param {{entry: object}} props Component props.
|
||||
* @returns {React.ReactElement} Log line.
|
||||
*/
|
||||
function LogLine({ entry }) {
|
||||
const color =
|
||||
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
|
||||
return (
|
||||
<Box component="div">
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
{formatTime(entry.time)}{' '}
|
||||
</Box>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>
|
||||
{entry.automat}.{entry.action}
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
({entry.args})
|
||||
</Box>
|
||||
{entry.status === 'error' ? (
|
||||
<Box component="span" sx={{ color: 'error.main' }}>
|
||||
{' '}
|
||||
✗ {entry.error}
|
||||
</Box>
|
||||
) : entry.status === 'ok' ? (
|
||||
<Box component="span" sx={{ color: 'text.disabled' }}>
|
||||
{' '}
|
||||
→ {entry.result}
|
||||
</Box>
|
||||
) : null}{' '}
|
||||
<Box component="span" sx={{ color, ml: 0.5 }}>
|
||||
{entry.status === 'running'
|
||||
? '…'
|
||||
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom-docked, collapsible log of every captured automat action call.
|
||||
*
|
||||
* @param {object} props Component props.
|
||||
* @param {boolean} props.logOpen Whether the log body is visible.
|
||||
* @param {Array<object>} props.log Log entries.
|
||||
* @param {import('react').RefObject<HTMLElement>} props.logRef Ref of the scroll container.
|
||||
* @param {() => void} props.onToggle Toggles the log.
|
||||
* @param {() => void} props.onCopy Copies the log.
|
||||
* @param {() => void} props.onClear Clears the log.
|
||||
* @returns {React.ReactElement} Log panel.
|
||||
*/
|
||||
export default function ActionLogPanel({ logOpen, log, logRef, onToggle, onCopy, onClear }) {
|
||||
const errorCount = log.filter((entry) => entry.status === 'error').length;
|
||||
|
||||
return (
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={onToggle}
|
||||
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
|
||||
>
|
||||
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
<Typography variant="caption" sx={{ fontWeight: 600 }}>
|
||||
Action-Log
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={log.length} />
|
||||
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
|
||||
</ButtonBase>
|
||||
<Tooltip title="Log als Text kopieren">
|
||||
<span>
|
||||
<IconButton size="small" onClick={onCopy} disabled={log.length === 0}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Log leeren">
|
||||
<span>
|
||||
<IconButton size="small" onClick={onClear} disabled={log.length === 0}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{logOpen ? (
|
||||
<Box
|
||||
ref={logRef}
|
||||
component="div"
|
||||
sx={{
|
||||
m: 0,
|
||||
mx: 1.5,
|
||||
mb: 1,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
maxHeight: '40vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{log.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Noch keine Action-Aufrufe protokolliert.
|
||||
</Typography>
|
||||
) : (
|
||||
log.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
154
src/devtools/inspector/AutomatEntry.jsx
Normal file
154
src/devtools/inspector/AutomatEntry.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { ARITY } from './actionLog';
|
||||
import { formatState } from './serialize';
|
||||
|
||||
/**
|
||||
* One automat entry: header chips, flashing/clickable actions and the state dump.
|
||||
*
|
||||
* @param {object} props Component props.
|
||||
* @param {import('react-automat').Automat} props.automat Automat instance.
|
||||
* @param {boolean} props.isExpanded Whether the details are visible.
|
||||
* @param {Object<string, number>} props.flash Per-action call counters driving the flash animation.
|
||||
* @param {(name: string) => void} props.onToggle Toggles the entry.
|
||||
* @param {(name: string) => void} props.onCopy Copies the state.
|
||||
* @param {(name: string, action: string) => void} props.onRunAction Runs a parameterless action.
|
||||
* @returns {React.ReactElement} Entry element.
|
||||
*/
|
||||
export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onCopy, onRunAction }) {
|
||||
const name = automat.name;
|
||||
const actionNames = Object.keys(automat.actions ?? {});
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
bgcolor: 'background.paper',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={() => onToggle(name)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 1,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
sx={{ fontFamily: 'monospace', fontWeight: 600, flexGrow: 1, minWidth: 0 }}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
<Chip size="small" variant="outlined" label={`subs ${automat.subscriberCount}`} />
|
||||
<Chip
|
||||
size="small"
|
||||
color={automat.isReady ? 'success' : 'default'}
|
||||
variant={automat.isReady ? 'filled' : 'outlined'}
|
||||
label={automat.isReady ? 'ready' : 'unready'}
|
||||
/>
|
||||
{automat.isDirty ? <Chip size="small" color="warning" label="dirty" /> : null}
|
||||
</ButtonBase>
|
||||
|
||||
{isExpanded ? (
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', px: 1.25, py: 1 }}>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||
{automat.url ? <Chip size="small" variant="outlined" label={`url: ${automat.url}`} /> : null}
|
||||
{automat.persist ? <Chip size="small" variant="outlined" label="persist" /> : null}
|
||||
{automat.error ? (
|
||||
<Chip size="small" color="error" label={`error: ${automat.error?.message ?? automat.error}`} />
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||
Aktionen ({actionNames.length})
|
||||
</Typography>
|
||||
<Tooltip title="State als Text kopieren">
|
||||
<IconButton size="small" onClick={() => onCopy(name)}>
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap" sx={{ mb: 1 }}>
|
||||
{actionNames.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
keine
|
||||
</Typography>
|
||||
) : (
|
||||
actionNames.map((action) => {
|
||||
const fn = automat.actions[action];
|
||||
const runnable = fn?.[ARITY] === 0;
|
||||
const count = flash[`${name}.${action}`] ?? 0;
|
||||
return (
|
||||
<Chip
|
||||
// Changing key remounts the chip so the CSS animation replays on every call.
|
||||
key={`${action}-${count}`}
|
||||
size="small"
|
||||
label={action}
|
||||
color={runnable ? 'primary' : 'default'}
|
||||
variant={runnable ? 'outlined' : 'filled'}
|
||||
onClick={runnable ? () => onRunAction(name, action) : undefined}
|
||||
sx={
|
||||
count > 0
|
||||
? {
|
||||
'@keyframes automatFlash': {
|
||||
'0%': { backgroundColor: 'warning.main', boxShadow: '0 0 8px 2px orange' },
|
||||
'100%': { boxShadow: '0 0 0 0 transparent' },
|
||||
},
|
||||
animation: 'automatFlash 0.7s ease-out',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
State
|
||||
</Typography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
m: 0,
|
||||
mt: 0.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{formatState(automat.state)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
187
src/devtools/inspector/actionLog.js
Normal file
187
src/devtools/inspector/actionLog.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { formatState } from './serialize';
|
||||
|
||||
/**
|
||||
* Live registry view. Named automats register themselves in the constructor,
|
||||
* so the map may be absent until the first one is created.
|
||||
*
|
||||
* @returns {Map<string, import('react-automat').Automat>} Registered automats.
|
||||
*/
|
||||
export function getRegistry() {
|
||||
const registry = window.__AUTOMATS__;
|
||||
return registry instanceof Map ? registry : new Map();
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of retained action log entries (ring buffer). */
|
||||
const MAX_LOG = 300;
|
||||
|
||||
/** Marks action functions that already carry the logging proxy. */
|
||||
const LOGGED = Symbol('automatInspectorLogged');
|
||||
|
||||
/** Compact single-line rendering limit for args/results in the log. */
|
||||
const LOG_VALUE_LIMIT = 240;
|
||||
|
||||
/**
|
||||
* Ring buffer of captured action calls, oldest first. Kept at module scope so
|
||||
* entries survive component re-renders (and are shared by every inspector).
|
||||
*
|
||||
* @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
|
||||
*/
|
||||
export const actionLog = [];
|
||||
|
||||
/** Monotonic id source for log entries. */
|
||||
let logSeq = 0;
|
||||
|
||||
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
|
||||
export const logListeners = new Set();
|
||||
|
||||
/** Marks the declared parameter count on a wrapped action. */
|
||||
export const ARITY = Symbol('automatInspectorArity');
|
||||
|
||||
/** @type {Set<(automat: string, action: string) => void>} Listeners notified on every action call. */
|
||||
export const callListeners = new Set();
|
||||
|
||||
/** Notifies every log listener, swallowing individual errors. */
|
||||
export function notifyLog() {
|
||||
for (const listener of logListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
/* listeners are dev-only; never let one break logging */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an entry to the ring buffer and notifies listeners.
|
||||
*
|
||||
* @param {object} entry Log entry.
|
||||
*/
|
||||
function pushLog(entry) {
|
||||
actionLog.push(entry);
|
||||
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
|
||||
notifyLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a value as a compact single-line string for the log.
|
||||
*
|
||||
* @param {*} value Value to summarize.
|
||||
* @param {number} [limit] Maximum length before truncation.
|
||||
* @returns {string} Compact representation.
|
||||
*/
|
||||
function compact(value, limit = LOG_VALUE_LIMIT) {
|
||||
const text = formatState(value).replace(/\s+/g, ' ').trim();
|
||||
return text.length > limit ? `${text.slice(0, limit)} …` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp as `HH:MM:SS.mmm`.
|
||||
*
|
||||
* @param {Date} date Timestamp.
|
||||
* @returns {string} Formatted time.
|
||||
*/
|
||||
export function formatTime(date) {
|
||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps every action of an automat with a logging proxy that records call
|
||||
* arguments, duration, return value and errors. Idempotent: already wrapped
|
||||
* actions are left untouched, and the original function is always invoked with
|
||||
* the caller's `this` and arguments.
|
||||
*
|
||||
* @param {import('react-automat').Automat} automat Automat instance.
|
||||
*/
|
||||
export function installActionLogging(automat) {
|
||||
const actions = automat.actions;
|
||||
if (!actions) return;
|
||||
for (const key of Object.keys(actions)) {
|
||||
const original = actions[key];
|
||||
if (typeof original !== 'function' || original[LOGGED]) continue;
|
||||
|
||||
const wrapped = function loggedAction(...args) {
|
||||
const started = performance.now();
|
||||
const entry = {
|
||||
id: ++logSeq,
|
||||
time: new Date(),
|
||||
automat: automat.name ?? '(anonym)',
|
||||
action: key,
|
||||
args: compact(args),
|
||||
status: 'running',
|
||||
duration: null,
|
||||
result: '',
|
||||
error: null,
|
||||
};
|
||||
pushLog(entry);
|
||||
for (const listener of callListeners) {
|
||||
try {
|
||||
listener(entry.automat, key);
|
||||
} catch {
|
||||
/* dev-only */
|
||||
}
|
||||
}
|
||||
|
||||
const settle = (status, payload, err) => {
|
||||
entry.status = status;
|
||||
entry.duration = performance.now() - started;
|
||||
if (status === 'error') entry.error = err?.message ?? String(err);
|
||||
else entry.result = compact(payload);
|
||||
notifyLog();
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = original.apply(this, args);
|
||||
} catch (err) {
|
||||
settle('error', undefined, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (result && typeof result.then === 'function') {
|
||||
return result.then(
|
||||
(value) => {
|
||||
settle('ok', value);
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
settle('error', undefined, err);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
settle('ok', result);
|
||||
return result;
|
||||
};
|
||||
|
||||
try {
|
||||
Object.defineProperty(wrapped, 'name', { value: key });
|
||||
} catch {
|
||||
/* name is cosmetic */
|
||||
}
|
||||
wrapped[LOGGED] = true;
|
||||
wrapped[ARITY] = original.length;
|
||||
actions[key] = wrapped;
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks the registry Map once its `set` has been wrapped. */
|
||||
const WATCHED = Symbol('automatInspectorWatched');
|
||||
|
||||
/**
|
||||
* Wraps the live registry's `set` so automats created after mount get their
|
||||
* actions instrumented immediately instead of on the next poll tick.
|
||||
*/
|
||||
export function watchRegistry() {
|
||||
const registry = window.__AUTOMATS__;
|
||||
if (!(registry instanceof Map) || registry[WATCHED]) return;
|
||||
const originalSet = registry.set.bind(registry);
|
||||
registry.set = (name, automat) => {
|
||||
const result = originalSet(name, automat);
|
||||
installActionLogging(automat);
|
||||
return result;
|
||||
};
|
||||
registry[WATCHED] = true;
|
||||
}
|
||||
118
src/devtools/inspector/serialize.js
Normal file
118
src/devtools/inspector/serialize.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/** Serialization limits so huge states stay renderable. */
|
||||
const MAX_DEPTH = 6;
|
||||
const MAX_STRING = 1500;
|
||||
const MAX_ITEMS = 50;
|
||||
|
||||
/**
|
||||
* Quotes a string and truncates oversized values.
|
||||
*
|
||||
* @param {string} text Value to quote.
|
||||
* @returns {string} Quoted, possibly truncated string.
|
||||
*/
|
||||
function quote(text) {
|
||||
if (text.length <= MAX_STRING) return JSON.stringify(text);
|
||||
const rest = text.length - MAX_STRING;
|
||||
return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders `entries` as an indented block.
|
||||
*
|
||||
* @param {string} open Opening bracket.
|
||||
* @param {string} close Closing bracket.
|
||||
* @param {Array<string>} entries Already serialized entries.
|
||||
* @param {string} pad Indentation of the enclosing level.
|
||||
* @returns {string} Indented block.
|
||||
*/
|
||||
function block(open, close, entries, pad) {
|
||||
if (entries.length === 0) return `${open}${close}`;
|
||||
const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
|
||||
return `${open}\n${body}\n${pad}${close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes an arbitrary state value for display. Handles functions, blobs,
|
||||
* DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
|
||||
* would drop or throw on.
|
||||
*
|
||||
* @param {*} value Value to serialize.
|
||||
* @param {number} depth Current nesting depth.
|
||||
* @param {WeakSet<object>} ancestors Objects on the current path, for cycle detection.
|
||||
* @param {string} pad Indentation of the current level.
|
||||
* @returns {string} Human-readable representation.
|
||||
*/
|
||||
function serialize(value, depth, ancestors, pad) {
|
||||
if (value === null) return 'null';
|
||||
if (value === undefined) return 'undefined';
|
||||
|
||||
const type = typeof value;
|
||||
if (type === 'string') return quote(value);
|
||||
if (type === 'number' || type === 'boolean') return String(value);
|
||||
if (type === 'bigint') return `${value}n`;
|
||||
if (type === 'function') return `[Function ${value.name || 'anonym'}]`;
|
||||
if (type === 'symbol') return `[Symbol ${value.description ?? ''}]`;
|
||||
if (type !== 'object') return String(value);
|
||||
|
||||
if (value instanceof Date) return `[Date ${value.toISOString()}]`;
|
||||
if (value instanceof Blob) return `[Blob ${value.size}B ${value.type || 'ohne Typ'}]`;
|
||||
if (value instanceof ArrayBuffer) return `[ArrayBuffer ${value.byteLength}B]`;
|
||||
if (value instanceof Promise) return '[Promise]';
|
||||
if (typeof Node !== 'undefined' && value instanceof Node) return `[Node ${value.nodeName}]`;
|
||||
|
||||
if (ancestors.has(value)) return '[Circular]';
|
||||
if (depth >= MAX_DEPTH) return Array.isArray(value) ? '[Array …]' : '[Object …]';
|
||||
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (value instanceof Map) {
|
||||
const shown = Array.from(value.entries()).slice(0, MAX_ITEMS);
|
||||
const entries = shown.map(
|
||||
([k, v]) =>
|
||||
`${serialize(k, depth + 1, ancestors, `${pad} `)}: ${serialize(v, depth + 1, ancestors, `${pad} `)}`,
|
||||
);
|
||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||
return block(`Map(${value.size}) {`, '}', entries, pad);
|
||||
}
|
||||
|
||||
if (value instanceof Set) {
|
||||
const shown = Array.from(value.values()).slice(0, MAX_ITEMS);
|
||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||
if (value.size > shown.length) entries.push(`${JSON.stringify('…')}: +${value.size - shown.length} weitere`);
|
||||
return block(`Set(${value.size}) {`, '}', entries, pad);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const shown = value.slice(0, MAX_ITEMS);
|
||||
const entries = shown.map((item) => serialize(item, depth + 1, ancestors, `${pad} `));
|
||||
if (value.length > shown.length) entries.push(`${JSON.stringify('…')}: +${value.length - shown.length} weitere`);
|
||||
return block('[', ']', entries, pad);
|
||||
}
|
||||
|
||||
const keys = Object.keys(value);
|
||||
const shown = keys.slice(0, MAX_ITEMS);
|
||||
const entries = shown.map(
|
||||
(key) => `${JSON.stringify(key)}: ${serialize(value[key], depth + 1, ancestors, `${pad} `)}`,
|
||||
);
|
||||
if (keys.length > shown.length) entries.push(`${JSON.stringify('…')}: +${keys.length - shown.length} weitere Schlüssel`);
|
||||
|
||||
const label = value.constructor && value.constructor !== Object ? `${value.constructor.name} ` : '';
|
||||
return block(`${label}{`, '}', entries, pad);
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an automat state for the overlay. Serialization failures are surfaced
|
||||
* instead of being swallowed.
|
||||
*
|
||||
* @param {*} value Automat state.
|
||||
* @returns {string} Display text.
|
||||
*/
|
||||
export function formatState(value) {
|
||||
try {
|
||||
return serialize(value, 0, new WeakSet(), '');
|
||||
} catch (err) {
|
||||
return `[Serialisierung fehlgeschlagen: ${err?.message ?? err}]`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
@import 'ag-grid-community/styles/ag-grid.css';
|
||||
@import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
|
||||
31
src/nav.js
31
src/nav.js
@@ -17,6 +17,9 @@ import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import LabelIcon from '@mui/icons-material/Label';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import { isKategorienPath } from './components/category/categoryRoute';
|
||||
import { deliveryEditorAutomat } from './automats/deliveryEditorAutomat';
|
||||
|
||||
@@ -229,6 +232,29 @@ export const SUB_NAV = {
|
||||
help: 'Kundengruppen für Staffelpreise und Rabatte verwalten',
|
||||
},
|
||||
],
|
||||
'/infos': [
|
||||
{
|
||||
segment: '',
|
||||
label: 'ArticleHistory',
|
||||
title: 'ArticleHistory',
|
||||
icon: HistoryIcon,
|
||||
help: 'Historie aller Artikeländerungen und Ereignisse',
|
||||
},
|
||||
{
|
||||
segment: 'globalhistory',
|
||||
label: 'GlobalHistory',
|
||||
title: 'GlobalHistory',
|
||||
icon: PublicIcon,
|
||||
help: 'Globale System- und Änderungshistorie',
|
||||
},
|
||||
{
|
||||
segment: 'statistics',
|
||||
label: 'Statistics',
|
||||
title: 'Statistics',
|
||||
icon: BarChartIcon,
|
||||
help: 'Statistische Auswertungen und Kennzahlen',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function isNavItemActive(pathname, navPath) {
|
||||
@@ -281,6 +307,11 @@ export function resolveActiveSub(pathname, sectionPath) {
|
||||
return rest;
|
||||
}
|
||||
|
||||
// Treat /infos/articlehistory as segment ''
|
||||
if (sectionPath === '/infos' && rest === 'articlehistory') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const active = subNav.find((item) => {
|
||||
if (item.segment === 'kategorien') {
|
||||
return isKategorienPath(pathname);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import Typography from '@mui/material/Typography';
|
||||
import PageLayout from '../components/PageLayout';
|
||||
|
||||
export default function InfosPage() {
|
||||
return (
|
||||
<PageLayout title="Infos">
|
||||
<Typography color="text.secondary">Infos — Inhalt folgt.</Typography>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
152
src/routes/infos/ArticleHistoryPage.jsx
Normal file
152
src/routes/infos/ArticleHistoryPage.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
||||
import { articleHistoryGridAutomat } from '../../automats';
|
||||
import { router } from '../../appRouter';
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{
|
||||
headerName: 'ID',
|
||||
field: 'id',
|
||||
maxWidth: 90,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'date',
|
||||
minWidth: 170,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Artikel-Nr.',
|
||||
field: 'sku',
|
||||
minWidth: 130,
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
cellRenderer: (p) => {
|
||||
if (!p.value) return '';
|
||||
return (
|
||||
<a
|
||||
href={`/artikel/${encodeURIComponent(p.value)}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void router.navigate(`/artikel/${encodeURIComponent(p.value)}`);
|
||||
}}
|
||||
style={{ color: '#2563eb', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{p.value}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: 'Artikelname',
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
headerName: 'Änderung / Ereignis',
|
||||
field: 'text',
|
||||
minWidth: 300,
|
||||
flex: 2,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
||||
autoHeight: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Benutzer',
|
||||
field: 'user',
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) =>
|
||||
p.data?.user ||
|
||||
p.data?.employee ||
|
||||
p.data?.companyuser ||
|
||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
export default class ArticleHistoryPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const s = articleHistoryGridAutomat.state;
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
totalRows: s.totalRows,
|
||||
};
|
||||
this.handleReload = this.handleReload.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = articleHistoryGridAutomat.subscribe(this, (state) => ({
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
totalRows: state.totalRows,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
handleReload() {
|
||||
articleHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, totalRows } = this.state;
|
||||
|
||||
const actions = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{loading && <CircularProgress size={18} thickness={5} />}
|
||||
{totalRows >= 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalRows} Einträge
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
disabled={loading}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelTile title="Artikel-Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}>
|
||||
<AutomatGrid
|
||||
automat={articleHistoryGridAutomat}
|
||||
columnDefs={COLUMN_DEFS}
|
||||
rowIdField="id"
|
||||
cacheBlockSize={50}
|
||||
height="100%"
|
||||
width="100%"
|
||||
noRowsOverlayText="Keine Historieneinträge vorhanden"
|
||||
/>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
}
|
||||
137
src/routes/infos/GlobalHistoryPage.jsx
Normal file
137
src/routes/infos/GlobalHistoryPage.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { AutomatGrid } from 'react-virtual-aggridautomat';
|
||||
import { globalHistoryGridAutomat } from '../../automats';
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{
|
||||
headerName: 'ID',
|
||||
field: 'id',
|
||||
maxWidth: 90,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Datum',
|
||||
field: 'date',
|
||||
minWidth: 170,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
valueFormatter: (p) => (p.value ? new Date(p.value).toLocaleString('de-DE') : ''),
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Typ',
|
||||
field: 'type',
|
||||
minWidth: 120,
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
headerName: 'Referenz / Key',
|
||||
field: 'reference',
|
||||
minWidth: 140,
|
||||
maxWidth: 200,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) => p.data?.reference || p.data?.p1 || '',
|
||||
},
|
||||
{
|
||||
headerName: 'Meldung / Beschreibung',
|
||||
field: 'text',
|
||||
minWidth: 300,
|
||||
flex: 2,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) => p.data?.text || p.data?.value || '',
|
||||
cellStyle: { whiteSpace: 'pre-wrap', lineHeight: '1.4' },
|
||||
autoHeight: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Benutzer',
|
||||
field: 'user',
|
||||
maxWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
valueGetter: (p) =>
|
||||
p.data?.user ||
|
||||
p.data?.employee ||
|
||||
(p.data?.companyuserId ? `User #${p.data.companyuserId}` : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
export default class GlobalHistoryPage extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const s = globalHistoryGridAutomat.state;
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
totalRows: s.totalRows,
|
||||
};
|
||||
this.handleReload = this.handleReload.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = globalHistoryGridAutomat.subscribe(this, (state) => ({
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
totalRows: state.totalRows,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub?.();
|
||||
}
|
||||
|
||||
handleReload() {
|
||||
globalHistoryGridAutomat.actions.reload();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, totalRows } = this.state;
|
||||
|
||||
const actions = (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
{loading && <CircularProgress size={18} thickness={5} />}
|
||||
{totalRows >= 0 && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{totalRows} Einträge
|
||||
</Typography>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={this.handleReload}
|
||||
disabled={loading}
|
||||
>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelTile title="Globale Historie" actions={actions} sx={{ p: 0, height: 'calc(100vh - 140px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, width: '100%' }}>
|
||||
<AutomatGrid
|
||||
automat={globalHistoryGridAutomat}
|
||||
columnDefs={COLUMN_DEFS}
|
||||
rowIdField="id"
|
||||
cacheBlockSize={50}
|
||||
height="100%"
|
||||
width="100%"
|
||||
noRowsOverlayText="Keine globalen Historieneinträge vorhanden"
|
||||
/>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
}
|
||||
36
src/routes/infos/StatisticsPage.jsx
Normal file
36
src/routes/infos/StatisticsPage.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
|
||||
export default class StatisticsPage extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<PanelTile title="Statistik">
|
||||
<Box
|
||||
sx={{
|
||||
py: 8,
|
||||
px: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.secondary',
|
||||
textAlign: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<BarChartIcon sx={{ fontSize: 48, opacity: 0.4 }} />
|
||||
<Typography variant="h6" color="text.primary" fontWeight={600}>
|
||||
Statistik & Auswertungen
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 420 }}>
|
||||
Hier entstehen demnächst grafische Auswertungen, Umsatzstatistiken und
|
||||
Bestandskennzahlen.
|
||||
</Typography>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
);
|
||||
}
|
||||
}
|
||||
3
src/routes/infos/index.js
Normal file
3
src/routes/infos/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ArticleHistoryPage } from './ArticleHistoryPage';
|
||||
export { default as GlobalHistoryPage } from './GlobalHistoryPage';
|
||||
export { default as StatisticsPage } from './StatisticsPage';
|
||||
@@ -69,7 +69,9 @@ export function primaryPrice(article) {
|
||||
|
||||
export function priceBrutto(price) {
|
||||
if (price?.net == null || price?.taxRate == null) return null;
|
||||
return price.net * (1 + price.taxRate / 100);
|
||||
const gross = price.net * (1 + price.taxRate / 100);
|
||||
// Round to cents; epsilon guards float noise (e.g. 9.994999999 vs 9.995).
|
||||
return Math.round((gross + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -240,9 +240,14 @@ export function autoCropWhiteAndTransparent(img) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Crop tightly to bounding box
|
||||
const cropW = maxX - minX + 1;
|
||||
const cropH = maxY - minY + 1;
|
||||
// Restore up to 5 pixels border around the detected content
|
||||
const PADDING = 5;
|
||||
const cropX = Math.max(0, minX - PADDING);
|
||||
const cropY = Math.max(0, minY - PADDING);
|
||||
const cropMaxX = Math.min(width - 1, maxX + PADDING);
|
||||
const cropMaxY = Math.min(height - 1, maxY + PADDING);
|
||||
const cropW = cropMaxX - cropX + 1;
|
||||
const cropH = cropMaxY - cropY + 1;
|
||||
|
||||
const croppedCanvas = document.createElement('canvas');
|
||||
croppedCanvas.width = cropW;
|
||||
@@ -253,7 +258,7 @@ export function autoCropWhiteAndTransparent(img) {
|
||||
return canvas;
|
||||
}
|
||||
|
||||
croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH);
|
||||
croppedCtx.drawImage(canvas, cropX, cropY, cropW, cropH, 0, 0, cropW, cropH);
|
||||
return croppedCanvas;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,14 @@ const sharedDeps = [
|
||||
'@emotion/styled',
|
||||
'@mui/material',
|
||||
'@mui/icons-material',
|
||||
'react-automat',
|
||||
];
|
||||
|
||||
const sharedResolve = {
|
||||
dedupe: sharedDeps,
|
||||
alias: {
|
||||
'node:module': path.resolve(__dirname, 'src/utils/emptyNodeModule.js'),
|
||||
'react-automat': path.resolve(__dirname, '../automat'),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user