Compare commits

..

16 Commits

Author SHA1 Message Date
seb
39aa898324 u 2026-10-15 00:20:54 +02:00
seb
7ce2e3216f u 2026-10-15 00:08:21 +02:00
seb
002f63a47a u 2026-10-14 23:58:55 +02:00
seb
ed7bb80006 u 2026-10-14 23:58:37 +02:00
seb
e2a2d02cb4 u 2026-10-14 23:24:45 +02:00
seb
eb4654dfbf u 2026-10-14 23:13:46 +02:00
seb
c62834833d u 2026-10-14 20:46:07 +02:00
seb
fdb67b7221 u 2026-10-14 20:39:42 +02:00
seb
c76777d0b8 u 2026-10-14 20:28:56 +02:00
seb
c54d932e36 u 2026-10-14 15:20:39 +02:00
seb
a0d71f70b7 u 2026-10-14 15:07:47 +02:00
seb
b8fdef4e11 u 2026-10-14 15:00:31 +02:00
seb
4188d8f46f u 2026-10-14 14:46:44 +02:00
seb
49f8f2f93f u 2026-10-14 13:48:45 +02:00
seb
61dd2f3a5d u 2026-10-14 13:45:17 +02:00
seb
62fa58e4ec u 2026-10-14 12:53:33 +02:00
28 changed files with 3409 additions and 484 deletions

View File

