Compare commits

..

11 Commits

Author SHA1 Message Date
seb
ee3012670f u 2026-10-14 01:19:27 +02:00
seb
937f434641 u 2026-10-14 01:16:32 +02:00
seb
424609a0f6 u 2026-10-14 01:13:09 +02:00
seb
2dd6533481 u 2026-10-14 00:47:36 +02:00
seb
c0600ecb3b u 2026-10-14 00:22:02 +02:00
seb
56a86fc005 u 2026-10-14 00:10:50 +02:00
seb
f6cb3d3e35 u 2026-10-13 23:20:54 +02:00
seb
e2bf08d340 u 2026-10-13 22:50:36 +02:00
seb
bf7ff349f3 u 2026-10-13 22:05:30 +02:00
seb
bc583a180f u 2026-10-13 17:49:52 +02:00
seb
345d6f5752 u 2026-10-13 17:19:33 +02:00
50 changed files with 7230 additions and 336 deletions

View File

@@ -7,7 +7,7 @@ description: >-
# Dash API Routes Overview (All)
Overview of all 229 API endpoints across all modules.
Overview of all 232 API endpoints across all modules.
### Notation
@@ -17,55 +17,60 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ order: T2 }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: 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 }`
- `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 }`
- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T25[], files: T11[], externalNumber?: str|null, createdAt?: str, ledger?: T46 }`
- `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 }`
- `T8` = `{ labeltemplate: T9 }`
- `T9` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T21, thumb?: str|null }`
- `T10` = `{ quote: T13 }`
- `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 }`
- `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[] }`
- `T13` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T25[], createdAt?: str }`
- `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 }`
- `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 }`
- `T16` = `{ ledger: T17[] }`
- `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 }`
- `T18` = `{ id: int, name: str, sort: int, exclusive: bool, values: T37[], articleCount?: int }`
- `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 }`
- `T20` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T21` = `{ objects: T26[] }`
- `T22` = `{ objects: T27[] }`
- `T23` = `{ printer: T29 }`
- `T24` = `{ entity: T32 }`
- `T25` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T26` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T27` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T28` = `{ shipping: T33 }`
- `T29` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T30` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T31` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T32` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T33` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T34` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T35` = `{ article: T40 }`
- `T36` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T37` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T38` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T39` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T40` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T41` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T42` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T43` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T44` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T45` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T46` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T47` = `{ warehouse: T49 }`
- `T48` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T49` = `{ id: int, name: str, sort: int }`
- `T50` = `{ id: int, categoryIds: int[] }`
- `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: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }`
- `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }`
- `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, manual: bool, files: T17, totalSizeBytes: int, durationMs: int }`
- `T10` = `{ labeltemplate: T11 }`
- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }`
- `T12` = `{ quote: T15 }`
- `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, 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, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str }`
- `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` = `{ vfs: T51, storage: T51, database: T44 }`
- `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` = `{ ledger: T20[] }`
- `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` = `{ id: int, name: str, sort: int, exclusive: bool, values: T40[], articleCount?: int }`
- `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` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `T24` = `{ objects: T29[] }`
- `T25` = `{ objects: T30[] }`
- `T26` = `{ printer: T32 }`
- `T27` = `{ entity: T35 }`
- `T28` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T29` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T31` = `{ shipping: T36 }`
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T38` = `{ article: T43 }`
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T52` = `{ warehouse: T54 }`
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T54` = `{ id: int, name: str, sort: int }`
- `T55` = `{ id: int, categoryIds: int[] }`
### Routes
@@ -95,7 +100,7 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T35`
- Returns: `201 T38`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -131,10 +136,10 @@ _none_
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
- Returns: `201 T35`
- Returns: `201 T38`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: 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}`
- Params: `sku: str, childSku: str, query: deleteArticle?: 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 }[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- 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`
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
- Returns: `200 T3`
@@ -179,10 +184,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T14[] }`
- Returns: `200 { suppliers: T16[] }`
#### 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 }`
- Returns: `201 { supplier: T14 }`
- Returns: `201 { supplier: T16 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -191,7 +196,7 @@ _none_
- Returns: `204 void`
#### 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 }`
- Returns: `200 { supplier: T14 }`
- Returns: `200 { supplier: T16 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T3`
@@ -203,19 +208,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T18[] }`
- Returns: `200 { attributenames: T21[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T18 }`
- Returns: `201 { attributename: T21 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T37[] }`
- Returns: `200 { values: T40[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- 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`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -224,7 +229,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T18 }`
- Returns: `200 { attributename: T21 }`
### Section: /attributevalue
@@ -233,28 +238,37 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T37 }`
- Returns: `200 { attributevalue: T40 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
- 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
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T41 }`
- Returns: `200 { message: str, user: T45 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T41, googleClientId: str }`
- Returns: `200 { user: T45, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- 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
@@ -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 }`
#### Create brand — `POST /brand`
- 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}`
- Params: `id: int`
- Returns: `204 void`
@@ -272,10 +286,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: 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`
- 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
@@ -299,7 +313,7 @@ _none_
- Returns: `200 { category: { id: int, name: str, slug: str, parentId: int, sort: int } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: int`
- Returns: `200 { articles: { id: int, sku: str, name: str, slug: str, track_stock: bool, optionsCount: int, articleListCount: int, childCount: int, net?: num|null, taxRate?: num|null, parentArticleId?: int|null, parentSku?: str|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null, brand?: 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`
- Params: `id: int`
- 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`
_none_
- Returns: `200 { companies: T20[] }`
- Returns: `200 { companies: T23[] }`
#### Create company — `POST /company`
- 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`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -353,7 +367,7 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T20 }`
- Returns: `200 { company: T23 }`
### Section: /customergroup
@@ -362,7 +376,7 @@ _none_
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: str }`
- Returns: `201 { customergroup: T49 }`
- Returns: `201 { customergroup: T54 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -371,15 +385,15 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T49 }`
- Returns: `200 { customergroup: T54 }`
### Section: /delivery
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: 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`
- 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`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
@@ -400,7 +414,7 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### 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`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
@@ -410,46 +424,46 @@ _none_
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T32[] }`
- Returns: `200 { entities: T35[] }`
#### Create entity — `POST /entity`
- 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`
_none_
- Returns: `200 { number: str }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: int`
- Returns: `200 { addresses: T15[] }`
- Returns: `200 { addresses: T18[] }`
#### 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 }`
- Returns: `201 { address: T15 }`
- Returns: `201 { address: T18 }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### 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 }`
- Returns: `200 { address: T15 }`
- Returns: `200 { address: T18 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T44[] }`
- Returns: `200 { contacts: T48[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T44 }`
- Returns: `201 { contact: T48 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T44 }`
- Returns: `200 { contact: T48 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T24`
- Returns: `200 T27`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T24`
- Returns: `200 T27`
### Section: /events
@@ -472,6 +486,8 @@ _none_
- `modelconversation`: `{}`
- `modelconversation-<id>`: `{}`
- `brand`: `{ sender, brandId }`
- `draft`: `{ sender, action, name, content }`
- `draft:request`: `{ requestId, action, name, target, sender }`
### Section: /health
@@ -491,35 +507,35 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T22 }`
- Returns: `201 T8`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T25 }`
- Returns: `201 T10`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: int`
- Returns: `200 T8`
- Returns: `200 T10`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T22 }`
- Returns: `200 T8`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
#### Add ledger entry — `POST /ledger`
- 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}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T16`
- Returns: `200 T19`
### Section: /mcp
@@ -549,16 +565,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T19[] }`
- Returns: `200 { conversations: T22[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T19 }`
- Returns: `201 { conversation: T22 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T19 }`
- Returns: `200 { conversation: T22 }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: int`
- Returns: `200 { messages: { id: int, conversation_id: int, created_at: str, role: str, message: str }[] }`
@@ -597,9 +613,9 @@ _none_
#### List orders — `GET /order`
- 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`
- 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`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -608,14 +624,14 @@ _none_
- Params: `id: int`
- Returns: `200 T1`
#### 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`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T39[] }`
- Returns: `200 { payments: T42[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T39 }`
- Returns: `201 { payment: T42 }`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
@@ -624,10 +640,10 @@ _none_
- Returns: `201 { shipment: T7 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T33[] }`
- Returns: `200 { shippings: T36[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T28`
- Returns: `201 T31`
### Section: /package
@@ -645,37 +661,37 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T12[] }`
- Returns: `200 { posterminals: T14[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T12 }`
- Returns: `200 { posterminal: T14 }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update POS terminal — `PATCH /posterminal/{id}`
- 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`
- Params: `id: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T50`
- Returns: `200 T55`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T50`
- Returns: `200 T55`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T29[] }`
- Returns: `200 { printers: T32[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T23`
- Returns: `201 T26`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -684,22 +700,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T23`
- Returns: `200 T26`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T23`
- Returns: `200 T26`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T23`
- Returns: `200 T26`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T38[] }`
- Returns: `200 { propertynames: T41[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T38 }`
- Returns: `201 { propertyname: T41 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -708,7 +724,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T38 }`
- Returns: `200 { propertyname: T41 }`
### Section: /public
@@ -731,17 +747,17 @@ _none_
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T30[], entityId?: int|null }`
- Returns: `201 T10`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Returns: `201 T12`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T10`
- Returns: `200 T12`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T30[] }`
- Returns: `200 T10`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Returns: `200 T12`
### Section: /serper
@@ -768,10 +784,10 @@ _none_
- Returns: `200 { shipment: T7 }`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T33[] }`
- Returns: `200 { shippings: T36[] }`
#### Create package for a shipment — `POST /shipment/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `201 T28`
- Returns: `201 T31`
### Section: /shipping
@@ -780,7 +796,7 @@ _none_
- Returns: `204 void`
#### Update shipping — `PATCH /shipping/{id}`
- Params: `id: int, body: { type?: str, state?: str, trackingCode?: str, carrier?: str, warehouseId?: int }`
- Returns: `200 T28`
- Returns: `200 T31`
#### List packages of a shipping — `GET /shipping/{id}/package`
- Params: `id: int`
- Returns: `200 { packages: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null }[] }`
@@ -798,28 +814,28 @@ _none_
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: str, warehouseId?: int, amount?: num, text?: str|null, lines?: { sku: str, amount: num, warehouseId?: int, text?: str }[] }`
- Returns: `201 { manifest: { id: int, number: str, type: str, date: str, createdAt: str }, lines: { manifestarticle_id: int, sku: str, name: str, warehouse_id: int, amount: num, text: str }[], ledger: 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
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T45[] }`
- Returns: `200 { suppliers: T49[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T45 }`
- Returns: `201 { supplier: T49 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T45 }`
- Returns: `200 { supplier: T49 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: 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`
- 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
@@ -834,10 +850,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T36[] }`
- Returns: `200 { units: T39[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T36 }`
- Returns: `200 { unit: T39 }`
### Section: /v1
@@ -862,6 +878,9 @@ _none_
#### List virtual filesystem files — `GET /vfs`
_none_
- Returns: `200 { files: { name: str, size: int, mime: str, updated_at: str, created_at: str, isText: bool, isImage: bool, isPdf: bool }[] }`
#### Respond to a frontend draft request — `POST /vfs/drafts/response`
- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }`
- Returns: `200 { success: bool }`
#### Create directory in virtual filesystem — `POST /vfs/mkdir`
- Params: `body: { path: str }`
- Returns: `200 { success: bool, dir: str }`
@@ -888,10 +907,10 @@ _none_
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T49[] }`
- Returns: `200 { warehouses: T54[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T47`
- Returns: `201 T52`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -900,10 +919,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T47`
- Returns: `200 T52`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T47`
- Returns: `200 T52`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T47`
- Returns: `200 T52`

View File

@@ -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` |
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
| **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` |
| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |

1
.vscode/tmstate.json vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"groups":{},"history":{},"addons":{},"selectedGroup":null,"previousSelectedGroup":null,"quickSlots":{}}

View File

@@ -259,6 +259,9 @@ npm run build
# Preview production build
npm run preview
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
npm run automat-graph
```
> [!NOTE]
@@ -425,7 +428,7 @@ The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/d
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
├── /auftrag
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
│ ├── /neu → AuftragAngebotNeuPage (Create quote)
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
│ ├── /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
```
### 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

View File

@@ -58,10 +58,13 @@ future test cases.
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
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/auth/googleSignIn.js` — Google Identity Services helper.
- `src/auth/AuthProvider.jsx` — session state + login/logout.
- `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

File diff suppressed because one or more lines are too long

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

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,8 @@
"preview:https": "VITE_HTTPS=1 vite preview",
"rig:collect": "node test/collect.mjs",
"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": {
"@emotion/react": "^11.14.0",

31
src/api/backup.js Normal file
View 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
};

View File

@@ -31,4 +31,4 @@ export { shipping } from './shipping.js';
export { payment } from './payment.js';
export { account } from './account.js';
export { mcp } from './mcp.js';
export { backup } from './backup.js';

View File

@@ -143,4 +143,42 @@ export const vfs = {
});
},
// #endregion
/**
* POST /api/vfs/drafts/response
* Send response to virtual draft request (action: read or list).
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
*/
// #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
};

View File

@@ -16,7 +16,7 @@ import LieferungNeuPage from './routes/lieferung/neu';
import LieferungLieferantenPage from './routes/lieferung/lieferanten';
import LieferungEditPage from './routes/lieferung/_deliveryId';
import AuftragAngebotPage from './routes/auftrag';
import AuftragAngebotNeuPage from './routes/auftrag/neu';
import AuftragAngebotNeuPage from './routes/auftrag/neuesangebot';
import AuftragAngebotEditPage from './routes/auftrag/_quoteId';
import AuftragBestellungPage from './routes/auftrag/bestellung';
import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu';
@@ -97,7 +97,8 @@ const routes = [
element: <SectionLayout sectionPath="/auftrag" />,
children: [
{ 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/neu', element: <AuftragBestellungNeuPage /> },
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },

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

View File

@@ -638,6 +638,37 @@ export function createDeliveryEditorAutomat(options = {}) {
...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, {

190
src/automats/draftSync.js Normal file
View 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);
}
}

View File

@@ -5,6 +5,8 @@
*/
export * from './deliveryEditorAutomat';
export * from './manifestDraftAutomat';
export * from './draftSync';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
@@ -69,6 +71,7 @@ export {
default as orderAutomat,
selectOrders,
selectOrderTotal,
selectOrderUnfilteredTotal,
selectOrderPagination,
selectOnlyUnpaid,
selectOnlyUndelivered,
@@ -77,11 +80,14 @@ export {
isOrderDelivered,
getNegativeSeekCutoff,
formatNegativeSeek,
getChunkStats,
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';
export { default as numberAutomat } from './numberAutomat';
export { default as accountAutomat } from './accountAutomat';
export { default as backupAutomat } from './backupAutomat';
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';

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

View File

@@ -2,6 +2,13 @@
* @file orderAutomat.js
* @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:
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
* - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`.
@@ -40,17 +47,25 @@ import authAutomat from './authAutomat';
* @typedef {Object} OrderAutomatState
* @property {Order[]|null} items Pre-filled array of orders/nulls, initially null.
* @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 {Error|string|null} error Last load error.
*/
export const CHUNK_SIZE = 50;
export const MAX_LRU_CHUNKS = 50;
const PREFETCH_CHUNKS = 1;
const CHUNK_DEBOUNCE_MS = 130;
const initialState = {
items: null,
total: 0,
unfilteredTotal: 0,
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
@@ -67,27 +82,318 @@ let filterDebounceTimer = null;
let chunkDebounceTimer = null;
let currentEventTick = 0;
let lastRequireChunkTick = -1;
const pendingTargetChunks = new Set();
let pendingChunkPromise = null;
let pendingChunkResolve = null;
let pendingChunkReject = null;
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();
// 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
function clearChunkDebounce() {
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
chunkDebounceTimer = null;
}
pendingTargetChunks.clear();
if (pendingTargetChunks.size > 0) {
pendingTargetChunks.clear();
notifyChunkStats();
}
pendingChunkPromise = null;
pendingChunkResolve = null;
pendingChunkReject = null;
}
// 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) {
return Promise.resolve(automat.state.items);
}
@@ -97,9 +403,16 @@ function _executeRequiredChunks(chunks, targetEpoch, prefetch = true) {
return Promise.resolve(automat.state.items);
}
recordChunkEvent({
type: 'execute',
chunks: chunkList,
filterKey: targetFilterKey,
message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`,
});
const promises = [];
for (const c of chunkList) {
promises.push(actions.loadChunk(c, targetEpoch));
promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
}
// 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 (prefetch) {
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++) {
if (total > 0 && c * CHUNK_SIZE >= total) {
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;
@@ -171,27 +491,27 @@ export function formatNegativeSeek(months) {
return `-${parts.join(' ')}`;
}
// Builds server query parameters from current automat filter state
function buildQueryParams(offset = 0, limit = CHUNK_SIZE) {
const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = automat.state;
// Builds server query parameters from filter params
function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const params = { offset, limit };
const trimmed = String(filter ?? '').trim();
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (onlyUnpaid) params.onlyUnpaid = true;
if (onlyUndelivered) params.onlyUndelivered = true;
if (negativeSeekMonths != null && Number(negativeSeekMonths) < 0) {
params.seekMonths = Number(negativeSeekMonths);
if (filterParams.onlyUnpaid) params.onlyUnpaid = true;
if (filterParams.onlyUndelivered) params.onlyUndelivered = true;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
return params;
}
// MARK: fetches a single chunk from GET /api/order with server-side filters
async function fetchOrdersPage(offset = 0, limit = CHUNK_SIZE) {
const params = buildQueryParams(offset, limit);
// Fetches a single chunk from GET /api/order with server-side filters
async function fetchOrdersPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const params = buildQueryParamsFor(filterParams, offset, limit);
const res = await orderApi.list(params);
const orders = Array.isArray(res?.orders) ? res.orders : [];
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) {
@@ -209,12 +529,64 @@ function onFilterChange(immediate = false) {
}
}
// Invoked when active filters change: switches to the matching filter cache without wiping!
function applyFilterReload() {
queryEpoch++;
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
void actions.loadChunk(0, queryEpoch);
const currentKey = getFilterKey(automat.state);
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 = {
@@ -255,13 +627,20 @@ const actions = {
start = Math.max(0, Math.floor(start));
end = Math.max(start, Math.floor(end));
const prefetch = options.prefetch !== false;
const currentKey = getFilterKey(automat.state);
// If initial items are null or immediate requested, bypass debounce and load immediately
if (options.immediate || automat.state.items === null) {
clearChunkDebounce();
const chunks = new Set();
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
@@ -282,6 +661,13 @@ const actions = {
for (let c = start; c <= end; c++) {
pendingTargetChunks.add(c);
}
recordChunkEvent({
type: 'pending',
chunks: Array.from(pendingTargetChunks),
filterKey: currentKey,
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
});
notifyChunkStats();
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
@@ -300,6 +686,7 @@ const actions = {
chunkDebounceTimer = null;
const chunksToLoad = new Set(pendingTargetChunks);
pendingTargetChunks.clear();
notifyChunkStats();
const resolve = pendingChunkResolve;
const reject = pendingChunkReject;
@@ -307,7 +694,14 @@ const actions = {
pendingChunkResolve = 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))
.catch((err) => reject?.(err));
}, CHUNK_DEBOUNCE_MS);
@@ -315,75 +709,137 @@ const actions = {
return pendingChunkPromise;
},
// MARK: fetches a single chunk and populates prefilled array
loadChunk(chunkIndex, targetEpoch = queryEpoch) {
// MARK: fetches a single chunk and populates the filter cache and prefilled array
loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
const index = Math.max(0, Number(chunkIndex) || 0);
if (targetEpoch !== queryEpoch) {
return Promise.resolve(automat.state.items);
const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
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 limit = CHUNK_SIZE;
if (index === 0 && automat.state.items === null) {
automat.setState({ status: 'loading', error: null });
const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
entry.status = 'loading';
entry.error = null;
if (isActiveFilter) {
automat.setState({ status: 'loading', error: null });
}
}
const promise = fetchOrdersPage(offset, limit)
.then(({ orders, total }) => {
// Discard if active filter query changed while request was in flight
if (targetEpoch !== queryEpoch) {
return automat.state.items;
}
const startTime = Date.now();
recordChunkEvent({
type: 'start',
chunkIndex: index,
filterKey: targetFilterKey,
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`,
});
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;
entry.total = currentTotal;
entry.unfilteredTotal = unfilteredTotal;
if (items === null || items.length !== currentTotal) {
// Pre-fill items array with empty (null) items based on server filtered total
items = new Array(currentTotal).fill(null);
if (entry.items === null || entry.items.length !== currentTotal) {
entry.items = new Array(currentTotal).fill(null);
} else {
items = [...items];
entry.items = [...entry.items];
}
// Fill in real received items for this chunk
for (let i = 0; i < orders.length; i++) {
const idx = offset + i;
if (idx < items.length) {
items[idx] = orders[i];
if (idx < entry.items.length) {
entry.items[idx] = orders[i];
}
}
loadedChunks.add(index);
entry.loadedChunks.add(index);
entry.status = 'ready';
entry.error = null;
entry.lastAccessed = Date.now();
automat.setState({
items,
total: currentTotal,
status: 'ready',
error: null,
// Register chunk in global LRU (evicts oldest chunk if > MAX_LRU_CHUNKS)
registerChunkLRU(targetFilterKey, index, orders.length);
const currentActive = targetFilterKey === getFilterKey(automat.state);
if (currentActive) {
automat.setState({
items: entry.items,
total: currentTotal,
unfilteredTotal,
status: 'ready',
error: null,
});
}
const duration = Date.now() - startTime;
recordChunkEvent({
type: 'loaded',
chunkIndex: index,
filterKey: targetFilterKey,
count: 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) => {
if (targetEpoch === queryEpoch) {
console.error(`[orderAutomat] loadChunk(${index}) error:`, err);
entry.status = 'failed';
entry.error = err;
const currentActive = targetFilterKey === getFilterKey(automat.state);
if (currentActive) {
if (index === 0 && automat.state.items === null) {
automat.setState({ status: 'failed', error: err });
}
}
const duration = Date.now() - startTime;
console.error(`[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;
})
.finally(() => {
inflightChunks.delete(index);
inflightChunks.delete(inflightKey);
notifyChunkStats();
});
inflightChunks.set(index, promise);
inflightChunks.set(inflightKey, promise);
notifyChunkStats();
return promise;
},
@@ -451,7 +907,7 @@ const actions = {
// MARK: initial load
load() {
return actions.loadChunk(0, queryEpoch);
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
},
// MARK: ensure loaded
@@ -459,25 +915,46 @@ const actions = {
if (automat.state.items !== null) {
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
async reload() {
// MARK: reload active filter or invalidate all
async reload(onlyCurrent = false) {
queryEpoch++;
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
await actions.loadChunk(0, queryEpoch);
if (onlyCurrent) {
// 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;
},
// MARK: replace items directly
replace(items) {
clearChunkDebounce();
loadedChunks.clear();
inflightChunks.clear();
const currentKey = getFilterKey(automat.state);
actions.clearFilterCache(currentKey);
const count = Array.isArray(items) ? items.length : 0;
const entry = getOrCreateFilterCache(currentKey, automat.state);
entry.items = items;
entry.total = count;
entry.status = 'ready';
entry.error = null;
recordChunkEvent({
type: 'replace',
filterKey: currentKey,
message: `Items ersetzt (${count} Einträge)`,
});
notifyChunkStats();
automat.setState({
items,
total: count,
@@ -487,24 +964,134 @@ const actions = {
return items;
},
// MARK: patches an order locally in the automat items cache
patchOrder(id, patch) {
if (!Array.isArray(automat.state.items)) return automat.state.items;
let changed = false;
const items = automat.state.items.map((o) => {
if (o && o.id === id) {
changed = true;
return { ...o, ...patch };
}
return o;
});
if (changed) {
automat.setState({ items });
// MARK: evicts a specific chunk from cache (optional filterKey, defaults to active)
evictChunk(chunkIndex, filterKey = getFilterKey(automat.state)) {
const index = Math.max(0, Number(chunkIndex) || 0);
const key = makeLruKey(filterKey, index);
if (lruChunks.has(key)) {
lruChunks.delete(key);
}
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() {
queryEpoch++;
if (filterDebounceTimer) {
@@ -512,8 +1099,15 @@ const actions = {
filterDebounceTimer = null;
}
clearChunkDebounce();
loadedChunks.clear();
filterCaches.clear();
lruChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'reset',
message: 'Order-Automat und sämtliche Caches zurückgesetzt',
});
notifyChunkStats();
automat.setState(initialState);
},
};
@@ -533,6 +1127,15 @@ export function requireChunk(chunkIndex, endOrOptions, maybeOptions) {
orderAutomat.getItems = getItems;
orderAutomat.requireChunk = requireChunk;
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
let lastCompanyId = null;
@@ -561,6 +1164,11 @@ export function selectOrderTotal(state = automat.state) {
return state.total;
}
/** Selects unfiltered total count */
export function selectOrderUnfilteredTotal(state = automat.state) {
return state.unfilteredTotal ?? 0;
}
/** Selects unpaid filter */
export function selectOnlyUnpaid(state = automat.state) {
return state.onlyUnpaid;

View File

@@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat';
import propertyAutomat from './propertyAutomat';
import supplierAutomat from './supplierAutomat';
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', () => {
sseAutomat.setState({ status: 'connected' });
});

View File

@@ -59,6 +59,7 @@ class AppDialog extends Component {
const {
transitionDuration: _transitionDuration,
TransitionProps: _transitionProps,
transitionProps: _lowerTransitionProps,
TransitionComponent: _transitionComponent,
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
// of spreading onto Dialog/DOM where it triggers
@@ -71,6 +72,7 @@ class AppDialog extends Component {
} = this.props;
void _transitionDuration;
void _transitionProps;
void _lowerTransitionProps;
void _transitionComponent;
const userPaper = userSlotProps?.paper ?? {};

View File

@@ -32,6 +32,8 @@ import PeopleIcon from '@mui/icons-material/People';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import TuneIcon from '@mui/icons-material/Tune';
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 { company, number as numberApi } from '../api';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
@@ -49,18 +51,22 @@ const NUMBER_SCHEMES = [
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
];
const PRESETS = [
{
id: 'standard',
label: 'Standard',
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-)',
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-)',
values: {
sku: { prefix: 'ART-', padding: 6, next: 1 },
entity: { prefix: 'KD-', padding: 6, next: 1001 },
delivery: { prefix: 'LS-', padding: 6, 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 },
delivery: { prefix: '', padding: 5, next: 30001 },
adjustment: { prefix: '', padding: 5, next: 90001 },
quote: { prefix: '', padding: 5, next: 40001 },
order: { prefix: '', padding: 5, next: 50001 },
},
},
{
id: 'compact',
label: 'Kurzkürzel',
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-)',
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-)',
values: {
sku: { prefix: 'A-', padding: 5, next: 1 },
entity: { prefix: 'K-', padding: 5, next: 1001 },
delivery: { prefix: 'L-', 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',
label: 'International',
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-)',
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-)',
values: {
sku: { prefix: 'SKU-', padding: 5, next: 1 },
entity: { prefix: 'C-', padding: 5, next: 1001 },
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
quote: { prefix: 'Q-', padding: 5, next: 1 },
order: { prefix: 'ORD-', padding: 5, next: 1 },
},
},
];

View File

@@ -82,7 +82,7 @@ export default function SectionLayout({ sectionPath }) {
let pageTitle = isDetail
? 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 (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';

View File

@@ -950,7 +950,7 @@ export default class ArticleUnifyDialog extends Component {
onChange={(e) => this.setState({ selectedHistory: { ...selectedHistory, left: e.target.checked } })}
/>
<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 }} />
<Typography variant="body2" fontWeight={700}>
{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 } })}
/>
<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 }} />
<Typography variant="body2" fontWeight={700}>
{rightHistory.length} Einträge vorhanden

View File

@@ -131,7 +131,7 @@ export default class ArticleSettingsTab extends Component {
return (
<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
type="button"
color="error"

View File

@@ -308,7 +308,7 @@ export default class ArticleSupplierTab extends PureComponent {
>
{/* Sort Order & Reorder Controls */}
<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' }}>
{index + 1}
</Typography>
@@ -403,7 +403,7 @@ export default class ArticleSupplierTab extends PureComponent {
{/* Actions */}
<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">
<IconButton
size="small"

View File

@@ -209,7 +209,7 @@ class ClickTooltip extends PureComponent {
disableTouchListener
enterDelay={0}
leaveDelay={0}
TransitionProps={{ timeout: 0 }}
slotProps={{ transition: { timeout: 0 } }}
title={title}
arrow
placement="top"

View File

@@ -14,6 +14,9 @@ import ManifestLines from './ManifestLines';
import ManifestPdfDialog from './ManifestPdfDialog';
import ArticleSearchDialog from './ArticleSearchDialog';
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 {
buildManifestBody,
@@ -26,22 +29,34 @@ import {
apiFor,
parseNum,
} from './manifestConfig';
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat';
class ManifestForm extends PureComponent {
constructor(props) {
super(props);
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 = {
status: props.mode === 'edit' ? 'loading' : 'loadingParties',
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
error: null,
parties: [],
partiesLoading: true,
date: '',
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
attachments: [],
parties: initialParties,
partiesLoading: !partiesReady,
date: initialDraft?.date || '',
partyId: initialDraft?.partyId ?? null,
number: initialDraft?.number || '',
externalNumber: initialDraft?.externalNumber || '',
lines: initialDraft?.lines?.length > 0 ? initialDraft.lines : [emptyLine()],
attachments: initialDraft?.attachments || [],
saving: false,
pdfUploading: false,
pdfDialogOpen: false,
@@ -74,21 +89,96 @@ class ManifestForm extends PureComponent {
componentDidMount() {
this._cancelled = false;
if (this.props.mode === 'edit') void this.loadEdit();
else void this.loadPartiesOnly();
if (this.props.kind === 'delivery') {
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 (
this.props.mode === 'edit' &&
prevProps.docId !== this.props.docId
) {
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() {
this._cancelled = true;
this.unsubDraft?.();
this.unsubParties?.();
this.unsubCompany?.();
if (this._parseAbortController) {
this._parseAbortController.abort();
this._parseAbortController = null;
@@ -96,7 +186,9 @@ class ManifestForm extends PureComponent {
}
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 {
const parties = await loadParties(this.props.kind);
if (this._cancelled) return;
@@ -107,11 +199,13 @@ class ManifestForm extends PureComponent {
});
} catch (err) {
if (this._cancelled) return;
this.setState({
status: 'failed',
error: backendErrorFrom(err),
partiesLoading: false,
});
if (this.state.parties.length === 0) {
this.setState({
status: 'failed',
error: backendErrorFrom(err),
partiesLoading: false,
});
}
notifyError(err);
}
}
@@ -297,12 +391,21 @@ class ManifestForm extends PureComponent {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
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 {
...l,
amount: l.amount || '1',
sku: art.sku || l.sku,
name: art.name || l.name || '',
tax: l.tax || taxVal,
taxrate: l.taxrate || taxVal,
tax: finalTax,
taxrate: finalTax,
rebate: l.rebate || '0',
net: l.net || netVal,
netprice: l.netprice || netVal,
gross: l.gross || grossVal,
@@ -322,6 +425,7 @@ class ManifestForm extends PureComponent {
handleAddArticleLine(article) {
if (!article) return;
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
const netVal =
article.price != null
? String(article.price)
@@ -335,7 +439,7 @@ class ManifestForm extends PureComponent {
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
: normTax;
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
@@ -357,7 +461,7 @@ class ManifestForm extends PureComponent {
net: netVal,
netprice: netVal,
gross: grossVal,
rebate: '',
rebate: '0',
pu: '1',
unit: article.unit || 'Stück',
};
@@ -394,6 +498,7 @@ class ManifestForm extends PureComponent {
});
return;
}
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
const netVal =
article.price != null
? String(article.price)
@@ -407,7 +512,7 @@ class ManifestForm extends PureComponent {
? String(article.taxrate)
: article.tax != null
? String(article.tax)
: '';
: normTax;
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const artGross = getArticleGross(article, taxNum ?? 0);
@@ -424,10 +529,12 @@ class ManifestForm extends PureComponent {
if (l.key !== targetLine.key) return l;
return {
...l,
amount: l.amount || '1',
sku: article.sku || l.sku,
name: article.name || l.name,
tax: taxVal || l.tax,
taxrate: taxVal || l.taxrate,
tax: taxVal || l.tax || normTax,
taxrate: taxVal || l.taxrate || normTax,
rebate: l.rebate || '0',
net: netVal || l.net,
netprice: netVal || l.netprice,
gross: grossVal || l.gross,
@@ -594,6 +701,7 @@ class ManifestForm extends PureComponent {
attachments: this.state.attachments,
},
{
kind: cfg.kind,
partyField: cfg.partyField,
requireAtLeastOne,
externalNumber: cfg.externalNumber,
@@ -612,6 +720,7 @@ class ManifestForm extends PureComponent {
requireObject(data, cfg.postPathLabel);
requireObject(data[cfg.docField], cfg.docField);
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
this.draftAutomat?.actions?.reset();
await cfg.listAutomat.actions.reload();
if (this._cancelled) return;
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });

View File

@@ -226,7 +226,9 @@ class ManifestFormLine extends PureComponent {
let val = line[id];
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 ?? '';
} else if (id === 'net' || id === 'netprice') {
val = line.netprice ?? line.net ?? '';

View File

@@ -11,10 +11,14 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile } from 'dashgui/panels';
import { enqueueSnackbar } from 'notistack';
import FileDropzone from '../FileDropzone';
import AttachmentList from './AttachmentList';
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 {
SURFACE_RAISED,
} from '../../colors';
@@ -22,17 +26,148 @@ import {
class ManifestHeader extends PureComponent {
constructor(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.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.handleFileChange = this.handleFileChange.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) {
this.setState({
createdParty: null,
partyInput: option ? partyDisplayName(this.props.kind, option) : '',
});
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() {
return Boolean(
this.props.hasArticles ??
@@ -111,7 +246,8 @@ class ManifestHeader extends PureComponent {
const useExternalNumber = Boolean(cfg.externalNumber);
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 isPartyEmpty = partyId == null || partyId === '';
@@ -147,13 +283,44 @@ class ManifestHeader extends PureComponent {
/>
<Autocomplete
size="small"
options={parties}
autoHighlight
options={parties || []}
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}
getOptionLabel={(option) => partyDisplayName(kind, option)}
isOptionEqualToValue={(option, value) => option.id === value.id}
isOptionEqualToValue={(option, value) => option?.id === value?.id}
onChange={this.handlePartyChange}
onInputChange={this.handlePartyInputChange}
onKeyDown={this.handlePartyKeyDown}
sx={{
flex: { xs: '1 1 100%', sm: '2 1 200px' },
minWidth: { sm: 160 },
@@ -163,6 +330,7 @@ class ManifestHeader extends PureComponent {
{...params}
label={partyLabel}
error={isPartyEmpty}
onKeyDown={this.handlePartyKeyDown}
slotProps={{
...params.slotProps,
htmlInput: {
@@ -176,9 +344,9 @@ class ManifestHeader extends PureComponent {
<SingleLineTextEdit
size="small"
label="Nummer"
value={number}
disabled={saving || pdfUploading || useExternalNumber}
onChange={onNumberChange}
value={number || ''}
disabled
placeholder="–"
sx={{
flex: { xs: '1 1 100%', sm: '1 1 120px' },
minWidth: { sm: 100 },

View File

@@ -11,7 +11,9 @@ import Typography from '@mui/material/Typography';
import Checkbox from '@mui/material/Checkbox';
import Switch from '@mui/material/Switch';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { enqueueSnackbar } from 'notistack';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import FillSpace from 'dashgui/FillSpace';
@@ -220,6 +222,7 @@ class OrderSearchField extends PureComponent {
constructor(props) {
super(props);
const s = orderAutomat.state;
const stats = orderAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
onlyUnpaid: s.onlyUnpaid ?? false,
@@ -227,6 +230,9 @@ class OrderSearchField extends PureComponent {
seekMonths: s.seekMonths ?? 0,
seekYears: s.seekYears ?? 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,
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() {
this.unsub?.();
this.unsubStats?.();
}
render() {
@@ -423,6 +439,26 @@ class OrderSearchField extends PureComponent {
Reset
</Button>
</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>
@@ -438,6 +474,8 @@ export class OrderList extends PureComponent {
// getItems() returns current items or null and triggers loading of the first chunk
this.state = {
items: orderAutomat.getItems(),
unfilteredTotal: orderAutomat.state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey?.() ?? '',
partiesById: {},
busyId: null,
};
@@ -454,9 +492,10 @@ export class OrderList extends PureComponent {
componentDidMount() {
this._cancelled = false;
// Subscribe to items only
this.unsub = orderAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: orderAutomat.getFilterKey(state),
}));
void this.loadParties();
}
@@ -546,7 +585,7 @@ export class OrderList extends PureComponent {
}
render() {
const { items, partiesById, busyId } = this.state;
const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
@@ -578,8 +617,10 @@ export class OrderList extends PureComponent {
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
renderItem={OrderRow}
virtualScrollbar
header={

View File

@@ -317,7 +317,7 @@ class RecordPaymentDialog extends PureComponent {
type="number"
value={amount}
onChange={(e) => this.setState({ amount: e.target.value })}
inputProps={{ min: 0.01, step: 'any' }}
slotProps={{ htmlInput: { min: 0.01, step: 'any' } }}
/>
</Box>

View File

@@ -388,7 +388,7 @@ class NewShipmentModal extends PureComponent {
value={currentVal}
onChange={(e) => this.handleAmountChange(line.sku, e.target.value)}
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 }}
/>
</TableCell>

View File

@@ -18,6 +18,7 @@ export { withManifestId } from './withManifestId';
export {
manifestConfig,
LINE_COLUMNS,
QUOTE_LINE_COLUMNS,
ORDER_LINE_COLUMNS,
DELIVERY_LINE_COLUMNS,
emptyLine,

View File

@@ -9,7 +9,10 @@ import customergroupAutomat from '../../automats/customergroupAutomat';
import deliveryAutomat from '../../automats/deliveryAutomat';
import quoteAutomat from '../../automats/quoteAutomat';
import orderAutomat from '../../automats/orderAutomat';
import { getArticle } from '../../automats/artikelAutomat';
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
import { netFromGross } from '../../utils/price';
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
import {
GREEN_PANEL,
ORANGE_PANEL,
@@ -22,7 +25,8 @@ export const EXCLUDED_CUSTOMER_GROUP_NAMES = new Set(['Lieferanten', 'POS']);
export function entityDisplayName(entity) {
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) {
@@ -81,19 +85,20 @@ export function dateInputValue(iso) {
}
export function emptyLine() {
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
return {
key: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
amount: '',
amount: '1',
sku: '',
tax: '',
rebate: '',
name: '',
tax: normTax,
taxrate: normTax,
rebate: '0',
net: '',
gross: '',
name: '',
pu: '',
pu: '1',
unit: '',
netprice: '',
taxrate: '',
};
}
@@ -148,9 +153,8 @@ export function hasArticleLines(lines) {
if (!l) return false;
const sku = String(l.sku ?? '').trim();
const name = String(l.name ?? '').trim();
const amount = String(l.amount ?? '').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++) {
const line = lines[i];
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 amount = parseNum(rawAmount);
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 rawPu = getFirstVal(line.pu, line.vpe);
const pu = parseNum(rawPu);
const blank =
!sku && !name && amount == null && net == null && gross == null && tax == null && rebate == null;
const blank = !sku && !name && net == null && gross == null;
if (blank) continue;
const missing = [];
@@ -442,7 +445,7 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
*/
export async function buildManifestBody(
form,
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber },
{ partyField, requireAtLeastOne, externalNumber: useExternalNumber, kind },
) {
const date = form.date;
if (!date) throw new Error('Datum fehlt.');
@@ -458,8 +461,11 @@ export async function buildManifestBody(
lines: buildLines(form.lines, { requireAtLeastOne }),
};
} else {
const number = String(form.number ?? '').trim();
if (!number) throw new Error('Nummer fehlt.');
let number = String(form.number ?? '').trim();
if (!number) {
number = await ensureDocumentNumber(kind || (partyField === 'entityId' ? 'quote' : 'doc'));
}
if (!number) throw new Error('Nummer konnte nicht ermittelt werden.');
body = {
number,
date,
@@ -499,12 +505,15 @@ export async function buildManifestBody(
export const LINE_COLUMNS = [
{ id: 'amount', label: 'Amount', width: 80, align: 'right', type: 'number' },
{ 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: 'rebate', label: 'Rebate', width: 75, align: 'right', type: 'number' },
{ id: 'net', label: 'Net', 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 = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ 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();
return sortSuppliers(list);
}
const [entities, groups] = await Promise.all([
entityAutomat.actions.ensureLoaded(),
customergroupAutomat.actions.ensureLoaded(),
]);
return filterCustomerEntities(entities, groups);
const entities = await entityAutomat.actions.ensureLoaded();
return entities ?? entityAutomat.state.items ?? [];
}
export function partyDisplayName(cfgOrKind, party) {
if (!party) return '';
if (typeof party === 'string') return party;
const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind;
if (kind === 'delivery') return String(party.name ?? '');
return entityDisplayName(party);
@@ -623,7 +630,7 @@ export function manifestConfig(cfgOrKind) {
docField: 'quote',
partyField: 'entityId',
listPath: '/auftrag',
createPath: '/auftrag/neu',
createPath: '/auftrag/neuesangebot',
editPath: (id) => `/auftrag/${id}`,
dateLabel: 'Gültig bis',
partyLabel: 'Kunde',

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

View File

@@ -101,9 +101,9 @@ export default class McpAuthorizationsPanel extends Component {
return (
<Box sx={{ maxWidth: 860 }}>
{/* 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>
<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 }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
MCP-Autorisierungen & Sicherheit
@@ -190,11 +190,13 @@ export default class McpAuthorizationsPanel extends Component {
<CardContent sx={{ p: 2.5, '&:last-child': { pb: 2.5 } }}>
<Stack
direction={{ xs: 'column', sm: 'row' }}
alignItems={{ xs: 'flex-start', sm: 'center' }}
justifyContent="space-between"
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
sx={{
width: 44,
@@ -212,7 +214,7 @@ export default class McpAuthorizationsPanel extends Component {
</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 }}>
{auth.clientName || 'Unbenannter Client'}
</Typography>
@@ -246,7 +248,7 @@ export default class McpAuthorizationsPanel extends Component {
Autorisiert: <strong>{authorizedDate}</strong>
</Typography>
{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' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
Aktive Tokens: <strong>{auth.tokenCount}</strong>

View File

@@ -20,7 +20,9 @@ import SettingsKeysPanel from './SettingsKeysPanel';
import ImportExportSettingsPanel from './ImportExportSettingsPanel';
import PosTerminalSettingsPanel from './PosTerminalSettingsPanel';
import McpAuthorizationsPanel from './McpAuthorizationsPanel';
import BackupSettingsPanel from './BackupSettingsPanel';
import SecurityIcon from '@mui/icons-material/Security';
import BackupTableOutlinedIcon from '@mui/icons-material/BackupTableOutlined';
import { SURFACE_BASE } from '../../colors';
const TAB_KEYS = {
@@ -29,6 +31,7 @@ const TAB_KEYS = {
numbers: 'numbers',
printers: 'printers',
importExport: 'importExport',
backup: 'backup',
posterminal: 'posterminal',
security: 'security',
};
@@ -39,6 +42,7 @@ const TABS = [
{ id: TAB_KEYS.numbers, label: 'Nummernkreise', icon: <NumbersIcon />, Panel: NumberSettingsPanel },
{ id: TAB_KEYS.printers, label: 'Drucker', icon: <PrintOutlinedIcon />, Panel: PrinterSettingsPanel },
{ 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.security, label: 'Sicherheit & MCP', icon: <SecurityIcon />, Panel: McpAuthorizationsPanel },
];

View File

@@ -227,7 +227,6 @@ export default class SettingsKeysPanel extends Component {
disabled={draft.busy}
onChange={(e) => this.handleChange(row.key, 'value', e.target.value)}
slotProps={{ htmlInput: { spellCheck: false } }}
inputProps={{ spellCheck: false }}
>
{enumOpts.map((opt) => {
const val = typeof opt === 'object' ? opt.value : opt;
@@ -258,7 +257,6 @@ export default class SettingsKeysPanel extends Component {
}}
autoComplete="off"
slotProps={{ htmlInput: { spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' } }}
inputProps={{ spellCheck: false, autoCorrect: 'off', autoCapitalize: 'off' }}
/>
)}
</TableCell>

View File

@@ -5,3 +5,5 @@ export { default as NumberSettingsPanel } from './NumberSettingsPanel';
export { default as PrinterSettingsPanel } from './PrinterSettingsPanel';
export { default as PosTerminalSettingsPanel } from './PosTerminalSettingsPanel';
export { default as ImportExportSettingsPanel } from './ImportExportSettingsPanel';
export { default as McpAuthorizationsPanel } from './McpAuthorizationsPanel';
export { default as BackupSettingsPanel } from './BackupSettingsPanel';

View File

@@ -129,7 +129,7 @@ export default class VfsAppletForm extends PureComponent {
disabled={Boolean(readOnly)}
helperText={prop?.description || (prop?.default !== undefined ? `Standard: ${String(prop.default)}` : '')}
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' } }}
/>
);

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,9 @@ const AutomatInspector = import.meta.env.DEV
const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor'))
: null;
const ChunkCacheMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/ChunkCacheMonitor'))
: null;
async function clearDevServiceWorker() {
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return;
@@ -71,6 +74,11 @@ createRoot(document.getElementById('root')).render(
<SseMonitor />
</Suspense>
) : null}
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
<PwaUpdateDialog />
</AuthBootstrap>
</SnackbarProvider>

View File

@@ -312,7 +312,7 @@ export function resolveActiveSub(pathname, sectionPath) {
sectionPath === '/kunde' && rest && !knownSegments.has(rest);
const isLieferungDetail =
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 =
sectionPath === '/auftrag' && rest && !knownSegments.has(rest);
if (isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc) {

View File

@@ -399,13 +399,15 @@ class ArtikelVorlagenPage extends PureComponent {
onChange={this.handleHeightMmChange}
onBlur={this.handleHeightMmBlur}
disabled={saving}
InputProps={{
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
}}
inputProps={{
min: MIN_HEIGHT_MM,
max: 300,
step: 1,
slotProps={{
input: {
endAdornment: <InputAdornment position="end">mm</InputAdornment>,
},
htmlInput: {
min: MIN_HEIGHT_MM,
max: 300,
step: 1,
},
}}
sx={{ width: 120 }}
/>

View File

@@ -1,10 +1,2 @@
import { PureComponent } from 'react';
import { ManifestForm } from '../../components/manifest';
export { default } from './neuesangebot';
class AuftragAngebotNeuPage extends PureComponent {
render() {
return <ManifestForm kind="quote" mode="create" />;
}
}
export default AuftragAngebotNeuPage;

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

View File

@@ -213,7 +213,7 @@ class KundeListePage extends PureComponent {
void entityAutomat.actions.load().catch((err) => notifyError(err));
}
if (customergroupAutomat.state.status !== 'ready') {
void customergroupAutomat.actions.load().catch(() => {});
void customergroupAutomat.actions.load().catch(() => { });
}
}

View File

@@ -226,7 +226,7 @@ export default class OAuthAuthorizePage extends Component {
justifyContent: 'space-between',
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Box
component="img"
src={quixposLogo}
@@ -271,7 +271,7 @@ export default class OAuthAuthorizePage extends Component {
) : (
<Stack spacing={3}>
{/* 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
sx={{
width: 56,
@@ -329,7 +329,7 @@ export default class OAuthAuthorizePage extends Component {
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
<Avatar
sx={{
width: 34,
@@ -366,7 +366,7 @@ export default class OAuthAuthorizePage extends Component {
borderColor: BORDER,
}}
>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', mb: 1 }}>
<Chip
label={scope}
size="small"

112
src/utils/documentNumber.js Normal file
View 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);
}