Compare commits
11 Commits
cd530ed448
...
ee3012670f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee3012670f | ||
|
|
937f434641 | ||
|
|
424609a0f6 | ||
|
|
2dd6533481 | ||
|
|
c0600ecb3b | ||
|
|
56a86fc005 | ||
|
|
f6cb3d3e35 | ||
|
|
e2bf08d340 | ||
|
|
bf7ff349f3 | ||
|
|
bc583a180f | ||
|
|
345d6f5752 |
@@ -7,7 +7,7 @@ description: >-
|
|||||||
|
|
||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 229 API endpoints across all modules.
|
Overview of all 232 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -17,55 +17,60 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
### Reusable types
|
### Reusable types
|
||||||
|
|
||||||
- `T1` = `{ order: T2 }`
|
- `T1` = `{ order: T2 }`
|
||||||
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str, shipping?: T33|null, shippings?: T33[], payment?: T39|null, payments?: T39[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
|
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str, shipping?: T36|null, shippings?: T36[], payment?: T42|null, payments?: T42[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T7[] }`
|
||||||
- `T3` = `{ article: T4 }`
|
- `T3` = `{ article: T4 }`
|
||||||
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T36, brand: T34|null, categoryIds: int[], photos: T31[], eans: str[], prices: T42[], suppliers: T14[] }`
|
- `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T16[] }`
|
||||||
- `T5` = `{ delivery: T6 }`
|
- `T5` = `{ delivery: T6 }`
|
||||||
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T25[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T46 }`
|
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T13[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }`
|
||||||
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T25[], shippings: T33[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
|
- `T7` = `{ id: int, number: str, type: str, date: str, lines: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
|
||||||
- `T8` = `{ labeltemplate: T9 }`
|
- `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }`
|
||||||
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T21, thumb?: str|null }`
|
- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, manual: bool, files: T17, totalSizeBytes: int, durationMs: int }`
|
||||||
- `T10` = `{ quote: T13 }`
|
- `T10` = `{ labeltemplate: T11 }`
|
||||||
- `T11` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
|
||||||
- `T12` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
- `T12` = `{ quote: T15 }`
|
||||||
- `T13` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str }`
|
- `T13` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||||
- `T14` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }`
|
- `T14` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
|
||||||
- `T15` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- `T15` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str }`
|
||||||
- `T16` = `{ ledger: T17[] }`
|
- `T16` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }`
|
||||||
- `T17` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
- `T17` = `{ vfs: T51, storage: T51, database: T44 }`
|
||||||
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[], articleCount?: int }`
|
- `T18` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- `T19` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
|
- `T19` = `{ ledger: T20[] }`
|
||||||
- `T20` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
- `T20` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
|
||||||
- `T21` = `{ objects: T26[] }`
|
- `T21` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
|
||||||
- `T22` = `{ objects: T27[] }`
|
- `T22` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
|
||||||
- `T23` = `{ printer: T29 }`
|
- `T23` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
||||||
- `T24` = `{ entity: T32 }`
|
- `T24` = `{ objects: T29[] }`
|
||||||
- `T25` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
- `T25` = `{ objects: T30[] }`
|
||||||
- `T26` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
- `T26` = `{ printer: T32 }`
|
||||||
- `T27` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
- `T27` = `{ entity: T35 }`
|
||||||
- `T28` = `{ shipping: T33 }`
|
- `T28` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||||
- `T29` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
- `T29` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||||
- `T30` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||||
- `T31` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T31` = `{ shipping: T36 }`
|
||||||
- `T32` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||||
- `T33` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||||
- `T34` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T35` = `{ article: T40 }`
|
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||||
- `T36` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||||
- `T37` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||||
- `T38` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
- `T38` = `{ article: T43 }`
|
||||||
- `T39` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||||
- `T40` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||||
- `T41` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||||
- `T42` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||||
- `T43` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||||
- `T44` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||||
- `T45` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||||
- `T46` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||||
- `T47` = `{ warehouse: T49 }`
|
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T48` = `{ net: num, taxId: int, taxRate?: num|null }`
|
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||||
- `T49` = `{ id: int, name: str, sort: int }`
|
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||||
- `T50` = `{ id: int, categoryIds: int[] }`
|
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||||
|
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||||
|
- `T52` = `{ warehouse: T54 }`
|
||||||
|
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||||
|
- `T54` = `{ id: int, name: str, sort: int }`
|
||||||
|
- `T55` = `{ id: int, categoryIds: int[] }`
|
||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
@@ -95,7 +100,7 @@ _none_
|
|||||||
|
|
||||||
#### Create article in category — `POST /article`
|
#### Create article in category — `POST /article`
|
||||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||||
- Returns: `201 T35`
|
- Returns: `201 T38`
|
||||||
#### Export articles as ZIP — `GET /article/export.zip`
|
#### Export articles as ZIP — `GET /article/export.zip`
|
||||||
- Params: `query: categoryId?: int`
|
- Params: `query: categoryId?: int`
|
||||||
- Returns: `200 zip`
|
- Returns: `200 zip`
|
||||||
@@ -131,10 +136,10 @@ _none_
|
|||||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||||
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
|
||||||
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||||
- Returns: `201 T35`
|
- Returns: `201 T38`
|
||||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
||||||
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||||
- Returns: `200 { article: T40, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- Returns: `200 { article: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||||
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||||
- Returns: `200 { success: bool, deleted: bool }`
|
- Returns: `200 { success: bool, deleted: bool }`
|
||||||
@@ -161,7 +166,7 @@ _none_
|
|||||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
||||||
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||||
- Returns: `200 T35`
|
- Returns: `200 T38`
|
||||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
||||||
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||||
- Returns: `200 T3`
|
- Returns: `200 T3`
|
||||||
@@ -179,10 +184,10 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### List article suppliers — `GET /article/{sku}/supplier`
|
#### List article suppliers — `GET /article/{sku}/supplier`
|
||||||
- Params: `sku: str`
|
- Params: `sku: str`
|
||||||
- Returns: `200 { suppliers: T14[] }`
|
- Returns: `200 { suppliers: T16[] }`
|
||||||
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
#### Assign supplier to article — `POST /article/{sku}/supplier`
|
||||||
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
- Params: `sku: str, body: { entityId: int, sku?: str|null, pu?: num, stock?: num|null, sort?: int|null, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||||
- Returns: `201 { supplier: T14 }`
|
- Returns: `201 { supplier: T16 }`
|
||||||
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
||||||
- Params: `sku: str, body: { ids: int[] }`
|
- Params: `sku: str, body: { ids: int[] }`
|
||||||
- Returns: `200 { ids: int[] }`
|
- Returns: `200 { ids: int[] }`
|
||||||
@@ -191,7 +196,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
|
||||||
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
- Params: `sku: str, id: int, body: { sku?: str|null, pu?: num, stock?: num|null, sort?: int, netPrice?: num|null, taxId?: int|null, taxRate?: num|null }`
|
||||||
- Returns: `200 { supplier: T14 }`
|
- Returns: `200 { supplier: T16 }`
|
||||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||||
- Params: `sku: str, body: { track_stock: bool }`
|
- Params: `sku: str, body: { track_stock: bool }`
|
||||||
- Returns: `200 T3`
|
- Returns: `200 T3`
|
||||||
@@ -203,19 +208,19 @@ _none_
|
|||||||
|
|
||||||
#### List attribute names — `GET /attributename`
|
#### List attribute names — `GET /attributename`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { attributenames: T18[] }`
|
- Returns: `200 { attributenames: T21[] }`
|
||||||
#### Create attribute name — `POST /attributename`
|
#### Create attribute name — `POST /attributename`
|
||||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `201 { attributename: T18 }`
|
- Returns: `201 { attributename: T21 }`
|
||||||
#### Reorder attribute names — `PUT /attributename/order`
|
#### Reorder attribute names — `PUT /attributename/order`
|
||||||
- Params: `body: { attributenameIds: int[] }`
|
- Params: `body: { attributenameIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
#### List values for an attribute — `GET /attributename/{attributeId}/value`
|
||||||
- Params: `attributeId: int`
|
- Params: `attributeId: int`
|
||||||
- Returns: `200 { values: T37[] }`
|
- Returns: `200 { values: T40[] }`
|
||||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||||
- Returns: `201 { attributevalue: T37 }`
|
- Returns: `201 { attributevalue: T40 }`
|
||||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
@@ -224,7 +229,7 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update attribute name — `PATCH /attributename/{id}`
|
#### Update attribute name — `PATCH /attributename/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `200 { attributename: T18 }`
|
- Returns: `200 { attributename: T21 }`
|
||||||
|
|
||||||
### Section: /attributevalue
|
### Section: /attributevalue
|
||||||
|
|
||||||
@@ -233,28 +238,37 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||||
- Returns: `200 { attributevalue: T37 }`
|
- Returns: `200 { attributevalue: T40 }`
|
||||||
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
|
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
|
||||||
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
|
||||||
- Params: `id: int, image/avif`
|
- Params: `id: int, image/avif`
|
||||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
|
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
|
||||||
|
|
||||||
### Section: /auth
|
### Section: /auth
|
||||||
|
|
||||||
#### Login with Google ID token — `POST /auth/login`
|
#### Login with Google ID token — `POST /auth/login`
|
||||||
- Params: `body: { idToken: str }`
|
- Params: `body: { idToken: str }`
|
||||||
- Returns: `200 { message: str, user: T41 }`
|
- Returns: `200 { message: str, user: T45 }`
|
||||||
#### Logout — `POST /auth/logout`
|
#### Logout — `POST /auth/logout`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { message: str }`
|
- Returns: `200 { message: str }`
|
||||||
#### Current user — `GET /auth/me`
|
#### Current user — `GET /auth/me`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { user: T41, googleClientId: str }`
|
- Returns: `200 { user: T45, googleClientId: str }`
|
||||||
#### Set selected company — `PUT /auth/selected-company`
|
#### Set selected company — `PUT /auth/selected-company`
|
||||||
- Params: `body: { companyId: int }`
|
- Params: `body: { companyId: int }`
|
||||||
- Returns: `200 { user: T41 }`
|
- Returns: `200 { user: T45 }`
|
||||||
|
|
||||||
|
### Section: /backup
|
||||||
|
|
||||||
|
#### List backups and service status — `GET /backup`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { status: { enabled: bool, isRunning: bool, dbProvider: str, mssqlWarning: bool, backupDir: str, retention: { hourlyMax: int, dailyMax: int, monthly: str }, counts: { hourly: int, daily: int, monthly: int }, latestHourly?: T8|null, latestDaily?: T8|null, latestMonthly?: T8|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T8[], daily: T8[], monthly: T8[] } }`
|
||||||
|
#### Trigger manual backup — `POST /backup/run`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: T9 }`
|
||||||
|
|
||||||
### Section: /brand
|
### Section: /brand
|
||||||
|
|
||||||
@@ -263,7 +277,7 @@ _none_
|
|||||||
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
|
||||||
#### Create brand — `POST /brand`
|
#### Create brand — `POST /brand`
|
||||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||||
- Returns: `201 { brand: { id: int, name: str, pics: T43[], gsprpinfo?: str|null } }`
|
- Returns: `201 { brand: { id: int, name: str, pics: T47[], gsprpinfo?: str|null } }`
|
||||||
#### Delete brand — `DELETE /brand/{id}`
|
#### Delete brand — `DELETE /brand/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -272,10 +286,10 @@ _none_
|
|||||||
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T43[] } }`
|
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T47[] } }`
|
||||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||||
- Params: `id: int, image/avif`
|
- Params: `id: int, image/avif`
|
||||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T43[] } }`
|
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T47[] } }`
|
||||||
|
|
||||||
### Section: /category
|
### Section: /category
|
||||||
|
|
||||||
@@ -299,7 +313,7 @@ _none_
|
|||||||
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
|
||||||
#### Articles in category — `GET /category/{id}/articles`
|
#### Articles in category — `GET /category/{id}/articles`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T34|null }[] }`
|
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: T37|null }[] }`
|
||||||
#### List category attribute values — `GET /category/{id}/attributevalue`
|
#### List category attribute values — `GET /category/{id}/attributevalue`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
- Returns: `200 { attributevalues: { id: int, attributeId: int, attributeName: str, attributeValueId: int, value: str, sort: int, categoryId?: int, exclusive?: bool }[] }`
|
||||||
@@ -326,10 +340,10 @@ _none_
|
|||||||
|
|
||||||
#### List companies — `GET /company`
|
#### List companies — `GET /company`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { companies: T20[] }`
|
- Returns: `200 { companies: T23[] }`
|
||||||
#### Create company — `POST /company`
|
#### Create company — `POST /company`
|
||||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `201 { company: T20, user: T41 }`
|
- Returns: `201 { company: T23, user: T45 }`
|
||||||
#### Get company history — `GET /company/history`
|
#### Get company history — `GET /company/history`
|
||||||
- Params: `query: limit?: int`
|
- Params: `query: limit?: int`
|
||||||
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
||||||
@@ -353,7 +367,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update company — `PUT /company/{id}`
|
#### Update company — `PUT /company/{id}`
|
||||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `200 { company: T20 }`
|
- Returns: `200 { company: T23 }`
|
||||||
|
|
||||||
### Section: /customergroup
|
### Section: /customergroup
|
||||||
|
|
||||||
@@ -362,7 +376,7 @@ _none_
|
|||||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||||
#### Create customer group — `POST /customergroup`
|
#### Create customer group — `POST /customergroup`
|
||||||
- Params: `body: { name: str }`
|
- Params: `body: { name: str }`
|
||||||
- Returns: `201 { customergroup: T49 }`
|
- Returns: `201 { customergroup: T54 }`
|
||||||
#### Reorder customer groups — `PUT /customergroup/order`
|
#### Reorder customer groups — `PUT /customergroup/order`
|
||||||
- Params: `body: { customergroupIds: int[] }`
|
- Params: `body: { customergroupIds: int[] }`
|
||||||
- Returns: `200 { customergroupIds: int[] }`
|
- Returns: `200 { customergroupIds: int[] }`
|
||||||
@@ -371,15 +385,15 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update customer group — `PATCH /customergroup/{id}`
|
#### Update customer group — `PATCH /customergroup/{id}`
|
||||||
- Params: `id: int, body: { name: str }`
|
- Params: `id: int, body: { name: str }`
|
||||||
- Returns: `200 { customergroup: T49 }`
|
- Returns: `200 { customergroup: T54 }`
|
||||||
|
|
||||||
### Section: /delivery
|
### Section: /delivery
|
||||||
|
|
||||||
#### List deliveries — `GET /delivery`
|
#### List deliveries — `GET /delivery`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T46 }[] }`
|
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T50 }[] }`
|
||||||
#### Create delivery — `POST /delivery`
|
#### Create delivery — `POST /delivery`
|
||||||
- Params: `body: { externalNumber: str, date: str, lines: T30[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
- Params: `body: { externalNumber: str, date: str, lines: T33[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||||
- Returns: `201 T5`
|
- Returns: `201 T5`
|
||||||
#### Parse document (SSE) — `POST /delivery/parse`
|
#### Parse document (SSE) — `POST /delivery/parse`
|
||||||
- Params: `form: { file: str }`
|
- Params: `form: { file: str }`
|
||||||
@@ -400,7 +414,7 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T5`
|
- Returns: `200 T5`
|
||||||
#### Update delivery — `PATCH /delivery/{id}`
|
#### Update delivery — `PATCH /delivery/{id}`
|
||||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T30[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T33[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||||
- Returns: `200 T5`
|
- Returns: `200 T5`
|
||||||
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
|
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
|
||||||
- Params: `id: int, fileId: int, query: download?: bool`
|
- Params: `id: int, fileId: int, query: download?: bool`
|
||||||
@@ -410,46 +424,46 @@ _none_
|
|||||||
|
|
||||||
#### List entities — `GET /entity`
|
#### List entities — `GET /entity`
|
||||||
- Params: `query: customergroupId?: int, role?: str`
|
- Params: `query: customergroupId?: int, role?: str`
|
||||||
- Returns: `200 { entities: T32[] }`
|
- Returns: `200 { entities: T35[] }`
|
||||||
#### Create entity — `POST /entity`
|
#### Create entity — `POST /entity`
|
||||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||||
- Returns: `201 T24`
|
- Returns: `201 T27`
|
||||||
#### Peek next customer number — `GET /entity/next-number`
|
#### Peek next customer number — `GET /entity/next-number`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { number: str }`
|
- Returns: `200 { number: str }`
|
||||||
#### List entity addresses — `GET /entity/{entityId}/address`
|
#### List entity addresses — `GET /entity/{entityId}/address`
|
||||||
- Params: `entityId: int`
|
- Params: `entityId: int`
|
||||||
- Returns: `200 { addresses: T15[] }`
|
- Returns: `200 { addresses: T18[] }`
|
||||||
#### Create entity address — `POST /entity/{entityId}/address`
|
#### Create entity address — `POST /entity/{entityId}/address`
|
||||||
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- Params: `entityId: int, body: { addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- Returns: `201 { address: T15 }`
|
- Returns: `201 { address: T18 }`
|
||||||
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
|
||||||
- Params: `entityId: int, id: int`
|
- Params: `entityId: int, id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
|
||||||
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
- Params: `entityId: int, id: int, body: { addressType?: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
|
||||||
- Returns: `200 { address: T15 }`
|
- Returns: `200 { address: T18 }`
|
||||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||||
- Params: `entityId: int`
|
- Params: `entityId: int`
|
||||||
- Returns: `200 { contacts: T44[] }`
|
- Returns: `200 { contacts: T48[] }`
|
||||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||||
- Returns: `201 { contact: T44 }`
|
- Returns: `201 { contact: T48 }`
|
||||||
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
|
||||||
- Params: `entityId: int, id: int`
|
- Params: `entityId: int, id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
|
||||||
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
|
||||||
- Returns: `200 { contact: T44 }`
|
- Returns: `200 { contact: T48 }`
|
||||||
#### Delete entity — `DELETE /entity/{id}`
|
#### Delete entity — `DELETE /entity/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get entity — `GET /entity/{id}`
|
#### Get entity — `GET /entity/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T24`
|
- Returns: `200 T27`
|
||||||
#### Update entity — `PATCH /entity/{id}`
|
#### Update entity — `PATCH /entity/{id}`
|
||||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||||
- Returns: `200 T24`
|
- Returns: `200 T27`
|
||||||
|
|
||||||
### Section: /events
|
### Section: /events
|
||||||
|
|
||||||
@@ -472,6 +486,8 @@ _none_
|
|||||||
- `modelconversation`: `{}`
|
- `modelconversation`: `{}`
|
||||||
- `modelconversation-<id>`: `{}`
|
- `modelconversation-<id>`: `{}`
|
||||||
- `brand`: `{ sender, brandId }`
|
- `brand`: `{ sender, brandId }`
|
||||||
|
- `draft`: `{ sender, action, name, content }`
|
||||||
|
- `draft:request`: `{ requestId, action, name, target, sender }`
|
||||||
|
|
||||||
### Section: /health
|
### Section: /health
|
||||||
|
|
||||||
@@ -491,35 +507,35 @@ _none_
|
|||||||
_none_
|
_none_
|
||||||
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
|
||||||
#### Create label template — `POST /labeltemplate`
|
#### Create label template — `POST /labeltemplate`
|
||||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T22 }`
|
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T25 }`
|
||||||
- Returns: `201 T8`
|
- Returns: `201 T10`
|
||||||
#### Delete label template — `DELETE /labeltemplate/{id}`
|
#### Delete label template — `DELETE /labeltemplate/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get label template by ID — `GET /labeltemplate/{id}`
|
#### Get label template by ID — `GET /labeltemplate/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T8`
|
- Returns: `200 T10`
|
||||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
|
||||||
- Returns: `200 T8`
|
- Returns: `200 T10`
|
||||||
#### Update label template — `PUT /labeltemplate/{id}`
|
#### Update label template — `PUT /labeltemplate/{id}`
|
||||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
|
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
|
||||||
- Returns: `200 T8`
|
- Returns: `200 T10`
|
||||||
|
|
||||||
### Section: /ledger
|
### Section: /ledger
|
||||||
|
|
||||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||||
- Returns: `200 T16`
|
- Returns: `200 T19`
|
||||||
#### Add ledger entry — `POST /ledger`
|
#### Add ledger entry — `POST /ledger`
|
||||||
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
||||||
- Returns: `201 { ledger: T17 }`
|
- Returns: `201 { ledger: T20 }`
|
||||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||||
- Returns: `200 T16`
|
- Returns: `200 T19`
|
||||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||||
- Params: `sku: str, query: transaction_type?: str`
|
- Params: `sku: str, query: transaction_type?: str`
|
||||||
- Returns: `200 T16`
|
- Returns: `200 T19`
|
||||||
|
|
||||||
### Section: /mcp
|
### Section: /mcp
|
||||||
|
|
||||||
@@ -549,16 +565,16 @@ _none_
|
|||||||
|
|
||||||
#### List conversations — `GET /modelconversation`
|
#### List conversations — `GET /modelconversation`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { conversations: T19[] }`
|
- Returns: `200 { conversations: T22[] }`
|
||||||
#### Create conversation — `POST /modelconversation`
|
#### Create conversation — `POST /modelconversation`
|
||||||
- Params: `body: { provider: str, model: str }`
|
- Params: `body: { provider: str, model: str }`
|
||||||
- Returns: `201 { conversation: T19 }`
|
- Returns: `201 { conversation: T22 }`
|
||||||
#### Delete conversation — `DELETE /modelconversation/{id}`
|
#### Delete conversation — `DELETE /modelconversation/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get conversation — `GET /modelconversation/{id}`
|
#### Get conversation — `GET /modelconversation/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { conversation: T19 }`
|
- Returns: `200 { conversation: T22 }`
|
||||||
#### List conversation messages — `GET /modelconversation/{id}/message`
|
#### List conversation messages — `GET /modelconversation/{id}/message`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
|
||||||
@@ -597,9 +613,9 @@ _none_
|
|||||||
|
|
||||||
#### List orders — `GET /order`
|
#### List orders — `GET /order`
|
||||||
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
|
||||||
- Returns: `200 { orders: T2[], total: int, offset: int, limit: int }`
|
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||||
#### Create order — `POST /order`
|
#### Create order — `POST /order`
|
||||||
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
|
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
|
||||||
- Returns: `201 T1`
|
- Returns: `201 T1`
|
||||||
#### Delete order — `DELETE /order/{id}`
|
#### Delete order — `DELETE /order/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
@@ -608,14 +624,14 @@ _none_
|
|||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T1`
|
- Returns: `200 T1`
|
||||||
#### Update order — `PATCH /order/{id}`
|
#### Update order — `PATCH /order/{id}`
|
||||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
|
||||||
- Returns: `200 T1`
|
- Returns: `200 T1`
|
||||||
#### List payments of an order — `GET /order/{id}/payment`
|
#### List payments of an order — `GET /order/{id}/payment`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { payments: T39[] }`
|
- Returns: `200 { payments: T42[] }`
|
||||||
#### Add payment to an order — `POST /order/{id}/payment`
|
#### Add payment to an order — `POST /order/{id}/payment`
|
||||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||||
- Returns: `201 { payment: T39 }`
|
- Returns: `201 { payment: T42 }`
|
||||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shipments: T7[] }`
|
- Returns: `200 { shipments: T7[] }`
|
||||||
@@ -624,10 +640,10 @@ _none_
|
|||||||
- Returns: `201 { shipment: T7 }`
|
- Returns: `201 { shipment: T7 }`
|
||||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shippings: T33[] }`
|
- Returns: `200 { shippings: T36[] }`
|
||||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||||
- Returns: `201 T28`
|
- Returns: `201 T31`
|
||||||
|
|
||||||
### Section: /package
|
### Section: /package
|
||||||
|
|
||||||
@@ -645,37 +661,37 @@ _none_
|
|||||||
|
|
||||||
#### List POS terminals — `GET /posterminal`
|
#### List POS terminals — `GET /posterminal`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { posterminals: T12[] }`
|
- Returns: `200 { posterminals: T14[] }`
|
||||||
#### Create POS terminal — `PUT /posterminal`
|
#### Create POS terminal — `PUT /posterminal`
|
||||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
||||||
- Returns: `200 { posterminal: T12 }`
|
- Returns: `200 { posterminal: T14 }`
|
||||||
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
#### Delete POS terminal — `DELETE /posterminal/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update POS terminal — `PATCH /posterminal/{id}`
|
#### Update POS terminal — `PATCH /posterminal/{id}`
|
||||||
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
|
- Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }`
|
||||||
- Returns: `200 { posterminal: T12 }`
|
- Returns: `200 { posterminal: T14 }`
|
||||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T50`
|
- Returns: `200 T55`
|
||||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T50`
|
- Returns: `200 T55`
|
||||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||||
- Params: `id: int, categoryId: int`
|
- Params: `id: int, categoryId: int`
|
||||||
- Returns: `200 T50`
|
- Returns: `200 T55`
|
||||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||||
- Params: `id: int, categoryId: int`
|
- Params: `id: int, categoryId: int`
|
||||||
- Returns: `200 T50`
|
- Returns: `200 T55`
|
||||||
|
|
||||||
### Section: /printer
|
### Section: /printer
|
||||||
|
|
||||||
#### List printers — `GET /printer`
|
#### List printers — `GET /printer`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { printers: T29[] }`
|
- Returns: `200 { printers: T32[] }`
|
||||||
#### Create printer — `POST /printer`
|
#### Create printer — `POST /printer`
|
||||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `201 T23`
|
- Returns: `201 T26`
|
||||||
#### Reorder printers — `PUT /printer/order`
|
#### Reorder printers — `PUT /printer/order`
|
||||||
- Params: `body: { printerIds: int[] }`
|
- Params: `body: { printerIds: int[] }`
|
||||||
- Returns: `200 { printerIds: int[] }`
|
- Returns: `200 { printerIds: int[] }`
|
||||||
@@ -684,22 +700,22 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get printer by ID — `GET /printer/{id}`
|
#### Get printer by ID — `GET /printer/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T23`
|
- Returns: `200 T26`
|
||||||
#### Update printer (partial) — `PATCH /printer/{id}`
|
#### Update printer (partial) — `PATCH /printer/{id}`
|
||||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `200 T23`
|
- Returns: `200 T26`
|
||||||
#### Update printer — `PUT /printer/{id}`
|
#### Update printer — `PUT /printer/{id}`
|
||||||
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||||
- Returns: `200 T23`
|
- Returns: `200 T26`
|
||||||
|
|
||||||
### Section: /propertyname
|
### Section: /propertyname
|
||||||
|
|
||||||
#### List property names — `GET /propertyname`
|
#### List property names — `GET /propertyname`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { propertynames: T38[] }`
|
- Returns: `200 { propertynames: T41[] }`
|
||||||
#### Create property name — `POST /propertyname`
|
#### Create property name — `POST /propertyname`
|
||||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `201 { propertyname: T38 }`
|
- Returns: `201 { propertyname: T41 }`
|
||||||
#### Reorder property names — `PUT /propertyname/order`
|
#### Reorder property names — `PUT /propertyname/order`
|
||||||
- Params: `body: { propertynameIds: int[] }`
|
- Params: `body: { propertynameIds: int[] }`
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
@@ -708,7 +724,7 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### Update property name — `PATCH /propertyname/{id}`
|
#### Update property name — `PATCH /propertyname/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
|
||||||
- Returns: `200 { propertyname: T38 }`
|
- Returns: `200 { propertyname: T41 }`
|
||||||
|
|
||||||
### Section: /public
|
### Section: /public
|
||||||
|
|
||||||
@@ -731,17 +747,17 @@ _none_
|
|||||||
_none_
|
_none_
|
||||||
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
|
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
|
||||||
#### Create quote — `POST /quote`
|
#### Create quote — `POST /quote`
|
||||||
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
|
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
|
||||||
- Returns: `201 T10`
|
- Returns: `201 T12`
|
||||||
#### Delete quote — `DELETE /quote/{id}`
|
#### Delete quote — `DELETE /quote/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get quote — `GET /quote/{id}`
|
#### Get quote — `GET /quote/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T10`
|
- Returns: `200 T12`
|
||||||
#### Update quote — `PATCH /quote/{id}`
|
#### Update quote — `PATCH /quote/{id}`
|
||||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
|
||||||
- Returns: `200 T10`
|
- Returns: `200 T12`
|
||||||
|
|
||||||
### Section: /serper
|
### Section: /serper
|
||||||
|
|
||||||
@@ -768,10 +784,10 @@ _none_
|
|||||||
- Returns: `200 { shipment: T7 }`
|
- Returns: `200 { shipment: T7 }`
|
||||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { shippings: T33[] }`
|
- Returns: `200 { shippings: T36[] }`
|
||||||
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
#### Create package for a shipment — `POST /shipment/{id}/shipping`
|
||||||
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||||
- Returns: `201 T28`
|
- Returns: `201 T31`
|
||||||
|
|
||||||
### Section: /shipping
|
### Section: /shipping
|
||||||
|
|
||||||
@@ -780,7 +796,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update shipping — `PATCH /shipping/{id}`
|
#### Update shipping — `PATCH /shipping/{id}`
|
||||||
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
|
||||||
- Returns: `200 T28`
|
- Returns: `200 T31`
|
||||||
#### List packages of a shipping — `GET /shipping/{id}/package`
|
#### List packages of a shipping — `GET /shipping/{id}/package`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
|
||||||
@@ -798,28 +814,28 @@ _none_
|
|||||||
|
|
||||||
#### Adjust stock manually — `POST /stock/adjust`
|
#### Adjust stock manually — `POST /stock/adjust`
|
||||||
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
|
||||||
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T17[] }`
|
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: T20[] }`
|
||||||
|
|
||||||
### Section: /supplier
|
### Section: /supplier
|
||||||
|
|
||||||
#### List suppliers — `GET /supplier`
|
#### List suppliers — `GET /supplier`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { suppliers: T45[] }`
|
- Returns: `200 { suppliers: T49[] }`
|
||||||
#### Create supplier — `POST /supplier`
|
#### Create supplier — `POST /supplier`
|
||||||
- Params: `body: { name: str }`
|
- Params: `body: { name: str }`
|
||||||
- Returns: `201 { supplier: T45 }`
|
- Returns: `201 { supplier: T49 }`
|
||||||
#### Delete supplier — `DELETE /supplier/{id}`
|
#### Delete supplier — `DELETE /supplier/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update supplier — `PATCH /supplier/{id}`
|
#### Update supplier — `PATCH /supplier/{id}`
|
||||||
- Params: `id: int, body: { name: str }`
|
- Params: `id: int, body: { name: str }`
|
||||||
- Returns: `200 { supplier: T45 }`
|
- Returns: `200 { supplier: T49 }`
|
||||||
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
|
||||||
- Params: `id: int, query: skus?: string`
|
- Params: `id: int, query: skus?: string`
|
||||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
|
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
|
||||||
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
|
||||||
- Params: `id: int, body?: { skus?: str[] }`
|
- Params: `id: int, body?: { skus?: str[] }`
|
||||||
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T48|null }[] }`
|
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }[] }`
|
||||||
|
|
||||||
### Section: /thumb
|
### Section: /thumb
|
||||||
|
|
||||||
@@ -834,10 +850,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### List units — `GET /unit`
|
#### List units — `GET /unit`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { units: T36[] }`
|
- Returns: `200 { units: T39[] }`
|
||||||
#### Upsert unit — `PUT /unit`
|
#### Upsert unit — `PUT /unit`
|
||||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||||
- Returns: `200 { unit: T36 }`
|
- Returns: `200 { unit: T39 }`
|
||||||
|
|
||||||
### Section: /v1
|
### Section: /v1
|
||||||
|
|
||||||
@@ -862,6 +878,9 @@ _none_
|
|||||||
#### List virtual filesystem files — `GET /vfs`
|
#### List virtual filesystem files — `GET /vfs`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
|
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
|
||||||
|
#### Respond to a frontend draft request — `POST /vfs/drafts/response`
|
||||||
|
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }`
|
||||||
|
- Returns: `200 { success: bool }`
|
||||||
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
|
||||||
- Params: `body: { path: str }`
|
- Params: `body: { path: str }`
|
||||||
- Returns: `200 { success: bool, dir: str }`
|
- Returns: `200 { success: bool, dir: str }`
|
||||||
@@ -888,10 +907,10 @@ _none_
|
|||||||
|
|
||||||
#### List warehouses — `GET /warehouse`
|
#### List warehouses — `GET /warehouse`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { warehouses: T49[] }`
|
- Returns: `200 { warehouses: T54[] }`
|
||||||
#### Create warehouse — `POST /warehouse`
|
#### Create warehouse — `POST /warehouse`
|
||||||
- Params: `body: { name: str, sort?: int }`
|
- Params: `body: { name: str, sort?: int }`
|
||||||
- Returns: `201 T47`
|
- Returns: `201 T52`
|
||||||
#### Reorder warehouses — `PUT /warehouse/order`
|
#### Reorder warehouses — `PUT /warehouse/order`
|
||||||
- Params: `body: { warehouseIds: int[] }`
|
- Params: `body: { warehouseIds: int[] }`
|
||||||
- Returns: `200 { warehouseIds: int[] }`
|
- Returns: `200 { warehouseIds: int[] }`
|
||||||
@@ -900,10 +919,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 T47`
|
- Returns: `200 T52`
|
||||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||||
- Returns: `200 T47`
|
- Returns: `200 T52`
|
||||||
#### Update warehouse — `PUT /warehouse/{id}`
|
#### Update warehouse — `PUT /warehouse/{id}`
|
||||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||||
- Returns: `200 T47`
|
- Returns: `200 T52`
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Reference guide for the backend LLM agent when emitting route navigation actions
|
|||||||
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
|
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
|
||||||
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
|
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
|
||||||
| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` |
|
| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` |
|
||||||
| **New Quote** | `/auftrag/neu` | Static draft form | `/auftrag/neu` |
|
| **New Quote** | `/auftrag/neuesangebot` | Static draft form (Neues Angebot) | `/auftrag/neuesangebot` |
|
||||||
| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
|
| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
|
||||||
| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |
|
| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |
|
||||||
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
|
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
|
||||||
|
|||||||
1
.vscode/tmstate.json
vendored
Normal file
1
.vscode/tmstate.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"groups":{},"history":{},"addons":{},"selectedGroup":null,"previousSelectedGroup":null,"quickSlots":{}}
|
||||||
12
AGENTS.md
12
AGENTS.md
@@ -259,6 +259,9 @@ npm run build
|
|||||||
|
|
||||||
# Preview production build
|
# Preview production build
|
||||||
npm run preview
|
npm run preview
|
||||||
|
|
||||||
|
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
|
||||||
|
npm run automat-graph
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
@@ -425,7 +428,7 @@ The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/d
|
|||||||
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
|
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
|
||||||
├── /auftrag
|
├── /auftrag
|
||||||
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
|
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
|
||||||
│ ├── /neu → AuftragAngebotNeuPage (Create quote)
|
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
|
||||||
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
|
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
|
||||||
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
|
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
|
||||||
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
|
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
|
||||||
@@ -488,6 +491,13 @@ npm run cert # Generates local certs in ./certs/
|
|||||||
npm run dev:https # Runs Vite HTTPS server on port 4308
|
npm run dev:https # Runs Vite HTTPS server on port 4308
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`)
|
||||||
|
- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`).
|
||||||
|
- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`.
|
||||||
|
- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI).
|
||||||
|
- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange).
|
||||||
|
- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 16. Agent Cheatsheet & Code Recipes
|
## 16. Agent Cheatsheet & Code Recipes
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ future test cases.
|
|||||||
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
|
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
|
||||||
requirement without needing HTTPS on the frontend dev server.
|
requirement without needing HTTPS on the frontend dev server.
|
||||||
|
|
||||||
## Architecture
|
## Architecture & State Management
|
||||||
|
|
||||||
|
- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs).
|
||||||
|
- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships.
|
||||||
|
- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm.
|
||||||
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
|
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
|
||||||
- `src/auth/googleSignIn.js` — Google Identity Services helper.
|
- `src/auth/googleSignIn.js` — Google Identity Services helper.
|
||||||
- `src/auth/AuthProvider.jsx` — session state + login/logout.
|
- `src/auth/AuthProvider.jsx` — session state + login/logout.
|
||||||
- `src/auth/useAuth.js` — auth context hook.
|
- `src/auth/useAuth.js` — auth context hook.
|
||||||
- `src/components/*` — small function components with local state.
|
- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors.
|
||||||
|
|||||||
162
automat-graph.dot
Normal file
162
automat-graph.dot
Normal file
File diff suppressed because one or more lines are too long
2193
automat-graph.svg
Normal file
2193
automat-graph.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 255 KiB |
1034
generate-automat-graph.js
Normal file
1034
generate-automat-graph.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@
|
|||||||
"preview:https": "VITE_HTTPS=1 vite preview",
|
"preview:https": "VITE_HTTPS=1 vite preview",
|
||||||
"rig:collect": "node test/collect.mjs",
|
"rig:collect": "node test/collect.mjs",
|
||||||
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
|
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
|
||||||
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001"
|
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
|
||||||
|
"automat-graph": "node generate-automat-graph.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
31
src/api/backup.js
Normal file
31
src/api/backup.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { http } from './http.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup API for listing backups, checking service status, and triggering manual backups.
|
||||||
|
*/
|
||||||
|
export const backup = {
|
||||||
|
/**
|
||||||
|
* GET /api/backup
|
||||||
|
* Returns: 200 { status: BackupStatus, backups: { counts, hourly, daily, monthly } }
|
||||||
|
*/
|
||||||
|
// #region GET /api/backup — getStatus
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
getStatus() {
|
||||||
|
return http('/api/backup');
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/backup/run
|
||||||
|
* Triggers a manual backup run.
|
||||||
|
* Returns: 200 { success: bool, id: str, timestamp: str, promotedDaily: bool, promotedMonthly: bool, totalSizeBytes: int, pruneResult: { hourlyPruned: int, dailyPruned: int }, manifest: object }
|
||||||
|
*/
|
||||||
|
// #region POST /api/backup/run — run
|
||||||
|
// MARK: calls POST /api/backup/run from ./api/index.js
|
||||||
|
run() {
|
||||||
|
return http('/api/backup/run', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
};
|
||||||
@@ -31,4 +31,4 @@ export { shipping } from './shipping.js';
|
|||||||
export { payment } from './payment.js';
|
export { payment } from './payment.js';
|
||||||
export { account } from './account.js';
|
export { account } from './account.js';
|
||||||
export { mcp } from './mcp.js';
|
export { mcp } from './mcp.js';
|
||||||
|
export { backup } from './backup.js';
|
||||||
|
|||||||
@@ -143,4 +143,42 @@ export const vfs = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/vfs/drafts/response
|
||||||
|
* Send response to virtual draft request (action: read or list).
|
||||||
|
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
|
||||||
|
*/
|
||||||
|
// #region POST /api/vfs/drafts/response — respondDraft
|
||||||
|
async respondDraft(body) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/vfs/drafts/response', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...clientIdHeaders(),
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let errText = '';
|
||||||
|
try {
|
||||||
|
const errData = await res.json();
|
||||||
|
errText = errData.error || errData.message;
|
||||||
|
} catch {
|
||||||
|
errText = await res.text().catch(() => '');
|
||||||
|
}
|
||||||
|
console.error('[vfs] respondDraft failed:', res.status, errText);
|
||||||
|
return { ok: false, error: errText };
|
||||||
|
}
|
||||||
|
if (res.status === 204) return { ok: true };
|
||||||
|
const text = await res.text();
|
||||||
|
return text ? JSON.parse(text) : { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vfs] respondDraft error:', err);
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import LieferungNeuPage from './routes/lieferung/neu';
|
|||||||
import LieferungLieferantenPage from './routes/lieferung/lieferanten';
|
import LieferungLieferantenPage from './routes/lieferung/lieferanten';
|
||||||
import LieferungEditPage from './routes/lieferung/_deliveryId';
|
import LieferungEditPage from './routes/lieferung/_deliveryId';
|
||||||
import AuftragAngebotPage from './routes/auftrag';
|
import AuftragAngebotPage from './routes/auftrag';
|
||||||
import AuftragAngebotNeuPage from './routes/auftrag/neu';
|
import AuftragAngebotNeuPage from './routes/auftrag/neuesangebot';
|
||||||
import AuftragAngebotEditPage from './routes/auftrag/_quoteId';
|
import AuftragAngebotEditPage from './routes/auftrag/_quoteId';
|
||||||
import AuftragBestellungPage from './routes/auftrag/bestellung';
|
import AuftragBestellungPage from './routes/auftrag/bestellung';
|
||||||
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
|
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
|
||||||
@@ -97,7 +97,8 @@ const routes = [
|
|||||||
element: <SectionLayout sectionPath="/auftrag" />,
|
element: <SectionLayout sectionPath="/auftrag" />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <AuftragAngebotPage /> },
|
{ index: true, element: <AuftragAngebotPage /> },
|
||||||
{ path: 'neu', element: <AuftragAngebotNeuPage /> },
|
{ path: 'neuesangebot', element: <AuftragAngebotNeuPage /> },
|
||||||
|
{ path: 'neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
|
||||||
{ path: 'bestellung', element: <AuftragBestellungPage /> },
|
{ path: 'bestellung', element: <AuftragBestellungPage /> },
|
||||||
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
|
||||||
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
|
||||||
|
|||||||
209
src/automats/backupAutomat.js
Normal file
209
src/automats/backupAutomat.js
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* @file backupAutomat.js
|
||||||
|
* @description Manages backup status and backup archives (`GET /backup`, `POST /backup/run`).
|
||||||
|
*
|
||||||
|
* Upstream Triggers:
|
||||||
|
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||||
|
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
|
||||||
|
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
|
||||||
|
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, or `actions.runBackup()`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { backup as backupApi } from '../api';
|
||||||
|
import authAutomat from './authAutomat';
|
||||||
|
import sseAutomat from './sseAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupItem
|
||||||
|
* @property {string} name Archive file or directory name
|
||||||
|
* @property {string} path Full storage path
|
||||||
|
* @property {number} totalSizeBytes Total size in bytes
|
||||||
|
* @property {string|null} timestamp Creation timestamp
|
||||||
|
* @property {object|null} [manifest] Backup manifest details
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupStatus
|
||||||
|
* @property {boolean} enabled Whether automated backups are enabled
|
||||||
|
* @property {boolean} isRunning Whether a backup is currently executing
|
||||||
|
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
|
||||||
|
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
|
||||||
|
* @property {string} backupDir Root directory where backups are stored
|
||||||
|
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
|
||||||
|
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
|
||||||
|
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
|
||||||
|
* @property {BackupItem|null} [latestDaily] Most recent daily backup
|
||||||
|
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BackupState
|
||||||
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
|
||||||
|
* @property {string|null} error Last error message
|
||||||
|
* @property {boolean} runningBackup Whether a manual backup run is currently in progress
|
||||||
|
* @property {BackupStatus|null} backupStatus Service status and retention settings
|
||||||
|
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
|
||||||
|
* @property {object|null} lastRunResult Manifest/result from the most recent manual backup run
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {BackupState} */
|
||||||
|
const initialState = {
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
runningBackup: false,
|
||||||
|
backupStatus: null,
|
||||||
|
backups: {
|
||||||
|
counts: { hourly: 0, daily: 0, monthly: 0 },
|
||||||
|
hourly: [],
|
||||||
|
daily: [],
|
||||||
|
monthly: [],
|
||||||
|
},
|
||||||
|
lastRunResult: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backupAutomat;
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
// #region fetch
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
async function fetchBackupData() {
|
||||||
|
const data = await backupApi.getStatus();
|
||||||
|
const status = data?.status ?? null;
|
||||||
|
const rawBackups = data?.backups ?? {};
|
||||||
|
|
||||||
|
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
|
||||||
|
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
|
||||||
|
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
|
||||||
|
|
||||||
|
const counts = rawBackups.counts ?? {
|
||||||
|
hourly: hourly.length,
|
||||||
|
daily: daily.length,
|
||||||
|
monthly: monthly.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backupStatus: status,
|
||||||
|
backups: {
|
||||||
|
counts,
|
||||||
|
hourly,
|
||||||
|
daily,
|
||||||
|
monthly,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
// #region load
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Loads backup status and archive listing.
|
||||||
|
* Deduplicates concurrent callers.
|
||||||
|
*
|
||||||
|
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
|
||||||
|
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
|
||||||
|
*/
|
||||||
|
load(silent = false) {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
if (!silent) {
|
||||||
|
backupAutomat.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
try {
|
||||||
|
const { backupStatus, backups } = await fetchBackupData();
|
||||||
|
backupAutomat.setState({
|
||||||
|
backupStatus,
|
||||||
|
backups,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
return { backupStatus, backups };
|
||||||
|
} catch (err) {
|
||||||
|
const error = err?.message ?? String(err);
|
||||||
|
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region reload
|
||||||
|
// MARK: calls GET /api/backup from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Silently re-fetches backup status and archives in the background.
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
return actions.load(true);
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region runBackup
|
||||||
|
// MARK: calls POST /api/backup/run from ./api/index.js
|
||||||
|
/**
|
||||||
|
* Triggers an immediate manual backup run on the backend.
|
||||||
|
*
|
||||||
|
* @returns {Promise<object>} The backup run result containing manifest and pruneResult
|
||||||
|
*/
|
||||||
|
async runBackup() {
|
||||||
|
backupAutomat.setState({ runningBackup: true, error: null });
|
||||||
|
try {
|
||||||
|
const result = await backupApi.run();
|
||||||
|
backupAutomat.setState({ lastRunResult: result });
|
||||||
|
await actions.reload();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
const error = err?.message ?? String(err);
|
||||||
|
backupAutomat.setState({ error });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
backupAutomat.setState({ runningBackup: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the automat state to initial empty values.
|
||||||
|
*/
|
||||||
|
clear() {
|
||||||
|
backupAutomat.setState({ ...initialState });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
|
||||||
|
|
||||||
|
// #region sseListener
|
||||||
|
if (sseAutomat?.actions?.on) {
|
||||||
|
sseAutomat.actions.on('backup', () => {
|
||||||
|
void backupAutomat.actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region authSync
|
||||||
|
function authKey(authState) {
|
||||||
|
if (authState?.status !== 'authed' || !authState.user) return null;
|
||||||
|
const companyId = authState.user.selectedCompanyId;
|
||||||
|
return companyId == null ? null : String(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastAuthKey;
|
||||||
|
function syncWithAuth(authState) {
|
||||||
|
const key = authKey(authState);
|
||||||
|
if (key === lastAuthKey) return;
|
||||||
|
lastAuthKey = key;
|
||||||
|
if (key == null) {
|
||||||
|
backupAutomat.actions.clear();
|
||||||
|
} else {
|
||||||
|
void backupAutomat.actions.load().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authAutomat.subscribe(syncWithAuth);
|
||||||
|
syncWithAuth(authAutomat.state);
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
export default backupAutomat;
|
||||||
@@ -638,6 +638,37 @@ export function createDeliveryEditorAutomat(options = {}) {
|
|||||||
...initialState,
|
...initialState,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Serializes the delivery into a VFS draft manifest format. @returns {object} */
|
||||||
|
getManifest() {
|
||||||
|
const state = automat.state;
|
||||||
|
const rawLines = state.lines ?? state.lineitems ?? [];
|
||||||
|
const lines = rawLines
|
||||||
|
.filter((l) => Boolean(l && (l.sku || l.name || l.amount != null || l.netprice != null || l.net != null)))
|
||||||
|
.map((l) => ({
|
||||||
|
sku: l.sku || null,
|
||||||
|
name: l.name || null,
|
||||||
|
amount: l.amount != null ? Number(l.amount) : null,
|
||||||
|
pu: l.pu != null ? Number(l.pu) : 1,
|
||||||
|
unit: l.unit || null,
|
||||||
|
netprice: l.netprice != null ? Number(l.netprice) : (l.net != null ? Number(l.net) : null),
|
||||||
|
taxrate: l.taxrate != null ? Number(l.taxrate) : (l.tax != null ? Number(l.tax) : null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
kind: 'delivery',
|
||||||
|
id: null,
|
||||||
|
date: state.date || null,
|
||||||
|
partyId: state.partyId ?? state.deliveryEntity ?? null,
|
||||||
|
externalNumber: state.externalNumber ?? state.external_number ?? null,
|
||||||
|
lines,
|
||||||
|
items: lines,
|
||||||
|
notes: state.notes ?? [],
|
||||||
|
attachments: (state.attachments ?? []).map((a) => ({ name: a.name, type: a.type })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
automat = new Automat(initialState, actions, {
|
automat = new Automat(initialState, actions, {
|
||||||
|
|||||||
190
src/automats/draftSync.js
Normal file
190
src/automats/draftSync.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* @file draftSync.js
|
||||||
|
* @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery).
|
||||||
|
* Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat)
|
||||||
|
* with backend VFS requests and broadcasts.
|
||||||
|
*
|
||||||
|
* Incoming Signals:
|
||||||
|
* - SSE 'draft:request': Backend requests a draft (read or list).
|
||||||
|
* - SSE 'draft': Backend broadcasts an external update to a draft.
|
||||||
|
*
|
||||||
|
* Outgoing Calls:
|
||||||
|
* - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
|
||||||
|
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
|
||||||
|
import { vfs } from '../api/vfs';
|
||||||
|
import { getStoredClientId } from '../api/http';
|
||||||
|
import { getSseClientId } from './sseAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery').
|
||||||
|
* Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc.
|
||||||
|
*
|
||||||
|
* @param {string} [name]
|
||||||
|
* @returns {string} Normalized key
|
||||||
|
*/
|
||||||
|
export function normalizeDraftName(name) {
|
||||||
|
if (!name) return '';
|
||||||
|
return String(name)
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/?(api\/)?vfs\//, '')
|
||||||
|
.replace(/^drafts\//, '')
|
||||||
|
.replace(/\.json$/, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported virtual draft names.
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timestamps of last modifications per draft.
|
||||||
|
* @type {Map<string, string>}
|
||||||
|
*/
|
||||||
|
const draftTimestamps = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves active draft content for a given draft name from its managing automat.
|
||||||
|
*
|
||||||
|
* @param {string} rawName
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
export function getActiveDraftContent(rawName) {
|
||||||
|
const name = normalizeDraftName(rawName);
|
||||||
|
if (name === 'quote') {
|
||||||
|
return quoteDraftAutomat.actions.getManifest();
|
||||||
|
}
|
||||||
|
if (name === 'order') {
|
||||||
|
return orderDraftAutomat.actions.getManifest();
|
||||||
|
}
|
||||||
|
if (name === 'delivery') {
|
||||||
|
return deliveryEditorAutomat.actions.getManifest();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the list of virtual draft files for the VFS listing.
|
||||||
|
*
|
||||||
|
* @returns {Array<{ name: string, size: number, updated_at: string }>}
|
||||||
|
*/
|
||||||
|
export function getActiveDraftsList() {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return SUPPORTED_DRAFTS.map((name) => {
|
||||||
|
const content = getActiveDraftContent(name);
|
||||||
|
let size = 0;
|
||||||
|
if (content != null) {
|
||||||
|
try {
|
||||||
|
size = JSON.stringify(content).length;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z');
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
updated_at: updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies incoming draft content update to the managing automat.
|
||||||
|
*
|
||||||
|
* @param {string} rawName
|
||||||
|
* @param {object|string} rawContent
|
||||||
|
*/
|
||||||
|
export function applyDraftUpdate(rawName, rawContent) {
|
||||||
|
const name = normalizeDraftName(rawName);
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
let content = rawContent;
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
try {
|
||||||
|
content = JSON.parse(content);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[draft] failed to parse incoming draft string for ${name}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draftTimestamps.set(name, new Date().toISOString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (name === 'quote') {
|
||||||
|
quoteDraftAutomat.actions.setManifest(content);
|
||||||
|
} else if (name === 'order') {
|
||||||
|
orderDraftAutomat.actions.setManifest(content);
|
||||||
|
} else if (name === 'delivery') {
|
||||||
|
const manifest = content?.manifest || content || {};
|
||||||
|
const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? [];
|
||||||
|
const lines = Array.isArray(rawLines)
|
||||||
|
? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l)))
|
||||||
|
: [];
|
||||||
|
const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null;
|
||||||
|
const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null;
|
||||||
|
|
||||||
|
deliveryEditorAutomat.actions.setDelivery({
|
||||||
|
...manifest,
|
||||||
|
partyId,
|
||||||
|
deliveryEntity: partyId,
|
||||||
|
externalNumber,
|
||||||
|
external_number: externalNumber,
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[draft] error applying draft update to ${name} automat:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming 'draft:request' SSE event from backend.
|
||||||
|
*
|
||||||
|
* @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function handleDraftRequest(payload) {
|
||||||
|
if (!payload || !payload.requestId) return;
|
||||||
|
const { requestId, action, name, target } = payload;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'read') {
|
||||||
|
const draftName = normalizeDraftName(name || target);
|
||||||
|
const content = getActiveDraftContent(draftName);
|
||||||
|
if (content != null) {
|
||||||
|
await vfs.respondDraft({ requestId, content });
|
||||||
|
} else {
|
||||||
|
await vfs.respondDraft({ requestId, error: 'Draft not open' });
|
||||||
|
}
|
||||||
|
} else if (action === 'list') {
|
||||||
|
const files = getActiveDraftsList();
|
||||||
|
await vfs.respondDraft({ requestId, files });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[draft] error handling draft:request:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles incoming 'draft' SSE broadcast from backend.
|
||||||
|
*
|
||||||
|
* @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload
|
||||||
|
*/
|
||||||
|
export function handleDraftEvent(payload) {
|
||||||
|
if (!payload) return;
|
||||||
|
const { sender, action, name, target, content } = payload;
|
||||||
|
|
||||||
|
const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null);
|
||||||
|
if (sender && myClientId && sender === myClientId) {
|
||||||
|
// Ignore self-echo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'update') {
|
||||||
|
const draftName = normalizeDraftName(name || target);
|
||||||
|
applyDraftUpdate(draftName, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './deliveryEditorAutomat';
|
export * from './deliveryEditorAutomat';
|
||||||
|
export * from './manifestDraftAutomat';
|
||||||
|
export * from './draftSync';
|
||||||
|
|
||||||
export { default as authAutomat } from './authAutomat';
|
export { default as authAutomat } from './authAutomat';
|
||||||
export * from './authAutomat';
|
export * from './authAutomat';
|
||||||
@@ -69,6 +71,7 @@ export {
|
|||||||
default as orderAutomat,
|
default as orderAutomat,
|
||||||
selectOrders,
|
selectOrders,
|
||||||
selectOrderTotal,
|
selectOrderTotal,
|
||||||
|
selectOrderUnfilteredTotal,
|
||||||
selectOrderPagination,
|
selectOrderPagination,
|
||||||
selectOnlyUnpaid,
|
selectOnlyUnpaid,
|
||||||
selectOnlyUndelivered,
|
selectOnlyUndelivered,
|
||||||
@@ -77,11 +80,14 @@ export {
|
|||||||
isOrderDelivered,
|
isOrderDelivered,
|
||||||
getNegativeSeekCutoff,
|
getNegativeSeekCutoff,
|
||||||
formatNegativeSeek,
|
formatNegativeSeek,
|
||||||
|
getChunkStats,
|
||||||
|
subscribeChunkStats,
|
||||||
} from './orderAutomat';
|
} from './orderAutomat';
|
||||||
export { default as entityAutomat } from './entityAutomat';
|
export { default as entityAutomat } from './entityAutomat';
|
||||||
export { default as customergroupAutomat } from './customergroupAutomat';
|
export { default as customergroupAutomat } from './customergroupAutomat';
|
||||||
export { default as numberAutomat } from './numberAutomat';
|
export { default as numberAutomat } from './numberAutomat';
|
||||||
export { default as accountAutomat } from './accountAutomat';
|
export { default as accountAutomat } from './accountAutomat';
|
||||||
|
export { default as backupAutomat } from './backupAutomat';
|
||||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
235
src/automats/manifestDraftAutomat.js
Normal file
235
src/automats/manifestDraftAutomat.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* @file manifestDraftAutomat.js
|
||||||
|
* @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat).
|
||||||
|
* Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Automat } from 'react-automat';
|
||||||
|
import { selectCompanyTaxrate } from './companyAutomat';
|
||||||
|
import artikelAutomat, { getArticle } from './artikelAutomat';
|
||||||
|
import {
|
||||||
|
emptyLine,
|
||||||
|
lineFromApi,
|
||||||
|
dateInputValue,
|
||||||
|
parseNum,
|
||||||
|
} from '../components/manifest/manifestConfig';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the initial empty draft state for a manifest kind.
|
||||||
|
*
|
||||||
|
* @param {'quote'|'order'} kind
|
||||||
|
* @returns {object} Initial state
|
||||||
|
*/
|
||||||
|
export function createInitialManifestDraftState(kind) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
mode: 'create',
|
||||||
|
id: null,
|
||||||
|
date: '',
|
||||||
|
partyId: null,
|
||||||
|
number: '',
|
||||||
|
externalNumber: '',
|
||||||
|
lines: [emptyLine()],
|
||||||
|
attachments: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Automat instance managing draft state for a manifest kind.
|
||||||
|
*
|
||||||
|
* @param {{ kind: 'quote'|'order', name: string }} options
|
||||||
|
* @returns {Automat}
|
||||||
|
*/
|
||||||
|
export function createManifestDraftAutomat({ kind, name }) {
|
||||||
|
let automat;
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
/** @param {string} date */
|
||||||
|
setDate(date) {
|
||||||
|
automat.setState({ date: dateInputValue(date) });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {number|string|null} partyId */
|
||||||
|
setPartyId(partyId) {
|
||||||
|
automat.setState({ partyId: partyId != null ? partyId : null });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} externalNumber */
|
||||||
|
setExternalNumber(externalNumber) {
|
||||||
|
automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} number */
|
||||||
|
setNumber(number) {
|
||||||
|
automat.setState({ number: number != null ? String(number) : '' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Array<object>} lines */
|
||||||
|
setLines(lines) {
|
||||||
|
const list = Array.isArray(lines) && lines.length > 0 ? lines : [emptyLine()];
|
||||||
|
automat.setState({ lines: list });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing line by key.
|
||||||
|
* @param {string} key
|
||||||
|
* @param {string|object} patchOrField
|
||||||
|
* @param {*} [value]
|
||||||
|
*/
|
||||||
|
updateLine(key, patchOrField, value) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const next = current.map((line) => {
|
||||||
|
if (line.key !== key) return line;
|
||||||
|
if (typeof patchOrField === 'object' && patchOrField != null) {
|
||||||
|
return { ...line, ...patchOrField };
|
||||||
|
}
|
||||||
|
return { ...line, [patchOrField]: value };
|
||||||
|
});
|
||||||
|
automat.setState({ lines: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {object} [line] */
|
||||||
|
addLine(line = null) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const newLine = line ? { ...emptyLine(), ...line } : emptyLine();
|
||||||
|
automat.setState({ lines: [...current, newLine] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {string} key */
|
||||||
|
removeLine(key) {
|
||||||
|
const current = automat.state.lines || [];
|
||||||
|
const next = current.filter((l) => l.key !== key);
|
||||||
|
automat.setState({ lines: next.length > 0 ? next : [emptyLine()] });
|
||||||
|
},
|
||||||
|
|
||||||
|
removeAllLines() {
|
||||||
|
automat.setState({ lines: [emptyLine()] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @param {Array<object>} attachments */
|
||||||
|
setAttachments(attachments) {
|
||||||
|
automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resets draft state back to clean initial state. */
|
||||||
|
reset() {
|
||||||
|
automat.setState(createInitialManifestDraftState(kind));
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies full manifest update (from external agent, VFS, or SSE).
|
||||||
|
* @param {object} content
|
||||||
|
*/
|
||||||
|
setManifest(content) {
|
||||||
|
if (!content) return;
|
||||||
|
const manifest = content.manifest || content;
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
if (manifest.date !== undefined) {
|
||||||
|
updates.date = dateInputValue(manifest.date);
|
||||||
|
}
|
||||||
|
const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId;
|
||||||
|
if (party !== undefined) {
|
||||||
|
updates.partyId = party != null ? party : null;
|
||||||
|
}
|
||||||
|
const ext = manifest.externalNumber ?? manifest.external_number;
|
||||||
|
if (ext !== undefined) {
|
||||||
|
updates.externalNumber = ext != null ? String(ext) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems;
|
||||||
|
if (Array.isArray(rawLines)) {
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
|
const lines = rawLines.map((row) => {
|
||||||
|
const mapped = lineFromApi(row);
|
||||||
|
if (!mapped.amount) mapped.amount = '1';
|
||||||
|
if (!mapped.tax) {
|
||||||
|
mapped.tax = normTax;
|
||||||
|
mapped.taxrate = normTax;
|
||||||
|
}
|
||||||
|
if (!mapped.rebate) mapped.rebate = '0';
|
||||||
|
if (!mapped.name && mapped.sku) {
|
||||||
|
const art = getArticle(mapped.sku);
|
||||||
|
if (art?.name) mapped.name = art.name;
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
});
|
||||||
|
updates.lines = lines.length > 0 ? lines : [emptyLine()];
|
||||||
|
|
||||||
|
for (const l of updates.lines) {
|
||||||
|
if (l.sku) {
|
||||||
|
void artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState(updates);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes current draft state into VFS draft manifest format.
|
||||||
|
* @returns {object} Manifest payload
|
||||||
|
*/
|
||||||
|
getManifest() {
|
||||||
|
const state = automat.state;
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
|
|
||||||
|
const formattedLines = (state.lines || [])
|
||||||
|
.filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross)))
|
||||||
|
.map((l) => ({
|
||||||
|
sku: l.sku || null,
|
||||||
|
name: l.name || null,
|
||||||
|
amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1),
|
||||||
|
net: parseNum(l.net) ?? parseNum(l.netprice) ?? null,
|
||||||
|
gross: parseNum(l.gross) ?? null,
|
||||||
|
taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax),
|
||||||
|
rebate: parseNum(l.rebate) ?? 0,
|
||||||
|
pu: parseNum(l.pu) ?? 1,
|
||||||
|
unit: l.unit || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifest: {
|
||||||
|
kind,
|
||||||
|
mode: 'create',
|
||||||
|
id: null,
|
||||||
|
date: state.date || null,
|
||||||
|
partyId: state.partyId ?? null,
|
||||||
|
externalNumber: state.externalNumber || null,
|
||||||
|
lines: formattedLines,
|
||||||
|
items: formattedLines,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
automat = new Automat(createInitialManifestDraftState(kind), actions, {
|
||||||
|
name,
|
||||||
|
persist: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return automat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shared singleton automat for quote draft. */
|
||||||
|
export const quoteDraftAutomat = createManifestDraftAutomat({
|
||||||
|
kind: 'quote',
|
||||||
|
name: 'quote-draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Shared singleton automat for order draft. */
|
||||||
|
export const orderDraftAutomat = createManifestDraftAutomat({
|
||||||
|
kind: 'order',
|
||||||
|
name: 'order-draft',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the draft automat for a given manifest kind.
|
||||||
|
* @param {'quote'|'order'} kind
|
||||||
|
* @returns {Automat|null}
|
||||||
|
*/
|
||||||
|
export function getManifestDraftAutomat(kind) {
|
||||||
|
if (kind === 'quote') return quoteDraftAutomat;
|
||||||
|
if (kind === 'order') return orderDraftAutomat;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,13 @@
|
|||||||
* @file orderAutomat.js
|
* @file orderAutomat.js
|
||||||
* @description Manages cached and chunk-paginated company sales orders (`GET /api/order`).
|
* @description Manages cached and chunk-paginated company sales orders (`GET /api/order`).
|
||||||
*
|
*
|
||||||
|
* Implements a multi-filter chunk cache:
|
||||||
|
* - Each filter combination has its own isolated cache entry (total, items array, loadedChunks).
|
||||||
|
* - Total chunk capacity across all filter combinations is bounded by a global LRU (Least Recently Used) limit of 50 chunks.
|
||||||
|
* - When filter settings change, previously loaded chunks remain cached in memory up to the 50-chunk limit.
|
||||||
|
* - Switching back to a previously used filter is instant with zero network requests if its chunks are in RAM.
|
||||||
|
* - Real-time stats and events can be observed via `subscribeChunkStats` or the dev ChunkCacheMonitor.
|
||||||
|
*
|
||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
|
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
|
||||||
* - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
|
* - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
|
||||||
@@ -40,17 +47,25 @@ import authAutomat from './authAutomat';
|
|||||||
* @typedef {Object} OrderAutomatState
|
* @typedef {Object} OrderAutomatState
|
||||||
* @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
|
* @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
|
||||||
* @property {number} total Total unpaged count for pagination calculation.
|
* @property {number} total Total unpaged count for pagination calculation.
|
||||||
|
* @property {string} filter Search term.
|
||||||
|
* @property {boolean} onlyUnpaid Unpaid orders only.
|
||||||
|
* @property {boolean} onlyUndelivered Undelivered orders only.
|
||||||
|
* @property {number} seekMonths Seek month count.
|
||||||
|
* @property {number} seekYears Seek year count.
|
||||||
|
* @property {number} negativeSeekMonths Negative seek months offset.
|
||||||
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state.
|
||||||
* @property {Error|string|null} error Last load error.
|
* @property {Error|string|null} error Last load error.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const CHUNK_SIZE = 50;
|
export const CHUNK_SIZE = 50;
|
||||||
|
export const MAX_LRU_CHUNKS = 50;
|
||||||
const PREFETCH_CHUNKS = 1;
|
const PREFETCH_CHUNKS = 1;
|
||||||
const CHUNK_DEBOUNCE_MS = 130;
|
const CHUNK_DEBOUNCE_MS = 130;
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
items: null,
|
items: null,
|
||||||
total: 0,
|
total: 0,
|
||||||
|
unfilteredTotal: 0,
|
||||||
filter: '',
|
filter: '',
|
||||||
onlyUnpaid: false,
|
onlyUnpaid: false,
|
||||||
onlyUndelivered: false,
|
onlyUndelivered: false,
|
||||||
@@ -67,27 +82,318 @@ let filterDebounceTimer = null;
|
|||||||
let chunkDebounceTimer = null;
|
let chunkDebounceTimer = null;
|
||||||
let currentEventTick = 0;
|
let currentEventTick = 0;
|
||||||
let lastRequireChunkTick = -1;
|
let lastRequireChunkTick = -1;
|
||||||
|
|
||||||
const pendingTargetChunks = new Set();
|
const pendingTargetChunks = new Set();
|
||||||
let pendingChunkPromise = null;
|
let pendingChunkPromise = null;
|
||||||
let pendingChunkResolve = null;
|
let pendingChunkResolve = null;
|
||||||
let pendingChunkReject = null;
|
let pendingChunkReject = null;
|
||||||
const loadedChunks = new Set();
|
|
||||||
|
// Multi-Filter Cache & Global LRU Management
|
||||||
|
// filterCaches: Map<filterKey, FilterCacheEntry>
|
||||||
|
const filterCaches = new Map();
|
||||||
|
// lruChunks: Map<lruKey, { filterKey, chunkIndex, loadedAt, accessedAt, orderCount }>
|
||||||
|
// In JS, Map keys iteration preserves insertion order. Oldest (LRU) is lruChunks.keys().next().value.
|
||||||
|
const lruChunks = new Map();
|
||||||
|
// inflightChunks: Map<inflightKey, Promise>
|
||||||
const inflightChunks = new Map();
|
const inflightChunks = new Map();
|
||||||
|
|
||||||
|
// Dev / Observability: Chunk event history and subscriber registry
|
||||||
|
const MAX_CHUNK_EVENTS = 150;
|
||||||
|
let nextEventId = 0;
|
||||||
|
const chunkEvents = [];
|
||||||
|
const chunkStatsListeners = new Set();
|
||||||
|
|
||||||
|
export function makeLruKey(filterKey, chunkIndex) {
|
||||||
|
return `${filterKey}:::${chunkIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLruKey(key) {
|
||||||
|
const [filterKey, chunkStr] = key.split(':::');
|
||||||
|
return { filterKey, chunkIndex: Number(chunkStr) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: pure filter helpers
|
||||||
|
export function getFilterParams(state = automat?.state || initialState) {
|
||||||
|
const filter = String(state.filter ?? '').trim();
|
||||||
|
const onlyUnpaid = Boolean(state.onlyUnpaid);
|
||||||
|
const onlyUndelivered = Boolean(state.onlyUndelivered);
|
||||||
|
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
|
||||||
|
? Number(state.negativeSeekMonths)
|
||||||
|
: 0;
|
||||||
|
return { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilterKey(state = automat?.state || initialState) {
|
||||||
|
const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = getFilterParams(state);
|
||||||
|
return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, s: negativeSeekMonths });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFilterLabel(filterKeyOrState) {
|
||||||
|
let f, u, d, s;
|
||||||
|
if (typeof filterKeyOrState === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(filterKeyOrState);
|
||||||
|
f = parsed.f;
|
||||||
|
u = parsed.u;
|
||||||
|
d = parsed.d;
|
||||||
|
s = parsed.s;
|
||||||
|
} catch {
|
||||||
|
return filterKeyOrState;
|
||||||
|
}
|
||||||
|
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
|
||||||
|
if ('f' in filterKeyOrState) {
|
||||||
|
f = filterKeyOrState.f;
|
||||||
|
u = filterKeyOrState.u;
|
||||||
|
d = filterKeyOrState.d;
|
||||||
|
s = filterKeyOrState.s;
|
||||||
|
} else {
|
||||||
|
const params = getFilterParams(filterKeyOrState);
|
||||||
|
f = params.filter;
|
||||||
|
u = params.onlyUnpaid;
|
||||||
|
d = params.onlyUndelivered;
|
||||||
|
s = params.negativeSeekMonths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (f) parts.push(`Suche: „${f}“`);
|
||||||
|
if (u) parts.push('Nur unbezahlt');
|
||||||
|
if (d) parts.push('Nur ungeliefert');
|
||||||
|
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
|
||||||
|
if (parts.length === 0) return 'Standard (Alle Aufträge)';
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNonNullItems(items) {
|
||||||
|
if (!Array.isArray(items)) return 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i] != null) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) {
|
||||||
|
let entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) {
|
||||||
|
const params = getFilterParams(filterState);
|
||||||
|
entry = {
|
||||||
|
key: filterKey,
|
||||||
|
label: formatFilterLabel(params),
|
||||||
|
params,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat?.state?.unfilteredTotal || 0,
|
||||||
|
items: null,
|
||||||
|
loadedChunks: new Set(),
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
lastAccessed: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
};
|
||||||
|
filterCaches.set(filterKey, entry);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch an existing chunk in the global LRU cache to mark it as Most Recently Used
|
||||||
|
function touchChunkLRU(filterKey, chunkIndex) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
const existing = lruChunks.get(key);
|
||||||
|
if (existing) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
lruChunks.set(key, { ...existing, accessedAt: Date.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a newly loaded chunk into the LRU cache and evict the oldest if limit exceeded
|
||||||
|
function registerChunkLRU(filterKey, chunkIndex, orderCount = 0) {
|
||||||
|
const key = makeLruKey(filterKey, chunkIndex);
|
||||||
|
if (lruChunks.has(key)) {
|
||||||
|
lruChunks.delete(key);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
lruChunks.set(key, {
|
||||||
|
filterKey,
|
||||||
|
chunkIndex,
|
||||||
|
loadedAt: now,
|
||||||
|
accessedAt: now,
|
||||||
|
orderCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enforce global MAX_LRU_CHUNKS limit across all filter caches
|
||||||
|
while (lruChunks.size > MAX_LRU_CHUNKS) {
|
||||||
|
const oldestKey = lruChunks.keys().next().value;
|
||||||
|
const oldestInfo = lruChunks.get(oldestKey);
|
||||||
|
lruChunks.delete(oldestKey);
|
||||||
|
if (oldestInfo) {
|
||||||
|
evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evicts a chunk from memory: sets items back to null in the filter cache and removes from loadedChunks
|
||||||
|
function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') {
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (entry.loadedChunks.has(chunkIndex)) {
|
||||||
|
entry.loadedChunks.delete(chunkIndex);
|
||||||
|
if (Array.isArray(entry.items)) {
|
||||||
|
const offset = chunkIndex * CHUNK_SIZE;
|
||||||
|
for (let i = offset; i < offset + CHUNK_SIZE && i < entry.items.length; i++) {
|
||||||
|
entry.items[i] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
if (filterKey === currentKey) {
|
||||||
|
automat.setState({ items: [...entry.items] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = reason === 'lru' ? 'lru_evict' : 'evict';
|
||||||
|
const msg = reason === 'lru'
|
||||||
|
? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen (${lruChunks.size}/${MAX_LRU_CHUNKS} im Cache)`
|
||||||
|
: `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`;
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: eventType,
|
||||||
|
chunkIndex,
|
||||||
|
filterKey,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordChunkEvent(entry) {
|
||||||
|
const event = {
|
||||||
|
id: ++nextEventId,
|
||||||
|
time: Date.now(),
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: entry.filterKey ?? getFilterKey(automat?.state),
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
chunkEvents.push(event);
|
||||||
|
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
|
||||||
|
chunkEvents.shift();
|
||||||
|
}
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyChunkStats() {
|
||||||
|
if (chunkStatsListeners.size === 0) return;
|
||||||
|
const stats = getChunkStats();
|
||||||
|
for (const listener of chunkStatsListeners) {
|
||||||
|
try {
|
||||||
|
listener(stats);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[orderAutomat] chunkStatsListener error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChunkStats() {
|
||||||
|
const currentKey = getFilterKey(automat?.state);
|
||||||
|
const activeEntry = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
const total = activeEntry?.total ?? automat?.state?.total ?? 0;
|
||||||
|
const activeLoaded = activeEntry?.loadedChunks ?? new Set();
|
||||||
|
const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : (activeLoaded.size > 0 ? Math.max(...activeLoaded) + 1 : 0);
|
||||||
|
const items = activeEntry?.items ?? automat?.state?.items;
|
||||||
|
const loadedItemsCount = countNonNullItems(items);
|
||||||
|
|
||||||
|
// Summarize all cached filter combinations
|
||||||
|
const cachedFiltersSummary = Array.from(filterCaches.values()).map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
label: entry.label,
|
||||||
|
params: entry.params,
|
||||||
|
total: entry.total,
|
||||||
|
loadedChunksCount: entry.loadedChunks.size,
|
||||||
|
loadedChunks: Array.from(entry.loadedChunks).sort((a, b) => a - b),
|
||||||
|
chunkCount: entry.total > 0 ? Math.ceil(entry.total / CHUNK_SIZE) : (entry.loadedChunks.size > 0 ? Math.max(...entry.loadedChunks) + 1 : 0),
|
||||||
|
loadedItemsCount: countNonNullItems(entry.items),
|
||||||
|
lastAccessed: entry.lastAccessed,
|
||||||
|
isActive: entry.key === currentKey,
|
||||||
|
status: entry.status,
|
||||||
|
items: entry.items,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// LRU queue list from oldest (1) to newest (50)
|
||||||
|
const lruEntries = Array.from(lruChunks.entries()).map(([key, info], idx) => ({
|
||||||
|
lruKey: key,
|
||||||
|
filterKey: info.filterKey,
|
||||||
|
filterLabel: filterCaches.get(info.filterKey)?.label || info.filterKey,
|
||||||
|
chunkIndex: info.chunkIndex,
|
||||||
|
loadedAt: info.loadedAt,
|
||||||
|
accessedAt: info.accessedAt,
|
||||||
|
orderCount: info.orderCount,
|
||||||
|
lruRank: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Active filter's inflight chunks
|
||||||
|
const activeInflight = [];
|
||||||
|
for (const key of inflightChunks.keys()) {
|
||||||
|
if (key.startsWith(`${currentKey}:::`)) {
|
||||||
|
const parsed = parseLruKey(key);
|
||||||
|
activeInflight.push(parsed.chunkIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
automatName: 'order',
|
||||||
|
chunkSize: CHUNK_SIZE,
|
||||||
|
maxLruChunks: MAX_LRU_CHUNKS,
|
||||||
|
lruChunksCount: lruChunks.size,
|
||||||
|
total,
|
||||||
|
chunkCount,
|
||||||
|
loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b),
|
||||||
|
inflightChunks: activeInflight.sort((a, b) => a - b),
|
||||||
|
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
|
||||||
|
loadedItemsCount,
|
||||||
|
status: automat?.state?.status ?? 'idle',
|
||||||
|
queryEpoch,
|
||||||
|
filter: automat?.state?.filter ?? '',
|
||||||
|
onlyUnpaid: automat?.state?.onlyUnpaid ?? false,
|
||||||
|
onlyUndelivered: automat?.state?.onlyUndelivered ?? false,
|
||||||
|
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
|
||||||
|
items,
|
||||||
|
activeFilterKey: currentKey,
|
||||||
|
activeFilterLabel: formatFilterLabel(currentKey),
|
||||||
|
filterCaches: cachedFiltersSummary,
|
||||||
|
lruEntries,
|
||||||
|
events: [...chunkEvents],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeChunkStats(listener) {
|
||||||
|
chunkStatsListeners.add(listener);
|
||||||
|
try {
|
||||||
|
listener(getChunkStats());
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
chunkStatsListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Clears pending debounced chunk requests
|
// Clears pending debounced chunk requests
|
||||||
function clearChunkDebounce() {
|
function clearChunkDebounce() {
|
||||||
if (chunkDebounceTimer) {
|
if (chunkDebounceTimer) {
|
||||||
clearTimeout(chunkDebounceTimer);
|
clearTimeout(chunkDebounceTimer);
|
||||||
chunkDebounceTimer = null;
|
chunkDebounceTimer = null;
|
||||||
}
|
}
|
||||||
pendingTargetChunks.clear();
|
if (pendingTargetChunks.size > 0) {
|
||||||
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
}
|
||||||
pendingChunkPromise = null;
|
pendingChunkPromise = null;
|
||||||
pendingChunkResolve = null;
|
pendingChunkResolve = null;
|
||||||
pendingChunkReject = null;
|
pendingChunkReject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executes chunk loading for the standstill target and prefetches lookahead chunks
|
// Executes chunk loading for the standstill target and prefetches lookahead chunks
|
||||||
function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
|
function _executeRequiredChunks(chunks, targetEpoch, prefetch = true, targetFilterKey = getFilterKey(automat.state)) {
|
||||||
if (targetEpoch !== queryEpoch) {
|
if (targetEpoch !== queryEpoch) {
|
||||||
return Promise.resolve(automat.state.items);
|
return Promise.resolve(automat.state.items);
|
||||||
}
|
}
|
||||||
@@ -97,9 +403,16 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
|
|||||||
return Promise.resolve(automat.state.items);
|
return Promise.resolve(automat.state.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'execute',
|
||||||
|
chunks: chunkList,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`,
|
||||||
|
});
|
||||||
|
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for (const c of chunkList) {
|
for (const c of chunkList) {
|
||||||
promises.push(actions.loadChunk(c, targetEpoch));
|
promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Once active chunks finish loading, prefetch the next lookahead chunk in the background
|
// Once active chunks finish loading, prefetch the next lookahead chunk in the background
|
||||||
@@ -107,12 +420,19 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
|
|||||||
if (targetEpoch !== queryEpoch) return automat.state.items;
|
if (targetEpoch !== queryEpoch) return automat.state.items;
|
||||||
if (prefetch) {
|
if (prefetch) {
|
||||||
const maxChunk = chunkList[chunkList.length - 1];
|
const maxChunk = chunkList[chunkList.length - 1];
|
||||||
const total = automat.state.total || 0;
|
const entry = filterCaches.get(targetFilterKey);
|
||||||
|
const total = entry?.total || automat.state.total || 0;
|
||||||
for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
|
for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
|
||||||
if (total > 0 && c * CHUNK_SIZE >= total) {
|
if (total > 0 && c * CHUNK_SIZE >= total) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
void actions.loadChunk(c, targetEpoch);
|
recordChunkEvent({
|
||||||
|
type: 'prefetch',
|
||||||
|
chunkIndex: c,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`,
|
||||||
|
});
|
||||||
|
void actions.loadChunk(c, targetEpoch, targetFilterKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return automat.state.items;
|
return automat.state.items;
|
||||||
@@ -171,27 +491,27 @@ export function formatNegativeSeek(months) {
|
|||||||
return `-${parts.join(' ')}`;
|
return `-${parts.join(' ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds server query parameters from current automat filter state
|
// Builds server query parameters from filter params
|
||||||
function buildQueryParams(offset = 0, limit = CHUNK_SIZE) {
|
function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
|
||||||
const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state;
|
|
||||||
const params = { offset, limit };
|
const params = { offset, limit };
|
||||||
const trimmed = String(filter ?? '').trim();
|
const trimmed = String(filterParams.filter ?? '').trim();
|
||||||
if (trimmed) params.search = trimmed;
|
if (trimmed) params.search = trimmed;
|
||||||
if (onlyUnpaid) params.onlyUnpaid = true;
|
if (filterParams.onlyUnpaid) params.onlyUnpaid = true;
|
||||||
if (onlyUndelivered) params.onlyUndelivered = true;
|
if (filterParams.onlyUndelivered) params.onlyUndelivered = true;
|
||||||
if (negativeSeekMonths != null && Number(negativeSeekMonths) < 0) {
|
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
|
||||||
params.seekMonths = Number(negativeSeekMonths);
|
params.seekMonths = Number(filterParams.negativeSeekMonths);
|
||||||
}
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: fetches a single chunk from GET /api/order with server-side filters
|
// Fetches a single chunk from GET /api/order with server-side filters
|
||||||
async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) {
|
async function fetchOrdersPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
|
||||||
const params = buildQueryParams(offset, limit);
|
const params = buildQueryParamsFor(filterParams, offset, limit);
|
||||||
const res = await orderApi.list(params);
|
const res = await orderApi.list(params);
|
||||||
const orders = Array.isArray(res?.orders) ? res.orders : [];
|
const orders = Array.isArray(res?.orders) ? res.orders : [];
|
||||||
const total = Number(res?.total ?? orders.length);
|
const total = Number(res?.total ?? orders.length);
|
||||||
return { orders, total };
|
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
|
||||||
|
return { orders, total, unfilteredTotal };
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFilterChange(immediate = false) {
|
function onFilterChange(immediate = false) {
|
||||||
@@ -209,12 +529,64 @@ function onFilterChange(immediate = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invoked when active filters change: switches to the matching filter cache without wiping!
|
||||||
function applyFilterReload() {
|
function applyFilterReload() {
|
||||||
queryEpoch++;
|
queryEpoch++;
|
||||||
clearChunkDebounce();
|
clearChunkDebounce();
|
||||||
loadedChunks.clear();
|
|
||||||
inflightChunks.clear();
|
const currentKey = getFilterKey(automat.state);
|
||||||
void actions.loadChunk(0, queryEpoch);
|
const cached = filterCaches.get(currentKey);
|
||||||
|
|
||||||
|
if (cached && cached.items !== null) {
|
||||||
|
// CACHE HIT FOR THIS FILTER COMBINATION: restore instantly from RAM!
|
||||||
|
cached.lastAccessed = Date.now();
|
||||||
|
for (const c of cached.loadedChunks) {
|
||||||
|
touchChunkLRU(currentKey, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: cached.items,
|
||||||
|
total: cached.total,
|
||||||
|
unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: cached.status || 'ready',
|
||||||
|
error: cached.error || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_hit',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Aufträge im RAM)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
// If chunk 0 was evicted or not yet loaded in this filter, fetch chunk 0
|
||||||
|
if (!cached.loadedChunks.has(0)) {
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CACHE MISS: initialize fresh filter entry and load chunk 0
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
unfilteredTotal: automat.state.unfilteredTotal ?? 0,
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'filter_miss',
|
||||||
|
epoch: queryEpoch,
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
|
void actions.loadChunk(0, queryEpoch, currentKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
@@ -255,13 +627,20 @@ const actions = {
|
|||||||
start = Math.max(0, Math.floor(start));
|
start = Math.max(0, Math.floor(start));
|
||||||
end = Math.max(start, Math.floor(end));
|
end = Math.max(start, Math.floor(end));
|
||||||
const prefetch = options.prefetch !== false;
|
const prefetch = options.prefetch !== false;
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
|
||||||
// If initial items are null or immediate requested, bypass debounce and load immediately
|
// If initial items are null or immediate requested, bypass debounce and load immediately
|
||||||
if (options.immediate || automat.state.items === null) {
|
if (options.immediate || automat.state.items === null) {
|
||||||
clearChunkDebounce();
|
clearChunkDebounce();
|
||||||
const chunks = new Set();
|
const chunks = new Set();
|
||||||
for (let c = start; c <= end; c++) chunks.add(c);
|
for (let c = start; c <= end; c++) chunks.add(c);
|
||||||
return _executeRequiredChunks(chunks, queryEpoch, prefetch);
|
recordChunkEvent({
|
||||||
|
type: 'require_immediate',
|
||||||
|
chunks: Array.from(chunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`,
|
||||||
|
});
|
||||||
|
return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// New event tick (drag mousemove / pagination click) replaces previous target position
|
// New event tick (drag mousemove / pagination click) replaces previous target position
|
||||||
@@ -282,6 +661,13 @@ const actions = {
|
|||||||
for (let c = start; c <= end; c++) {
|
for (let c = start; c <= end; c++) {
|
||||||
pendingTargetChunks.add(c);
|
pendingTargetChunks.add(c);
|
||||||
}
|
}
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'pending',
|
||||||
|
chunks: Array.from(pendingTargetChunks),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
if (chunkDebounceTimer) {
|
if (chunkDebounceTimer) {
|
||||||
clearTimeout(chunkDebounceTimer);
|
clearTimeout(chunkDebounceTimer);
|
||||||
@@ -300,6 +686,7 @@ const actions = {
|
|||||||
chunkDebounceTimer = null;
|
chunkDebounceTimer = null;
|
||||||
const chunksToLoad = new Set(pendingTargetChunks);
|
const chunksToLoad = new Set(pendingTargetChunks);
|
||||||
pendingTargetChunks.clear();
|
pendingTargetChunks.clear();
|
||||||
|
notifyChunkStats();
|
||||||
|
|
||||||
const resolve = pendingChunkResolve;
|
const resolve = pendingChunkResolve;
|
||||||
const reject = pendingChunkReject;
|
const reject = pendingChunkReject;
|
||||||
@@ -307,7 +694,14 @@ const actions = {
|
|||||||
pendingChunkResolve = null;
|
pendingChunkResolve = null;
|
||||||
pendingChunkReject = null;
|
pendingChunkReject = null;
|
||||||
|
|
||||||
_executeRequiredChunks(chunksToLoad, captureEpoch, true)
|
recordChunkEvent({
|
||||||
|
type: 'debounce_fire',
|
||||||
|
chunks: Array.from(chunksToLoad),
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
_executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey)
|
||||||
.then((res) => resolve?.(res))
|
.then((res) => resolve?.(res))
|
||||||
.catch((err) => reject?.(err));
|
.catch((err) => reject?.(err));
|
||||||
}, CHUNK_DEBOUNCE_MS);
|
}, CHUNK_DEBOUNCE_MS);
|
||||||
@@ -315,75 +709,137 @@ const actions = {
|
|||||||
return pendingChunkPromise;
|
return pendingChunkPromise;
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: fetches a single chunk and populates prefilled array
|
// MARK: fetches a single chunk and populates the filter cache and prefilled array
|
||||||
loadChunk(chunkIndex, targetEpoch = queryEpoch) {
|
loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
|
||||||
const index = Math.max(0, Number(chunkIndex) || 0);
|
const index = Math.max(0, Number(chunkIndex) || 0);
|
||||||
if (targetEpoch !== queryEpoch) {
|
const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
|
||||||
return Promise.resolve(automat.state.items);
|
|
||||||
|
if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) {
|
||||||
|
return Promise.resolve(entry.items);
|
||||||
}
|
}
|
||||||
if (loadedChunks.has(index)) {
|
|
||||||
return Promise.resolve(automat.state.items);
|
if (entry.loadedChunks.has(index)) {
|
||||||
|
touchChunkLRU(targetFilterKey, index);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`,
|
||||||
|
});
|
||||||
|
return Promise.resolve(targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items);
|
||||||
}
|
}
|
||||||
if (inflightChunks.has(index)) {
|
|
||||||
return inflightChunks.get(index);
|
const inflightKey = makeLruKey(targetFilterKey, index);
|
||||||
|
if (inflightChunks.has(inflightKey)) {
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'inflight_hit',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`,
|
||||||
|
});
|
||||||
|
return inflightChunks.get(inflightKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = index * CHUNK_SIZE;
|
const offset = index * CHUNK_SIZE;
|
||||||
const limit = CHUNK_SIZE;
|
const limit = CHUNK_SIZE;
|
||||||
if (index === 0 && automat.state.items === null) {
|
const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
|
||||||
automat.setState({ status: 'loading', error: null });
|
|
||||||
|
if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
|
||||||
|
entry.status = 'loading';
|
||||||
|
entry.error = null;
|
||||||
|
if (isActiveFilter) {
|
||||||
|
automat.setState({ status: 'loading', error: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promise = fetchOrdersPage(offset, limit)
|
const startTime = Date.now();
|
||||||
.then(({ orders, total }) => {
|
recordChunkEvent({
|
||||||
// Discard if active filter query changed while request was in flight
|
type: 'start',
|
||||||
if (targetEpoch !== queryEpoch) {
|
chunkIndex: index,
|
||||||
return automat.state.items;
|
filterKey: targetFilterKey,
|
||||||
}
|
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
let items = automat.state.items;
|
const filterParams = entry.params || getFilterParams(automat.state);
|
||||||
|
const promise = fetchOrdersPageFor(filterParams, offset, limit)
|
||||||
|
.then(({ orders, total, unfilteredTotal }) => {
|
||||||
const currentTotal = total > 0 ? total : 0;
|
const currentTotal = total > 0 ? total : 0;
|
||||||
|
entry.total = currentTotal;
|
||||||
|
entry.unfilteredTotal = unfilteredTotal;
|
||||||
|
|
||||||
if (items === null || items.length !== currentTotal) {
|
if (entry.items === null || entry.items.length !== currentTotal) {
|
||||||
// Pre-fill items array with empty (null) items based on server filtered total
|
entry.items = new Array(currentTotal).fill(null);
|
||||||
items = new Array(currentTotal).fill(null);
|
|
||||||
} else {
|
} else {
|
||||||
items = [...items];
|
entry.items = [...entry.items];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill in real received items for this chunk
|
// Fill in real received items for this chunk
|
||||||
for (let i = 0; i < orders.length; i++) {
|
for (let i = 0; i < orders.length; i++) {
|
||||||
const idx = offset + i;
|
const idx = offset + i;
|
||||||
if (idx < items.length) {
|
if (idx < entry.items.length) {
|
||||||
items[idx] = orders[i];
|
entry.items[idx] = orders[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadedChunks.add(index);
|
entry.loadedChunks.add(index);
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
entry.lastAccessed = Date.now();
|
||||||
|
|
||||||
automat.setState({
|
// Register chunk in global LRU (evicts oldest chunk if > MAX_LRU_CHUNKS)
|
||||||
items,
|
registerChunkLRU(targetFilterKey, index, orders.length);
|
||||||
total: currentTotal,
|
|
||||||
status: 'ready',
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
error: null,
|
if (currentActive) {
|
||||||
|
automat.setState({
|
||||||
|
items: entry.items,
|
||||||
|
total: currentTotal,
|
||||||
|
unfilteredTotal,
|
||||||
|
status: 'ready',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'loaded',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
count: orders.length,
|
||||||
|
duration,
|
||||||
|
message: `Chunk #${index} geladen (${orders.length} Aufträge in ${duration}ms) für "${entry.label}"`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return items;
|
return currentActive ? automat.state.items : entry.items;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (targetEpoch === queryEpoch) {
|
entry.status = 'failed';
|
||||||
console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
|
entry.error = err;
|
||||||
|
const currentActive = targetFilterKey === getFilterKey(automat.state);
|
||||||
|
if (currentActive) {
|
||||||
if (index === 0 && automat.state.items === null) {
|
if (index === 0 && automat.state.items === null) {
|
||||||
automat.setState({ status: 'failed', error: err });
|
automat.setState({ status: 'failed', error: err });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
console.error(`[orderAutomat] loadChunk(${index}, ${targetFilterKey}) error:`, err);
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'error',
|
||||||
|
chunkIndex: index,
|
||||||
|
filterKey: targetFilterKey,
|
||||||
|
duration,
|
||||||
|
error: err?.message || String(err),
|
||||||
|
message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`,
|
||||||
|
});
|
||||||
throw err;
|
throw err;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inflightChunks.delete(index);
|
inflightChunks.delete(inflightKey);
|
||||||
|
notifyChunkStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
inflightChunks.set(index, promise);
|
inflightChunks.set(inflightKey, promise);
|
||||||
|
notifyChunkStats();
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -451,7 +907,7 @@ const actions = {
|
|||||||
|
|
||||||
// MARK: initial load
|
// MARK: initial load
|
||||||
load() {
|
load() {
|
||||||
return actions.loadChunk(0, queryEpoch);
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: ensure loaded
|
// MARK: ensure loaded
|
||||||
@@ -459,25 +915,46 @@ const actions = {
|
|||||||
if (automat.state.items !== null) {
|
if (automat.state.items !== null) {
|
||||||
return Promise.resolve(automat.state.items);
|
return Promise.resolve(automat.state.items);
|
||||||
}
|
}
|
||||||
return actions.loadChunk(0, queryEpoch);
|
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: reload with current active server filters
|
// MARK: reload active filter or invalidate all
|
||||||
async reload() {
|
async reload(onlyCurrent = false) {
|
||||||
queryEpoch++;
|
queryEpoch++;
|
||||||
clearChunkDebounce();
|
clearChunkDebounce();
|
||||||
loadedChunks.clear();
|
|
||||||
inflightChunks.clear();
|
if (onlyCurrent) {
|
||||||
await actions.loadChunk(0, queryEpoch);
|
// Invalidate only current filter's cached chunks
|
||||||
|
const currentKey = getFilterKey(automat.state);
|
||||||
|
actions.clearFilterCache(currentKey);
|
||||||
|
} else {
|
||||||
|
// Full reload (e.g. after SSE mutation or document deletion): clear all cached data
|
||||||
|
actions.clearCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
|
||||||
return automat.state.items;
|
return automat.state.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: replace items directly
|
// MARK: replace items directly
|
||||||
replace(items) {
|
replace(items) {
|
||||||
clearChunkDebounce();
|
clearChunkDebounce();
|
||||||
loadedChunks.clear();
|
const currentKey = getFilterKey(automat.state);
|
||||||
inflightChunks.clear();
|
actions.clearFilterCache(currentKey);
|
||||||
|
|
||||||
const count = Array.isArray(items) ? items.length : 0;
|
const count = Array.isArray(items) ? items.length : 0;
|
||||||
|
const entry = getOrCreateFilterCache(currentKey, automat.state);
|
||||||
|
entry.items = items;
|
||||||
|
entry.total = count;
|
||||||
|
entry.status = 'ready';
|
||||||
|
entry.error = null;
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'replace',
|
||||||
|
filterKey: currentKey,
|
||||||
|
message: `Items ersetzt (${count} Einträge)`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
automat.setState({
|
automat.setState({
|
||||||
items,
|
items,
|
||||||
total: count,
|
total: count,
|
||||||
@@ -487,24 +964,134 @@ const actions = {
|
|||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: patches an order locally in the automat items cache
|
// MARK: evicts a specific chunk from cache (optional filterKey, defaults to active)
|
||||||
patchOrder(id, patch) {
|
evictChunk(chunkIndex, filterKey = getFilterKey(automat.state)) {
|
||||||
if (!Array.isArray(automat.state.items)) return automat.state.items;
|
const index = Math.max(0, Number(chunkIndex) || 0);
|
||||||
let changed = false;
|
const key = makeLruKey(filterKey, index);
|
||||||
const items = automat.state.items.map((o) => {
|
if (lruChunks.has(key)) {
|
||||||
if (o && o.id === id) {
|
lruChunks.delete(key);
|
||||||
changed = true;
|
|
||||||
return { ...o, ...patch };
|
|
||||||
}
|
|
||||||
return o;
|
|
||||||
});
|
|
||||||
if (changed) {
|
|
||||||
automat.setState({ items });
|
|
||||||
}
|
}
|
||||||
return items;
|
evictChunkFromMemory(filterKey, index, 'manual');
|
||||||
|
return automat.state.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
// MARK: resets state
|
// MARK: clears a specific filter cache and removes its chunks from LRU
|
||||||
|
clearFilterCache(filterKey = getFilterKey(automat.state)) {
|
||||||
|
// Remove all chunks of this filter from LRU
|
||||||
|
for (const [lruKey, info] of Array.from(lruChunks.entries())) {
|
||||||
|
if (info.filterKey === filterKey) {
|
||||||
|
lruChunks.delete(lruKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = filterCaches.get(filterKey);
|
||||||
|
if (entry) {
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.items = null;
|
||||||
|
entry.total = 0;
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterKey === getFilterKey(automat.state)) {
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear_filter',
|
||||||
|
filterKey,
|
||||||
|
message: `Filter-Cache für "${formatFilterLabel(filterKey)}" geleert`,
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
// MARK: clears all loaded chunks and filter caches from RAM
|
||||||
|
clearCache() {
|
||||||
|
clearChunkDebounce();
|
||||||
|
lruChunks.clear();
|
||||||
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
for (const entry of filterCaches.values()) {
|
||||||
|
entry.loadedChunks.clear();
|
||||||
|
entry.items = null;
|
||||||
|
entry.total = 0;
|
||||||
|
entry.status = 'idle';
|
||||||
|
entry.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
automat.setState({
|
||||||
|
items: null,
|
||||||
|
total: 0,
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'clear',
|
||||||
|
message: 'Gesamter LRU-Cache und alle Filter-Caches geleert',
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
// MARK: prefetches all remaining chunks for a filter (bounded by MAX_LRU_CHUNKS)
|
||||||
|
prefetchAllChunks(filterKey = getFilterKey(automat.state)) {
|
||||||
|
const entry = getOrCreateFilterCache(filterKey, automat.state);
|
||||||
|
const total = entry.total || automat.state.total || 0;
|
||||||
|
const chunkCount = Math.ceil(total / CHUNK_SIZE);
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'prefetch_all',
|
||||||
|
filterKey,
|
||||||
|
message: `Starte Prefetch aller ${chunkCount} Chunks für "${entry.label}"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let c = 0; c < chunkCount; c++) {
|
||||||
|
const inflightKey = makeLruKey(filterKey, c);
|
||||||
|
if (!entry.loadedChunks.has(c) && !inflightChunks.has(inflightKey)) {
|
||||||
|
void actions.loadChunk(c, queryEpoch, filterKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notifyChunkStats();
|
||||||
|
},
|
||||||
|
|
||||||
|
// MARK: patches an order locally across all filter caches
|
||||||
|
patchOrder(id, patch) {
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of filterCaches.values()) {
|
||||||
|
if (Array.isArray(entry.items)) {
|
||||||
|
entry.items = entry.items.map((o) => {
|
||||||
|
if (o && o.id === id) {
|
||||||
|
changed = true;
|
||||||
|
return { ...o, ...patch };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(automat.state.items)) {
|
||||||
|
const items = automat.state.items.map((o) => {
|
||||||
|
if (o && o.id === id) {
|
||||||
|
changed = true;
|
||||||
|
return { ...o, ...patch };
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
});
|
||||||
|
if (changed) {
|
||||||
|
automat.setState({ items });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return automat.state.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
// MARK: resets state and all caches completely (e.g. on logout / company switch)
|
||||||
reset() {
|
reset() {
|
||||||
queryEpoch++;
|
queryEpoch++;
|
||||||
if (filterDebounceTimer) {
|
if (filterDebounceTimer) {
|
||||||
@@ -512,8 +1099,15 @@ const actions = {
|
|||||||
filterDebounceTimer = null;
|
filterDebounceTimer = null;
|
||||||
}
|
}
|
||||||
clearChunkDebounce();
|
clearChunkDebounce();
|
||||||
loadedChunks.clear();
|
filterCaches.clear();
|
||||||
|
lruChunks.clear();
|
||||||
inflightChunks.clear();
|
inflightChunks.clear();
|
||||||
|
|
||||||
|
recordChunkEvent({
|
||||||
|
type: 'reset',
|
||||||
|
message: 'Order-Automat und sämtliche Caches zurückgesetzt',
|
||||||
|
});
|
||||||
|
notifyChunkStats();
|
||||||
automat.setState(initialState);
|
automat.setState(initialState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -533,6 +1127,15 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) {
|
|||||||
orderAutomat.getItems = getItems;
|
orderAutomat.getItems = getItems;
|
||||||
orderAutomat.requireChunk = requireChunk;
|
orderAutomat.requireChunk = requireChunk;
|
||||||
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
|
orderAutomat.CHUNK_SIZE = CHUNK_SIZE;
|
||||||
|
orderAutomat.MAX_LRU_CHUNKS = MAX_LRU_CHUNKS;
|
||||||
|
orderAutomat.getChunkStats = getChunkStats;
|
||||||
|
orderAutomat.subscribeChunkStats = subscribeChunkStats;
|
||||||
|
orderAutomat.evictChunk = actions.evictChunk;
|
||||||
|
orderAutomat.clearFilterCache = actions.clearFilterCache;
|
||||||
|
orderAutomat.clearCache = actions.clearCache;
|
||||||
|
orderAutomat.prefetchAllChunks = actions.prefetchAllChunks;
|
||||||
|
orderAutomat.getFilterKey = getFilterKey;
|
||||||
|
orderAutomat.formatFilterLabel = formatFilterLabel;
|
||||||
|
|
||||||
// Reset on logout or company change
|
// Reset on logout or company change
|
||||||
let lastCompanyId = null;
|
let lastCompanyId = null;
|
||||||
@@ -561,6 +1164,11 @@ export function selectOrderTotal(state = automat.state) {
|
|||||||
return state.total;
|
return state.total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Selects unfiltered total count */
|
||||||
|
export function selectOrderUnfilteredTotal(state = automat.state) {
|
||||||
|
return state.unfilteredTotal ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Selects unpaid filter */
|
/** Selects unpaid filter */
|
||||||
export function selectOnlyUnpaid(state = automat.state) {
|
export function selectOnlyUnpaid(state = automat.state) {
|
||||||
return state.onlyUnpaid;
|
return state.onlyUnpaid;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat';
|
|||||||
import propertyAutomat from './propertyAutomat';
|
import propertyAutomat from './propertyAutomat';
|
||||||
import supplierAutomat from './supplierAutomat';
|
import supplierAutomat from './supplierAutomat';
|
||||||
import orderAutomat from './orderAutomat';
|
import orderAutomat from './orderAutomat';
|
||||||
|
import { handleDraftRequest, handleDraftEvent } from './draftSync';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -250,6 +251,24 @@ function connect() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
addTrackedListener(es, 'draft:request', (event) => {
|
||||||
|
try {
|
||||||
|
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||||
|
void handleDraftRequest(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sse] draft:request handling failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addTrackedListener(es, 'draft', (event) => {
|
||||||
|
try {
|
||||||
|
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
||||||
|
handleDraftEvent(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[sse] draft event handling failed', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
addTrackedListener(es, 'open', () => {
|
addTrackedListener(es, 'open', () => {
|
||||||
sseAutomat.setState({ status: 'connected' });
|
sseAutomat.setState({ status: 'connected' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class AppDialog extends Component {
|
|||||||
const {
|
const {
|
||||||
transitionDuration: _transitionDuration,
|
transitionDuration: _transitionDuration,
|
||||||
TransitionProps: _transitionProps,
|
TransitionProps: _transitionProps,
|
||||||
|
transitionProps: _lowerTransitionProps,
|
||||||
TransitionComponent: _transitionComponent,
|
TransitionComponent: _transitionComponent,
|
||||||
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
|
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
|
||||||
// of spreading onto Dialog/DOM where it triggers
|
// of spreading onto Dialog/DOM where it triggers
|
||||||
@@ -71,6 +72,7 @@ class AppDialog extends Component {
|
|||||||
} = this.props;
|
} = this.props;
|
||||||
void _transitionDuration;
|
void _transitionDuration;
|
||||||
void _transitionProps;
|
void _transitionProps;
|
||||||
|
void _lowerTransitionProps;
|
||||||
void _transitionComponent;
|
void _transitionComponent;
|
||||||
|
|
||||||
const userPaper = userSlotProps?.paper ?? {};
|
const userPaper = userSlotProps?.paper ?? {};
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import PeopleIcon from '@mui/icons-material/People';
|
|||||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
|
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
|
||||||
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||||
import BackendError from './BackendError';
|
import BackendError from './BackendError';
|
||||||
import { company, number as numberApi } from '../api';
|
import { company, number as numberApi } from '../api';
|
||||||
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
|
||||||
@@ -49,18 +51,22 @@ const NUMBER_SCHEMES = [
|
|||||||
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
|
||||||
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
|
||||||
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
|
||||||
|
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
|
||||||
|
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRESETS = [
|
const PRESETS = [
|
||||||
{
|
{
|
||||||
id: 'standard',
|
id: 'standard',
|
||||||
label: 'Standard',
|
label: 'Standard',
|
||||||
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-)',
|
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
sku: { prefix: 'ART-', padding: 6, next: 1 },
|
||||||
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
entity: { prefix: 'KD-', padding: 6, next: 1001 },
|
||||||
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
delivery: { prefix: 'LS-', padding: 6, next: 1 },
|
||||||
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
|
||||||
|
quote: { prefix: 'ANG-', padding: 6, next: 1 },
|
||||||
|
order: { prefix: 'AUF-', padding: 6, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,28 +78,34 @@ const PRESETS = [
|
|||||||
entity: { prefix: '', padding: 5, next: 20001 },
|
entity: { prefix: '', padding: 5, next: 20001 },
|
||||||
delivery: { prefix: '', padding: 5, next: 30001 },
|
delivery: { prefix: '', padding: 5, next: 30001 },
|
||||||
adjustment: { prefix: '', padding: 5, next: 90001 },
|
adjustment: { prefix: '', padding: 5, next: 90001 },
|
||||||
|
quote: { prefix: '', padding: 5, next: 40001 },
|
||||||
|
order: { prefix: '', padding: 5, next: 50001 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'compact',
|
id: 'compact',
|
||||||
label: 'Kurzkürzel',
|
label: 'Kurzkürzel',
|
||||||
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-)',
|
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'A-', padding: 5, next: 1 },
|
sku: { prefix: 'A-', padding: 5, next: 1 },
|
||||||
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
entity: { prefix: 'K-', padding: 5, next: 1001 },
|
||||||
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
delivery: { prefix: 'L-', padding: 5, next: 1 },
|
||||||
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
adjustment: { prefix: 'B-', padding: 5, next: 1 },
|
||||||
|
quote: { prefix: 'AG-', padding: 5, next: 1 },
|
||||||
|
order: { prefix: 'AU-', padding: 5, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'international',
|
id: 'international',
|
||||||
label: 'International',
|
label: 'International',
|
||||||
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-)',
|
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-)',
|
||||||
values: {
|
values: {
|
||||||
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
sku: { prefix: 'SKU-', padding: 5, next: 1 },
|
||||||
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
entity: { prefix: 'C-', padding: 5, next: 1001 },
|
||||||
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
|
||||||
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
|
||||||
|
quote: { prefix: 'Q-', padding: 5, next: 1 },
|
||||||
|
order: { prefix: 'ORD-', padding: 5, next: 1 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function SectionLayout({ sectionPath }) {
|
|||||||
let pageTitle = isDetail
|
let pageTitle = isDetail
|
||||||
? title
|
? title
|
||||||
: (activeSub?.title ?? activeSub?.label ?? title);
|
: (activeSub?.title ?? activeSub?.label ?? title);
|
||||||
if (pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
|
if (pathname === '/auftrag/neuesangebot' || pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
|
||||||
else if (isAngebotDoc) pageTitle = angebotLabel;
|
else if (isAngebotDoc) pageTitle = angebotLabel;
|
||||||
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';
|
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';
|
||||||
|
|
||||||
|
|||||||
@@ -950,7 +950,7 @@ export default class ArticleUnifyDialog extends Component {
|
|||||||
onChange={(e) => this.setState({ selectedHistory: { ...selectedHistory, left: e.target.checked } })}
|
onChange={(e) => this.setState({ selectedHistory: { ...selectedHistory, left: e.target.checked } })}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||||
<HistoryOutlinedIcon fontSize="small" sx={{ color: 'text.secondary', flexShrink: 0 }} />
|
<HistoryOutlinedIcon fontSize="small" sx={{ color: 'text.secondary', flexShrink: 0 }} />
|
||||||
<Typography variant="body2" fontWeight={700}>
|
<Typography variant="body2" fontWeight={700}>
|
||||||
{leftHistory.length} Einträge vorhanden
|
{leftHistory.length} Einträge vorhanden
|
||||||
@@ -977,7 +977,7 @@ export default class ArticleUnifyDialog extends Component {
|
|||||||
onChange={(e) => this.setState({ selectedHistory: { ...selectedHistory, right: e.target.checked } })}
|
onChange={(e) => this.setState({ selectedHistory: { ...selectedHistory, right: e.target.checked } })}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||||
<HistoryOutlinedIcon fontSize="small" sx={{ color: 'text.secondary', flexShrink: 0 }} />
|
<HistoryOutlinedIcon fontSize="small" sx={{ color: 'text.secondary', flexShrink: 0 }} />
|
||||||
<Typography variant="body2" fontWeight={700}>
|
<Typography variant="body2" fontWeight={700}>
|
||||||
{rightHistory.length} Einträge vorhanden
|
{rightHistory.length} Einträge vorhanden
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default class ArticleSettingsTab extends Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" spacing={1.5} alignItems="center" useFlexGap sx={{ flexWrap: 'wrap' }}>
|
<Stack direction="row" spacing={1.5} useFlexGap sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
color="error"
|
color="error"
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
>
|
>
|
||||||
{/* Sort Order & Reorder Controls */}
|
{/* Sort Order & Reorder Controls */}
|
||||||
<TableCell sx={{ pl: 1 }}>
|
<TableCell sx={{ pl: 1 }}>
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||||
<Typography variant="caption" sx={{ minWidth: 16, fontWeight: 700, color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ minWidth: 16, fontWeight: 700, color: 'text.secondary' }}>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -403,7 +403,7 @@ export default class ArticleSupplierTab extends PureComponent {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<TableCell align="right" sx={{ pr: 1 }}>
|
<TableCell align="right" sx={{ pr: 1 }}>
|
||||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end' }}>
|
||||||
<Tooltip title="Bearbeiten">
|
<Tooltip title="Bearbeiten">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class ClickTooltip extends PureComponent {
|
|||||||
disableTouchListener
|
disableTouchListener
|
||||||
enterDelay={0}
|
enterDelay={0}
|
||||||
leaveDelay={0}
|
leaveDelay={0}
|
||||||
TransitionProps={{ timeout: 0 }}
|
slotProps={{ transition: { timeout: 0 } }}
|
||||||
title={title}
|
title={title}
|
||||||
arrow
|
arrow
|
||||||
placement="top"
|
placement="top"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import ManifestLines from './ManifestLines';
|
|||||||
import ManifestPdfDialog from './ManifestPdfDialog';
|
import ManifestPdfDialog from './ManifestPdfDialog';
|
||||||
import ArticleSearchDialog from './ArticleSearchDialog';
|
import ArticleSearchDialog from './ArticleSearchDialog';
|
||||||
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
|
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
|
||||||
|
import entityAutomat from '../../automats/entityAutomat';
|
||||||
|
import supplierAutomat from '../../automats/supplierAutomat';
|
||||||
|
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
|
||||||
import { netFromGross, getArticleGross } from '../../utils/price';
|
import { netFromGross, getArticleGross } from '../../utils/price';
|
||||||
import {
|
import {
|
||||||
buildManifestBody,
|
buildManifestBody,
|
||||||
@@ -26,22 +29,34 @@ import {
|
|||||||
apiFor,
|
apiFor,
|
||||||
parseNum,
|
parseNum,
|
||||||
} from './manifestConfig';
|
} from './manifestConfig';
|
||||||
|
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
|
||||||
|
import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat';
|
||||||
|
|
||||||
class ManifestForm extends PureComponent {
|
class ManifestForm extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.cfg = manifestConfig(props.kind);
|
this.cfg = manifestConfig(props.kind);
|
||||||
|
const initialParties = props.kind === 'delivery'
|
||||||
|
? (supplierAutomat.state.status === 'ready' ? (supplierAutomat.state.suppliers ?? []) : [])
|
||||||
|
: (entityAutomat.state.status === 'ready' ? (entityAutomat.state.items ?? []) : []);
|
||||||
|
const partiesReady = props.kind === 'delivery'
|
||||||
|
? supplierAutomat.state.status === 'ready'
|
||||||
|
: entityAutomat.state.status === 'ready';
|
||||||
|
|
||||||
|
this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null;
|
||||||
|
const initialDraft = this.draftAutomat?.state;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
status: props.mode === 'edit' ? 'loading' : 'loadingParties',
|
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
|
||||||
error: null,
|
error: null,
|
||||||
parties: [],
|
parties: initialParties,
|
||||||
partiesLoading: true,
|
partiesLoading: !partiesReady,
|
||||||
date: '',
|
date: initialDraft?.date || '',
|
||||||
partyId: null,
|
partyId: initialDraft?.partyId ?? null,
|
||||||
number: '',
|
number: initialDraft?.number || '',
|
||||||
externalNumber: '',
|
externalNumber: initialDraft?.externalNumber || '',
|
||||||
lines: [emptyLine()],
|
lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()],
|
||||||
attachments: [],
|
attachments: initialDraft?.attachments || [],
|
||||||
saving: false,
|
saving: false,
|
||||||
pdfUploading: false,
|
pdfUploading: false,
|
||||||
pdfDialogOpen: false,
|
pdfDialogOpen: false,
|
||||||
@@ -74,21 +89,96 @@ class ManifestForm extends PureComponent {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
if (this.props.mode === 'edit') void this.loadEdit();
|
if (this.props.kind === 'delivery') {
|
||||||
else void this.loadPartiesOnly();
|
this.unsubParties = supplierAutomat.subscribe(this, (state) => ({
|
||||||
|
parties: state.status === 'ready' ? (state.suppliers ?? []) : [],
|
||||||
|
partiesLoading: state.status === 'loading',
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
this.unsubParties = entityAutomat.subscribe(this, (state) => ({
|
||||||
|
parties: state.items ?? [],
|
||||||
|
partiesLoading: state.status === 'loading',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (companyAutomat.state.status === 'idle') {
|
||||||
|
void companyAutomat.actions.load();
|
||||||
|
}
|
||||||
|
this.unsubCompany = companyAutomat.subscribe(this, (state) => {
|
||||||
|
const taxrate = state.current?.taxrate;
|
||||||
|
if (taxrate != null) {
|
||||||
|
this.setState((prev) => {
|
||||||
|
if (
|
||||||
|
prev.lines.length === 1 &&
|
||||||
|
!prev.lines[0].sku &&
|
||||||
|
!prev.lines[0].name &&
|
||||||
|
!prev.lines[0].net &&
|
||||||
|
prev.lines[0].tax !== String(taxrate)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
...prev.lines[0],
|
||||||
|
tax: String(taxrate),
|
||||||
|
taxrate: String(taxrate),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.props.mode === 'edit') {
|
||||||
|
void this.loadEdit();
|
||||||
|
} else {
|
||||||
|
void this.loadPartiesOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.draftAutomat) {
|
||||||
|
this.unsubDraft = this.draftAutomat.subscribe(this, (state) => ({
|
||||||
|
date: state.date || '',
|
||||||
|
partyId: state.partyId,
|
||||||
|
externalNumber: state.externalNumber || '',
|
||||||
|
lines: state.lines?.length > 0 ? state.lines : [emptyLine()],
|
||||||
|
attachments: state.attachments || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
if (
|
if (
|
||||||
this.props.mode === 'edit' &&
|
this.props.mode === 'edit' &&
|
||||||
prevProps.docId !== this.props.docId
|
prevProps.docId !== this.props.docId
|
||||||
) {
|
) {
|
||||||
void this.loadEdit();
|
void this.loadEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.props.mode === 'create' && this.draftAutomat) {
|
||||||
|
if (
|
||||||
|
prevState.date !== this.state.date ||
|
||||||
|
prevState.partyId !== this.state.partyId ||
|
||||||
|
prevState.externalNumber !== this.state.externalNumber ||
|
||||||
|
prevState.lines !== this.state.lines ||
|
||||||
|
prevState.attachments !== this.state.attachments
|
||||||
|
) {
|
||||||
|
this.draftAutomat.setState({
|
||||||
|
date: this.state.date,
|
||||||
|
partyId: this.state.partyId,
|
||||||
|
externalNumber: this.state.externalNumber,
|
||||||
|
lines: this.state.lines,
|
||||||
|
attachments: this.state.attachments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
this._cancelled = true;
|
||||||
|
this.unsubDraft?.();
|
||||||
|
this.unsubParties?.();
|
||||||
|
this.unsubCompany?.();
|
||||||
if (this._parseAbortController) {
|
if (this._parseAbortController) {
|
||||||
this._parseAbortController.abort();
|
this._parseAbortController.abort();
|
||||||
this._parseAbortController = null;
|
this._parseAbortController = null;
|
||||||
@@ -96,7 +186,9 @@ class ManifestForm extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadPartiesOnly() {
|
async loadPartiesOnly() {
|
||||||
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
if (this.state.parties.length === 0) {
|
||||||
|
this.setState({ status: 'loadingParties', error: null, partiesLoading: true });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const parties = await loadParties(this.props.kind);
|
const parties = await loadParties(this.props.kind);
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
@@ -107,11 +199,13 @@ class ManifestForm extends PureComponent {
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
this.setState({
|
if (this.state.parties.length === 0) {
|
||||||
status: 'failed',
|
this.setState({
|
||||||
error: backendErrorFrom(err),
|
status: 'failed',
|
||||||
partiesLoading: false,
|
error: backendErrorFrom(err),
|
||||||
});
|
partiesLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
notifyError(err);
|
notifyError(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,12 +391,21 @@ class ManifestForm extends PureComponent {
|
|||||||
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
|
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
|
||||||
grossVal = String(g);
|
grossVal = String(g);
|
||||||
}
|
}
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
|
const finalTax =
|
||||||
|
art.taxrate != null
|
||||||
|
? String(art.taxrate)
|
||||||
|
: art.tax != null
|
||||||
|
? String(art.tax)
|
||||||
|
: l.tax || normTax;
|
||||||
return {
|
return {
|
||||||
...l,
|
...l,
|
||||||
|
amount: l.amount || '1',
|
||||||
sku: art.sku || l.sku,
|
sku: art.sku || l.sku,
|
||||||
name: art.name || l.name || '',
|
name: art.name || l.name || '',
|
||||||
tax: l.tax || taxVal,
|
tax: finalTax,
|
||||||
taxrate: l.taxrate || taxVal,
|
taxrate: finalTax,
|
||||||
|
rebate: l.rebate || '0',
|
||||||
net: l.net || netVal,
|
net: l.net || netVal,
|
||||||
netprice: l.netprice || netVal,
|
netprice: l.netprice || netVal,
|
||||||
gross: l.gross || grossVal,
|
gross: l.gross || grossVal,
|
||||||
@@ -322,6 +425,7 @@ class ManifestForm extends PureComponent {
|
|||||||
|
|
||||||
handleAddArticleLine(article) {
|
handleAddArticleLine(article) {
|
||||||
if (!article) return;
|
if (!article) return;
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
const netVal =
|
const netVal =
|
||||||
article.price != null
|
article.price != null
|
||||||
? String(article.price)
|
? String(article.price)
|
||||||
@@ -335,7 +439,7 @@ class ManifestForm extends PureComponent {
|
|||||||
? String(article.taxrate)
|
? String(article.taxrate)
|
||||||
: article.tax != null
|
: article.tax != null
|
||||||
? String(article.tax)
|
? String(article.tax)
|
||||||
: '';
|
: normTax;
|
||||||
const netNum = parseNum(netVal);
|
const netNum = parseNum(netVal);
|
||||||
const taxNum = parseNum(taxVal);
|
const taxNum = parseNum(taxVal);
|
||||||
const artGross = getArticleGross(article, taxNum ?? 0);
|
const artGross = getArticleGross(article, taxNum ?? 0);
|
||||||
@@ -357,7 +461,7 @@ class ManifestForm extends PureComponent {
|
|||||||
net: netVal,
|
net: netVal,
|
||||||
netprice: netVal,
|
netprice: netVal,
|
||||||
gross: grossVal,
|
gross: grossVal,
|
||||||
rebate: '',
|
rebate: '0',
|
||||||
pu: '1',
|
pu: '1',
|
||||||
unit: article.unit || 'Stück',
|
unit: article.unit || 'Stück',
|
||||||
};
|
};
|
||||||
@@ -394,6 +498,7 @@ class ManifestForm extends PureComponent {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
const netVal =
|
const netVal =
|
||||||
article.price != null
|
article.price != null
|
||||||
? String(article.price)
|
? String(article.price)
|
||||||
@@ -407,7 +512,7 @@ class ManifestForm extends PureComponent {
|
|||||||
? String(article.taxrate)
|
? String(article.taxrate)
|
||||||
: article.tax != null
|
: article.tax != null
|
||||||
? String(article.tax)
|
? String(article.tax)
|
||||||
: '';
|
: normTax;
|
||||||
const netNum = parseNum(netVal);
|
const netNum = parseNum(netVal);
|
||||||
const taxNum = parseNum(taxVal);
|
const taxNum = parseNum(taxVal);
|
||||||
const artGross = getArticleGross(article, taxNum ?? 0);
|
const artGross = getArticleGross(article, taxNum ?? 0);
|
||||||
@@ -424,10 +529,12 @@ class ManifestForm extends PureComponent {
|
|||||||
if (l.key !== targetLine.key) return l;
|
if (l.key !== targetLine.key) return l;
|
||||||
return {
|
return {
|
||||||
...l,
|
...l,
|
||||||
|
amount: l.amount || '1',
|
||||||
sku: article.sku || l.sku,
|
sku: article.sku || l.sku,
|
||||||
name: article.name || l.name,
|
name: article.name || l.name,
|
||||||
tax: taxVal || l.tax,
|
tax: taxVal || l.tax || normTax,
|
||||||
taxrate: taxVal || l.taxrate,
|
taxrate: taxVal || l.taxrate || normTax,
|
||||||
|
rebate: l.rebate || '0',
|
||||||
net: netVal || l.net,
|
net: netVal || l.net,
|
||||||
netprice: netVal || l.netprice,
|
netprice: netVal || l.netprice,
|
||||||
gross: grossVal || l.gross,
|
gross: grossVal || l.gross,
|
||||||
@@ -594,6 +701,7 @@ class ManifestForm extends PureComponent {
|
|||||||
attachments: this.state.attachments,
|
attachments: this.state.attachments,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: cfg.kind,
|
||||||
partyField: cfg.partyField,
|
partyField: cfg.partyField,
|
||||||
requireAtLeastOne,
|
requireAtLeastOne,
|
||||||
externalNumber: cfg.externalNumber,
|
externalNumber: cfg.externalNumber,
|
||||||
@@ -612,6 +720,7 @@ class ManifestForm extends PureComponent {
|
|||||||
requireObject(data, cfg.postPathLabel);
|
requireObject(data, cfg.postPathLabel);
|
||||||
requireObject(data[cfg.docField], cfg.docField);
|
requireObject(data[cfg.docField], cfg.docField);
|
||||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||||
|
this.draftAutomat?.actions?.reset();
|
||||||
await cfg.listAutomat.actions.reload();
|
await cfg.listAutomat.actions.reload();
|
||||||
if (this._cancelled) return;
|
if (this._cancelled) return;
|
||||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||||
|
|||||||
@@ -226,7 +226,9 @@ class ManifestFormLine extends PureComponent {
|
|||||||
|
|
||||||
let val = line[id];
|
let val = line[id];
|
||||||
if (val == null || val === '') {
|
if (val == null || val === '') {
|
||||||
if (id === 'tax' || id === 'taxrate') {
|
if (id === 'name' && this.state.cachedArticle?.name) {
|
||||||
|
val = this.state.cachedArticle.name;
|
||||||
|
} else if (id === 'tax' || id === 'taxrate') {
|
||||||
val = line.taxrate ?? line.tax ?? '';
|
val = line.taxrate ?? line.tax ?? '';
|
||||||
} else if (id === 'net' || id === 'netprice') {
|
} else if (id === 'net' || id === 'netprice') {
|
||||||
val = line.netprice ?? line.net ?? '';
|
val = line.netprice ?? line.net ?? '';
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
|||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import { PanelTile } from 'dashgui/panels';
|
import { PanelTile } from 'dashgui/panels';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import FileDropzone from '../FileDropzone';
|
import FileDropzone from '../FileDropzone';
|
||||||
import AttachmentList from './AttachmentList';
|
import AttachmentList from './AttachmentList';
|
||||||
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
|
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
|
||||||
import { notifyErrorMessage } from '../../utils/notify';
|
import { notifyError, notifyErrorMessage } from '../../utils/notify';
|
||||||
|
import entityAutomat from '../../automats/entityAutomat';
|
||||||
|
import customergroupAutomat from '../../automats/customergroupAutomat';
|
||||||
|
import supplierAutomat from '../../automats/supplierAutomat';
|
||||||
import {
|
import {
|
||||||
SURFACE_RAISED,
|
SURFACE_RAISED,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
@@ -22,17 +26,148 @@ import {
|
|||||||
class ManifestHeader extends PureComponent {
|
class ManifestHeader extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
const selected =
|
||||||
|
(props.parties || []).find((row) => row.id === props.partyId) ?? null;
|
||||||
|
this.state = {
|
||||||
|
partyInput: selected ? partyDisplayName(props.kind, selected) : '',
|
||||||
|
creatingParty: false,
|
||||||
|
createdParty: null,
|
||||||
|
};
|
||||||
this.fileInputRef = createRef();
|
this.fileInputRef = createRef();
|
||||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||||
|
this.handlePartyInputChange = this.handlePartyInputChange.bind(this);
|
||||||
|
this.handlePartyKeyDown = this.handlePartyKeyDown.bind(this);
|
||||||
|
this.handleCreateParty = this.handleCreateParty.bind(this);
|
||||||
this.handleUploadClick = this.handleUploadClick.bind(this);
|
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||||
this.handleFileChange = this.handleFileChange.bind(this);
|
this.handleFileChange = this.handleFileChange.bind(this);
|
||||||
this.handleFileDrop = this.handleFileDrop.bind(this);
|
this.handleFileDrop = this.handleFileDrop.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.partyId !== this.props.partyId) {
|
||||||
|
const selected =
|
||||||
|
(this.props.parties || []).find((row) => row.id === this.props.partyId) ??
|
||||||
|
(this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null);
|
||||||
|
if (selected) {
|
||||||
|
this.setState({ partyInput: partyDisplayName(this.props.kind, selected) });
|
||||||
|
} else if (this.props.partyId == null) {
|
||||||
|
this.setState({ partyInput: '', createdParty: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handlePartyChange(_event, option) {
|
handlePartyChange(_event, option) {
|
||||||
|
this.setState({
|
||||||
|
createdParty: null,
|
||||||
|
partyInput: option ? partyDisplayName(this.props.kind, option) : '',
|
||||||
|
});
|
||||||
this.props.onPartyChange(option?.id ?? null);
|
this.props.onPartyChange(option?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handlePartyInputChange(_event, value, reason) {
|
||||||
|
if (reason === 'reset') {
|
||||||
|
const selected =
|
||||||
|
(this.props.parties || []).find((row) => row.id === this.props.partyId) ??
|
||||||
|
this.state.createdParty;
|
||||||
|
this.setState({
|
||||||
|
partyInput: selected ? partyDisplayName(this.props.kind, selected) : value,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState({ partyInput: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePartyKeyDown(event) {
|
||||||
|
if (event.key !== 'Enter') return;
|
||||||
|
if (event.defaultPrevented) return;
|
||||||
|
|
||||||
|
const activeDescendant = event.target?.getAttribute?.('aria-activedescendant');
|
||||||
|
if (activeDescendant) return;
|
||||||
|
|
||||||
|
const raw = this.state.partyInput?.trim();
|
||||||
|
if (!raw || this.state.creatingParty) return;
|
||||||
|
|
||||||
|
const { parties, kind } = this.props;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const existing = (parties || []).find((p) => {
|
||||||
|
const label = partyDisplayName(kind, p).toLowerCase();
|
||||||
|
return label === lower || String(p.name ?? '').toLowerCase() === lower;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.setState({
|
||||||
|
createdParty: null,
|
||||||
|
partyInput: partyDisplayName(kind, existing),
|
||||||
|
});
|
||||||
|
this.props.onPartyChange(existing.id);
|
||||||
|
event.target?.blur?.();
|
||||||
|
} else {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void this.handleCreateParty(raw, event.target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleCreateParty(rawInput, targetEl) {
|
||||||
|
const raw = String(rawInput ?? this.state.partyInput ?? '').trim();
|
||||||
|
if (!raw || this.state.creatingParty) return;
|
||||||
|
|
||||||
|
this.setState({ creatingParty: true });
|
||||||
|
try {
|
||||||
|
const { kind, onPartyChange } = this.props;
|
||||||
|
if (kind === 'delivery') {
|
||||||
|
const row = await supplierAutomat.actions.create({ name: raw });
|
||||||
|
const displayName = row?.name || raw;
|
||||||
|
enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' });
|
||||||
|
this.setState({ creatingParty: false, createdParty: row, partyInput: displayName });
|
||||||
|
onPartyChange?.(row.id);
|
||||||
|
} else {
|
||||||
|
await customergroupAutomat.actions.ensureLoaded();
|
||||||
|
const groups = customergroupAutomat.state.items ?? [];
|
||||||
|
const defaultGroup = groups.find((g) => g.editable !== false) ?? groups[0];
|
||||||
|
if (!defaultGroup) {
|
||||||
|
throw new Error('Keine Kundengruppe vorhanden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = raw;
|
||||||
|
let firstName = null;
|
||||||
|
|
||||||
|
if (raw.includes(',')) {
|
||||||
|
const parts = raw.split(',').map((p) => p.trim());
|
||||||
|
name = parts[0];
|
||||||
|
firstName = parts.slice(1).join(' ') || null;
|
||||||
|
} else if (raw.includes(' ') && !/gmbh|ag|kg|ug|inc|ltd|co\./i.test(raw)) {
|
||||||
|
const parts = raw.split(' ');
|
||||||
|
firstName = parts[0];
|
||||||
|
name = parts.slice(1).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
customergroupId: defaultGroup.id,
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
if (firstName) body.firstName = firstName;
|
||||||
|
|
||||||
|
const row = await entityAutomat.actions.create(body);
|
||||||
|
const displayName = [row.firstName, row.name].filter(Boolean).join(' ') || row.name || raw;
|
||||||
|
enqueueSnackbar(`Kunde „${displayName}“ angelegt.`, { variant: 'success' });
|
||||||
|
this.setState({ creatingParty: false, createdParty: row, partyInput: displayName });
|
||||||
|
onPartyChange?.(row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetEl?.blur) {
|
||||||
|
targetEl.blur();
|
||||||
|
} else if (document.activeElement?.blur) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ creatingParty: false });
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hasArticles() {
|
hasArticles() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
this.props.hasArticles ??
|
this.props.hasArticles ??
|
||||||
@@ -111,7 +246,8 @@ class ManifestHeader extends PureComponent {
|
|||||||
const useExternalNumber = Boolean(cfg.externalNumber);
|
const useExternalNumber = Boolean(cfg.externalNumber);
|
||||||
|
|
||||||
const selected =
|
const selected =
|
||||||
parties.find((row) => row.id === partyId) ?? null;
|
(parties || []).find((row) => row.id === partyId) ??
|
||||||
|
(this.state.createdParty?.id === partyId ? this.state.createdParty : null);
|
||||||
|
|
||||||
const isDateEmpty = !date || String(date).trim() === '';
|
const isDateEmpty = !date || String(date).trim() === '';
|
||||||
const isPartyEmpty = partyId == null || partyId === '';
|
const isPartyEmpty = partyId == null || partyId === '';
|
||||||
@@ -147,13 +283,44 @@ class ManifestHeader extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
size="small"
|
size="small"
|
||||||
options={parties}
|
autoHighlight
|
||||||
|
options={parties || []}
|
||||||
value={selected}
|
value={selected}
|
||||||
loading={Boolean(partiesLoading)}
|
loading={Boolean(partiesLoading) || this.state.creatingParty}
|
||||||
|
loadingText={this.state.creatingParty ? 'Wird angelegt…' : 'Lädt…'}
|
||||||
|
noOptionsText={
|
||||||
|
this.state.creatingParty ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<span>Wird angelegt…</span>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const raw = this.state.partyInput?.trim();
|
||||||
|
if (raw) void this.handleCreateParty(raw);
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'primary.main',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Enter zum anlegen
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
disabled={saving || pdfUploading}
|
disabled={saving || pdfUploading}
|
||||||
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
getOptionLabel={(option) => partyDisplayName(kind, option)}
|
||||||
isOptionEqualToValue={(option, value) => option.id === value.id}
|
isOptionEqualToValue={(option, value) => option?.id === value?.id}
|
||||||
onChange={this.handlePartyChange}
|
onChange={this.handlePartyChange}
|
||||||
|
onInputChange={this.handlePartyInputChange}
|
||||||
|
onKeyDown={this.handlePartyKeyDown}
|
||||||
sx={{
|
sx={{
|
||||||
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
flex: { xs: '1 1 100%', sm: '2 1 200px' },
|
||||||
minWidth: { sm: 160 },
|
minWidth: { sm: 160 },
|
||||||
@@ -163,6 +330,7 @@ class ManifestHeader extends PureComponent {
|
|||||||
{...params}
|
{...params}
|
||||||
label={partyLabel}
|
label={partyLabel}
|
||||||
error={isPartyEmpty}
|
error={isPartyEmpty}
|
||||||
|
onKeyDown={this.handlePartyKeyDown}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...params.slotProps,
|
...params.slotProps,
|
||||||
htmlInput: {
|
htmlInput: {
|
||||||
@@ -176,9 +344,9 @@ class ManifestHeader extends PureComponent {
|
|||||||
<SingleLineTextEdit
|
<SingleLineTextEdit
|
||||||
size="small"
|
size="small"
|
||||||
label="Nummer"
|
label="Nummer"
|
||||||
value={number}
|
value={number || ''}
|
||||||
disabled={saving || pdfUploading || useExternalNumber}
|
disabled
|
||||||
onChange={onNumberChange}
|
placeholder="–"
|
||||||
sx={{
|
sx={{
|
||||||
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
flex: { xs: '1 1 100%', sm: '1 1 120px' },
|
||||||
minWidth: { sm: 100 },
|
minWidth: { sm: 100 },
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import Typography from '@mui/material/Typography';
|
|||||||
import Checkbox from '@mui/material/Checkbox';
|
import Checkbox from '@mui/material/Checkbox';
|
||||||
import Switch from '@mui/material/Switch';
|
import Switch from '@mui/material/Switch';
|
||||||
import Skeleton from '@mui/material/Skeleton';
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
import FillSpace from 'dashgui/FillSpace';
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
@@ -220,6 +222,7 @@ class OrderSearchField extends PureComponent {
|
|||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const s = orderAutomat.state;
|
const s = orderAutomat.state;
|
||||||
|
const stats = orderAutomat.getChunkStats?.();
|
||||||
this.state = {
|
this.state = {
|
||||||
input: s.filter ?? '',
|
input: s.filter ?? '',
|
||||||
onlyUnpaid: s.onlyUnpaid ?? false,
|
onlyUnpaid: s.onlyUnpaid ?? false,
|
||||||
@@ -227,6 +230,9 @@ class OrderSearchField extends PureComponent {
|
|||||||
seekMonths: s.seekMonths ?? 0,
|
seekMonths: s.seekMonths ?? 0,
|
||||||
seekYears: s.seekYears ?? 0,
|
seekYears: s.seekYears ?? 0,
|
||||||
negativeSeekMonths: s.negativeSeekMonths ?? 0,
|
negativeSeekMonths: s.negativeSeekMonths ?? 0,
|
||||||
|
lruChunksCount: stats?.lruChunksCount || 0,
|
||||||
|
maxLruChunks: stats?.maxLruChunks || 50,
|
||||||
|
filterCachesCount: stats?.filterCaches?.length || 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +245,20 @@ class OrderSearchField extends PureComponent {
|
|||||||
seekYears: state.seekYears ?? 0,
|
seekYears: state.seekYears ?? 0,
|
||||||
negativeSeekMonths: state.negativeSeekMonths ?? 0,
|
negativeSeekMonths: state.negativeSeekMonths ?? 0,
|
||||||
}));
|
}));
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
this.unsubStats = orderAutomat.subscribeChunkStats?.((stats) => {
|
||||||
|
this.setState({
|
||||||
|
lruChunksCount: stats.lruChunksCount,
|
||||||
|
maxLruChunks: stats.maxLruChunks,
|
||||||
|
filterCachesCount: stats.filterCaches?.length || 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
|
this.unsubStats?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -423,6 +439,26 @@ class OrderSearchField extends PureComponent {
|
|||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<Tooltip title={`Chunk-Cache-Monitor öffnen (Alt+Shift+C) — ${this.state.lruChunksCount || 0}/${this.state.maxLruChunks || 50} Chunks im RAM über ${this.state.filterCachesCount || 1} Filter`}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<ViewModuleIcon sx={{ fontSize: 16 }} />}
|
||||||
|
label={this.state.lruChunksCount > 0 ? `Chunk-Cache (${this.state.lruChunksCount}/${this.state.maxLruChunks || 50})` : 'Chunk-Cache'}
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('dash:toggle-chunk-monitor'))}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
height: 28,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 1,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -438,6 +474,8 @@ export class OrderList extends PureComponent {
|
|||||||
// getItems() returns current items or null and triggers loading of the first chunk
|
// getItems() returns current items or null and triggers loading of the first chunk
|
||||||
this.state = {
|
this.state = {
|
||||||
items: orderAutomat.getItems(),
|
items: orderAutomat.getItems(),
|
||||||
|
unfilteredTotal: orderAutomat.state.unfilteredTotal ?? 0,
|
||||||
|
filterKey: orderAutomat.getFilterKey?.() ?? '',
|
||||||
partiesById: {},
|
partiesById: {},
|
||||||
busyId: null,
|
busyId: null,
|
||||||
};
|
};
|
||||||
@@ -454,9 +492,10 @@ export class OrderList extends PureComponent {
|
|||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this._cancelled = false;
|
this._cancelled = false;
|
||||||
// Subscribe to items only
|
|
||||||
this.unsub = orderAutomat.subscribe(this, (state) => ({
|
this.unsub = orderAutomat.subscribe(this, (state) => ({
|
||||||
items: state.items,
|
items: state.items,
|
||||||
|
unfilteredTotal: state.unfilteredTotal ?? 0,
|
||||||
|
filterKey: orderAutomat.getFilterKey(state),
|
||||||
}));
|
}));
|
||||||
void this.loadParties();
|
void this.loadParties();
|
||||||
}
|
}
|
||||||
@@ -546,7 +585,7 @@ export class OrderList extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { items, partiesById, busyId } = this.state;
|
const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
|
||||||
|
|
||||||
rowContext.partiesById = partiesById;
|
rowContext.partiesById = partiesById;
|
||||||
rowContext.busyId = busyId;
|
rowContext.busyId = busyId;
|
||||||
@@ -578,8 +617,10 @@ export class OrderList extends PureComponent {
|
|||||||
reserveBottom={32}
|
reserveBottom={32}
|
||||||
minPageSize={8}
|
minPageSize={8}
|
||||||
minHeight={0}
|
minHeight={0}
|
||||||
|
resetKey={filterKey}
|
||||||
items={items || []}
|
items={items || []}
|
||||||
totalCount={items ? items.length : 0}
|
totalCount={items ? items.length : 0}
|
||||||
|
unfilteredTotal={unfilteredTotal}
|
||||||
renderItem={OrderRow}
|
renderItem={OrderRow}
|
||||||
virtualScrollbar
|
virtualScrollbar
|
||||||
header={
|
header={
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ class RecordPaymentDialog extends PureComponent {
|
|||||||
type="number"
|
type="number"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => this.setState({ amount: e.target.value })}
|
onChange={(e) => this.setState({ amount: e.target.value })}
|
||||||
inputProps={{ min: 0.01, step: 'any' }}
|
slotProps={{ htmlInput: { min: 0.01, step: 'any' } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ class NewShipmentModal extends PureComponent {
|
|||||||
value={currentVal}
|
value={currentVal}
|
||||||
onChange={(e) => this.handleAmountChange(line.sku, e.target.value)}
|
onChange={(e) => this.handleAmountChange(line.sku, e.target.value)}
|
||||||
disabled={remaining <= 0}
|
disabled={remaining <= 0}
|
||||||
inputProps={{ min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } }}
|
slotProps={{ htmlInput: { min: 0, max: remaining, step: 'any', style: { textAlign: 'right' } } }}
|
||||||
sx={{ width: 100 }}
|
sx={{ width: 100 }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export { withManifestId } from './withManifestId';
|
|||||||
export {
|
export {
|
||||||
manifestConfig,
|
manifestConfig,
|
||||||
LINE_COLUMNS,
|
LINE_COLUMNS,
|
||||||
|
QUOTE_LINE_COLUMNS,
|
||||||
ORDER_LINE_COLUMNS,
|
ORDER_LINE_COLUMNS,
|
||||||
DELIVERY_LINE_COLUMNS,
|
DELIVERY_LINE_COLUMNS,
|
||||||
emptyLine,
|
emptyLine,
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import customergroupAutomat from '../../automats/customergroupAutomat';
|
|||||||
import deliveryAutomat from '../../automats/deliveryAutomat';
|
import deliveryAutomat from '../../automats/deliveryAutomat';
|
||||||
import quoteAutomat from '../../automats/quoteAutomat';
|
import quoteAutomat from '../../automats/quoteAutomat';
|
||||||
import orderAutomat from '../../automats/orderAutomat';
|
import orderAutomat from '../../automats/orderAutomat';
|
||||||
|
import { getArticle } from '../../automats/artikelAutomat';
|
||||||
|
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
|
||||||
import { netFromGross } from '../../utils/price';
|
import { netFromGross } from '../../utils/price';
|
||||||
|
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
|
||||||
import {
|
import {
|
||||||
GREEN_PANEL,
|
GREEN_PANEL,
|
||||||
ORANGE_PANEL,
|
ORANGE_PANEL,
|
||||||
@@ -22,7 +25,8 @@ export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
|
|||||||
|
|
||||||
export function entityDisplayName(entity) {
|
export function entityDisplayName(entity) {
|
||||||
if (!entity) return '';
|
if (!entity) return '';
|
||||||
return [entity.firstName, entity.name].filter(Boolean).join(' ');
|
const fullName = [entity.firstName, entity.name].filter(Boolean).join(' ');
|
||||||
|
return fullName || entity.name || entity.firstName || (entity.number ? `Kunde ${entity.number}` : (entity.id ? `Kunde #${entity.id}` : ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortEntities(entities) {
|
export function sortEntities(entities) {
|
||||||
@@ -81,19 +85,20 @@ export function dateInputValue(iso) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function emptyLine() {
|
export function emptyLine() {
|
||||||
|
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
|
||||||
return {
|
return {
|
||||||
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
amount: '',
|
amount: '1',
|
||||||
sku: '',
|
sku: '',
|
||||||
tax: '',
|
name: '',
|
||||||
rebate: '',
|
tax: normTax,
|
||||||
|
taxrate: normTax,
|
||||||
|
rebate: '0',
|
||||||
net: '',
|
net: '',
|
||||||
gross: '',
|
gross: '',
|
||||||
name: '',
|
pu: '1',
|
||||||
pu: '',
|
|
||||||
unit: '',
|
unit: '',
|
||||||
netprice: '',
|
netprice: '',
|
||||||
taxrate: '',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,9 +153,8 @@ export function hasArticleLines(lines) {
|
|||||||
if (!l) return false;
|
if (!l) return false;
|
||||||
const sku = String(l.sku ?? '').trim();
|
const sku = String(l.sku ?? '').trim();
|
||||||
const name = String(l.name ?? '').trim();
|
const name = String(l.name ?? '').trim();
|
||||||
const amount = String(l.amount ?? '').trim();
|
|
||||||
const net = String(l.net ?? l.netprice ?? l.gross ?? '').trim();
|
const net = String(l.net ?? l.netprice ?? l.gross ?? '').trim();
|
||||||
return Boolean(sku || name || amount || net);
|
return Boolean(sku || name || net);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +166,7 @@ export function buildLines(lines, { requireAtLeastOne }) {
|
|||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
const sku = String(line.sku ?? '').trim();
|
const sku = String(line.sku ?? '').trim();
|
||||||
const name = String(line.name ?? '').trim();
|
const name = String(line.name ?? (sku ? getArticle(sku)?.name : '') ?? '').trim();
|
||||||
const rawAmount = getFirstVal(line.amount, line.count);
|
const rawAmount = getFirstVal(line.amount, line.count);
|
||||||
const amount = parseNum(rawAmount);
|
const amount = parseNum(rawAmount);
|
||||||
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
const rawNet = getFirstVal(line.net, line.netprice, line.netPrice, line.eknetto);
|
||||||
@@ -177,8 +181,7 @@ export function buildLines(lines, { requireAtLeastOne }) {
|
|||||||
const rebate = parseNum(getFirstVal(line.rebate));
|
const rebate = parseNum(getFirstVal(line.rebate));
|
||||||
const rawPu = getFirstVal(line.pu, line.vpe);
|
const rawPu = getFirstVal(line.pu, line.vpe);
|
||||||
const pu = parseNum(rawPu);
|
const pu = parseNum(rawPu);
|
||||||
const blank =
|
const blank = !sku && !name && net == null && gross == null;
|
||||||
!sku && !name && amount == null && net == null && gross == null && tax == null && rebate == null;
|
|
||||||
if (blank) continue;
|
if (blank) continue;
|
||||||
|
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -442,7 +445,7 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
|
|||||||
*/
|
*/
|
||||||
export async function buildManifestBody(
|
export async function buildManifestBody(
|
||||||
form,
|
form,
|
||||||
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber },
|
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber, kind },
|
||||||
) {
|
) {
|
||||||
const date = form.date;
|
const date = form.date;
|
||||||
if (!date) throw new Error('Datum fehlt.');
|
if (!date) throw new Error('Datum fehlt.');
|
||||||
@@ -458,8 +461,11 @@ export async function buildManifestBody(
|
|||||||
lines: buildLines(form.lines, { requireAtLeastOne }),
|
lines: buildLines(form.lines, { requireAtLeastOne }),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const number = String(form.number ?? '').trim();
|
let number = String(form.number ?? '').trim();
|
||||||
if (!number) throw new Error('Nummer fehlt.');
|
if (!number) {
|
||||||
|
number = await ensureDocumentNumber(kind || (partyField === 'entityId' ? 'quote' : 'doc'));
|
||||||
|
}
|
||||||
|
if (!number) throw new Error('Nummer konnte nicht ermittelt werden.');
|
||||||
body = {
|
body = {
|
||||||
number,
|
number,
|
||||||
date,
|
date,
|
||||||
@@ -499,12 +505,15 @@ export async function buildManifestBody(
|
|||||||
export const LINE_COLUMNS = [
|
export const LINE_COLUMNS = [
|
||||||
{ id: 'amount', label: 'Amount', width: 80, align: 'right', type: 'number' },
|
{ id: 'amount', label: 'Amount', width: 80, align: 'right', type: 'number' },
|
||||||
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
|
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
|
||||||
|
{ id: 'name', label: 'Name', width: 200, align: 'left', type: 'text' },
|
||||||
{ id: 'tax', label: 'Tax', width: 70, align: 'right', type: 'number' },
|
{ id: 'tax', label: 'Tax', width: 70, align: 'right', type: 'number' },
|
||||||
{ id: 'rebate', label: 'Rebate', width: 75, align: 'right', type: 'number' },
|
{ id: 'rebate', label: 'Rebate', width: 75, align: 'right', type: 'number' },
|
||||||
{ id: 'net', label: 'Net', width: 95, align: 'right', type: 'number' },
|
{ id: 'net', label: 'Net', width: 95, align: 'right', type: 'number' },
|
||||||
{ id: 'gross', label: 'Gross', width: 95, align: 'right', type: 'number' },
|
{ id: 'gross', label: 'Gross', width: 95, align: 'right', type: 'number' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const QUOTE_LINE_COLUMNS = LINE_COLUMNS;
|
||||||
|
|
||||||
export const ORDER_LINE_COLUMNS = [
|
export const ORDER_LINE_COLUMNS = [
|
||||||
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
|
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
|
||||||
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
|
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
|
||||||
@@ -557,15 +566,13 @@ export async function loadParties(cfgOrKind) {
|
|||||||
const list = await supplierAutomat.actions.load();
|
const list = await supplierAutomat.actions.load();
|
||||||
return sortSuppliers(list);
|
return sortSuppliers(list);
|
||||||
}
|
}
|
||||||
const [entities, groups] = await Promise.all([
|
const entities = await entityAutomat.actions.ensureLoaded();
|
||||||
entityAutomat.actions.ensureLoaded(),
|
return entities ?? entityAutomat.state.items ?? [];
|
||||||
customergroupAutomat.actions.ensureLoaded(),
|
|
||||||
]);
|
|
||||||
return filterCustomerEntities(entities, groups);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partyDisplayName(cfgOrKind, party) {
|
export function partyDisplayName(cfgOrKind, party) {
|
||||||
if (!party) return '';
|
if (!party) return '';
|
||||||
|
if (typeof party === 'string') return party;
|
||||||
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
|
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
|
||||||
if (kind === 'delivery') return String(party.name ?? '');
|
if (kind === 'delivery') return String(party.name ?? '');
|
||||||
return entityDisplayName(party);
|
return entityDisplayName(party);
|
||||||
@@ -623,7 +630,7 @@ export function manifestConfig(cfgOrKind) {
|
|||||||
docField: 'quote',
|
docField: 'quote',
|
||||||
partyField: 'entityId',
|
partyField: 'entityId',
|
||||||
listPath: '/auftrag',
|
listPath: '/auftrag',
|
||||||
createPath: '/auftrag/neu',
|
createPath: '/auftrag/neuesangebot',
|
||||||
editPath: (id) => `/auftrag/${id}`,
|
editPath: (id) => `/auftrag/${id}`,
|
||||||
dateLabel: 'Gültig bis',
|
dateLabel: 'Gültig bis',
|
||||||
partyLabel: 'Kunde',
|
partyLabel: 'Kunde',
|
||||||
|
|||||||
567
src/components/settings/BackupSettingsPanel.jsx
Normal file
567
src/components/settings/BackupSettingsPanel.jsx
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Card from '@mui/material/Card';
|
||||||
|
import CardContent from '@mui/material/CardContent';
|
||||||
|
import Chip from '@mui/material/Chip';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import Table from '@mui/material/Table';
|
||||||
|
import TableBody from '@mui/material/TableBody';
|
||||||
|
import TableCell from '@mui/material/TableCell';
|
||||||
|
import TableContainer from '@mui/material/TableContainer';
|
||||||
|
import TableHead from '@mui/material/TableHead';
|
||||||
|
import TableRow from '@mui/material/TableRow';
|
||||||
|
import Tabs from '@mui/material/Tabs';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import BackupOutlinedIcon from '@mui/icons-material/BackupOutlined';
|
||||||
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutlined';
|
||||||
|
import CloudQueueOutlinedIcon from '@mui/icons-material/CloudQueueOutlined';
|
||||||
|
import FolderOutlinedIcon from '@mui/icons-material/FolderOutlined';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||||
|
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import StorageIcon from '@mui/icons-material/Storage';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import backupAutomat from '../../automats/backupAutomat';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
import {
|
||||||
|
BORDER,
|
||||||
|
CHROMA,
|
||||||
|
DARK_1,
|
||||||
|
DARK_2,
|
||||||
|
DARK_4,
|
||||||
|
LIGHT_3,
|
||||||
|
PRIMARY,
|
||||||
|
SURFACE_BASE,
|
||||||
|
SURFACE_RAISED,
|
||||||
|
WHITE,
|
||||||
|
} from '../../colors';
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes == null || isNaN(bytes) || bytes === 0) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts) {
|
||||||
|
if (!ts) return '—';
|
||||||
|
try {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return String(ts);
|
||||||
|
return d.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return String(ts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
if (ms == null || isNaN(ms)) return '—';
|
||||||
|
if (ms < 1000) return `${ms} ms`;
|
||||||
|
return `${(ms / 1000).toFixed(2)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class BackupSettingsPanel extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
const { status, error, runningBackup, backupStatus, backups } = backupAutomat.state;
|
||||||
|
this.state = {
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
runningBackup,
|
||||||
|
backupStatus,
|
||||||
|
backups,
|
||||||
|
tierTab: 'hourly', // 'hourly' | 'daily' | 'monthly'
|
||||||
|
expandedItemName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleRefresh = this.handleRefresh.bind(this);
|
||||||
|
this.handleRunBackup = this.handleRunBackup.bind(this);
|
||||||
|
this.handleTierChange = this.handleTierChange.bind(this);
|
||||||
|
this.handleToggleExpand = this.handleToggleExpand.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.unsub = backupAutomat.subscribe(this, (state) => ({
|
||||||
|
status: state.status,
|
||||||
|
error: state.error,
|
||||||
|
runningBackup: state.runningBackup,
|
||||||
|
backupStatus: state.backupStatus,
|
||||||
|
backups: state.backups,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (backupAutomat.state.status === 'idle') {
|
||||||
|
void this.loadBackups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.unsub?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region loadBackups
|
||||||
|
// MARK: calls backupAutomat.actions.load from ./api/index.js
|
||||||
|
async loadBackups() {
|
||||||
|
try {
|
||||||
|
await backupAutomat.actions.load();
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region handleRefresh
|
||||||
|
// MARK: calls backupAutomat.actions.reload from ./api/index.js
|
||||||
|
async handleRefresh() {
|
||||||
|
try {
|
||||||
|
await backupAutomat.actions.reload();
|
||||||
|
enqueueSnackbar('Backup-Status aktualisiert', { variant: 'info' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region handleRunBackup
|
||||||
|
// MARK: calls POST /backup/run via backupAutomat.actions.runBackup from ./api/index.js
|
||||||
|
async handleRunBackup() {
|
||||||
|
if (this.state.runningBackup || this.state.backupStatus?.isRunning) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await backupAutomat.actions.runBackup();
|
||||||
|
const sizeStr = result?.totalSizeBytes != null ? ` (${formatBytes(result.totalSizeBytes)})` : '';
|
||||||
|
enqueueSnackbar(`Manuelles Backup erfolgreich erstellt${sizeStr}.`, {
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
handleTierChange(_event, newValue) {
|
||||||
|
this.setState({ tierTab: newValue, expandedItemName: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleExpand(name) {
|
||||||
|
this.setState((prev) => ({
|
||||||
|
expandedItemName: prev.expandedItemName === name ? null : name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { status, error, runningBackup, backupStatus, backups, tierTab, expandedItemName } = this.state;
|
||||||
|
|
||||||
|
if (status === 'loading' && !backupStatus) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 8, gap: 2 }}>
|
||||||
|
<CircularProgress size={36} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Backup-Status wird geladen...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'failed' && !backupStatus) {
|
||||||
|
return (
|
||||||
|
<Stack spacing={2} sx={{ py: 2 }}>
|
||||||
|
<Alert severity="error">
|
||||||
|
{error || 'Fehler beim Laden des Backup-Status.'}
|
||||||
|
</Alert>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExecuting = Boolean(runningBackup || backupStatus?.isRunning);
|
||||||
|
const retention = backupStatus?.retention ?? { hourlyMax: 24, dailyMax: 7, monthly: 'Monatserster' };
|
||||||
|
const counts = backups?.counts ?? { hourly: 0, daily: 0, monthly: 0 };
|
||||||
|
const hourlyList = backups?.hourly || [];
|
||||||
|
const dailyList = backups?.daily || [];
|
||||||
|
const monthlyList = backups?.monthly || [];
|
||||||
|
|
||||||
|
const activeList = tierTab === 'hourly'
|
||||||
|
? hourlyList
|
||||||
|
: tierTab === 'daily'
|
||||||
|
? dailyList
|
||||||
|
: monthlyList;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{/* Header Title & Actions */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ color: DARK_4, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<BackupOutlinedIcon sx={{ color: PRIMARY }} />
|
||||||
|
Datensicherung & Backups
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
|
Automatische Sicherung von Datenbank, virtuellen Dateisystemen (VFS) und Dokumentenspeicher.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={this.handleRefresh}
|
||||||
|
disabled={isExecuting}
|
||||||
|
>
|
||||||
|
Aktualisieren
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayArrowOutlinedIcon />}
|
||||||
|
onClick={this.handleRunBackup}
|
||||||
|
disabled={isExecuting}
|
||||||
|
sx={{ bgcolor: PRIMARY, '&:hover': { bgcolor: CHROMA.PETROL.dark } }}
|
||||||
|
>
|
||||||
|
{isExecuting ? 'Sicherung läuft...' : 'Jetzt sichern'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status Badges Bar */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
bgcolor: SURFACE_BASE,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={backupStatus?.enabled ? <CheckCircleOutlineIcon /> : <WarningAmberIcon />}
|
||||||
|
label={backupStatus?.enabled ? 'Dienst: Aktiv' : 'Dienst: Deaktiviert'}
|
||||||
|
color={backupStatus?.enabled ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{isExecuting && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<CircularProgress size={12} color="inherit" />}
|
||||||
|
label="Backup wird ausgeführt..."
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<StorageIcon />}
|
||||||
|
label={`Provider: ${backupStatus?.dbProvider?.toUpperCase() || 'DATENBANK'}`}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{backupStatus?.backupDir && (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<FolderOutlinedIcon />}
|
||||||
|
label={`Verzeichnis: ${backupStatus.backupDir}`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ maxWidth: 360 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* MSSQL Warning if present */}
|
||||||
|
{backupStatus?.mssqlWarning && (
|
||||||
|
<Alert severity="warning" icon={<WarningAmberIcon />}>
|
||||||
|
MSSQL-Hinweis: Datenbank-Backups erfordern Schreibberechtigungen des SQL-Server-Dienstes auf das Backup-Verzeichnis oder werden getrennt verwaltet.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overview KPI Cards */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hourly KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: 'uppercase' }}>
|
||||||
|
Stündliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.hourly}{' '}
|
||||||
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
|
/ {retention.hourlyMax} max
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestHourly?.timestamp || hourlyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestHourly?.totalSizeBytes || hourlyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Daily KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: 'uppercase' }}>
|
||||||
|
Tägliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.daily}{' '}
|
||||||
|
<Typography component="span" variant="body2" color="text.secondary">
|
||||||
|
/ {retention.dailyMax} max
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestDaily?.timestamp || dailyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestDaily?.totalSizeBytes || dailyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Monthly KPI */}
|
||||||
|
<Card variant="outlined" sx={{ bgcolor: WHITE, borderColor: BORDER, borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ textTransform: 'uppercase' }}>
|
||||||
|
Monatliche Sicherungen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" fontWeight={700} sx={{ color: DARK_4, mt: 0.5 }}>
|
||||||
|
{counts.monthly}
|
||||||
|
</Typography>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Zuletzt: {formatDate(backupStatus?.latestMonthly?.timestamp || monthlyList[0]?.timestamp)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Größe: {formatBytes(backupStatus?.latestMonthly?.totalSizeBytes || monthlyList[0]?.totalSizeBytes)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Retention Policy Note */}
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Aufbewahrungsrichtlinie: Maximal {retention.hourlyMax} stündliche Snapshots, {retention.dailyMax} tägliche Snapshots; monatliche Archivierung ({retention.monthly || 'am 1. Tag des Monats'}). Ältere Snapshots werden automatisch bereinigt.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Backups Table Section with Tabs */}
|
||||||
|
<Box sx={{ border: `1px solid ${BORDER}`, borderRadius: 2, bgcolor: WHITE, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ borderBottom: `1px solid ${BORDER}`, bgcolor: SURFACE_RAISED, px: 2 }}>
|
||||||
|
<Tabs
|
||||||
|
value={tierTab}
|
||||||
|
onChange={this.handleTierChange}
|
||||||
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
sx={{ minHeight: 44 }}
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
value="hourly"
|
||||||
|
label={`Stündlich (${counts.hourly})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="daily"
|
||||||
|
label={`Täglich (${counts.daily})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
value="monthly"
|
||||||
|
label={`Monatlich (${counts.monthly})`}
|
||||||
|
sx={{ minHeight: 44, textTransform: 'none', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{activeList.length === 0 ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', py: 6, gap: 1 }}>
|
||||||
|
<CloudQueueOutlinedIcon sx={{ fontSize: 40, color: DARK_1 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Keine Sicherungen in dieser Kategorie vorhanden.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<TableContainer sx={{ maxHeight: 360 }}>
|
||||||
|
<Table size="small" stickyHeader>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ width: 40 }} />
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Zeitpunkt</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Archivname</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Größe</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Typ</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{activeList.map((item) => {
|
||||||
|
const isExpanded = expandedItemName === item.name;
|
||||||
|
const manifest = item.manifest;
|
||||||
|
const isManual = Boolean(manifest?.manual);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackupTableRow
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isManual={isManual}
|
||||||
|
manifest={manifest}
|
||||||
|
onToggleExpand={() => this.handleToggleExpand(item.name)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupTableRow({ item, isExpanded, isManual, manifest, onToggleExpand }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableRow
|
||||||
|
hover
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: isExpanded ? LIGHT_3 : 'inherit',
|
||||||
|
'& > *': { borderBottom: isExpanded ? 'none !important' : undefined },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TableCell sx={{ py: 1 }}>
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggleExpand(); }}>
|
||||||
|
{isExpanded ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, fontWeight: 500 }}>
|
||||||
|
{formatDate(item.timestamp)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, color: DARK_2, fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||||
|
{item.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1, fontWeight: 600 }}>
|
||||||
|
{formatBytes(item.totalSizeBytes)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ py: 1 }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={isManual ? 'Manuell' : 'Automatisch'}
|
||||||
|
color={isManual ? 'primary' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 22, fontSize: '0.75rem' }}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow sx={{ bgcolor: LIGHT_3 }}>
|
||||||
|
<TableCell colSpan={5} sx={{ py: 0, px: 3 }}>
|
||||||
|
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||||
|
<Box sx={{ py: 2 }}>
|
||||||
|
<Typography variant="caption" fontWeight={700} color="text.secondary" sx={{ textTransform: 'uppercase' }}>
|
||||||
|
Backup-Details & Manifest
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: 'repeat(3, 1fr)' },
|
||||||
|
gap: 1.5,
|
||||||
|
mt: 1,
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
bgcolor: WHITE,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Datenbank
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.database
|
||||||
|
? `${formatBytes(manifest.files.database.sizeBytes)} (${manifest.files.database.backedUp ? 'OK' : manifest.files.database.reason || 'Übersprungen'})`
|
||||||
|
: '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
VFS (Dateien & Belege)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.vfs ? formatBytes(manifest.files.vfs.sizeBytes) : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Dokumentenspeicher (Storage)
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest?.files?.storage ? formatBytes(manifest.files.storage.sizeBytes) : '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{manifest?.durationMs != null && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Dauer
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{formatDuration(manifest.durationMs)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{manifest?.dbProvider && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Datenbank-Provider
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{manifest.dbProvider.toUpperCase()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ gridColumn: { sm: 'span 3' } }}>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">
|
||||||
|
Speicherpfad
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ fontFamily: 'monospace', color: DARK_2, wordBreak: 'break-all' }}>
|
||||||
|
{item.path}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -101,9 +101,9 @@ export default class McpAuthorizationsPanel extends Component {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 860 }}>
|
<Box sx={{ maxWidth: 860 }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" sx={{ mb: 2.5 }}>
|
<Stack direction="row" sx={{ alignItems: 'flex-start', justifyContent: 'space-between', mb: 2.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 0.5 }}>
|
||||||
<SecurityIcon sx={{ color: 'primary.main', fontSize: 24 }} />
|
<SecurityIcon sx={{ color: 'primary.main', fontSize: 24 }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
MCP-Autorisierungen & Sicherheit
|
MCP-Autorisierungen & Sicherheit
|
||||||
@@ -190,11 +190,13 @@ export default class McpAuthorizationsPanel extends Component {
|
|||||||
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
direction={{ xs: 'column', sm: 'row' }}
|
||||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
|
||||||
justifyContent="space-between"
|
|
||||||
spacing={2}
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: { xs: 'flex-start', sm: 'center' },
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" spacing={2} alignItems="center">
|
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 44,
|
width: 44,
|
||||||
@@ -212,7 +214,7 @@ export default class McpAuthorizationsPanel extends Component {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap">
|
<Stack direction="row" spacing={1} useFlexGap sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
{auth.clientName || 'Unbenannter Client'}
|
{auth.clientName || 'Unbenannter Client'}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -246,7 +248,7 @@ export default class McpAuthorizationsPanel extends Component {
|
|||||||
Autorisiert: <strong>{authorizedDate}</strong>
|
Autorisiert: <strong>{authorizedDate}</strong>
|
||||||
</Typography>
|
</Typography>
|
||||||
{auth.tokenCount != null && (
|
{auth.tokenCount != null && (
|
||||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||||
<KeyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
|
<KeyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
Aktive Tokens: <strong>{auth.tokenCount}</strong>
|
Aktive Tokens: <strong>{auth.tokenCount}</strong>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
|
|||||||
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
|
||||||
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
|
||||||
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
|
||||||
|
import BackupSettingsPanel from './BackupSettingsPanel';
|
||||||
import SecurityIcon from '@mui/icons-material/Security';
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
|
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
|
||||||
import { SURFACE_BASE } from '../../colors';
|
import { SURFACE_BASE } from '../../colors';
|
||||||
|
|
||||||
const TAB_KEYS = {
|
const TAB_KEYS = {
|
||||||
@@ -29,6 +31,7 @@ const TAB_KEYS = {
|
|||||||
numbers: 'numbers',
|
numbers: 'numbers',
|
||||||
printers: 'printers',
|
printers: 'printers',
|
||||||
importExport: 'importExport',
|
importExport: 'importExport',
|
||||||
|
backup: 'backup',
|
||||||
posterminal: 'posterminal',
|
posterminal: 'posterminal',
|
||||||
security: 'security',
|
security: 'security',
|
||||||
};
|
};
|
||||||
@@ -39,6 +42,7 @@ const TABS = [
|
|||||||
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
|
||||||
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
|
||||||
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
{ id: TAB_KEYS.importExport, label: 'Import/Export', icon: <ImportExportIcon />, Panel: ImportExportSettingsPanel },
|
||||||
|
{ id: TAB_KEYS.backup, label: 'Backups', icon: <BackupTableOutlinedIcon />, Panel: BackupSettingsPanel },
|
||||||
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
{ id: TAB_KEYS.posterminal, label: 'POS Terminal', icon: <PointOfSaleIcon />, Panel: PosTerminalSettingsPanel },
|
||||||
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
{ id: TAB_KEYS.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ export default class SettingsKeysPanel extends Component {
|
|||||||
disabled={draft.busy}
|
disabled={draft.busy}
|
||||||
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
|
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
|
||||||
slotProps={{ htmlInput: { spellCheck: false } }}
|
slotProps={{ htmlInput: { spellCheck: false } }}
|
||||||
inputProps={{ spellCheck: false }}
|
|
||||||
>
|
>
|
||||||
{enumOpts.map((opt) => {
|
{enumOpts.map((opt) => {
|
||||||
const val = typeof opt === 'object' ? opt.value : opt;
|
const val = typeof opt === 'object' ? opt.value : opt;
|
||||||
@@ -258,7 +257,6 @@ export default class SettingsKeysPanel extends Component {
|
|||||||
}}
|
}}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
|
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
|
||||||
inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' }}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export { default as NumberSettingsPanel } from './NumberSettingsPanel';
|
|||||||
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
|
||||||
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
|
||||||
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
|
||||||
|
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
|
||||||
|
export { default as BackupSettingsPanel } from './BackupSettingsPanel';
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default class VfsAppletForm extends PureComponent {
|
|||||||
disabled={Boolean(readOnly)}
|
disabled={Boolean(readOnly)}
|
||||||
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
|
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
|
||||||
onChange={(e) => this.handleFieldChange(key, e.target.value)}
|
onChange={(e) => this.handleFieldChange(key, e.target.value)}
|
||||||
inputProps={{ autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false }}
|
slotProps={{ htmlInput: { autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off', spellCheck: false } }}
|
||||||
sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }}
|
sx={{ '& .MuiInputBase-input': { fontSize: '0.85rem' } }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
1064
src/devtools/ChunkCacheMonitor.jsx
Normal file
1064
src/devtools/ChunkCacheMonitor.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,9 @@ const AutomatInspector = import.meta.env.DEV
|
|||||||
const SseMonitor = import.meta.env.DEV
|
const SseMonitor = import.meta.env.DEV
|
||||||
? lazy(() => import('./devtools/SseMonitor'))
|
? lazy(() => import('./devtools/SseMonitor'))
|
||||||
: null;
|
: null;
|
||||||
|
const ChunkCacheMonitor = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./devtools/ChunkCacheMonitor'))
|
||||||
|
: null;
|
||||||
|
|
||||||
async function clearDevServiceWorker() {
|
async function clearDevServiceWorker() {
|
||||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
|
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
|
||||||
@@ -71,6 +74,11 @@ createRoot(document.getElementById('root')).render(
|
|||||||
<SseMonitor />
|
<SseMonitor />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
|
{ChunkCacheMonitor ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ChunkCacheMonitor />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
<PwaUpdateDialog />
|
<PwaUpdateDialog />
|
||||||
</AuthBootstrap>
|
</AuthBootstrap>
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export function resolveActiveSub(pathname, sectionPath) {
|
|||||||
sectionPath === '/kunde' && rest && !knownSegments.has(rest);
|
sectionPath === '/kunde' && rest && !knownSegments.has(rest);
|
||||||
const isLieferungDetail =
|
const isLieferungDetail =
|
||||||
sectionPath === '/lieferung' && rest && !knownSegments.has(rest);
|
sectionPath === '/lieferung' && rest && !knownSegments.has(rest);
|
||||||
// Angebot neu/edit: /auftrag/neu or /auftrag/:quoteId (not a known sub)
|
// Angebot neu/edit: /auftrag/neuesangebot, /auftrag/neu or /auftrag/:quoteId (not a known sub)
|
||||||
const isAngebotDoc =
|
const isAngebotDoc =
|
||||||
sectionPath === '/auftrag' && rest && !knownSegments.has(rest);
|
sectionPath === '/auftrag' && rest && !knownSegments.has(rest);
|
||||||
if (isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc) {
|
if (isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc) {
|
||||||
|
|||||||
@@ -399,13 +399,15 @@ class ArtikelVorlagenPage extends PureComponent {
|
|||||||
onChange={this.handleHeightMmChange}
|
onChange={this.handleHeightMmChange}
|
||||||
onBlur={this.handleHeightMmBlur}
|
onBlur={this.handleHeightMmBlur}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
InputProps={{
|
slotProps={{
|
||||||
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
|
input: {
|
||||||
}}
|
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
|
||||||
inputProps={{
|
},
|
||||||
min: MIN_HEIGHT_MM,
|
htmlInput: {
|
||||||
max: 300,
|
min: MIN_HEIGHT_MM,
|
||||||
step: 1,
|
max: 300,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
sx={{ width: 120 }}
|
sx={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,10 +1,2 @@
|
|||||||
import { PureComponent } from 'react';
|
export { default } from './neuesangebot';
|
||||||
import { ManifestForm } from '../../components/manifest';
|
|
||||||
|
|
||||||
class AuftragAngebotNeuPage extends PureComponent {
|
|
||||||
render() {
|
|
||||||
return <ManifestForm kind="quote" mode="create" />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AuftragAngebotNeuPage;
|
|
||||||
|
|||||||
10
src/routes/auftrag/neuesangebot.jsx
Normal file
10
src/routes/auftrag/neuesangebot.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import { ManifestForm } from '../../components/manifest';
|
||||||
|
|
||||||
|
class AuftragAngebotNeuPage extends PureComponent {
|
||||||
|
render() {
|
||||||
|
return <ManifestForm kind="quote" mode="create" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AuftragAngebotNeuPage;
|
||||||
@@ -213,7 +213,7 @@ class KundeListePage extends PureComponent {
|
|||||||
void entityAutomat.actions.load().catch((err) => notifyError(err));
|
void entityAutomat.actions.load().catch((err) => notifyError(err));
|
||||||
}
|
}
|
||||||
if (customergroupAutomat.state.status !== 'ready') {
|
if (customergroupAutomat.state.status !== 'ready') {
|
||||||
void customergroupAutomat.actions.load().catch(() => {});
|
void customergroupAutomat.actions.load().catch(() => { });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export default class OAuthAuthorizePage extends Component {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||||
<Box
|
<Box
|
||||||
component="img"
|
component="img"
|
||||||
src={quixposLogo}
|
src={quixposLogo}
|
||||||
@@ -271,7 +271,7 @@ export default class OAuthAuthorizePage extends Component {
|
|||||||
) : (
|
) : (
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
{/* Visual handshake illustration */}
|
{/* Visual handshake illustration */}
|
||||||
<Stack direction="row" alignItems="center" justifyContent="center" spacing={2.5} sx={{ py: 1 }}>
|
<Stack direction="row" spacing={2.5} sx={{ alignItems: 'center', justifyContent: 'center', py: 1 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 56,
|
width: 56,
|
||||||
@@ -329,7 +329,7 @@ export default class OAuthAuthorizePage extends Component {
|
|||||||
borderColor: BORDER,
|
borderColor: BORDER,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||||
<Avatar
|
<Avatar
|
||||||
sx={{
|
sx={{
|
||||||
width: 34,
|
width: 34,
|
||||||
@@ -366,7 +366,7 @@ export default class OAuthAuthorizePage extends Component {
|
|||||||
borderColor: BORDER,
|
borderColor: BORDER,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={scope}
|
label={scope}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
112
src/utils/documentNumber.js
Normal file
112
src/utils/documentNumber.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import numberAutomat from '../automats/numberAutomat';
|
||||||
|
import quoteAutomat from '../automats/quoteAutomat';
|
||||||
|
import orderAutomat from '../automats/orderAutomat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number pattern given prefix, padding width, and counter.
|
||||||
|
*
|
||||||
|
* @param {string} [prefix='']
|
||||||
|
* @param {number} [padding=0]
|
||||||
|
* @param {number} [next=1]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function formatNumberPattern(prefix, padding, next) {
|
||||||
|
const pad = Math.max(0, Number(padding) || 0);
|
||||||
|
const num = Math.max(1, Number(next) || 1);
|
||||||
|
const numStr = pad > 0 ? String(num).padStart(pad, '0') : String(num);
|
||||||
|
return `${prefix ?? ''}${numStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next document number for quote or order based on company sequences.
|
||||||
|
*
|
||||||
|
* @param {'quote'|'order'|string} kind
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getNextDocumentNumber(kind) {
|
||||||
|
const numbers = numberAutomat.state.items ?? [];
|
||||||
|
const seq = numbers.find((r) => r.type === kind);
|
||||||
|
if (seq) {
|
||||||
|
return formatNumberPattern(seq.prefix, seq.padding, seq.next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect style from existing company sequences (sku, delivery, entity)
|
||||||
|
const skuSeq = numbers.find((r) => r.type === 'sku');
|
||||||
|
const deliverySeq = numbers.find((r) => r.type === 'delivery');
|
||||||
|
const entitySeq = numbers.find((r) => r.type === 'entity');
|
||||||
|
const refSeq = skuSeq || deliverySeq || entitySeq;
|
||||||
|
|
||||||
|
const pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
|
||||||
|
const prefixStyle = refSeq?.prefix ?? '';
|
||||||
|
|
||||||
|
let prefix = '';
|
||||||
|
let startNum = 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
prefixStyle.startsWith('ART-') ||
|
||||||
|
prefixStyle.startsWith('LS-') ||
|
||||||
|
prefixStyle.startsWith('KD-') ||
|
||||||
|
prefixStyle.endsWith('-')
|
||||||
|
) {
|
||||||
|
// Standard German ERP prefix style (ART-, KD-, LS-, KORR-)
|
||||||
|
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : '');
|
||||||
|
} else if (
|
||||||
|
prefixStyle.startsWith('SKU-') ||
|
||||||
|
prefixStyle.startsWith('DEL-') ||
|
||||||
|
prefixStyle.startsWith('C-')
|
||||||
|
) {
|
||||||
|
prefix = kind === 'quote' ? 'Q-' : (kind === 'order' ? 'ORD-' : '');
|
||||||
|
} else if (
|
||||||
|
prefixStyle.startsWith('A-') ||
|
||||||
|
prefixStyle.startsWith('L-') ||
|
||||||
|
prefixStyle.startsWith('K-')
|
||||||
|
) {
|
||||||
|
prefix = kind === 'quote' ? 'AG-' : (kind === 'order' ? 'AU-' : '');
|
||||||
|
} else if (!prefixStyle && refSeq?.next >= 10000) {
|
||||||
|
prefix = '';
|
||||||
|
startNum = kind === 'quote' ? 40001 : 50001;
|
||||||
|
} else {
|
||||||
|
prefix = kind === 'quote' ? 'ANG-' : (kind === 'order' ? 'AUF-' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing docs from quoteAutomat / orderAutomat to avoid collisions
|
||||||
|
const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : null);
|
||||||
|
const existingDocs = Array.isArray(listAutomat?.state?.items) ? listAutomat.state.items : [];
|
||||||
|
let maxFound = startNum - 1;
|
||||||
|
|
||||||
|
for (const doc of existingDocs) {
|
||||||
|
if (!doc || typeof doc !== 'object') continue;
|
||||||
|
const docNum = String(doc.number ?? '').trim();
|
||||||
|
if (!docNum) continue;
|
||||||
|
if (prefix && docNum.startsWith(prefix)) {
|
||||||
|
const rest = docNum.slice(prefix.length);
|
||||||
|
const parsed = parseInt(rest, 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed > maxFound) {
|
||||||
|
maxFound = parsed;
|
||||||
|
}
|
||||||
|
} else if (!prefix) {
|
||||||
|
const parsed = parseInt(docNum, 10);
|
||||||
|
if (!Number.isNaN(parsed) && parsed >= startNum && parsed > maxFound) {
|
||||||
|
maxFound = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextVal = maxFound >= startNum ? maxFound + 1 : startNum;
|
||||||
|
return formatNumberPattern(prefix, pad, nextVal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously ensures required stores are loaded, then returns the next document number.
|
||||||
|
*
|
||||||
|
* @param {'quote'|'order'|string} kind
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function ensureDocumentNumber(kind) {
|
||||||
|
const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : null);
|
||||||
|
await Promise.all([
|
||||||
|
numberAutomat.actions.ensureLoaded().catch(() => {}),
|
||||||
|
listAutomat?.actions?.ensureLoaded ? listAutomat.actions.ensureLoaded().catch(() => {}) : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
return getNextDocumentNumber(kind);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user