Compare commits
16 Commits
e7b3044903
...
39aa898324
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39aa898324 | ||
|
|
7ce2e3216f | ||
|
|
002f63a47a | ||
|
|
ed7bb80006 | ||
|
|
e2a2d02cb4 | ||
|
|
eb4654dfbf | ||
|
|
c62834833d | ||
|
|
fdb67b7221 | ||
|
|
c76777d0b8 | ||
|
|
c54d932e36 | ||
|
|
a0d71f70b7 | ||
|
|
b8fdef4e11 | ||
|
|
4188d8f46f | ||
|
|
49f8f2f93f | ||
|
|
61dd2f3a5d | ||
|
|
62fa58e4ec |
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
72
src/components/BreakpointOverlay.jsx
Normal file
72
src/components/BreakpointOverlay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
553
src/components/manifest/QuoteList.jsx
Normal file
553
src/components/manifest/QuoteList.jsx
Normal 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"> </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);
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user