@@ -7,7 +7,7 @@ description: >-
# Dash API Routes Overview (All)
Overview of all 233 API endpoints across all modules.
Overview of all 246 API endpoints across all modules.
### Notation
@@ -17,60 +17,61 @@ 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: 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[] }`
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], createdAt?: str, shipping?: T37|null, shippings?: T37[], payment?: T43|null, payments?: T43[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T5[], files?: T15[] }`
- `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: 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: 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, 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[] }`
- `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: T40, brand: T38|null, categoryIds: int[], photos: T35[], eans: str[], prices: T47[], suppliers: T17[] }`
- `T5` = `{ id: int, number: str, type: str, date: str, lines: T29[], shippings: T37[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T15[] }`
- `T6` = `{ delivery: T7 }`
- `T7` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T29[], files: T15[], externalNumber?: str|null, createdAt?: str, ledger?: T51 }`
- `T8` = `{ quote: T9 }`
- `T9` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T15[] }`
- `T10` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T11|null }`
- `T11` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T18, totalSizeBytes: int, durationMs: int }`
- `T12` = `{ labeltemplate: T14 }`
- `T13` = `{ attachment: T15, fileUrl: str }`
- `T14` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T25, thumb?: str|null }`
- `T15` = `{ 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 }`
- `T16` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T17` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T54|null }`
- `T18` = `{ vfs: T52, storage: T52, database: T45 }`
- `T19` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T20` = `{ ledger: T21[] }`
- `T21` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T22` = `{ id: int, name: str, sort: int, exclusive: bool, values: T41[], articleCount?: int }`
- `T23` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str, started_by?: integer,null, first_message?: string,null }`
- `T24` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
- `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 }`
- `T26` = `{ objects: T31[] }`
- `T27` = `{ printer: T33 }`
- `T28` = `{ entity: T36 }`
- `T29` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T30` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T31` = `{ shipping: T36 }`
- `T32` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T33` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T34` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T36` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T37` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T38` = `{ article: T43 }`
- `T39` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T40` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T42` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T43` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T44` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T45` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T46` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T47` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T48` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T49` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T50` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T51` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T52` = `{ warehouse: T54 }`
- `T53` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T54` = `{ id: int, name: str, sort: int }`
- `T55` = `{ id: int, categoryIds: int[] }`
- `T31` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T32` = `{ shipping: T37 }`
- `T33` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T34` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T35` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T36` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T37` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
- `T38` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T39` = `{ article: T44 }`
- `T40` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T41` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T42` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T43` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
- `T44` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T45` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
- `T46` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T47` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T48` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T49` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T50` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T51` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T52` = `{ name: str, sizeBytes: int, sourceExists: bool }`
- `T53` = `{ warehouse: T55 }`
- `T54` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T55` = `{ id: int, name: str, sort: int }`
- `T56` = `{ id: int, categoryIds: int[] }`
### Routes
@@ -100,7 +101,7 @@ _none_
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T38`
- Returns: `201 T39`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
- Returns: `200 zip`
@@ -136,10 +137,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 T38`
- Returns: `201 T39`
#### 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: T43, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T44, 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 }`
@@ -166,7 +167,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 T38`
- Returns: `200 T39`
#### 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`
@@ -184,10 +185,10 @@ _none_
- Returns: `200 { ok: bool }`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: str`
- Returns: `200 { suppliers: T16[] }`
- Returns: `200 { suppliers: T17[] }`
#### 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: T16 }`
- Returns: `201 { supplier: T17 }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: str, body: { ids: int[] }`
- Returns: `200 { ids: int[] }`
@@ -196,7 +197,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: T16 }`
- Returns: `200 { supplier: T17 }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: str, body: { track_stock: bool }`
- Returns: `200 T3`
@@ -208,19 +209,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T21[] }`
- Returns: `200 { attributenames: T22[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T21 }`
- Returns: `201 { attributename: T22 }`
#### 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: T40[] }`
- Returns: `200 { values: T41[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T40 }`
- Returns: `201 { attributevalue: T41 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -229,7 +230,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: T21 }`
- Returns: `200 { attributename: T22 }`
### Section: /attributevalue
@@ -238,34 +239,34 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T40 }`
- Returns: `200 { attributevalue: T41 }`
#### 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: T47[] } }`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T48[] } }`
#### 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: T47[] } }`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T48[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T45 }`
- Returns: `200 { message: str, user: T46 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T45, googleClientId: str }`
- Returns: `200 { user: T46, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T45 }`
- Returns: `200 { user: T46 }`
### 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[] } }`
- 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?: T10|null, latestDaily?: T10|null, latestMonthly?: T10|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T10[], daily: T10[], monthly: T10[] } }`
#### List VFS files in a backup — `GET /backup/{id}/vfs`
- Params: `id: str, query: companyId?: int`
- Returns: `200 { backupId: str, companyId: int, files: { name: str, size: int, mime: str, mtime: str }[] }`
@@ -280,7 +281,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: T47[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T48[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -289,10 +290,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: T47[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T48[] } }`
#### 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: T47[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T48[] } }`
### Section: /category
@@ -316,7 +317,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?: T37|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?: T38|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 }[] }`
@@ -343,10 +344,16 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T23[] }`
- Returns: `200 { companies: T24[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T23, user: T45 }`
- Returns: `201 { company: T24, user: T46 }`
#### Export company catalog as zip archive (SSE) — `GET /company/export`
- Params: `query: stream?: str`
- Returns: `200 SSE`
#### Export company catalog as zip archive (SSE) — `POST /company/export`
_none_
- Returns: `200 SSE`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -370,7 +377,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: T23 }`
- Returns: `200 { company: T24 }`
### Section: /customergroup
@@ -379,7 +386,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: T54 }`
- Returns: `201 { customergroup: T55 }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: int[] }`
- Returns: `200 { customergroupIds: int[] }`
@@ -388,16 +395,16 @@ _none_
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { customergroup: T54 }`
- Returns: `200 { customergroup: T55 }`
### 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?: T50 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T51 }[] }`
#### Create delivery — `POST /delivery`
- 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`
- Params: `body: { externalNumber: str, date: str, lines: T34[], 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 T6`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
@@ -415,58 +422,64 @@ _none_
- Returns: `204 void`
#### Get delivery — `GET /delivery/{id}`
- Params: `id: int`
- Returns: `200 T5`
- Returns: `200 T6`
#### Update delivery — `PATCH /delivery/{id}`
- 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`
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T34[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `200 T6`
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
### Section: /entity
#### List entities — `GET /entity`
- Params: `query: customergroupId?: int, role?: str`
- Returns: `200 { entities: T35[] }`
- Returns: `200 { entities: T36[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T27`
- Returns: `201 T28`
#### 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: T18[] }`
- Returns: `200 { addresses: T19[] }`
#### 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: T18 }`
- Returns: `201 { address: T19 }`
#### 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: T18 }`
- Returns: `200 { address: T19 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T48[] }`
- Returns: `200 { contacts: T49[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T48 }`
- Returns: `201 { contact: T49 }`
#### 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: T48 }`
- Returns: `200 { contact: T49 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T27`
- Returns: `200 T28`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T27`
- Returns: `200 T28`
### Section: /events
@@ -510,35 +523,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: T25 }`
- Returns: `201 T10`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T26 }`
- Returns: `201 T12`
#### 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 T10`
- Returns: `200 T12`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T12`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }`
- Returns: `200 T10`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
- Returns: `200 T12`
### Section: /ledger
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
- Returns: `201 { ledger: T20 }`
- Returns: `201 { ledger: T21 }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: int, query: transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: str, query: transaction_type?: str`
- Returns: `200 T19`
- Returns: `200 T20`
### Section: /mcp
@@ -568,16 +581,16 @@ _none_
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: T22[] }`
- Returns: `200 { conversations: T23[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: str, model: str }`
- Returns: `201 { conversation: T22 }`
- Returns: `201 { conversation: T23 }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: int`
- Returns: `200 { conversation: T22 }`
- Returns: `200 { conversation: T23 }`
#### 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 }[] }`
@@ -618,7 +631,7 @@ _none_
- Params: `query: offset?: int, limit?: int, search?: str, onlyUnpaid?: bool, onlyUndelivered?: bool, seekMonths?: int`
- Returns: `200 { orders: T2[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create order — `POST /order`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Params: `body: { number: str, date: str, lines: T34[], entityId?: int|null }`
- Returns: `201 T1`
#### Delete order — `DELETE /order/{id}`
- Params: `id: int`
@@ -627,26 +640,35 @@ _none_
- Params: `id: int`
- Returns: `200 T1`
#### Update order — `PATCH /order/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T34[] }`
- Returns: `200 T1`
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### Get order attachment file content — `GET /order/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### List payments of an order — `GET /order/{id}/payment`
- Params: `id: int`
- Returns: `200 { payments: T42[] }`
- Returns: `200 { payments: T43[] }`
#### Add payment to an order — `POST /order/{id}/payment`
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
- Returns: `201 { payment: T42 }`
- Returns: `201 { payment: T43 }`
#### Generate and attach order PDF — `POST /order/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
#### List shipments of an order — `GET /order/{id}/shipment`
- Params: `id: int`
- Returns: `200 { shipments: T7[] }`
- Returns: `200 { shipments: T5[] }`
#### Create shipment for an order — `POST /order/{id}/shipment`
- Params: `id: int, body: { lines: { sku: str, amount: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[], date?: str, externalNumber?: str }`
- Returns: `201 { shipment: T7 }`
- Returns: `201 { shipment: T5 }`
#### List shippings of an order — `GET /order/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T36[] }`
- Returns: `200 { shippings: T37[] }`
#### Create shipping for an order — `POST /order/{id}/shipping`
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
- Returns: `201 T31`
- Returns: `201 T32`
### Section: /package
@@ -664,37 +686,37 @@ _none_
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: T14[] }`
- Returns: `200 { posterminals: T16[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
- Returns: `200 { posterminal: T14 }`
- Returns: `200 { posterminal: T16 }`
#### 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: T14 }`
- Returns: `200 { posterminal: T16 }`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T55`
- Returns: `200 T56`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: int, categoryId: int`
- Returns: `200 T55`
- Returns: `200 T56`
### Section: /printer
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T32[] }`
- Returns: `200 { printers: T33[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T26`
- Returns: `201 T27`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -703,22 +725,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T26`
- Returns: `200 T27`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T26`
- Returns: `200 T27`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T26`
- Returns: `200 T27`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T41[] }`
- Returns: `200 { propertynames: T42[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T41 }`
- Returns: `201 { propertyname: T42 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -727,7 +749,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: T41 }`
- Returns: `200 { propertyname: T42 }`
### Section: /public
@@ -747,20 +769,29 @@ _none_
### Section: /quote
#### List quotes — `GET /quote`
_none_
- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
- Returns: `200 { quotes: T9[], total: int, unfilteredTotal: int, offset: int, limit: int }`
#### Create quote — `POST /quote`
- Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }`
- Returns: `201 T12`
- Params: `body: { number: str, date: str, lines: T34[], entityId?: int|null }`
- Returns: `201 T8`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: int`
- Returns: `200 T12`
- Returns: `200 T8`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }`
- Returns: `200 T12`
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T34[] }`
- Returns: `200 T8`
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### Get quote attachment file content — `GET /quote/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
### Section: /serper
@@ -784,13 +815,22 @@ _none_
- Returns: `204 void`
#### Get shipment by ID — `GET /shipment/{id}`
- Params: `id: int`
- Returns: `200 { shipment: T7 }`
- Returns: `200 { shipment: T5 }`
#### Delete shipment file attachment — `DELETE /shipment/{id}/file/{fileId}`
- Params: `id: int, fileId: int`
- Returns: `204 void`
#### Get shipment attachment file content — `GET /shipment/{id}/file/{fileId}`
- Params: `id: int, fileId: int, query: download?: bool`
- Returns: `200 str`
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
- Params: `id: int`
- Returns: `201 T13`
#### List packages of a shipment — `GET /shipment/{id}/shipping`
- Params: `id: int`
- Returns: `200 { shippings: T36[] }`
- Returns: `200 { shippings: T37[] }`
#### 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 T31`
- Returns: `201 T32`
### Section: /shipping
@@ -799,7 +839,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 T31`
- Returns: `200 T32`
#### 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 }[] }`
@@ -817,28 +857,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: T20[] }`
- 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: T21[] }`
### Section: /supplier
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T49[] }`
- Returns: `200 { suppliers: T50[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T49 }`
- Returns: `201 { supplier: T50 }`
#### 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: T49 }`
- Returns: `200 { supplier: T50 }`
#### 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?: T53|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?: T54|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?: T53|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?: T54|null }[] }`
### Section: /thumb
@@ -853,10 +893,10 @@ _none_
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: T39[] }`
- Returns: `200 { units: T40[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
- Returns: `200 { unit: T39 }`
- Returns: `200 { unit: T40 }`
### Section: /v1
@@ -910,10 +950,10 @@ _none_
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: T54[] }`
- Returns: `200 { warehouses: T55[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: str, sort?: int }`
- Returns: `201 T52`
- Returns: `201 T53`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: int[] }`
- Returns: `200 { warehouseIds: int[] }`
@@ -922,10 +962,10 @@ _none_
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: int`
- Returns: `200 T52`
- Returns: `200 T53`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T52`
- Returns: `200 T53`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: int, body: { name?: str, sort?: int }`
- Returns: `200 T52`
- Returns: `200 T53`

View File

@@ -193,4 +193,144 @@ export const company = {
});
},
// #endregion
/**
* GET /api/company/export
* Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks.
*
* @param {object} [callbacks]
* @param {(message: string) => void} [callbacks.onStatus]
* @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress]
* @param {(chunk: string) => void} [callbacks.onChunk]
* @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone]
* @param {(error: string) => void} [callbacks.onError]
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete
* @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>}
*/
// #region GET /api/company/export — exportCatalog
async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) {
const response = await fetch('/api/company/export', {
method: 'GET',
headers: { Accept: 'text/event-stream' },
credentials: 'include',
signal,
});
if (!response.ok) {
let errMessage = 'Export failed';
try {
const error = await response.json();
errMessage = error.error || error.message || errMessage;
} catch {
// ignore JSON parse error
}
throw new Error(errMessage);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const base64Chunks = [];
let filename = 'company-export.zip';
let donePayload = null;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? ''; // keep trailing incomplete message
for (const part of parts) {
const lines = part.split('\n');
let event = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
}
if (event === 'status') {
try {
const payload = JSON.parse(data);
callbacks.onStatus?.(payload.message || data);
} catch {
callbacks.onStatus?.(data);
}
} else if (event === 'progress') {
try {
const payload = JSON.parse(data);
callbacks.onProgress?.(payload);
} catch {
// ignore
}
} else if (event === 'chunk') {
base64Chunks.push(data);
callbacks.onChunk?.(data);
} else if (event === 'done') {
try {
const payload = JSON.parse(data);
donePayload = payload;
if (payload.filename) filename = payload.filename;
callbacks.onDone?.(payload);
} catch {
// ignore
}
} else if (event === 'error') {
let errorMsg = 'Export error';
try {
const payload = JSON.parse(data);
errorMsg = payload.message || errorMsg;
} catch {
errorMsg = data || errorMsg;
}
callbacks.onError?.(errorMsg);
throw new Error(errorMsg);
}
}
}
} finally {
reader.releaseLock();
}
// Reconstruct Blob from base64 chunks
const byteArrays = base64Chunks.map((b64) => {
const binaryStr = atob(b64);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return bytes;
});
const blob = new Blob(byteArrays, { type: 'application/zip' });
if (triggerDownload) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
return {
blob,
filename,
size: blob.size,
totalChunks: base64Chunks.length,
stats: donePayload?.stats,
};
},
// #endregion
};
export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);

View File

@@ -84,6 +84,19 @@ export const delivery = {
},
// #endregion
/**
* DELETE /api/delivery/:id/file/:fileId
* Deletes delivery file attachment.
* @param {number|string} id Delivery ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/delivery/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)

View File

@@ -4,7 +4,7 @@ export { attributename } from './attributename.js';
export { attributevalue } from './attributevalue.js';
export { brand } from './brand.js';
export { category } from './category.js';
export { company } from './company.js';
export { company, exportCompanyCatalog } from './company.js';
export { customergroup } from './customergroup.js';
export { delivery } from './delivery.js';
export { entity } from './entity.js';

View File

@@ -62,4 +62,17 @@ export const order = {
return http(`/api/order/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* DELETE /api/order/:id/file/:fileId
* Deletes order file attachment.
* @param {number|string} id Order ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/order/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/order/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -7,10 +7,22 @@ import { http } from './http.js';
* LineInput = { sku, amount, net, tax, rebate?, comment? }
*/
export const quote = {
/** GET /api/quote → { quotes: QuoteSummary[] } */
/**
* GET /api/quote?offset=0&limit=50&search=...&seekMonths=-1
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
*/
// #region GET /api/quote — list
list() {
return http('/api/quote');
list({ offset = 0, limit = 50, search, seekMonths } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
return http(`/api/quote${qs ? `?${qs}` : ''}`);
},
// #endregion
@@ -49,4 +61,30 @@ export const quote = {
return http(`/api/quote/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/quote/:id/pdf
* Generates and attaches quote PDF.
* @param {number|string} id Quote ID
* → 201 { attachment: object, fileUrl: string }
*/
// #region POST /api/quote/:id/pdf — createPdf
createPdf(id) {
return http(`/api/quote/${id}/pdf`, { method: 'POST' });
},
// #endregion
/**
* DELETE /api/quote/:id/file/:fileId
* Deletes quote attachment file.
* @param {number|string} id Quote ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/quote/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/quote/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -375,6 +375,24 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.actions.removeLineitem(payload);
},
/**
* Moves a lineitem from one index to another.
* @param {number} fromIndex Source index.
* @param {number} toIndex Target index.
* @returns {void}
*/
moveLineitem(fromIndex, toIndex) {
const currentLines = automat.state.lines ?? [];
if (fromIndex < 0 || fromIndex >= currentLines.length || toIndex < 0 || toIndex >= currentLines.length) return;
const nextLines = [...currentLines];
const [item] = nextLines.splice(fromIndex, 1);
nextLines.splice(toIndex, 0, item);
automat.setState({
lines: nextLines,
lineitems: nextLines,
});
},
/** Clears all lines from the editor. @returns {void} */
clearLineitems() {
automat.setState({

View File

@@ -233,3 +233,358 @@ export function getManifestDraftAutomat(kind) {
if (kind === 'order') return orderDraftAutomat;
return null;
}
/**
* Computes detailed diff between current manifest draft/edit state and baseline snapshot.
*
* @param {object} current
* @param {object|null} baseline
* @returns {{ isDirty: boolean, fields: { date: boolean, partyId: boolean, externalNumber: boolean, number: boolean, lines: boolean, attachments: boolean }, dirtyLineKeys: string[], summary: string[] }}
*/
export function getManifestDirtyDetails(current, baseline) {
if (!baseline) {
return {
isDirty: false,
fields: {
date: false,
partyId: false,
externalNumber: false,
number: false,
lines: false,
attachments: false,
},
dirtyLineKeys: [],
summary: [],
};
}
const fields = {
date: (current.date || '') !== (baseline.date || ''),
partyId:
(current.partyId != null && current.partyId !== '' ? Number(current.partyId) : null) !==
(baseline.partyId != null && baseline.partyId !== '' ? Number(baseline.partyId) : null),
externalNumber:
String(current.externalNumber || '').trim() !== String(baseline.externalNumber || '').trim(),
number: String(current.number || '').trim() !== String(baseline.number || '').trim(),
lines: false,
attachments: false,
};
const dirtyLineKeys = [];
const dirtyLineFields = {};
const curLines = current.lines || [];
const baseLines = baseline.lines || [];
const baseLinesByKey = new Map();
for (let i = 0; i < baseLines.length; i++) {
const bl = baseLines[i];
if (bl.key) baseLinesByKey.set(bl.key, bl);
if (bl.id != null) baseLinesByKey.set(`id-${bl.id}`, bl);
}
if (curLines.length !== baseLines.length) {
fields.lines = true;
} else {
for (let i = 0; i < curLines.length; i++) {
const cl = curLines[i];
const bl = baseLines[i];
const clIdentifier = cl?.key ?? (cl?.id != null ? `id-${cl.id}` : null);
const blIdentifier = bl?.key ?? (bl?.id != null ? `id-${bl.id}` : null);
if (clIdentifier && blIdentifier && clIdentifier !== blIdentifier) {
fields.lines = true;
break;
}
}
}
for (let i = 0; i < curLines.length; i++) {
const cl = curLines[i];
const key = cl.key ?? `idx-${i}`;
const bl =
(cl.key && baseLinesByKey.get(cl.key)) ||
(cl.id != null && baseLinesByKey.get(`id-${cl.id}`)) ||
(baseLinesByKey.size === 0 ? baseLines[i] : null);
if (!bl) {
dirtyLineKeys.push(key);
dirtyLineFields[key] = { __all__: true };
fields.lines = true;
} else {
const lineFieldDiffs = {};
if (String(cl.sku ?? '').trim() !== String(bl.sku ?? '').trim()) {
lineFieldDiffs.sku = true;
}
if (String(cl.name ?? '').trim() !== String(bl.name ?? '').trim()) {
lineFieldDiffs.name = true;
}
if (String(cl.amount ?? cl.count ?? '').trim() !== String(bl.amount ?? bl.count ?? '').trim()) {
lineFieldDiffs.amount = true;
lineFieldDiffs.count = true;
}
if (String(cl.net ?? cl.netprice ?? '').trim() !== String(bl.net ?? bl.netprice ?? '').trim()) {
lineFieldDiffs.net = true;
lineFieldDiffs.netprice = true;
}
if (String(cl.gross ?? '').trim() !== String(bl.gross ?? '').trim()) {
lineFieldDiffs.gross = true;
}
if (String(cl.rebate ?? '').trim() !== String(bl.rebate ?? '').trim()) {
lineFieldDiffs.rebate = true;
}
if (String(cl.tax ?? cl.taxrate ?? '').trim() !== String(bl.tax ?? bl.taxrate ?? '').trim()) {
lineFieldDiffs.tax = true;
lineFieldDiffs.taxrate = true;
}
if (String(cl.pu ?? cl.vpe ?? '').trim() !== String(bl.pu ?? bl.vpe ?? '').trim()) {
lineFieldDiffs.pu = true;
lineFieldDiffs.vpe = true;
}
if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) {
lineFieldDiffs.unit = true;
}
if (Object.keys(lineFieldDiffs).length > 0) {
dirtyLineKeys.push(key);
dirtyLineFields[key] = lineFieldDiffs;
fields.lines = true;
}
}
}
// Check attachments
const curAtts = current.attachments || [];
const baseAtts = baseline.attachments || [];
if (curAtts.length !== baseAtts.length) {
fields.attachments = true;
} else {
for (const a of curAtts) {
if (!a.isPersisted || a.isDirty) {
fields.attachments = true;
break;
}
}
}
const summary = [];
if (fields.date) summary.push('Datum');
if (fields.partyId) summary.push(current.kind === 'delivery' ? 'Lieferant' : 'Kunde');
if (fields.externalNumber) summary.push('Referenz');
if (fields.number) summary.push('Nummer');
if (fields.lines) {
const count = dirtyLineKeys.length;
summary.push(count > 0 ? `${count} Artikelzeile${count === 1 ? '' : 'n'}` : 'Artikelzeilen');
}
if (fields.attachments && current.kind !== 'quote') summary.push('Dateien');
const isDirty = summary.length > 0;
return {
isDirty,
fields,
dirtyLineKeys,
dirtyLineFields,
summary,
};
}
/**
* Compares current manifest draft/edit state against a baseline snapshot.
*
* @param {object} current
* @param {object|null} baseline
* @returns {boolean} True if any field or line has changed from baseline
*/
export function checkManifestDirty(current, baseline) {
return getManifestDirtyDetails(current, baseline).isDirty;
}
/**
* Creates an Automat managing the single last-edited manifest for that kind.
*
* @param {{ kind: 'quote'|'order', name: string }} options
* @returns {Automat}
*/
export function createManifestEditAutomat({ kind, name }) {
let automat;
const emptyFields = {
date: false,
partyId: false,
externalNumber: false,
number: false,
lines: false,
attachments: false,
};
const actions = {
loadDoc(id, doc) {
const date = dateInputValue(doc.date);
const partyId = doc.partyId ?? null;
const number = doc.number ?? '';
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
const lines = Array.isArray(doc.lines) && doc.lines.length > 0 ? doc.lines : [emptyLine()];
const attachments = Array.isArray(doc.attachments) ? doc.attachments : [];
const baseline = {
date,
partyId,
number,
externalNumber,
lines: lines.map((l) => ({ ...l })),
attachments: [...attachments],
};
automat.setState({
kind,
mode: 'edit',
id,
date,
partyId,
number,
externalNumber,
lines,
attachments,
baseline,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
update(updates) {
const next = { ...automat.state, ...updates };
const dirtyInfo = getManifestDirtyDetails(next, automat.state.baseline);
automat.setState({
...updates,
isDirty: dirtyInfo.isDirty,
dirtyFields: dirtyInfo.fields,
dirtyLineKeys: dirtyInfo.dirtyLineKeys,
dirtyLineFields: dirtyInfo.dirtyLineFields,
dirtySummary: dirtyInfo.summary,
});
},
setAttachments(attachments) {
const list = Array.isArray(attachments) ? attachments : [];
const base = automat.state.baseline;
const nextBaseline = base ? { ...base, attachments: [...list] } : null;
automat.setState({
attachments: list,
baseline: nextBaseline,
});
},
restoreBaseline() {
if (!automat.state.baseline) return;
const base = automat.state.baseline;
automat.setState({
date: base.date,
partyId: base.partyId,
number: base.number,
externalNumber: base.externalNumber,
lines: base.lines.map((l) => ({ ...l })),
attachments: [...base.attachments],
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
commitSaved(savedDoc) {
const date = dateInputValue(savedDoc.date);
const partyId = savedDoc.partyId ?? null;
const number = savedDoc.number ?? automat.state.number;
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
const lines = Array.isArray(savedDoc.lines) && savedDoc.lines.length > 0 ? savedDoc.lines : [emptyLine()];
const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments;
const baseline = {
date,
partyId,
number,
externalNumber,
lines: lines.map((l) => ({ ...l })),
attachments: [...attachments],
};
automat.setState({
date,
partyId,
number,
externalNumber,
lines,
attachments,
baseline,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
reset() {
automat.setState({
kind,
mode: 'edit',
id: null,
date: '',
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
attachments: [],
baseline: null,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
};
automat = new Automat(
{
kind,
mode: 'edit',
id: null,
date: '',
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
attachments: [],
baseline: null,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
},
actions,
{ name, persist: false },
);
return automat;
}
/** Shared singleton automat for the single last edited quote. */
export const quoteEditAutomat = createManifestEditAutomat({
kind: 'quote',
name: 'quote-edit',
});
/**
* Returns the edit automat for a given manifest kind.
* @param {'quote'|'order'} kind
* @returns {Automat|null}
*/
export function getManifestEditAutomat(kind) {
if (kind === 'quote') return quoteEditAutomat;
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat';
import propertyAutomat from './propertyAutomat';
import supplierAutomat from './supplierAutomat';
import orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
import { handleDraftRequest, handleDraftEvent } from './draftSync';
@@ -245,6 +246,12 @@ function connect() {
});
});
addTrackedListener(es, 'quote', () => {
void quoteAutomat.actions.reload().catch((err) => {
console.error('[sse] quote refresh failed', err);
});
});
addTrackedListener(es, 'shipment', () => {
void orderAutomat.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);

View File

@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { PureComponent, lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
@@ -21,6 +21,10 @@ import {
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
const BreakpointOverlay = 0 && import.meta.env.DEV
? lazy(() => import('./BreakpointOverlay'))
: null;
const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 88;
@@ -62,19 +66,37 @@ class AppSidebar extends PureComponent {
<Box
component="nav"
sx={(theme) => ({
width: SIDEBAR_WIDTH,
width: { xs: '100%', lg: SIDEBAR_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'stretch' },
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
background: theme.nav.background,
position: 'sticky',
top: 0,
zIndex: theme.zIndex.appBar || 1100,
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
height: { xs: 52, sm: 56, lg: 'var(--df-vh, 100vh)' },
px: { xs: 1, sm: 1.5, lg: 0 },
boxSizing: 'border-box',
boxShadow: { xs: '0 2px 8px rgba(0,0,0,0.18)', lg: 'none' },
})}
>
<List disablePadding sx={{ flexGrow: 1 }}>
<List
disablePadding
sx={{
flexGrow: 1,
display: { xs: 'flex', lg: 'block' },
flexDirection: 'row',
alignItems: 'center',
height: { xs: '100%', lg: 'auto' },
overflowX: { xs: 'auto', lg: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
gap: { xs: 0.5, sm: 1, lg: 0 },
}}
>
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
const active = path === activeNav;
@@ -82,6 +104,8 @@ class AppSidebar extends PureComponent {
<ListItemButton
key={path}
type="button"
aria-label={label}
title={label}
selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
@@ -106,12 +130,22 @@ class AppSidebar extends PureComponent {
}
}}
sx={(theme) => ({
borderRadius: 0,
px: 1.5,
py: 1.25,
borderLeft: '3px solid transparent',
borderRadius: { xs: 1, lg: 0 },
width: { xs: 'auto', lg: '100%' },
height: { xs: '100%', lg: 'auto' },
px: { xs: active ? 1.25 : 1, sm: active ? 1.5 : 1.25, md: 1.5, lg: 1.25 },
py: { xs: 0.75, sm: 1, lg: 1.25 },
borderLeft: { xs: 'none', lg: '3px solid transparent' },
borderBottom: { xs: '3px solid transparent', lg: 'none' },
color: theme.nav.item,
'& .MuiListItemIcon-root': { color: 'inherit' },
whiteSpace: 'nowrap',
flexShrink: 0,
'& .MuiListItemIcon-root': {
color: 'inherit',
minWidth: { xs: 'auto', lg: 32 },
mr: active ? { xs: 0.75, lg: 0 } : { xs: 0, md: 0.75, lg: 0 },
justifyContent: { xs: 'center', lg: 'flex-start' },
},
'&:hover': {
bgcolor: theme.nav.itemHover,
color: theme.nav.itemHoverText,
@@ -120,7 +154,8 @@ class AppSidebar extends PureComponent {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
fontWeight: 600,
borderLeftColor: theme.nav.itemActiveAccent,
borderLeftColor: { xs: 'transparent', lg: theme.nav.itemActiveAccent },
borderBottomColor: { xs: theme.nav.itemActiveAccent, lg: 'transparent' },
'&:hover': {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
@@ -128,18 +163,31 @@ class AppSidebar extends PureComponent {
},
})}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<ListItemIcon>
<HelpBadge title={label} help={help} placement="right">
<Icon />
</HelpBadge>
</ListItemIcon>
<ListItemText primary={label} />
<ListItemText
primary={label}
primaryTypographyProps={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
}}
sx={{
m: { xs: 0, lg: 'initial' },
display: active ? 'block' : { xs: 'none', md: 'block' },
}}
/>
</ListItemButton>
);
})}
</List>
<UserMenu />
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
<UserMenu />
</Box>
</Box>
);
}
@@ -183,10 +231,11 @@ class AppSubNav extends PureComponent {
return (
<Box
sx={{
width: SUB_NAV_WIDTH,
width: { xs: 0, lg: SUB_NAV_WIDTH },
display: { xs: 'none', lg: 'block' },
flexShrink: 0,
ml: 3,
mr: 2,
ml: { xs: 0, lg: 3 },
mr: { xs: 0, lg: 2 },
}}
/>
);
@@ -196,21 +245,34 @@ class AppSubNav extends PureComponent {
<Box
component="nav"
aria-label="Unternavigation"
sx={{
width: SUB_NAV_WIDTH,
sx={(theme) => ({
width: { xs: '100%', sm: 72, md: SUB_NAV_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
gap: 1.5,
gap: { xs: 1, sm: 1.5 },
position: 'sticky',
top: 0,
top: { xs: 52, sm: 56, lg: 0 },
zIndex: { xs: 1090, sm: 'auto' },
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
py: 4,
ml: 3,
mr: 2,
}}
height: {
xs: 'auto',
sm: 'calc(var(--df-vh, 100vh) - 56px)',
lg: 'var(--df-vh, 100vh)',
},
py: { xs: 0.75, sm: 2, lg: 4 },
px: { xs: 1.5, sm: 0 },
ml: { xs: 0, sm: 2, lg: 3 },
mr: { xs: 0, sm: 1.5, lg: 2 },
overflowX: { xs: 'auto', sm: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
bgcolor: { xs: theme.nav?.mainBackground || 'background.default', sm: 'transparent' },
borderBottom: { xs: '1px solid', sm: 'none' },
borderColor: { xs: 'divider', sm: 'transparent' },
boxSizing: 'border-box',
})}
>
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
const active = segment === activeSub;
@@ -219,6 +281,7 @@ class AppSubNav extends PureComponent {
<ButtonBase
key={segment || 'index'}
type="button"
title={label}
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
@@ -243,15 +306,17 @@ class AppSubNav extends PureComponent {
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
width: SUB_NAV_WIDTH,
py: 1,
px: 0.5,
width: { xs: 'auto', sm: 72, md: SUB_NAV_WIDTH },
py: { xs: 0.5, sm: 1 },
px: { xs: active ? 1.25 : 0.75, sm: 0.5 },
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
gap: { xs: active ? 0.75 : 0, sm: 0.5 },
flexShrink: 0,
whiteSpace: 'nowrap',
color: active
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
: theme.palette.text.secondary,
@@ -267,18 +332,19 @@ class AppSubNav extends PureComponent {
})}
>
<HelpBadge title={title || label} help={help} placement="right">
<Icon sx={{ fontSize: 32 }} />
<Icon sx={{ fontSize: { xs: 20, sm: 28, md: 32 } }} />
</HelpBadge>
{label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontSize: { xs: '0.75rem', sm: '0.68rem' },
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
maxWidth: '100%',
display: active ? 'block' : { xs: 'none', sm: 'block' },
}}
>
{label}
@@ -299,29 +365,51 @@ export default function AppShell() {
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
return (
<Box sx={{ display: 'flex', minHeight: 'var(--df-vh, 100vh)', bgcolor: 'background.default' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', lg: 'row' },
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
}}
>
<AppSidebar activeNav={activeNav} navigate={navigate} />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
sx={{
display: 'flex',
flexDirection: 'column',
py: 4,
pr: 3,
bgcolor: theme.nav.mainBackground,
flexDirection: { xs: 'column', sm: 'row' },
flexGrow: 1,
minWidth: 0,
})}
}}
>
<Outlet />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
py: { xs: 2, md: 3, lg: 4 },
pr: { xs: 1.5, sm: 2, lg: 3 },
pl: { xs: 1.5, sm: 2, lg: 0 },
bgcolor: theme.nav.mainBackground,
minWidth: 0,
})}
>
<Outlet />
</Box>
</Box>
<ChatFab />
<ChatDrawer />
{BreakpointOverlay && (
<Suspense fallback={null}>
<BreakpointOverlay />
</Suspense>
)}
</Box>
);
}

View File

@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
export default function BreakpointOverlay() {
if (!import.meta.env.DEV) {
return null;
}
const theme = useTheme();
const breakpoints = useMemo(() => {
const values = theme?.breakpoints?.values || {};
return [
{ name: 'xs', width: values.xs || 360, color: 'rgba(239, 68, 68, 0.75)' },
{ name: 'sm', width: values.sm || 600, color: 'rgba(245, 158, 11, 0.75)' },
{ name: 'md', width: values.md || 900, color: 'rgba(16, 185, 129, 0.75)' },
{ name: 'lg', width: values.lg || 1200, color: 'rgba(14, 165, 233, 0.75)' },
{ name: 'xl', width: values.xl || 1536, color: 'rgba(168, 85, 247, 0.75)' },
];
}, [theme]);
return (
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 99999,
overflow: 'hidden',
}}
>
{breakpoints.map((bp) => (
<Box
key={bp.name}
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: bp.width,
border: `1px solid ${bp.color}`,
boxSizing: 'border-box',
}}
>
<Box
component="span"
sx={{
position: 'absolute',
top: 0,
right: 0,
px: 0.6,
py: 0.2,
bgcolor: bp.color,
color: '#ffffff',
fontSize: '10px',
fontWeight: 700,
fontFamily: 'monospace',
lineHeight: 1,
borderBottomLeftRadius: 3,
userSelect: 'none',
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
{bp.name}
</Box>
</Box>
))}
</Box>
);
}

View File

@@ -110,9 +110,21 @@ class UserMenu extends PureComponent {
return `data:${mime};base64,${pic}`;
})();
const isTop = Boolean(
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
);
return (
<>
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<Box
sx={{
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 1.5 },
alignSelf: { xs: 'center', lg: 'flex-start' },
display: 'flex',
alignItems: 'center',
}}
>
<IconButton
onClick={this.handleOpenMenu}
onContextMenu={this.handleContextMenu}
@@ -138,8 +150,14 @@ class UserMenu extends PureComponent {
anchorEl={anchorEl}
open={open}
onClose={this.handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
anchorOrigin={{
vertical: isTop ? 'bottom' : 'top',
horizontal: isTop ? 'right' : 'left',
}}
transformOrigin={{
vertical: isTop ? 'top' : 'bottom',
horizontal: isTop ? 'right' : 'left',
}}
>
{pwaUpdateAvailable && (
<MenuItem onClick={this.handleOpenPwaUpdate} sx={{ bgcolor: 'action.hover' }}>

View File

@@ -73,7 +73,7 @@ export default class ChatFab extends PureComponent {
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
left: { xs: 20, sm: 164 },
left: { xs: 20, lg: 164 },
zIndex: theme.zIndex.speedDial || 1050,
})}
>

View File

@@ -89,11 +89,13 @@ export class Attachment extends PureComponent {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
} else {
const docId = attachment.manifestId;
const docId = this.props.docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
const kind = this.props.kind || 'delivery';
const prefix = kind === 'delivery' ? 'delivery' : kind === 'quote' ? 'quote' : 'order';
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
`/api/${prefix}/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
}
@@ -109,7 +111,7 @@ export class Attachment extends PureComponent {
}
render() {
const { attachment, disabled, sx, hasArticles } = this.props;
const { attachment, disabled, sx, hasArticles, kind, onRescan } = this.props;
const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
const name = attachment?.name || 'Dokument';
const size = attachment?.size;
@@ -229,7 +231,7 @@ export class Attachment extends PureComponent {
}}
onClick={(e) => e.stopPropagation()}
>
{isPdf || isText ? (
{kind === 'delivery' && Boolean(onRescan) && (isPdf || isText) ? (
<Tooltip
title={
hasArticles
@@ -270,24 +272,20 @@ export class Attachment extends PureComponent {
</span>
</Tooltip>
) : null}
<Tooltip title="Löschen">
<span>
<IconButton
size="small"
disabled={disabled}
onClick={this.handleDelete}
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': { bgcolor: 'error.main' },
}}
>
<DeleteOutlineOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<IconButton
size="small"
disabled={disabled}
onClick={this.handleDelete}
sx={{
bgcolor: SCRIM_ACTION,
color: WHITE,
p: 0.5,
backdropFilter: 'blur(2px)',
'&:hover': { bgcolor: 'error.main' },
}}
>
<DeleteOutlineOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Bottom file metadata overlay */}

View File

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

View File

@@ -4,7 +4,7 @@ import CircularProgress from '@mui/material/CircularProgress';
import { enqueueSnackbar } from 'notistack';
import BackendError from '../BackendError';
import { PanelTileGrid } from 'dashgui/panels';
import { delivery as deliveryApi } from '../../api';
import { delivery as deliveryApi, quote as quoteApi } from '../../api';
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
@@ -17,7 +17,7 @@ import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
import entityAutomat from '../../automats/entityAutomat';
import supplierAutomat from '../../automats/supplierAutomat';
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
import { netFromGross, getArticleGross } from '../../utils/price';
import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price';
import {
buildManifestBody,
restoreAttachmentsFromApi,
@@ -30,7 +30,10 @@ import {
parseNum,
} from './manifestConfig';
import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber';
import { getManifestDraftAutomat } from '../../automats/manifestDraftAutomat';
import {
getManifestDraftAutomat,
getManifestEditAutomat,
} from '../../automats/manifestDraftAutomat';
class ManifestForm extends PureComponent {
constructor(props) {
@@ -44,21 +47,33 @@ class ManifestForm extends PureComponent {
: entityAutomat.state.status === 'ready';
this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null;
this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null;
const initialDraft = this.draftAutomat?.state;
const initialEdit = (this.editAutomat && String(this.editAutomat.state?.id) === String(props.docId))
? this.editAutomat.state
: null;
const initialDoc = initialDraft || initialEdit;
this.state = {
status: props.mode === 'edit' ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
status: props.mode === 'edit' && !initialEdit ? 'loading' : (partiesReady ? 'ready' : 'loadingParties'),
error: null,
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 || [],
date: initialDoc?.date || '',
partyId: initialDoc?.partyId ?? null,
number: initialDoc?.number || '',
externalNumber: initialDoc?.externalNumber || '',
lines: initialDoc?.lines?.length > 0 ? initialDoc.lines : [emptyLine()],
attachments: initialDoc?.attachments || [],
isDirty: Boolean(initialEdit?.isDirty),
dirtyFields: initialEdit?.dirtyFields || null,
dirtyLineKeys: initialEdit?.dirtyLineKeys || [],
dirtyLineFields: initialEdit?.dirtyLineFields || {},
dirtySummary: initialEdit?.dirtySummary || [],
saving: false,
pdfUploading: false,
pdfCreating: false,
pdfDialogOpen: false,
pdfDialogData: null,
pdfFileName: '',
@@ -67,6 +82,7 @@ class ManifestForm extends PureComponent {
};
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleRestore = this.handleRestore.bind(this);
this.handlePartyChange = this.handlePartyChange.bind(this);
this.handleDateChange = this.handleDateChange.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
@@ -80,9 +96,12 @@ class ManifestForm extends PureComponent {
this.handleCloseArticleSearch = this.handleCloseArticleSearch.bind(this);
this.handleAssignArticleForLine = this.handleAssignArticleForLine.bind(this);
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleMoveLine = this.handleMoveLine.bind(this);
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
this.handleCreatePdf = this.handleCreatePdf.bind(this);
this.handleAttachmentDelete = this.handleAttachmentDelete.bind(this);
this.handleClosePdfDialog = this.handleClosePdfDialog.bind(this);
this.handleAbortParse = this.handleAbortParse.bind(this);
}
@@ -145,6 +164,26 @@ class ManifestForm extends PureComponent {
attachments: state.attachments || [],
}));
}
if (this.editAutomat) {
this.unsubEdit = this.editAutomat.subscribe(this, (state) => {
if (String(state.id) === String(this.props.docId)) {
return {
date: state.date || '',
partyId: state.partyId,
externalNumber: state.externalNumber || '',
lines: state.lines?.length > 0 ? state.lines : [emptyLine()],
attachments: state.attachments || [],
isDirty: Boolean(state.isDirty),
dirtyFields: state.dirtyFields,
dirtyLineKeys: state.dirtyLineKeys,
dirtyLineFields: state.dirtyLineFields,
dirtySummary: state.dirtySummary,
};
}
return null;
});
}
}
componentDidUpdate(prevProps, prevState) {
@@ -172,11 +211,41 @@ class ManifestForm extends PureComponent {
});
}
}
if (
this.props.mode === 'edit' &&
this.editAutomat &&
String(this.editAutomat.state.id) === String(this.props.docId)
) {
if (
prevState.date !== this.state.date ||
prevState.partyId !== this.state.partyId ||
prevState.externalNumber !== this.state.externalNumber ||
prevState.lines !== this.state.lines
) {
this.editAutomat.actions.update({
date: this.state.date,
partyId: this.state.partyId,
externalNumber: this.state.externalNumber,
lines: this.state.lines,
});
const { isDirty, dirtyFields, dirtyLineKeys, dirtyLineFields, dirtySummary } = this.editAutomat.state;
if (
this.state.isDirty !== isDirty ||
this.state.dirtyFields !== dirtyFields ||
this.state.dirtyLineFields !== dirtyLineFields ||
this.state.dirtySummary !== dirtySummary
) {
this.setState({ isDirty, dirtyFields, dirtyLineKeys, dirtyLineFields, dirtySummary });
}
}
}
}
componentWillUnmount() {
this._cancelled = true;
this.unsubDraft?.();
this.unsubEdit?.();
this.unsubParties?.();
this.unsubCompany?.();
if (this._parseAbortController) {
@@ -221,6 +290,30 @@ class ManifestForm extends PureComponent {
return;
}
// If editAutomat already holds this exact document in memory, preserve in-progress edits
if (
this.editAutomat &&
String(this.editAutomat.state.id) === String(docId) &&
this.editAutomat.state.baseline != null
) {
void this.loadPartiesOnly();
this.setState({
status: 'ready',
isDirty: Boolean(this.editAutomat.state.isDirty),
dirtyFields: this.editAutomat.state.dirtyFields,
dirtyLineKeys: this.editAutomat.state.dirtyLineKeys,
dirtyLineFields: this.editAutomat.state.dirtyLineFields,
dirtySummary: this.editAutomat.state.dirtySummary,
date: this.editAutomat.state.date,
partyId: this.editAutomat.state.partyId,
number: this.editAutomat.state.number,
externalNumber: this.editAutomat.state.externalNumber,
lines: this.editAutomat.state.lines,
attachments: this.editAutomat.state.attachments,
});
return;
}
this.setState({ status: 'loading', error: null, partiesLoading: true });
try {
const [docRes, parties] = await Promise.all([
@@ -240,10 +333,7 @@ class ManifestForm extends PureComponent {
const apiFiles = doc.files ?? [];
const attachments = await restoreAttachmentsFromApi(apiFiles);
this.setState({
status: 'ready',
parties,
partiesLoading: false,
const loadedDoc = {
date: dateInputValue(doc.date),
partyId: doc[cfg.partyField],
number: doc.number ?? '',
@@ -251,6 +341,22 @@ class ManifestForm extends PureComponent {
doc.externalNumber != null ? String(doc.externalNumber) : '',
lines,
attachments,
};
if (this.editAutomat) {
this.editAutomat.actions.loadDoc(docId, loadedDoc);
}
this.setState({
status: 'ready',
parties,
partiesLoading: false,
...loadedDoc,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
} catch (err) {
if (this._cancelled) return;
@@ -267,6 +373,26 @@ class ManifestForm extends PureComponent {
else void this.loadPartiesOnly();
}
handleRestore() {
if (!this.editAutomat) return;
this.editAutomat.actions.restoreBaseline();
const state = this.editAutomat.state;
this.setState({
date: state.date || '',
partyId: state.partyId,
number: state.number || '',
externalNumber: state.externalNumber || '',
lines: state.lines?.length > 0 ? state.lines : [emptyLine()],
attachments: state.attachments || [],
isDirty: false,
dirtyFields: state.dirtyFields,
dirtyLineKeys: state.dirtyLineKeys,
dirtyLineFields: state.dirtyLineFields,
dirtySummary: state.dirtySummary,
});
enqueueSnackbar('Änderungen verworfen und ursprünglicher Stand wiederhergestellt.', { variant: 'info' });
}
handlePartyChange(partyId) {
const { mode } = this.props;
const { requirePartyBeforeLines } = this.cfg;
@@ -296,10 +422,11 @@ class ManifestForm extends PureComponent {
if (field === 'gross') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const rebateVal = parseNum(line.rebate) ?? 0;
const grossNum = parseNum(value);
let updatedNet = line.net;
if (grossNum != null) {
const netNum = netFromGross(grossNum, taxVal);
const netNum = netFromGross(grossNum, taxVal, rebateVal);
if (netNum != null) {
updatedNet = String(netNum);
}
@@ -316,11 +443,12 @@ class ManifestForm extends PureComponent {
if (field === 'net' || field === 'netprice') {
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const rebateVal = parseNum(line.rebate) ?? 0;
const netNum = parseNum(value);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxVal / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
const grossNum = grossFromNet(netNum, taxVal, rebateVal);
updatedGross = grossNum != null ? String(grossNum) : '';
} else if (value === '') {
updatedGross = '';
}
@@ -335,11 +463,12 @@ class ManifestForm extends PureComponent {
if (field === 'tax' || field === 'taxrate') {
const taxNum = parseNum(value) ?? 0;
const rebateVal = parseNum(line.rebate) ?? 0;
const netNum = parseNum(line.net ?? line.netprice);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100;
updatedGross = String(grossNum);
const grossNum = grossFromNet(netNum, taxNum, rebateVal);
updatedGross = grossNum != null ? String(grossNum) : '';
}
return {
...line,
@@ -350,6 +479,22 @@ class ManifestForm extends PureComponent {
};
}
if (field === 'rebate') {
const rebateNum = parseNum(value) ?? 0;
const taxVal = parseNum(line.tax ?? line.taxrate) ?? 0;
const netNum = parseNum(line.net ?? line.netprice);
let updatedGross = line.gross;
if (netNum != null) {
const grossNum = grossFromNet(netNum, taxVal, rebateNum);
updatedGross = grossNum != null ? String(grossNum) : '';
}
return {
...line,
rebate: value,
gross: updatedGross,
};
}
return {
...line,
[field]: value,
@@ -383,13 +528,15 @@ class ManifestForm extends PureComponent {
: '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const rebateNum = parseNum(l.rebate) ?? 0;
const artGross = getArticleGross(art, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
if (artGross != null && rebateNum === 0) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
grossVal = String(grossFromNet(netNum, taxNum ?? 0, rebateNum));
} else if (artGross != null) {
grossVal = String(Math.round((artGross * (1 - rebateNum / 100) + Number.EPSILON) * 100) / 100);
}
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
const finalTax =
@@ -515,13 +662,15 @@ class ManifestForm extends PureComponent {
: normTax;
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const rebateNum = parseNum(targetLine.rebate) ?? 0;
const artGross = getArticleGross(article, taxNum ?? 0);
let grossVal = '';
if (artGross != null) {
if (artGross != null && rebateNum === 0) {
grossVal = String(artGross);
} else if (netNum != null) {
const g = Math.round((netNum * (1 + (taxNum ?? 0) / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
grossVal = String(grossFromNet(netNum, taxNum ?? 0, rebateNum));
} else if (artGross != null) {
grossVal = String(Math.round((artGross * (1 - rebateNum / 100) + Number.EPSILON) * 100) / 100);
}
this.setState((prev) => ({
@@ -553,6 +702,19 @@ class ManifestForm extends PureComponent {
});
}
handleMoveLine(key, delta) {
this.setState((prev) => {
const fromIndex = prev.lines.findIndex((l) => l.key === key);
if (fromIndex < 0) return null;
const toIndex = fromIndex + delta;
if (toIndex < 0 || toIndex >= prev.lines.length) return null;
const next = [...prev.lines];
const [item] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, item);
return { lines: next, isDirty: true };
});
}
handleRemoveAllLines() {
this.setState({ lines: [] });
}
@@ -682,6 +844,69 @@ class ManifestForm extends PureComponent {
this.setState({ pdfDialogOpen: false, pdfUploading: false });
}
async handleCreatePdf(e) {
e?.preventDefault?.();
e?.stopPropagation?.();
const { docId } = this.props;
if (this.state.pdfCreating || this.state.saving) return;
if (docId == null) return;
this.setState({ pdfCreating: true });
try {
const res = await quoteApi.createPdf(docId);
requireObject(res, 'POST /api/quote/:id/pdf');
const quoteRes = await quoteApi.get(docId);
if (quoteRes?.quote?.files && !this._cancelled) {
const attachments = await restoreAttachmentsFromApi(
quoteRes.quote.files,
this.state.attachments,
);
this.setState({ attachments });
if (this.editAutomat) {
this.editAutomat.actions.setAttachments?.(attachments);
}
}
enqueueSnackbar('Angebot-PDF erfolgreich erstellt.', { variant: 'success' });
} catch (err) {
if (!this._cancelled) {
notifyError(err);
}
} finally {
if (!this._cancelled) {
this.setState({ pdfCreating: false });
}
}
}
async handleAttachmentDelete(attachment) {
if (!attachment) return;
const fileId = attachment.fileId ?? attachment.id;
const { docId } = this.props;
const nextAttachments = (this.state.attachments || []).filter(
(a) => (a.fileId ?? a.id) !== fileId && a !== attachment,
);
this.setState({ attachments: nextAttachments });
if (this.editAutomat) {
this.editAutomat.actions.setAttachments?.(nextAttachments);
}
if (docId != null && fileId != null && attachment.isPersisted) {
try {
const api = apiFor(this.cfg);
if (typeof api.deleteFile === 'function') {
await api.deleteFile(docId, fileId);
enqueueSnackbar('Anhang gelöscht.', { variant: 'success' });
}
} catch (err) {
notifyError(err);
}
}
}
async handleSave() {
const { mode, docId } = this.props;
const cfg = this.cfg;
@@ -736,7 +961,7 @@ class ManifestForm extends PureComponent {
if (this._cancelled) return;
const doc = data[cfg.docField];
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
this.setState({
const savedDoc = {
date: dateInputValue(doc.date),
partyId: doc[cfg.partyField],
number: doc.number ?? '',
@@ -744,6 +969,20 @@ class ManifestForm extends PureComponent {
doc.externalNumber != null ? String(doc.externalNumber) : '',
lines:
apiLines.length > 0 ? apiLines.map(lineFromApi) : [emptyLine()],
attachments: this.state.attachments,
};
if (this.editAutomat && String(this.editAutomat.state.id) === String(docId)) {
this.editAutomat.actions.commitSaved(savedDoc);
}
this.setState({
...savedDoc,
isDirty: false,
dirtyFields: null,
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
saving: false,
});
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
@@ -756,7 +995,7 @@ class ManifestForm extends PureComponent {
renderArticles() {
const cfg = this.cfg;
const { lines, saving } = this.state;
const { lines, saving, dirtyLineFields } = this.state;
return (
<ManifestLines
@@ -766,12 +1005,14 @@ class ManifestForm extends PureComponent {
saving={saving}
wide={4}
linesColor={cfg.linesColor}
dirtyLineFields={dirtyLineFields || {}}
onLineChange={this.handleLineChange}
onSkuBlur={this.handleSkuBlur}
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onSearchArticle={this.handleOpenArticleSearchForLine}
onRemoveLine={this.handleRemoveLine}
onMoveLine={this.handleMoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
/>
@@ -826,8 +1067,17 @@ class ManifestForm extends PureComponent {
externalNumber={externalNumber}
saving={saving}
pdfUploading={pdfUploading}
pdfCreating={this.state.pdfCreating}
lines={lines}
attachments={this.state.attachments}
docId={this.props.docId}
isDirty={this.state.isDirty}
dirtyFields={this.state.dirtyFields}
dirtySummary={this.state.dirtySummary}
onRestore={this.props.mode === 'edit' && this.state.isDirty ? this.handleRestore : undefined}
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
onPdfDelete={this.handleAttachmentDelete}
onPdfCreate={kind === 'quote' ? this.handleCreatePdf : undefined}
onSave={this.handleSave}
onPartyChange={this.handlePartyChange}
onDateChange={this.handleDateChange}

View File

@@ -11,6 +11,8 @@ import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
import HelpOutlineIcon from '@mui/icons-material/HelpOutlineOutlined';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
@@ -19,11 +21,13 @@ import SearchIcon from '@mui/icons-material/Search';
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
import { getFirstVal, parseNum, LINE_COLUMNS } from './manifestConfig';
import ArticlePhoto from '../article/ArticlePhoto';
import { formatPrice } from '../../utils/price';
import { formatPrice, grossFromNet } from '../../utils/price';
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import { artikelDetailPath } from '../article/articleRoute';
import { BrandLabel } from '../brand';
import { articleSupplierAutomat } from '../../automats/articleDetailAutomat';
import { selectCompanyTaxrate } from '../../automats/companyAutomat';
import {
BORDER,
} from '../../colors';
@@ -35,8 +39,11 @@ class ManifestFormLine extends PureComponent {
this.state = {
cachedArticle: sku ? getArticle(sku) : null,
loadingArticle: false,
fallbackSuppliers: [],
};
this.handleRemove = this.handleRemove.bind(this);
this.handleMoveUp = this.handleMoveUp.bind(this);
this.handleMoveDown = this.handleMoveDown.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleCreateArticle = this.handleCreateArticle.bind(this);
this.handleSearchArticle = this.handleSearchArticle.bind(this);
@@ -64,14 +71,18 @@ class ManifestFormLine extends PureComponent {
componentWillUnmount() {
this.unsubArticle?.();
this.unsubSupplier?.();
}
subscribeArticle(sku) {
this.unsubArticle?.();
this.unsubArticle = null;
this.unsubSupplier?.();
this.unsubSupplier = null;
if (!sku) {
if (this.state.cachedArticle || this.state.loadingArticle) {
this.setState({ cachedArticle: null, loadingArticle: false });
if (this.state.cachedArticle || this.state.loadingArticle || this.state.fallbackSuppliers?.length) {
this.setState({ cachedArticle: null, loadingArticle: false, fallbackSuppliers: [] });
}
return;
}
@@ -99,12 +110,29 @@ class ManifestFormLine extends PureComponent {
...(art ? { loadingArticle: false } : {}),
};
});
// Fallback: subscribe to articleSupplierAutomat if cachedArticle doesn't have suppliers
if (!current?.suppliers?.length) {
const supKey = articleSupplierAutomat.key(sku);
this.unsubSupplier = supKey.subscribe(this, (item) => ({
fallbackSuppliers: Array.isArray(item?.data) ? item.data : [],
}));
void articleSupplierAutomat.load(sku).catch(() => {});
}
}
handleRemove() {
this.props.onRemove(this.props.line.key);
}
handleMoveUp() {
this.props.onMove?.(this.props.line.key, -1);
}
handleMoveDown() {
this.props.onMove?.(this.props.line.key, 1);
}
handleBlur(e, columnId) {
if (columnId === 'sku' && this.props.onSkuBlur) {
this.props.onSkuBlur(this.props.line.key, e.target.value);
@@ -235,8 +263,9 @@ class ManifestFormLine extends PureComponent {
} else if (id === 'gross') {
const netNum = parseNum(line.netprice ?? line.net);
const taxNum = parseNum(line.taxrate ?? line.tax) ?? 0;
const rebateNum = parseNum(line.rebate) ?? 0;
if (netNum != null) {
val = String(Math.round((netNum * (1 + taxNum / 100) + Number.EPSILON) * 100) / 100);
val = String(grossFromNet(netNum, taxNum, rebateNum));
}
}
}
@@ -267,6 +296,11 @@ class ManifestFormLine extends PureComponent {
const isFirstInput = hasPosCol ? colIdx === 1 : colIdx === 0;
const isLast = colIdx === totalCols - 1;
const { dirtyFields } = this.props;
const isFieldDirty = Boolean(
dirtyFields && (dirtyFields.__all__ || dirtyFields[id])
);
return (
<TextField
size="small"
@@ -290,6 +324,9 @@ class ManifestFormLine extends PureComponent {
'& .MuiOutlinedInput-root': {
borderRadius: isFirstInput ? '4px 0 0 4px' : isLast ? '0 4px 4px 0' : 0,
ml: isFirstInput ? 0 : '-1px',
...(isFieldDirty && {
bgcolor: 'rgba(237, 108, 2, 0.08)',
}),
'&:hover': {
zIndex: 1,
},
@@ -297,6 +334,16 @@ class ManifestFormLine extends PureComponent {
zIndex: 2,
},
},
...(isFieldDirty && {
zIndex: 1,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
},
}),
'& input[type=number]': {
MozAppearance: 'textfield',
},
@@ -357,6 +404,41 @@ class ManifestFormLine extends PureComponent {
: null)
: null;
const suppliers =
Array.isArray(cachedArticle.suppliers) && cachedArticle.suppliers.length > 0
? cachedArticle.suppliers
: (this.state.fallbackSuppliers ?? []);
const topSupplier =
suppliers.find((s) => s.sort === 0) ??
(suppliers.length > 0
? [...suppliers].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0))[0]
: null);
const ekNetto =
topSupplier?.price?.net != null
? Number(topSupplier.price.net)
: topSupplier?.netPrice != null
? Number(topSupplier.netPrice)
: topSupplier?.net != null
? Number(topSupplier.net)
: null;
const ekTaxRate =
topSupplier?.price?.taxRate != null
? Number(topSupplier.price.taxRate)
: topSupplier?.taxRate != null
? Number(topSupplier.taxRate)
: parseNum(this.props.line?.taxrate ?? this.props.line?.tax) ?? selectCompanyTaxrate() ?? 19;
const ekBrutto =
ekNetto != null
? Math.round((ekNetto * (1 + Number(ekTaxRate) / 100) + Number.EPSILON) * 100) / 100
: null;
const hasPriceInfo =
vkNetto != null || vkBrutto != null || ekNetto != null || ekBrutto != null || kind === 'quote';
return (
<Box
sx={{
@@ -462,8 +544,8 @@ class ManifestFormLine extends PureComponent {
</Typography>
)}
{/* VK Netto & VK Brutto: omit end on 2nd line for order */}
{!isOrder && (vkNetto != null || vkBrutto != null) && (
{/* EK Netto, EK Brutto, VK Netto, VK Brutto */}
{!isOrder && hasPriceInfo && (
<Box
sx={{
display: 'flex',
@@ -473,6 +555,47 @@ class ManifestFormLine extends PureComponent {
whiteSpace: 'nowrap',
}}
>
{/* EK Netto */}
<Tooltip
title={
topSupplier?.supplierName
? `Lieferant: ${topSupplier.supplierName}${topSupplier.sku ? ` (Lief.-Art.-Nr.: ${topSupplier.sku})` : ''} (Sort: ${topSupplier.sort ?? 0})`
: 'Kein Lieferant mit EK-Preis hinterlegt'
}
>
<Typography
variant="caption"
fontWeight={600}
sx={{
color: ekNetto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Netto: {ekNetto != null ? formatPrice(ekNetto) : '—'}
</Typography>
</Tooltip>
{/* EK Brutto */}
<Tooltip
title={
topSupplier?.supplierName
? `Lieferant: ${topSupplier.supplierName} (inkl. ${ekTaxRate}% MwSt.)`
: 'Kein Lieferant mit EK-Preis hinterlegt'
}
>
<Typography
variant="caption"
fontWeight={600}
sx={{
color: ekBrutto != null ? 'text.secondary' : 'text.disabled',
fontSize: '0.72rem',
}}
>
EK Brutto: {ekBrutto != null ? formatPrice(ekBrutto) : '—'}
</Typography>
</Tooltip>
{/* VK Netto */}
{vkNetto != null && (
<Typography
variant="caption"
@@ -482,6 +605,8 @@ class ManifestFormLine extends PureComponent {
VK Netto: {formatPrice(vkNetto)}
</Typography>
)}
{/* VK Brutto */}
{vkBrutto != null && (
<Typography
variant="caption"
@@ -849,20 +974,71 @@ class ManifestFormLine extends PureComponent {
<TableCell
key={col.id}
align={col.align}
sx={{ p: 0, width: col.width, verticalAlign: 'middle' }}
sx={{
p: 0,
width: col.width,
verticalAlign: 'middle',
}}
>
{this.renderField(col, colIdx, columns.length)}
</TableCell>
))}
<TableCell padding="none" sx={{ py: 0.25, pl: 0.75, pr: 0, verticalAlign: 'middle', width: 40 }}>
<IconButton
size="small"
aria-label="Zeile entfernen"
disabled={saving}
onClick={this.handleRemove}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
<TableCell
padding="none"
sx={{
py: 0.25,
pl: 0.5,
pr: 0.5,
verticalAlign: 'middle',
width: this.props.onMove ? 92 : 40,
whiteSpace: 'nowrap',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{this.props.onMove && (
<>
<Tooltip title="Nach oben">
<span>
<IconButton
size="small"
aria-label="Nach oben verschieben"
disabled={saving || this.props.isFirstLine}
onClick={this.handleMoveUp}
sx={{ p: 0.35 }}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Nach unten">
<span>
<IconButton
size="small"
aria-label="Nach unten verschieben"
disabled={saving || this.props.isLastLine}
onClick={this.handleMoveDown}
sx={{ p: 0.35 }}
>
<ArrowDownwardIcon sx={{ fontSize: 16 }} />
</IconButton>
</span>
</Tooltip>
</>
)}
<Tooltip title="Zeile entfernen">
<span>
<IconButton
size="small"
aria-label="Zeile entfernen"
disabled={saving}
onClick={this.handleRemove}
sx={{ p: 0.35 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
</Box>
</TableCell>
</TableRow>

View File

@@ -7,11 +7,13 @@ import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Chip from '@mui/material/Chip';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
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 RestoreIcon from '@mui/icons-material/Restore';
import FileDropzone from '../FileDropzone';
import AttachmentList from './AttachmentList';
import { hasArticleLines, manifestConfig, partyDisplayName } from './manifestConfig';
@@ -224,6 +226,8 @@ class ManifestHeader extends PureComponent {
pdfUploading,
attachments,
onPdfUpload,
onPdfCreate,
pdfCreating,
onPdfRescan,
onPdfDelete,
onPdfClick,
@@ -232,10 +236,14 @@ class ManifestHeader extends PureComponent {
onNumberChange,
onExternalNumberChange,
onSave,
onRestore,
actionsColor,
docId,
ledgerSummary,
onOpenBookDialog,
isDirty,
dirtyFields,
dirtySummary,
} = this.props;
const hasArticles = this.hasArticles();
@@ -250,7 +258,7 @@ class ManifestHeader extends PureComponent {
(this.state.createdParty?.id === partyId ? this.state.createdParty : null);
const isDateEmpty = !date || String(date).trim() === '';
const isPartyEmpty = partyId == null || partyId === '';
const isPartyEmpty = kind !== 'quote' && (partyId == null || partyId === '');
return (
<>
@@ -267,7 +275,7 @@ class ManifestHeader extends PureComponent {
<TextField
size="small"
type="date"
label={dateLabel}
label={dirtyFields?.date ? `${dateLabel} *` : dateLabel}
value={date}
error={isDateEmpty}
disabled={saving || pdfUploading}
@@ -279,6 +287,12 @@ class ManifestHeader extends PureComponent {
sx={{
flex: { xs: '1 1 100%', sm: '1 1 150px' },
minWidth: { sm: 130 },
...(dirtyFields?.date && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
<Autocomplete
@@ -328,7 +342,7 @@ class ManifestHeader extends PureComponent {
renderInput={(params) => (
<TextField
{...params}
label={partyLabel}
label={dirtyFields?.partyId ? `${partyLabel} *` : partyLabel}
error={isPartyEmpty}
onKeyDown={this.handlePartyKeyDown}
slotProps={{
@@ -338,6 +352,14 @@ class ManifestHeader extends PureComponent {
autoComplete: 'off',
},
}}
sx={{
...(dirtyFields?.partyId && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
)}
/>
@@ -355,13 +377,19 @@ class ManifestHeader extends PureComponent {
{useExternalNumber ? (
<SingleLineTextEdit
size="small"
label="Referenz"
label={dirtyFields?.externalNumber ? 'Referenz *' : 'Referenz'}
value={externalNumber ?? ''}
disabled={saving || pdfUploading}
onChange={onExternalNumberChange}
sx={{
flex: { xs: '1 1 100%', sm: '1 1 120px' },
minWidth: { sm: 100 },
...(dirtyFields?.externalNumber && {
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'warning.main',
borderWidth: 2,
},
}),
}}
/>
) : null}
@@ -403,6 +431,8 @@ class ManifestHeader extends PureComponent {
)}
</Box>
<AttachmentList
kind={kind}
docId={docId}
attachments={attachments}
onRescan={onPdfRescan}
onDelete={onPdfDelete}
@@ -424,7 +454,7 @@ class ManifestHeader extends PureComponent {
<Stack spacing={1.5} sx={{ pt: 0.5 }}>
<Button
variant="contained"
color="primary"
color={isDirty ? 'warning' : 'primary'}
disabled={saving}
onClick={onSave}
sx={{
@@ -432,10 +462,34 @@ class ManifestHeader extends PureComponent {
fontWeight: 700,
fontSize: '0.875rem',
whiteSpace: 'nowrap',
...(isDirty && {
boxShadow: (theme) => `0 0 0 2px ${theme.palette.warning.main}`,
}),
}}
>
{saving ? 'Speichern…' : 'Speichern'}
{saving ? 'Speichern…' : isDirty ? 'Speichern *' : 'Speichern'}
</Button>
{isDirty && onRestore && (
<Button
variant="outlined"
color="warning"
size="small"
startIcon={<RestoreIcon />}
disabled={saving}
onClick={onRestore}
sx={{
height: 34,
fontWeight: 700,
fontSize: '0.8rem',
whiteSpace: 'nowrap',
textTransform: 'none',
borderWidth: 1.5,
'&:hover': { borderWidth: 1.5 },
}}
>
Wiederherstellen
</Button>
)}
{docId != null && kind === 'delivery' && onOpenBookDialog && (
<Button
variant={ledgerSummary?.status === 'booked' ? 'outlined' : 'contained'}
@@ -469,6 +523,27 @@ class ManifestHeader extends PureComponent {
: 'Ins Lager buchen'}
</Button>
)}
{kind === 'quote' && onPdfCreate && docId != null && Boolean(number) && (
<Button
type="button"
variant="outlined"
startIcon={pdfCreating ? <CircularProgress size={16} color="inherit" /> : <PictureAsPdfIcon />}
disabled={saving || pdfCreating}
onClick={onPdfCreate}
title="PDF erstellen"
sx={{
whiteSpace: 'nowrap',
height: 40,
width: '100%',
px: 1.5,
boxSizing: 'border-box',
fontWeight: 600,
fontSize: '0.875rem',
}}
>
{pdfCreating ? 'Wird erstellt…' : 'PDF erstellen'}
</Button>
)}
{onPdfUpload ? (
<FileDropzone
accept="application/pdf,.pdf,text/plain,.txt,text/csv,.csv"

View File

@@ -24,9 +24,11 @@ class ManifestLines extends PureComponent {
lines,
saving,
linesColor,
dirtyLineFields = {},
onLineChange,
onAddLine,
onRemoveLine,
onMoveLine,
onRemoveAllLines,
columns = LINE_COLUMNS,
supplierArticlesMap = {},
@@ -70,9 +72,9 @@ class ManifestLines extends PureComponent {
...(wide >= 4 ? { gridColumn: { xs: '1 / -1', sm: '1 / -1', md: 'span 4' } } : {}),
...(!hasArticles
? {
border: '1.5px solid',
borderColor: 'error.main',
}
border: '1.5px solid',
borderColor: 'error.main',
}
: {}),
}}
>
@@ -97,7 +99,7 @@ class ManifestLines extends PureComponent {
{col.label}
</TableCell>
))}
<TableCell padding="none" sx={{ bgcolor: linesColor, borderBottom: 'none', width: 40 }} />
<TableCell padding="none" sx={{ bgcolor: linesColor, borderBottom: 'none', width: onMoveLine ? 92 : 40 }} />
</TableRow>
</TableHead>
<TableBody>
@@ -108,13 +110,19 @@ class ManifestLines extends PureComponent {
const isDelivery = kind === 'delivery';
const matched = isDelivery
? supplierArticlesByLineKey[lineKey] ||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null)
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null)
: null;
const isLineLoading = isDelivery
? loadingLineKeys[lineKey] ||
(supplierArticlesLoading && !matched)
(supplierArticlesLoading && !matched)
: false;
const ledgerLine = line.id != null ? ledgerLinesById[line.id] : null;
const lineDirtyFields =
dirtyLineFields[lineKey] ||
(line.key != null ? dirtyLineFields[line.key] : null) ||
(line.id != null ? dirtyLineFields[`id-${line.id}`] : null) ||
dirtyLineFields[`idx-${index}`] ||
null;
return (
<ManifestFormLine
@@ -125,11 +133,15 @@ class ManifestLines extends PureComponent {
line={line.key != null ? line : { ...line, key: index }}
columns={columns}
saving={saving}
dirtyFields={lineDirtyFields}
supplierArticle={matched}
loading={Boolean(isLineLoading)}
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
onChange={onLineChange}
onRemove={onRemoveLine}
onMove={onMoveLine}
isFirstLine={index === 0}
isLastLine={index === lines.length - 1}
onSkuBlur={onSkuBlur}
onCreateArticle={onCreateArticle}
onSearchArticle={onSearchArticle}
@@ -163,7 +175,6 @@ class ManifestLines extends PureComponent {
)}
<Button
size="small"
color="error"
startIcon={<DeleteSweepOutlinedIcon />}
disabled={saving || !lines || lines.length === 0}
onClick={onRemoveAllLines}

View File

@@ -0,0 +1,553 @@
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import ListItemButton from '@mui/material/ListItemButton';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import Tooltip from '@mui/material/Tooltip';
import 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';
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
import BackendError from '../BackendError';
import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat';
import { quote as quoteApi } from '../../api';
import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify';
import { formatPrice } from '../../utils/price';
import withNavigate from '../withNavigate';
import {
formatDate,
loadParties,
manifestConfig,
numberDisplay,
partyDisplayName,
} from './manifestConfig';
// MARK: pure helpers
function quoteGrossTotal(lines) {
if (!Array.isArray(lines)) return 0;
return lines.reduce((acc, line) => {
const net = (Number(line.amount) || 0) * (Number(line.net) || 0) * (1 - (Number(line.rebate) || 0) / 100);
return acc + net * (1 + (Number(line.taxrate) || 0) / 100);
}, 0);
}
const cfg = manifestConfig('quote');
const rowSx = {
alignItems: 'center',
height: 64,
minHeight: 64,
maxHeight: 64,
boxSizing: 'border-box',
py: 0,
gap: 1.5,
};
const ellipsisSx = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
/**
* Ambient context for `QuoteRow`: FillSpace renders rows as bare elements
* (`<Row item index />`), so the list publishes callbacks and the party map
* here on every render.
*/
const rowContext = {
partiesById: {},
busyId: null,
onOpen: null,
onDelete: null,
};
// MARK: <QuoteRow/>
class QuoteRow extends PureComponent {
partyName(partyId) {
if (partyId == null) return '–';
const party = rowContext.partiesById[partyId];
return partyDisplayName('quote', party) || `Kunde #${partyId}`;
}
render() {
const { item: quote, disabled } = this.props;
// Data-free probe (FillSpace measures the row height with `item` undefined).
if (!quote) {
if (disabled) {
return (
<ListItemButton disabled divider sx={{ ...rowSx, opacity: 0, pointerEvents: 'none' }}>
<Typography variant="body2">&nbsp;</Typography>
</ListItemButton>
);
}
return (
<ListItemButton disabled divider sx={rowSx}>
<Box sx={{ width: 120, flexShrink: 0 }}>
<Skeleton variant="text" width={80} height={20} />
<Skeleton variant="text" width={50} height={16} />
</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>
<Skeleton variant="text" width={70} height={20} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Skeleton variant="text" width="60%" height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0, display: 'flex', justifyContent: 'center' }}>
<Skeleton variant="rounded" width={28} height={20} />
</Box>
<Box sx={{ width: 96, flexShrink: 0, display: 'flex', justifyContent: 'flex-end' }}>
<Skeleton variant="text" width={60} height={20} />
</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</ListItemButton>
);
}
const { busyId, onOpen, onDelete } = rowContext;
const posCount = quote.positions != null ? quote.positions : (quote.lines?.length ?? 0);
const gross = quote.grossTotal != null ? Number(quote.grossTotal) : quoteGrossTotal(quote.lines);
const customer = quote.customerName || this.partyName(quote.entityId);
const quantityInfo = quote.totalQuantity != null ? ` · ${quote.totalQuantity} Stück` : '';
return (
<ListItemButton divider sx={rowSx} onClick={() => onOpen?.(quote)}>
<Box sx={{ width: 120, flexShrink: 0, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 600, ...ellipsisSx }}>
{quote.number}
</Typography>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{numberDisplay(quote.externalNumber)}
</Typography>
</Box>
<Box sx={{ width: 100, flexShrink: 0, minWidth: 0 }}>
<Typography variant="body2" color="text.secondary" sx={ellipsisSx}>
{quote.date ? formatDate(quote.date) : '–'}
</Typography>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body1" sx={{ fontWeight: 500, ...ellipsisSx }}>
{customer}
</Typography>
</Box>
<Chip
size="small"
label={posCount}
title={`${posCount} Positionen${quantityInfo}`}
sx={{
width: 40,
flexShrink: 0,
height: 24,
fontSize: '0.75rem',
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
bgcolor: 'action.hover',
color: posCount > 0 ? 'text.primary' : 'text.disabled',
'& .MuiChip-label': { px: 0.5 },
}}
/>
<Typography
variant="body2"
sx={{ width: 96, flexShrink: 0, textAlign: 'right', fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}
>
{gross > 0 ? formatPrice(gross) : '—'}
</Typography>
<IconButton
aria-label={`„${quote.number}“ löschen`}
disabled={busyId != null}
onClick={(e) => {
e.stopPropagation();
onDelete?.(quote);
}}
onMouseDown={(e) => e.stopPropagation()}
sx={{ width: 40, height: 40, flexShrink: 0 }}
>
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
</IconButton>
</ListItemButton>
);
}
}
// MARK: <QuoteSearchField/>
class QuoteSearchField extends PureComponent {
constructor(props) {
super(props);
const s = quoteAutomat.state;
const stats = quoteAutomat.getChunkStats?.();
this.state = {
input: s.filter ?? '',
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,
};
}
componentDidMount() {
this.unsub = quoteAutomat.subscribe(this, (state) => ({
input: state.filter ?? '',
seekMonths: state.seekMonths ?? 0,
seekYears: state.seekYears ?? 0,
negativeSeekMonths: state.negativeSeekMonths ?? 0,
}));
if (import.meta.env.DEV) {
this.unsubStats = quoteAutomat.subscribeChunkStats?.((stats) => {
this.setState({
lruChunksCount: stats.lruChunksCount,
maxLruChunks: stats.maxLruChunks,
filterCachesCount: stats.filterCaches?.length || 1,
});
});
}
}
componentWillUnmount() {
this.unsub?.();
this.unsubStats?.();
}
render() {
const { inputRef, onCreate } = this.props;
const { input, seekMonths, seekYears } = this.state;
const monthActive = seekMonths > 0;
const monthLabel = !monthActive ? '-1 Month' : `-${seekMonths} Month${seekMonths > 1 ? 's' : ''}`;
const yearActive = seekYears > 0;
const yearLabel = !yearActive ? '-1 Year' : `-${seekYears} Year${seekYears > 1 ? 's' : ''}`;
return (
<PanelTileGrid>
<Box
ref={inputRef}
sx={{
gridColumn: { xs: '1 / -1', sm: 'span 2', md: 'span 2' },
display: 'flex',
flexDirection: 'column',
gap: 1.25,
pb: 2,
}}
>
{/* Top row: search field & create button */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<SingleLineTextEdit
sx={{ flex: 1 }}
size="small"
search
clear
value={input}
onChange={(next) => quoteAutomat.actions.filter(next)}
/>
<Button variant="contained" onClick={onCreate}>
{cfg.createButton}
</Button>
</Box>
{/* Filter Bar: Negativeseek (-1 Month, -1 Year, Reset) & Chunk-Cache Monitor */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{/* Negativeseek controls (-1 Month, -1 Year, Reset) */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.75,
px: 1.25,
py: 0.25,
borderRadius: 2,
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
<Button
size="small"
variant={monthActive ? 'contained' : 'outlined'}
color={monthActive ? 'primary' : 'inherit'}
onClick={() => quoteAutomat.actions.addSeekMonth()}
sx={{
minWidth: 88,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: monthActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{monthLabel}
</Button>
<Button
size="small"
variant={yearActive ? 'contained' : 'outlined'}
color={yearActive ? 'primary' : 'inherit'}
onClick={() => quoteAutomat.actions.addSeekYear()}
sx={{
minWidth: 78,
px: 1.25,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
boxSizing: 'border-box',
border: '1px solid',
borderColor: yearActive ? 'primary.main' : 'divider',
whiteSpace: 'nowrap',
}}
>
{yearLabel}
</Button>
<Button
size="small"
variant="text"
color="inherit"
disabled={!monthActive && !yearActive}
onClick={() => quoteAutomat.actions.resetNegativeSeek()}
sx={{
minWidth: 'auto',
px: 1,
py: 0.25,
height: 28,
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'none',
}}
>
Reset
</Button>
</Box>
{import.meta.env.DEV && (
<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', { detail: { target: 'quote' } }))}
sx={{
cursor: 'pointer',
height: 28,
fontWeight: 600,
fontSize: '0.75rem',
bgcolor: 'background.paper',
boxShadow: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
/>
</Tooltip>
)}
</Box>
</Box>
</Box>
</PanelTileGrid>
);
}
}
// MARK: <QuoteList/>
export class QuoteList extends PureComponent {
constructor(props) {
super(props);
this.state = {
items: quoteAutomat.getItems(),
unfilteredTotal: quoteAutomat.state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey?.() ?? '',
partiesById: {},
busyId: null,
};
this.searchRef = createRef();
this._cancelled = false;
this.handleRetry = this.handleRetry.bind(this);
this.handleCreate = this.handleCreate.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleDelete = this.handleDelete.bind(this);
this.handlePageChange = this.handlePageChange.bind(this);
}
componentDidMount() {
this._cancelled = false;
this.unsub = quoteAutomat.subscribe(this, (state) => ({
items: state.items,
unfilteredTotal: state.unfilteredTotal ?? 0,
filterKey: quoteAutomat.getFilterKey(state),
}));
void this.loadParties();
}
componentWillUnmount() {
this._cancelled = true;
this.unsub?.();
}
async loadParties() {
try {
const parties = await loadParties('quote');
if (!this._cancelled) {
this.setState({ partiesById: cfg.partiesByIdFromList(parties) });
}
} catch {
// Non-fatal: names fall back to ids or customerName.
}
}
handlePageChange(page, meta) {
const pageSize = meta?.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const items = this.state.items;
const maxIndex = items ? items.length - 1 : startIndex;
const endIndex = Math.min(maxIndex, startIndex + pageSize - 1);
const startChunk = Math.floor(startIndex / CHUNK_SIZE);
const endChunk = Math.floor(endIndex / CHUNK_SIZE);
quoteAutomat.requireChunk(startChunk, endChunk);
}
handleRetry() {
void quoteAutomat.actions.requireChunk(0, 0, { immediate: true }).catch(() => {});
}
handleCreate() {
this.props.navigate(cfg.createPath);
}
handleOpen(quote) {
this.props.navigate(cfg.editPath(quote.id));
}
async handleDelete(quote) {
if (this.state.busyId != null) return;
if (!window.confirm(cfg.confirmDelete(quote.number))) return;
this.setState({ busyId: quote.id });
try {
await quoteApi.delete(quote.id);
await quoteAutomat.actions.reload();
enqueueSnackbar(cfg.deletedMsg, { variant: 'success' });
} catch (err) {
notifyError(err);
} finally {
if (!this._cancelled) this.setState({ busyId: null });
}
}
render() {
const { items, unfilteredTotal, filterKey, partiesById, busyId } = this.state;
rowContext.partiesById = partiesById;
rowContext.busyId = busyId;
rowContext.onOpen = this.handleOpen;
rowContext.onDelete = this.handleDelete;
if (items === null && quoteAutomat.state.status === 'failed') {
return (
<>
<QuoteSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ p: 2 }}>
<BackendError error={backendErrorFrom(quoteAutomat.state.error)} onRetry={this.handleRetry} />
</PanelTile>
</PanelTileGrid>
</>
);
}
return (
<>
<QuoteSearchField inputRef={this.searchRef} onCreate={this.handleCreate} />
<PanelTileGrid>
<PanelTile wide={3} minHeight={0} color={cfg.listColor} sx={{ pb: 0, position: 'relative' }}>
<FillSpace
aboveRef={[this.searchRef]}
reserveBottom={32}
minPageSize={8}
minHeight={0}
resetKey={filterKey}
items={items || []}
totalCount={items ? items.length : 0}
unfilteredTotal={unfilteredTotal}
renderItem={QuoteRow}
virtualScrollbar
header={
<Box
sx={{
display: 'flex',
alignItems: 'center',
height: 36,
minHeight: 36,
px: 2,
gap: 1.5,
borderBottom: '1px solid',
borderColor: 'divider',
color: 'text.secondary',
typography: 'caption',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
userSelect: 'none',
}}
>
<Box sx={{ width: 120, flexShrink: 0 }}>Nr. / Ref.</Box>
<Box sx={{ width: 100, flexShrink: 0 }}>Datum</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>Kunde</Box>
<Box sx={{ width: 40, flexShrink: 0, textAlign: 'center' }}>Pos.</Box>
<Box sx={{ width: 96, flexShrink: 0, textAlign: 'right' }}>Gesamt</Box>
<Box sx={{ width: 40, flexShrink: 0 }} />
</Box>
}
onPageChange={this.handlePageChange}
/>
{items === null && (
<Box
sx={{
position: 'absolute',
top: 52,
left: 0,
right: 0,
bottom: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 2,
}}
>
<CircularProgress size={32} />
</Box>
)}
</PanelTile>
</PanelTileGrid>
</>
);
}
}
export default withNavigate(QuoteList);

View File

@@ -1,6 +1,7 @@
export { default as ManifestList } from './ManifestList';
export { default as DeliveryList } from './DeliveryList';
export { default as OrderList } from './OrderList';
export { default as QuoteList } from './QuoteList';
export { default as ShipmentList } from './ShipmentList';
export { default as OrderPaymentList } from './OrderPaymentList';

View File

@@ -107,12 +107,13 @@ export function lineFromApi(row) {
const taxVal = row.taxrate != null ? String(row.taxrate) : row.tax != null ? String(row.tax) : '';
const netNum = parseNum(netVal);
const taxNum = parseNum(taxVal);
const rebateNum = parseNum(row.rebate) ?? 0;
let grossVal = '';
if (row.gross != null && row.gross !== '') {
grossVal = String(row.gross);
} else if (netNum != null) {
const rate = taxNum ?? 0;
const g = Math.round((netNum * (1 + rate / 100) + Number.EPSILON) * 100) / 100;
const g = Math.round((netNum * (1 + rate / 100) * (1 - rebateNum / 100) + Number.EPSILON) * 100) / 100;
grossVal = String(g);
}
return {
@@ -175,10 +176,10 @@ export function buildLines(lines, { requireAtLeastOne }) {
const tax = parseNum(rawTax);
const rawGross = getFirstVal(line.gross);
const gross = parseNum(rawGross);
if (net == null && gross != null && tax != null) {
net = netFromGross(gross, tax);
}
const rebate = parseNum(getFirstVal(line.rebate));
if (net == null && gross != null && tax != null) {
net = netFromGross(gross, tax, rebate ?? 0);
}
const rawPu = getFirstVal(line.pu, line.vpe);
const pu = parseNum(rawPu);
const blank = !sku && !name && net == null && gross == null;
@@ -503,6 +504,7 @@ export async function buildManifestBody(
}
export const LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ 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' },
@@ -526,6 +528,7 @@ export const ORDER_LINE_COLUMNS = [
];
export const DELIVERY_LINE_COLUMNS = [
{ id: 'pos', label: 'Pos', width: 44, align: 'center' },
{ id: 'sku', label: 'SKU', width: 130, align: 'left', type: 'text' },
{ id: 'name', label: 'Name', width: 220, align: 'left', type: 'text' },
{ id: 'amount', label: 'Amount', width: 88, align: 'right', type: 'number' },

View File

@@ -4,13 +4,8 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import CloudDownloadOutlinedIcon from '@mui/icons-material/CloudDownloadOutlined';
@@ -51,40 +46,14 @@ const PROGRESS_PHASES = [
{ key: 'receipts', label: 'Belege', totalKey: 'receiptstotal', doneKey: 'receiptsdone' },
];
const EXPORT_OPTIONS = [
{
id: 'articles',
label: 'Artikel & Medien (ZIP)',
desc: 'artikel.csv inklusive aller referenzierten AVIF-Bilder und Preise.',
endpoint: '/api/article/export.zip',
defaultFilename: 'artikel-export.zip',
},
{
id: 'categories',
label: 'Kategorien & Bilder (ZIP)',
desc: 'kategorien.csv inklusive Kategoriebäumen und AVIF-Vorschaubildern.',
endpoint: '/api/category/export.zip',
defaultFilename: 'kategorien-export.zip',
},
{
id: 'company',
label: 'Gesamtsicherung / Mandant (ZIP)',
desc: 'Vollständiger Datenbestand (Artikel, Kategorien, Kunden, Belege und Medien).',
endpoint: '/api/company/export.zip',
defaultFilename: 'mandant-gesamtsicherung.zip',
},
];
export default class ImportExportSettingsPanel extends Component {
constructor(props) {
super(props);
this.state = {
// Export state
exportType: 'articles',
downloading: false,
downloadProgress: null, // null = unknown, number 0-100
downloadLoaded: 0,
downloadTotal: 0,
downloadStatus: null,
downloadError: null,
// Import state (SSE streaming & upload tracking)
@@ -125,78 +94,40 @@ export default class ImportExportSettingsPanel extends Component {
}
async handleStartDownload() {
const { exportType } = this.state;
const option = EXPORT_OPTIONS.find((opt) => opt.id === exportType) ?? EXPORT_OPTIONS[0];
this.setState({
downloading: true,
downloadProgress: null,
downloadLoaded: 0,
downloadTotal: 0,
downloadStatus: null,
downloadError: null,
});
this.abortController = new AbortController();
try {
const response = await fetch(option.endpoint, {
method: 'GET',
credentials: 'include',
signal: this.abortController.signal,
});
await company.exportCatalog(
{
onStatus: (message) => {
this.setState({ downloadStatus: message });
},
onProgress: (progress) => {
const percent = typeof progress?.percent === 'number' ? progress.percent : null;
const stage = progress?.stage;
this.setState((prev) => ({
downloadProgress: percent ?? prev.downloadProgress,
downloadStatus: stage
? `Verarbeite ${stage} (${percent != null ? `${percent}%` : '...'})`
: prev.downloadStatus,
}));
},
onError: (errMessage) => {
this.setState({ downloadError: errMessage });
},
},
{ signal: this.abortController.signal, triggerDownload: true }
);
if (!response.ok) {
let errMessage = `Fehler beim Herunterladen (${response.status})`;
try {
const json = await response.json();
errMessage = json.error || json.message || errMessage;
} catch {
// Ignore json parse error
}
throw new Error(errMessage);
}
const contentLengthHeader = response.headers.get('content-length');
const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0;
const reader = response.body.getReader();
const chunks = [];
let loadedBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loadedBytes += value.length;
if (totalBytes > 0) {
const percent = Math.min(100, Math.round((loadedBytes / totalBytes) * 100));
this.setState({
downloadProgress: percent,
downloadLoaded: loadedBytes,
downloadTotal: totalBytes,
});
} else {
this.setState({
downloadProgress: null,
downloadLoaded: loadedBytes,
});
}
}
// Stream completed — trigger browser file save
const blob = new Blob(chunks, { type: 'application/zip' });
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = option.defaultFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
enqueueSnackbar(`${option.label} erfolgreich heruntergeladen.`, { variant: 'success' });
this.setState({ downloading: false, downloadProgress: 100 });
enqueueSnackbar('Katalog-Export erfolgreich heruntergeladen.', { variant: 'success' });
this.setState({ downloading: false, downloadProgress: 100, downloadStatus: null });
} catch (err) {
if (err.name === 'AbortError') {
enqueueSnackbar('Download abgebrochen.', { variant: 'info' });
@@ -351,11 +282,9 @@ export default class ImportExportSettingsPanel extends Component {
render() {
const {
exportType,
downloading,
downloadProgress,
downloadLoaded,
downloadTotal,
downloadStatus,
downloadError,
selectedFile,
uploading,
@@ -405,10 +334,10 @@ export default class ImportExportSettingsPanel extends Component {
<CloudDownloadOutlinedIcon color="primary" sx={{ fontSize: 28 }} />
<Box>
<Typography variant="subtitle1" fontWeight={600}>
Daten exportieren (Download)
Katalog exportieren (Download)
</Typography>
<Typography variant="caption" color="text.secondary">
Große Datenmengen werden als ZIP-Archiv gestreamt.
Exportiert den vollständigen Mandantenbestand (Kategorien, Artikel, Varianten, Marken, Pfand und Bilder) als ZIP-Archiv.
</Typography>
</Box>
</Box>
@@ -421,42 +350,13 @@ export default class ImportExportSettingsPanel extends Component {
</Alert>
)}
<FormControl component="fieldset" fullWidth sx={{ mb: 2 }}>
<FormLabel component="legend" sx={{ mb: 1, fontSize: '0.85rem' }}>
Export-Umfang auswählen
</FormLabel>
<RadioGroup
value={exportType}
onChange={(e) => this.setState({ exportType: e.target.value })}
>
{EXPORT_OPTIONS.map((opt) => (
<FormControlLabel
key={opt.id}
value={opt.id}
disabled={downloading}
control={<Radio size="small" />}
label={
<Box sx={{ py: 0.5 }}>
<Typography variant="body2" fontWeight={500}>
{opt.label}
</Typography>
<Typography variant="caption" color="text.secondary" display="block">
{opt.desc}
</Typography>
</Box>
}
/>
))}
</RadioGroup>
</FormControl>
{downloading && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'action.hover', borderRadius: 1.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
{downloadProgress != null
? `${downloadProgress}% (${formatBytes(downloadLoaded)} von ${formatBytes(downloadTotal)})`
: `${formatBytes(downloadLoaded)} geladen (wird gestreamt...)`}
{downloadStatus
? (downloadProgress != null ? `${downloadStatus} (${downloadProgress}%)` : downloadStatus)
: (downloadProgress != null ? `${downloadProgress}% geladen...` : 'Export wird vorbereitet...')}
</Typography>
<Button
size="small"
@@ -487,7 +387,7 @@ export default class ImportExportSettingsPanel extends Component {
disabled={downloading}
onClick={this.handleStartDownload}
>
{downloading ? 'Herunterladen...' : 'Download starten'}
{downloading ? 'Exportieren...' : 'Katalog exportieren'}
</Button>
</Box>
</Box>

View File

@@ -37,10 +37,23 @@ import orderAutomat, {
MAX_LRU_CHUNKS,
isOrderPaid,
isOrderDelivered,
subscribeChunkStats,
} from '../automats/orderAutomat';
import quoteAutomat from '../automats/quoteAutomat';
import { formatPrice } from '../utils/price';
function getAutomatFor(target) {
return target === 'quote' ? quoteAutomat : orderAutomat;
}
function getInitialTarget() {
if (typeof window !== 'undefined') {
const p = window.location.pathname;
if (p.startsWith('/auftrag/bestellung')) return 'order';
if (p.startsWith('/auftrag')) return 'quote';
}
return 'order';
}
const PANEL_WIDTH = 680;
const PANEL_HEIGHT = 520;
@@ -72,7 +85,9 @@ function orderGrossTotal(lines) {
class ChunkCacheMonitor extends PureComponent {
constructor(props) {
super(props);
const initialStats = orderAutomat.getChunkStats?.() || {
const target = getInitialTarget();
const automat = getAutomatFor(target);
const initialStats = automat.getChunkStats?.() || {
chunkSize: CHUNK_SIZE,
maxLruChunks: MAX_LRU_CHUNKS,
lruChunksCount: 0,
@@ -100,6 +115,7 @@ class ChunkCacheMonitor extends PureComponent {
open: false,
position: null,
tab: 'grid', // 'grid' | 'lru' | 'log'
target, // 'quote' | 'order'
inspectFilterKey: null, // null means follow activeFilterKey
selectedChunk: null,
pulseCounter: 0,
@@ -121,10 +137,12 @@ class ChunkCacheMonitor extends PureComponent {
this.handleApplyFilter = this.handleApplyFilter.bind(this);
this.handlePrefetchAll = this.handlePrefetchAll.bind(this);
this.handleReload = this.handleReload.bind(this);
this.switchTarget = this.switchTarget.bind(this);
}
componentDidMount() {
this.unsubscribeStats = subscribeChunkStats((stats) => {
const automat = getAutomatFor(this.state.target);
this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => {
this.setState((prev) => ({
stats,
pulseCounter: prev.pulseCounter + 1,
@@ -132,10 +150,39 @@ class ChunkCacheMonitor extends PureComponent {
});
window.addEventListener('keydown', this.handleKeyDown);
this.customEventListener = () => this.handleToggle();
this.customEventListener = (event) => {
const reqTarget = event?.detail?.target;
if (reqTarget === 'quote' || reqTarget === 'order') {
this.switchTarget(reqTarget);
} else {
const detected = getInitialTarget();
if (detected !== this.state.target) {
this.switchTarget(detected);
}
}
this.handleToggle();
};
window.addEventListener('dash:toggle-chunk-monitor', this.customEventListener);
}
switchTarget(newTarget) {
if (newTarget === this.state.target) return;
this.unsubscribeStats?.();
const automat = getAutomatFor(newTarget);
this.unsubscribeStats = automat.subscribeChunkStats?.((stats) => {
this.setState((prev) => ({
stats,
pulseCounter: prev.pulseCounter + 1,
}));
});
this.setState({
target: newTarget,
stats: automat.getChunkStats?.() || this.state.stats,
selectedChunk: null,
inspectFilterKey: null,
});
}
componentWillUnmount() {
this.unsubscribeStats?.();
window.removeEventListener('keydown', this.handleKeyDown);
@@ -204,51 +251,60 @@ class ChunkCacheMonitor extends PureComponent {
handleLoadChunk(chunkIndex, filterKey, e) {
e?.stopPropagation();
const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
void orderAutomat.actions.loadChunk(chunkIndex, orderAutomat.state.queryEpoch, targetFilter);
void automat.actions.loadChunk(chunkIndex, automat.state.queryEpoch, targetFilter);
}
handleEvictChunk(chunkIndex, filterKey, e) {
e?.stopPropagation();
const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
orderAutomat.evictChunk?.(chunkIndex, targetFilter);
automat.evictChunk?.(chunkIndex, targetFilter);
if (this.state.selectedChunk === chunkIndex) {
this.setState({ selectedChunk: null });
}
}
handleClearCache() {
orderAutomat.clearCache?.();
const automat = getAutomatFor(this.state.target);
automat.clearCache?.();
this.setState({ selectedChunk: null });
}
handleClearFilter(filterKey, e) {
e?.stopPropagation();
orderAutomat.clearFilterCache?.(filterKey);
const automat = getAutomatFor(this.state.target);
automat.clearFilterCache?.(filterKey);
if (this.state.inspectFilterKey === filterKey) {
this.setState({ inspectFilterKey: null, selectedChunk: null });
}
}
handleApplyFilter(filterKey) {
const automat = getAutomatFor(this.state.target);
const entry = this.state.stats.filterCaches?.find((f) => f.key === filterKey);
if (!entry) return;
const p = entry.params || {};
orderAutomat.actions.resetFilters();
if (p.filter) orderAutomat.actions.filter(p.filter);
if (p.onlyUnpaid) orderAutomat.actions.setOnlyUnpaid(true);
if (p.onlyUndelivered) orderAutomat.actions.setOnlyUndelivered(true);
if (p.negativeSeekMonths) orderAutomat.actions.addNegativeSeek(p.negativeSeekMonths);
automat.actions.resetFilters();
if (p.filter) automat.actions.filter(p.filter);
if (this.state.target === 'order') {
if (p.onlyUnpaid) automat.actions.setOnlyUnpaid(true);
if (p.onlyUndelivered) automat.actions.setOnlyUndelivered(true);
}
if (p.negativeSeekMonths) automat.actions.addNegativeSeek(p.negativeSeekMonths);
this.setState({ inspectFilterKey: null, selectedChunk: null });
}
handlePrefetchAll(filterKey) {
const automat = getAutomatFor(this.state.target);
const targetFilter = filterKey || this.getInspectedFilterKey();
orderAutomat.prefetchAllChunks?.(targetFilter);
automat.prefetchAllChunks?.(targetFilter);
}
handleReload() {
void orderAutomat.actions.reload(false);
const automat = getAutomatFor(this.state.target);
void automat.actions.reload(false);
this.setState({ selectedChunk: null });
}
@@ -658,9 +714,12 @@ class ChunkCacheMonitor extends PureComponent {
) : (
<Stack spacing={0.5}>
{chunkOrders.map(({ item: order, index }) => {
const paid = isOrderPaid(order);
const delivered = isOrderDelivered(order);
const gross = orderGrossTotal(order.lines);
const isOrd = this.state.target === 'order';
const paid = isOrd ? isOrderPaid(order) : false;
const delivered = isOrd ? isOrderDelivered(order) : false;
const gross = order.grossTotal != null ? Number(order.grossTotal) : orderGrossTotal(order.lines);
const customer = order.customerName || (order.entityId ? `Kunde #${order.entityId}` : '–');
const posCount = order.positions != null ? order.positions : (order.lines?.length ?? 0);
return (
<Box
@@ -690,20 +749,29 @@ class ChunkCacheMonitor extends PureComponent {
{order.date ? new Date(order.date).toLocaleDateString() : '–'}
</Typography>
<Typography variant="caption" sx={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
Kunde #{order.entityId || '–'}
{customer}
</Typography>
<Chip
size="small"
label={paid ? 'Bezahlt' : 'Unbezahlt'}
color={paid ? 'success' : 'default'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
size="small"
label={delivered ? 'Geliefert' : 'Offen'}
color={delivered ? 'primary' : 'default'}
label={`${posCount} Pos.`}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
{isOrd && (
<>
<Chip
size="small"
label={paid ? 'Bezahlt' : 'Unbezahlt'}
color={paid ? 'success' : 'default'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
<Chip
size="small"
label={delivered ? 'Geliefert' : 'Offen'}
color={delivered ? 'primary' : 'default'}
sx={{ height: 20, fontSize: '0.7rem' }}
/>
</>
)}
<Typography
variant="caption"
sx={{ fontWeight: 600, width: 75, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
@@ -961,7 +1029,24 @@ class ChunkCacheMonitor extends PureComponent {
Chunk-Cache-Monitor
</Typography>
<Chip size="small" color="primary" variant="outlined" label="orderAutomat" />
<Stack direction="row" spacing={0.5}>
<Chip
size="small"
color={this.state.target === 'quote' ? 'primary' : 'default'}
variant={this.state.target === 'quote' ? 'filled' : 'outlined'}
label="Angebote (quoteAutomat)"
onClick={() => this.switchTarget('quote')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'quote' ? 700 : 500 }}
/>
<Chip
size="small"
color={this.state.target === 'order' ? 'primary' : 'default'}
variant={this.state.target === 'order' ? 'filled' : 'outlined'}
label="Bestellungen (orderAutomat)"
onClick={() => this.switchTarget('order')}
sx={{ cursor: 'pointer', height: 24, fontSize: '0.72rem', fontWeight: this.state.target === 'order' ? 700 : 500 }}
/>
</Stack>
<Chip
size="small"
color={lruCount >= maxLru ? 'warning' : 'success'}

View File

@@ -1,9 +1,9 @@
import { PureComponent } from 'react';
import { ManifestList } from '../components/manifest';
import QuoteList from '../components/manifest/QuoteList';
class AuftragAngebotPage extends PureComponent {
render() {
return <ManifestList kind="quote" />;
return <QuoteList />;
}
}

View File

@@ -99,6 +99,7 @@ class LieferungNeuPage extends PureComponent {
this.handleAddLine = this.handleAddLine.bind(this);
this.handleAddArticleLine = this.handleAddArticleLine.bind(this);
this.handleRemoveLine = this.handleRemoveLine.bind(this);
this.handleMoveLine = this.handleMoveLine.bind(this);
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handlePdfUpload = this.handlePdfUpload.bind(this);
@@ -541,6 +542,13 @@ class LieferungNeuPage extends PureComponent {
this.automat.actions.removeLineitem(index >= 0 ? index : key);
}
handleMoveLine(key, delta) {
const fromIndex = this.findLineIndex(key);
if (fromIndex < 0) return;
const toIndex = fromIndex + delta;
this.automat.actions.moveLineitem(fromIndex, toIndex);
}
handleRemoveAllLines() {
this.automat.actions.clearLineitems();
}
@@ -1340,6 +1348,7 @@ class LieferungNeuPage extends PureComponent {
onAddLine={this.handleAddLine}
onAddArticleLine={this.handleAddArticleLine}
onRemoveLine={this.handleRemoveLine}
onMoveLine={this.handleMoveLine}
onRemoveAllLines={this.handleRemoveAllLines}
onSave={() => void this.handleSave()}
onCreateArticle={this.handleCreateArticleForLine}

View File

@@ -45,12 +45,26 @@ export function parsePriceInput(value) {
}
/** Convert gross (brutto) → net for PUT /prices. Rounded to 4 decimal places. */
export function netFromGross(gross, taxRate) {
export function netFromGross(gross, taxRate, rebate = 0) {
if (gross == null || taxRate == null) return null;
if (!Number.isFinite(gross) || !Number.isFinite(taxRate) || taxRate < 0) return null;
const factor = 1 + taxRate / 100;
if (factor <= 0) return null;
return Math.round((gross / factor) * 10000) / 10000;
const taxFactor = 1 + taxRate / 100;
if (taxFactor <= 0) return null;
const r = Number.isFinite(rebate) ? rebate : 0;
const rebateFactor = 1 - r / 100;
if (rebateFactor <= 0) return 0;
return Math.round((gross / (taxFactor * rebateFactor)) * 10000) / 10000;
}
/** Convert net → gross taking taxRate and optional rebate into account. Rounded to 2 decimal places. */
export function grossFromNet(net, taxRate = 0, rebate = 0) {
if (net == null) return null;
const netNum = Number(net);
if (!Number.isFinite(netNum)) return null;
const taxNum = Number(taxRate) || 0;
const rebateNum = Number(rebate) || 0;
const factor = (1 + taxNum / 100) * (1 - rebateNum / 100);
return Math.round((netNum * factor + Number.EPSILON) * 100) / 100;
}
/**