u
This commit is contained in:
@@ -7,7 +7,7 @@ description: >-
|
||||
|
||||
# Dash API Routes Overview (All)
|
||||
|
||||
Overview of all 247 API endpoints across all modules.
|
||||
Overview of all 249 API endpoints across all modules.
|
||||
|
||||
### Notation
|
||||
|
||||
@@ -17,61 +17,62 @@ 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: T29[], externalNumber?: str|null, 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[] }`
|
||||
- `T2` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, shipping?: T38|null, shippings?: T38[], payment?: T44|null, payments?: T44[], paid?: bool, shipped?: bool, delivered?: bool, orderTotal?: num, paidTotal?: num, orderedQuantity?: num, shippedQuantity?: num, shipments?: T6[], files?: T16[] }`
|
||||
- `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: 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` = `{ quote: T7 }`
|
||||
- `T7` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T29[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T15[] }`
|
||||
- `T8` = `{ delivery: T9 }`
|
||||
- `T9` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T29[], files: T15[], externalNumber?: str|null, createdAt?: str, ledger?: T51 }`
|
||||
- `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, skeledash?: 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[] }`
|
||||
- `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: T41, brand: T39|null, categoryIds: int[], photos: T36[], eans: str[], prices: T48[], suppliers: T18[] }`
|
||||
- `T5` = `{ shipment: T6 }`
|
||||
- `T6` = `{ id: int, number: str, type: str, date: str, lines: T30[], shippings: T38[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int, files?: T16[], state?: str }`
|
||||
- `T7` = `{ quote: T8 }`
|
||||
- `T8` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T30[], externalNumber?: str|null, createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null, files?: T16[] }`
|
||||
- `T9` = `{ delivery: T10 }`
|
||||
- `T10` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T30[], files: T16[], externalNumber?: str|null, createdAt?: str, ledger?: T52 }`
|
||||
- `T11` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T12|null }`
|
||||
- `T12` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T19, totalSizeBytes: int, durationMs: int }`
|
||||
- `T13` = `{ labeltemplate: T15 }`
|
||||
- `T14` = `{ attachment: T16, fileUrl: str }`
|
||||
- `T15` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T26, thumb?: str|null }`
|
||||
- `T16` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, skeledash?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
|
||||
- `T17` = `{ 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[] }`
|
||||
- `T18` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T55|null }`
|
||||
- `T19` = `{ vfs: T53, storage: T53, database: T46 }`
|
||||
- `T20` = `{ 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 }`
|
||||
- `T21` = `{ ledger: T22[] }`
|
||||
- `T22` = `{ 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 }`
|
||||
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T42[], articleCount?: int }`
|
||||
- `T24` = `{ 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 }`
|
||||
- `T25` = `{ id: int, name: str, rootCategoryId: int|null, languageId: int|null, iso639: str|null, taxrate: num, reducedtaxrate: num, taxfreerate: num }`
|
||||
- `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 }`
|
||||
- `T27` = `{ objects: T32[] }`
|
||||
- `T28` = `{ printer: T34 }`
|
||||
- `T29` = `{ entity: T37 }`
|
||||
- `T30` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
|
||||
- `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[] }`
|
||||
- `T32` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
|
||||
- `T33` = `{ shipping: T38 }`
|
||||
- `T34` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
|
||||
- `T35` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
|
||||
- `T36` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T37` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||
- `T38` = `{ id: int, manifestId: int, number: str, type: str, state: str, trackingCode?: str|null, carrier?: str|null }`
|
||||
- `T39` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||
- `T40` = `{ article: T45 }`
|
||||
- `T41` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||
- `T42` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
|
||||
- `T43` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
|
||||
- `T44` = `{ id: int, accountId: int, manifestId: int, type: str, amount: num }`
|
||||
- `T45` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||
- `T46` = `{ name: str|null, sizeBytes: int, backedUp: bool, reason?: str|null }`
|
||||
- `T47` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||
- `T48` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||
- `T49` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||
- `T50` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||
- `T51` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||
- `T52` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
|
||||
- `T53` = `{ name: str, sizeBytes: int, sourceExists: bool }`
|
||||
- `T54` = `{ warehouse: T56 }`
|
||||
- `T55` = `{ net: num, taxId: int, taxRate?: num|null }`
|
||||
- `T56` = `{ id: int, name: str, sort: int }`
|
||||
- `T57` = `{ id: int, categoryIds: int[] }`
|
||||
|
||||
### Routes
|
||||
|
||||
@@ -101,7 +102,7 @@ _none_
|
||||
|
||||
#### Create article in category — `POST /article`
|
||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||
- Returns: `201 T39`
|
||||
- Returns: `201 T40`
|
||||
#### Export articles as ZIP — `GET /article/export.zip`
|
||||
- Params: `query: categoryId?: int`
|
||||
- Returns: `200 zip`
|
||||
@@ -137,10 +138,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 T39`
|
||||
- Returns: `201 T40`
|
||||
#### 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: T44, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
||||
- Returns: `200 { article: T45, 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 }`
|
||||
@@ -167,7 +168,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 T39`
|
||||
- Returns: `200 T40`
|
||||
#### 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`
|
||||
@@ -185,10 +186,10 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### List article suppliers — `GET /article/{sku}/supplier`
|
||||
- Params: `sku: str`
|
||||
- Returns: `200 { suppliers: T17[] }`
|
||||
- Returns: `200 { suppliers: T18[] }`
|
||||
#### 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: T17 }`
|
||||
- Returns: `201 { supplier: T18 }`
|
||||
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
|
||||
- Params: `sku: str, body: { ids: int[] }`
|
||||
- Returns: `200 { ids: int[] }`
|
||||
@@ -197,7 +198,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: T17 }`
|
||||
- Returns: `200 { supplier: T18 }`
|
||||
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
|
||||
- Params: `sku: str, body: { track_stock: bool }`
|
||||
- Returns: `200 T3`
|
||||
@@ -209,19 +210,19 @@ _none_
|
||||
|
||||
#### List attribute names — `GET /attributename`
|
||||
_none_
|
||||
- Returns: `200 { attributenames: T22[] }`
|
||||
- Returns: `200 { attributenames: T23[] }`
|
||||
#### Create attribute name — `POST /attributename`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { attributename: T22 }`
|
||||
- Returns: `201 { attributename: T23 }`
|
||||
#### 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: T41[] }`
|
||||
- Returns: `200 { values: T42[] }`
|
||||
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
|
||||
- Params: `attributeId: int, body: { value: str, sort?: int }`
|
||||
- Returns: `201 { attributevalue: T41 }`
|
||||
- Returns: `201 { attributevalue: T42 }`
|
||||
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
|
||||
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -230,7 +231,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: T22 }`
|
||||
- Returns: `200 { attributename: T23 }`
|
||||
|
||||
### Section: /attributevalue
|
||||
|
||||
@@ -239,34 +240,34 @@ _none_
|
||||
- Returns: `200 { ok: bool }`
|
||||
#### Update attribute value — `PATCH /attributevalue/{id}`
|
||||
- Params: `id: int, body: { value?: str, sort?: int }`
|
||||
- Returns: `200 { attributevalue: T41 }`
|
||||
- Returns: `200 { attributevalue: T42 }`
|
||||
#### 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: T48[] } }`
|
||||
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T49[] } }`
|
||||
#### 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: T48[] } }`
|
||||
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T49[] } }`
|
||||
|
||||
### Section: /auth
|
||||
|
||||
#### Login with Google ID token — `POST /auth/login`
|
||||
- Params: `body: { idToken: str }`
|
||||
- Returns: `200 { message: str, user: T46 }`
|
||||
- Returns: `200 { message: str, user: T47 }`
|
||||
#### Logout — `POST /auth/logout`
|
||||
_none_
|
||||
- Returns: `200 { message: str }`
|
||||
#### Current user — `GET /auth/me`
|
||||
_none_
|
||||
- Returns: `200 { user: T46, googleClientId: str }`
|
||||
- Returns: `200 { user: T47, googleClientId: str }`
|
||||
#### Set selected company — `PUT /auth/selected-company`
|
||||
- Params: `body: { companyId: int }`
|
||||
- Returns: `200 { user: T46 }`
|
||||
- Returns: `200 { user: T47 }`
|
||||
|
||||
### 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?: T10|null, latestDaily?: T10|null, latestMonthly?: T10|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T10[], daily: T10[], monthly: T10[] } }`
|
||||
- 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?: T11|null, latestDaily?: T11|null, latestMonthly?: T11|null }, backups: { counts: { hourly: int, daily: int, monthly: int }, hourly: T11[], daily: T11[], monthly: T11[] } }`
|
||||
#### 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 }[] }`
|
||||
@@ -281,7 +282,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: T48[], gsprpinfo?: str|null } }`
|
||||
- Returns: `201 { brand: { id: int, name: str, pics: T49[], gsprpinfo?: str|null } }`
|
||||
#### Delete brand — `DELETE /brand/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
@@ -290,10 +291,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: T48[] } }`
|
||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T49[] } }`
|
||||
#### 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: T48[] } }`
|
||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T49[] } }`
|
||||
|
||||
### Section: /category
|
||||
|
||||
@@ -317,7 +318,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?: T38|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?: T39|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 }[] }`
|
||||
@@ -344,10 +345,10 @@ _none_
|
||||
|
||||
#### List companies — `GET /company`
|
||||
_none_
|
||||
- Returns: `200 { companies: T24[] }`
|
||||
- Returns: `200 { companies: T25[] }`
|
||||
#### Create company — `POST /company`
|
||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||
- Returns: `201 { company: T24, user: T46 }`
|
||||
- Returns: `201 { company: T25, user: T47 }`
|
||||
#### Export company catalog as zip archive (SSE) — `GET /company/export`
|
||||
- Params: `query: stream?: str`
|
||||
- Returns: `200 SSE`
|
||||
@@ -377,7 +378,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: T24 }`
|
||||
- Returns: `200 { company: T25 }`
|
||||
|
||||
### Section: /customergroup
|
||||
|
||||
@@ -386,7 +387,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: T55 }`
|
||||
- Returns: `201 { customergroup: T56 }`
|
||||
#### Reorder customer groups — `PUT /customergroup/order`
|
||||
- Params: `body: { customergroupIds: int[] }`
|
||||
- Returns: `200 { customergroupIds: int[] }`
|
||||
@@ -395,16 +396,16 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Update customer group — `PATCH /customergroup/{id}`
|
||||
- Params: `id: int, body: { name: str }`
|
||||
- Returns: `200 { customergroup: T55 }`
|
||||
- Returns: `200 { customergroup: T56 }`
|
||||
|
||||
### 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?: T51 }[] }`
|
||||
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T52 }[] }`
|
||||
#### Create delivery — `POST /delivery`
|
||||
- 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 T8`
|
||||
- Params: `body: { externalNumber: str, date: str, lines: T35[], 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 T9`
|
||||
#### Parse document (SSE) — `POST /delivery/parse`
|
||||
- Params: `form: { file: str }`
|
||||
- Returns: `200 SSE`
|
||||
@@ -422,10 +423,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get delivery — `GET /delivery/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T8`
|
||||
- Returns: `200 T9`
|
||||
#### Update delivery — `PATCH /delivery/{id}`
|
||||
- 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 T8`
|
||||
- Params: `id: int, body: { externalNumber?: str|null, date?: str, supplierId?: int|null, lines?: T35[], files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
|
||||
- Returns: `200 T9`
|
||||
#### Delete delivery file attachment — `DELETE /delivery/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -434,52 +435,52 @@ _none_
|
||||
- Returns: `200 str`
|
||||
#### Generate and attach delivery note PDF (goods receipt) — `POST /delivery/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 T13`
|
||||
- Returns: `201 T14`
|
||||
|
||||
### Section: /entity
|
||||
|
||||
#### List entities — `GET /entity`
|
||||
- Params: `query: customergroupId?: int, role?: str`
|
||||
- Returns: `200 { entities: T36[] }`
|
||||
- Returns: `200 { entities: T37[] }`
|
||||
#### Create entity — `POST /entity`
|
||||
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
|
||||
- Returns: `201 T28`
|
||||
- Returns: `201 T29`
|
||||
#### 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: T19[] }`
|
||||
- Returns: `200 { addresses: T20[] }`
|
||||
#### 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: T19 }`
|
||||
- Returns: `201 { address: T20 }`
|
||||
#### 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: T19 }`
|
||||
- Returns: `200 { address: T20 }`
|
||||
#### List entity contacts — `GET /entity/{entityId}/contact`
|
||||
- Params: `entityId: int`
|
||||
- Returns: `200 { contacts: T49[] }`
|
||||
- Returns: `200 { contacts: T50[] }`
|
||||
#### Create entity contact — `POST /entity/{entityId}/contact`
|
||||
- Params: `entityId: int, body: { contactType: str, contact: str }`
|
||||
- Returns: `201 { contact: T49 }`
|
||||
- Returns: `201 { contact: T50 }`
|
||||
#### 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: T49 }`
|
||||
- Returns: `200 { contact: T50 }`
|
||||
#### Delete entity — `DELETE /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get entity — `GET /entity/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T28`
|
||||
- Returns: `200 T29`
|
||||
#### Update entity — `PATCH /entity/{id}`
|
||||
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
|
||||
- Returns: `200 T28`
|
||||
- Returns: `200 T29`
|
||||
|
||||
### Section: /events
|
||||
|
||||
@@ -523,35 +524,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: T26 }`
|
||||
- Returns: `201 T12`
|
||||
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T27 }`
|
||||
- Returns: `201 T13`
|
||||
#### 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 T12`
|
||||
- Returns: `200 T13`
|
||||
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
|
||||
- Returns: `200 T12`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T27 }`
|
||||
- Returns: `200 T13`
|
||||
#### Update label template — `PUT /labeltemplate/{id}`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T26 }`
|
||||
- Returns: `200 T12`
|
||||
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T27 }`
|
||||
- Returns: `200 T13`
|
||||
|
||||
### Section: /ledger
|
||||
|
||||
#### Query ledger by manifest article ID or SKU — `GET /ledger`
|
||||
- Params: `query: manifestarticle_id?: int, sku?: str, transaction_type?: str`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T21`
|
||||
#### Add ledger entry — `POST /ledger`
|
||||
- Params: `body: { manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str }`
|
||||
- Returns: `201 { ledger: T21 }`
|
||||
- Returns: `201 { ledger: T22 }`
|
||||
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
|
||||
- Params: `manifestarticleId: int, query: transaction_type?: str`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T21`
|
||||
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
|
||||
- Params: `sku: str, query: transaction_type?: str`
|
||||
- Returns: `200 T20`
|
||||
- Returns: `200 T21`
|
||||
|
||||
### Section: /mcp
|
||||
|
||||
@@ -581,16 +582,16 @@ _none_
|
||||
|
||||
#### List conversations — `GET /modelconversation`
|
||||
_none_
|
||||
- Returns: `200 { conversations: T23[] }`
|
||||
- Returns: `200 { conversations: T24[] }`
|
||||
#### Create conversation — `POST /modelconversation`
|
||||
- Params: `body: { provider: str, model: str }`
|
||||
- Returns: `201 { conversation: T23 }`
|
||||
- Returns: `201 { conversation: T24 }`
|
||||
#### Delete conversation — `DELETE /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get conversation — `GET /modelconversation/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { conversation: T23 }`
|
||||
- Returns: `200 { conversation: T24 }`
|
||||
#### 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 }[] }`
|
||||
@@ -631,7 +632,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: T34[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Returns: `201 T1`
|
||||
#### Delete order — `DELETE /order/{id}`
|
||||
- Params: `id: int`
|
||||
@@ -640,7 +641,7 @@ _none_
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T1`
|
||||
#### Update order — `PATCH /order/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T34[] }`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }`
|
||||
- Returns: `200 T1`
|
||||
#### Delete order file attachment — `DELETE /order/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
@@ -650,25 +651,25 @@ _none_
|
||||
- Returns: `200 str`
|
||||
#### List payments of an order — `GET /order/{id}/payment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { payments: T43[] }`
|
||||
- Returns: `200 { payments: T44[] }`
|
||||
#### Add payment to an order — `POST /order/{id}/payment`
|
||||
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||
- Returns: `201 { payment: T43 }`
|
||||
- Returns: `201 { payment: T44 }`
|
||||
#### Generate and attach order PDF — `POST /order/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 T13`
|
||||
- Returns: `201 T14`
|
||||
#### List shipments of an order — `GET /order/{id}/shipment`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipments: T5[] }`
|
||||
- Returns: `200 { shipments: T6[] }`
|
||||
#### 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: T5 }`
|
||||
- Params: `id: int, body?: { date?: str, externalNumber?: str, lines?: { sku: str, amount?: num, name?: str, pu?: num, net?: num, rebate?: num, taxrate?: num, comment?: str }[] }`
|
||||
- Returns: `201 T5`
|
||||
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T37[] }`
|
||||
- Returns: `200 { shippings: T38[] }`
|
||||
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||
- Params: `id: int, body: { type: str, state: str, warehouseId?: int }`
|
||||
- Returns: `201 T32`
|
||||
- Returns: `201 T33`
|
||||
|
||||
### Section: /package
|
||||
|
||||
@@ -686,37 +687,37 @@ _none_
|
||||
|
||||
#### List POS terminals — `GET /posterminal`
|
||||
_none_
|
||||
- Returns: `200 { posterminals: T16[] }`
|
||||
- Returns: `200 { posterminals: T17[] }`
|
||||
#### Create POS terminal — `PUT /posterminal`
|
||||
- Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }`
|
||||
- Returns: `200 { posterminal: T16 }`
|
||||
- Returns: `200 { posterminal: T17 }`
|
||||
#### 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: T16 }`
|
||||
- Returns: `200 { posterminal: T17 }`
|
||||
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T56`
|
||||
- Returns: `200 T57`
|
||||
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T56`
|
||||
- Returns: `200 T57`
|
||||
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T56`
|
||||
- Returns: `200 T57`
|
||||
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
|
||||
- Params: `id: int, categoryId: int`
|
||||
- Returns: `200 T56`
|
||||
- Returns: `200 T57`
|
||||
|
||||
### Section: /printer
|
||||
|
||||
#### List printers — `GET /printer`
|
||||
_none_
|
||||
- Returns: `200 { printers: T33[] }`
|
||||
- Returns: `200 { printers: T34[] }`
|
||||
#### Create printer — `POST /printer`
|
||||
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
|
||||
- Returns: `201 T27`
|
||||
- Returns: `201 T28`
|
||||
#### Reorder printers — `PUT /printer/order`
|
||||
- Params: `body: { printerIds: int[] }`
|
||||
- Returns: `200 { printerIds: int[] }`
|
||||
@@ -725,22 +726,22 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get printer by ID — `GET /printer/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T27`
|
||||
- Returns: `200 T28`
|
||||
#### 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 T27`
|
||||
- Returns: `200 T28`
|
||||
#### 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 T27`
|
||||
- Returns: `200 T28`
|
||||
|
||||
### Section: /propertyname
|
||||
|
||||
#### List property names — `GET /propertyname`
|
||||
_none_
|
||||
- Returns: `200 { propertynames: T42[] }`
|
||||
- Returns: `200 { propertynames: T43[] }`
|
||||
#### Create property name — `POST /propertyname`
|
||||
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
|
||||
- Returns: `201 { propertyname: T42 }`
|
||||
- Returns: `201 { propertyname: T43 }`
|
||||
#### Reorder property names — `PUT /propertyname/order`
|
||||
- Params: `body: { propertynameIds: int[] }`
|
||||
- Returns: `200 { ok: bool }`
|
||||
@@ -749,7 +750,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: T42 }`
|
||||
- Returns: `200 { propertyname: T43 }`
|
||||
|
||||
### Section: /public
|
||||
|
||||
@@ -770,19 +771,19 @@ _none_
|
||||
|
||||
#### List quotes — `GET /quote`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int`
|
||||
- Returns: `200 { quotes: T7[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
- Returns: `200 { quotes: T8[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Create quote — `POST /quote`
|
||||
- Params: `body: { number: str, date: str, lines: T34[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Returns: `201 T6`
|
||||
- Params: `body: { number: str, date: str, lines: T35[], entityId?: int|null, externalNumber?: str|null }`
|
||||
- Returns: `201 T7`
|
||||
#### Delete quote — `DELETE /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get quote — `GET /quote/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T6`
|
||||
- Returns: `200 T7`
|
||||
#### Update quote — `PATCH /quote/{id}`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T34[] }`
|
||||
- Returns: `200 T6`
|
||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }`
|
||||
- Returns: `200 T7`
|
||||
#### Delete quote file attachment — `DELETE /quote/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -790,11 +791,11 @@ _none_
|
||||
- Params: `id: int, fileId: int, query: download?: bool`
|
||||
- Returns: `200 str`
|
||||
#### Create order from quote — `POST /quote/{id}/order`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T34[] }`
|
||||
- Params: `id: int, body?: { number?: str, date?: str, createdAt?: str, entityId?: int|null, externalNumber?: str|null, lines?: T35[] }`
|
||||
- Returns: `201 T1`
|
||||
#### Generate and attach quote PDF — `POST /quote/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 T13`
|
||||
- Returns: `201 T14`
|
||||
|
||||
### Section: /serper
|
||||
|
||||
@@ -813,12 +814,18 @@ _none_
|
||||
|
||||
### Section: /shipment
|
||||
|
||||
#### List shipments — `GET /shipment`
|
||||
- Params: `query: offset?: int, limit?: int, search?: str, state?: str, seekMonths?: int`
|
||||
- Returns: `200 { shipments: { id: int, number: str, type: str, date: str, positions: int, totalQuantity: num, packageCount: int, state: str, entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, orderId?: int|null, orderNumber?: str|null, customerName?: str|null, carrier?: str|null, trackingCode?: str|null }[], total: int, unfilteredTotal: int, offset: int, limit: int }`
|
||||
#### Delete shipment — `DELETE /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `204 void`
|
||||
#### Get shipment by ID — `GET /shipment/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shipment: T5 }`
|
||||
- Returns: `200 T5`
|
||||
#### Update shipment state or metadata — `PATCH /shipment/{id}`
|
||||
- Params: `id: int, body: { state?: str }`
|
||||
- Returns: `200 T5`
|
||||
#### Delete shipment file attachment — `DELETE /shipment/{id}/file/{fileId}`
|
||||
- Params: `id: int, fileId: int`
|
||||
- Returns: `204 void`
|
||||
@@ -827,13 +834,13 @@ _none_
|
||||
- Returns: `200 str`
|
||||
#### Generate and attach shipment PDF (delivery note) — `POST /shipment/{id}/pdf`
|
||||
- Params: `id: int`
|
||||
- Returns: `201 T13`
|
||||
- Returns: `201 T14`
|
||||
#### List packages of a shipment — `GET /shipment/{id}/shipping`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 { shippings: T37[] }`
|
||||
- Returns: `200 { shippings: T38[] }`
|
||||
#### 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 T32`
|
||||
- Returns: `201 T33`
|
||||
|
||||
### Section: /shipping
|
||||
|
||||
@@ -842,7 +849,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 T32`
|
||||
- Returns: `200 T33`
|
||||
#### 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 }[] }`
|
||||
@@ -860,28 +867,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: T21[] }`
|
||||
- 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: T22[] }`
|
||||
|
||||
### Section: /supplier
|
||||
|
||||
#### List suppliers — `GET /supplier`
|
||||
_none_
|
||||
- Returns: `200 { suppliers: T50[] }`
|
||||
- Returns: `200 { suppliers: T51[] }`
|
||||
#### Create supplier — `POST /supplier`
|
||||
- Params: `body: { name: str }`
|
||||
- Returns: `201 { supplier: T50 }`
|
||||
- Returns: `201 { supplier: T51 }`
|
||||
#### 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: T50 }`
|
||||
- Returns: `200 { supplier: T51 }`
|
||||
#### 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?: T54|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?: T55|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?: T54|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?: T55|null }[] }`
|
||||
|
||||
### Section: /thumb
|
||||
|
||||
@@ -896,10 +903,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### List units — `GET /unit`
|
||||
_none_
|
||||
- Returns: `200 { units: T40[] }`
|
||||
- Returns: `200 { units: T41[] }`
|
||||
#### Upsert unit — `PUT /unit`
|
||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||
- Returns: `200 { unit: T40 }`
|
||||
- Returns: `200 { unit: T41 }`
|
||||
|
||||
### Section: /v1
|
||||
|
||||
@@ -953,10 +960,10 @@ _none_
|
||||
|
||||
#### List warehouses — `GET /warehouse`
|
||||
_none_
|
||||
- Returns: `200 { warehouses: T55[] }`
|
||||
- Returns: `200 { warehouses: T56[] }`
|
||||
#### Create warehouse — `POST /warehouse`
|
||||
- Params: `body: { name: str, sort?: int }`
|
||||
- Returns: `201 T53`
|
||||
- Returns: `201 T54`
|
||||
#### Reorder warehouses — `PUT /warehouse/order`
|
||||
- Params: `body: { warehouseIds: int[] }`
|
||||
- Returns: `200 { warehouseIds: int[] }`
|
||||
@@ -965,10 +972,10 @@ _none_
|
||||
- Returns: `204 void`
|
||||
#### Get warehouse by ID — `GET /warehouse/{id}`
|
||||
- Params: `id: int`
|
||||
- Returns: `200 T53`
|
||||
- Returns: `200 T54`
|
||||
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T53`
|
||||
- Returns: `200 T54`
|
||||
#### Update warehouse — `PUT /warehouse/{id}`
|
||||
- Params: `id: int, body: { name?: str, sort?: int }`
|
||||
- Returns: `200 T53`
|
||||
- Returns: `200 T54`
|
||||
|
||||
@@ -87,4 +87,17 @@ export const order = {
|
||||
return http(`/api/order/${id}/pdf`, { method: 'POST' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/order/:id/shipment
|
||||
* Creates shipment from order.
|
||||
* @param {number|string} id Order ID
|
||||
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku: string, amount?: number }> }
|
||||
* → 201 { shipment: Shipment }
|
||||
*/
|
||||
// #region POST /api/order/:id/shipment — createShipment
|
||||
createShipment(id, body = {}) {
|
||||
return http(`/api/order/${id}/shipment`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
@@ -86,5 +86,18 @@ export const quote = {
|
||||
return http(`/api/quote/${id}/file/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* POST /api/quote/:id/order
|
||||
* Converts quote into sales order.
|
||||
* @param {number|string} id Quote ID
|
||||
* @param {object} [body] Optional overrides: { number, date, entityId, externalNumber, lines }
|
||||
* → 201 { order: Order }
|
||||
*/
|
||||
// #region POST /api/quote/:id/order — createOrder
|
||||
createOrder(id, body = {}) {
|
||||
return http(`/api/quote/${id}/order`, { method: 'POST', body });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,26 @@ import { http } from './http.js';
|
||||
* Manifest number format: <orderNumber><shipmentPrefix><index>, e.g. AU-1001-L-1.
|
||||
*/
|
||||
export const shipment = {
|
||||
// #region GET /api/shipment — list
|
||||
list(params = {}) {
|
||||
const q = new URLSearchParams();
|
||||
if (params.offset != null) q.set('offset', String(params.offset));
|
||||
if (params.limit != null) q.set('limit', String(params.limit));
|
||||
if (params.search) q.set('search', String(params.search));
|
||||
if (params.state) q.set('state', String(params.state));
|
||||
if (params.orderId != null) q.set('orderId', String(params.orderId));
|
||||
if (params.seekMonths != null) q.set('seekMonths', String(params.seekMonths));
|
||||
const qs = q.toString();
|
||||
return http(`/api/shipment${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region PATCH /api/shipment/:id — update
|
||||
update(id, body) {
|
||||
return http(`/api/shipment/${id}`, { method: 'PATCH', body });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region GET /api/order/:id/shipment — listByOrder
|
||||
listByOrder(orderId) {
|
||||
return http(`/api/order/${orderId}/shipment`);
|
||||
|
||||
@@ -88,6 +88,7 @@ export { default as customergroupAutomat } from './customergroupAutomat';
|
||||
export { default as numberAutomat } from './numberAutomat';
|
||||
export { default as accountAutomat } from './accountAutomat';
|
||||
export { default as backupAutomat } from './backupAutomat';
|
||||
export { default as shipmentAutomat } from './shipmentAutomat';
|
||||
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
|
||||
|
||||
|
||||
@@ -181,6 +182,18 @@ export {
|
||||
|
||||
export { default as vfsAutomat } from './vfsAutomat';
|
||||
|
||||
export { default as pinnedAppletAutomat } from './pinnedAppletAutomat';
|
||||
export {
|
||||
isPinned,
|
||||
getPinnedForRoute,
|
||||
pinApplet,
|
||||
unpinApplet,
|
||||
togglePin,
|
||||
executeApplet,
|
||||
closeExecution,
|
||||
normalizeRoute,
|
||||
} from './pinnedAppletAutomat';
|
||||
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
|
||||
304
src/automats/pinnedAppletAutomat.js
Normal file
304
src/automats/pinnedAppletAutomat.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* @file pinnedAppletAutomat.js
|
||||
* @description State container for applets pinned to browser routes.
|
||||
* Persists pin assignments in company settings using the 'applet.pinned' namespace,
|
||||
* with local storage fallback per company.
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Subscribes to auth changes — loads pinned applet settings when authed
|
||||
* with a selectedCompanyId, and resets when anon/logged out.
|
||||
* - UI / VfsAppletRunner: Calls `pinApplet`, `unpinApplet`, `togglePin`.
|
||||
* - UI / PinnedAppletFabs: Calls `executeApplet`, `closeExecution`.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { setting as settingApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
|
||||
/**
|
||||
* Normalizes a browser route pathname (strips trailing slashes, hashes, query params).
|
||||
* @param {string} rawRoute
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeRoute(rawRoute) {
|
||||
if (!rawRoute) return '/';
|
||||
const clean = String(rawRoute).split('?')[0].split('#')[0];
|
||||
const trimmed = clean.replace(/\/+$/, '');
|
||||
return trimmed || '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} PinnedAppletState
|
||||
* @property {Record<string, string[]>} pinnedByRoute Mapping of normalized route -> array of applet file paths
|
||||
* @property {'idle'|'loading'|'ready'} status
|
||||
* @property {string|null} activeExecution Currently active applet fileName to execute in dialog
|
||||
*/
|
||||
|
||||
/** @type {PinnedAppletState} */
|
||||
const initialState = {
|
||||
pinnedByRoute: {},
|
||||
status: 'idle',
|
||||
activeExecution: null,
|
||||
};
|
||||
|
||||
let pinnedAppletAutomat;
|
||||
|
||||
const actions = {
|
||||
/**
|
||||
* Check if an applet is pinned to the specified route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPinned(fileName, rawRoute) {
|
||||
if (!fileName) return false;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||
return Array.isArray(list) && list.includes(fileName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all applet file paths pinned to the specified route.
|
||||
* @param {string} rawRoute
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getPinnedForRoute(rawRoute) {
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
|
||||
return Array.isArray(list) ? list : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Pin an applet to a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
*/
|
||||
pinApplet(fileName, rawRoute) {
|
||||
if (!fileName) return;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||
const list = current[route] ? [...current[route]] : [];
|
||||
if (!list.includes(fileName)) {
|
||||
list.push(fileName);
|
||||
const next = { ...current, [route]: list };
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||
void actions.save(route);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unpin an applet from a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
*/
|
||||
unpinApplet(fileName, rawRoute) {
|
||||
if (!fileName) return;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const current = pinnedAppletAutomat.state.pinnedByRoute;
|
||||
const list = current[route] ? current[route].filter((f) => f !== fileName) : [];
|
||||
const next = { ...current };
|
||||
if (list.length > 0) {
|
||||
next[route] = list;
|
||||
} else {
|
||||
delete next[route];
|
||||
}
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: next });
|
||||
void actions.save(route);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle pin status for an applet on a route.
|
||||
* @param {string} fileName
|
||||
* @param {string} rawRoute
|
||||
* @returns {boolean} New pinned status
|
||||
*/
|
||||
togglePin(fileName, rawRoute) {
|
||||
if (!fileName) return false;
|
||||
const route = normalizeRoute(rawRoute);
|
||||
const isCurrentlyPinned = actions.isPinned(fileName, route);
|
||||
if (isCurrentlyPinned) {
|
||||
actions.unpinApplet(fileName, route);
|
||||
return false;
|
||||
}
|
||||
actions.pinApplet(fileName, route);
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Open dialog to execute the specified applet.
|
||||
* @param {string} fileName
|
||||
*/
|
||||
executeApplet(fileName) {
|
||||
pinnedAppletAutomat.setState({ activeExecution: fileName });
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the execution dialog.
|
||||
*/
|
||||
closeExecution() {
|
||||
pinnedAppletAutomat.setState({ activeExecution: null });
|
||||
},
|
||||
|
||||
/**
|
||||
* Load pinned applets from company settings namespace and localStorage.
|
||||
*/
|
||||
async load() {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
if (!companyId) return;
|
||||
|
||||
pinnedAppletAutomat.setState({ status: 'loading' });
|
||||
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||
let loadedMap = null;
|
||||
|
||||
// Fast-path: Synchronous read from localStorage to eliminate blank flash
|
||||
try {
|
||||
const rawLocal = localStorage.getItem(storageKey);
|
||||
if (rawLocal) {
|
||||
loadedMap = JSON.parse(rawLocal);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[pinnedAppletAutomat] localStorage read failed:', err);
|
||||
}
|
||||
|
||||
if (loadedMap && typeof loadedMap === 'object') {
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||
}
|
||||
|
||||
// Network read: Try company settings API with 'applet.pinned' namespace
|
||||
try {
|
||||
const res = await settingApi.get('applet.pinned');
|
||||
const val = res?.setting?.value;
|
||||
if (val) {
|
||||
const parsed = JSON.parse(val);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
loadedMap = { ...(loadedMap || {}), ...parsed };
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(loadedMap));
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore if key is not yet stored on backend
|
||||
}
|
||||
|
||||
// Also scan company settings list for any keys in namespace 'applet.pinned:'
|
||||
try {
|
||||
const listRes = await settingApi.list({ frontendOnly: true });
|
||||
const settings = Array.isArray(listRes?.settings) ? listRes.settings : [];
|
||||
let merged = false;
|
||||
const current = { ...(loadedMap || pinnedAppletAutomat.state.pinnedByRoute) };
|
||||
|
||||
for (const s of settings) {
|
||||
if (s.key === 'applet.pinned' && s.value) {
|
||||
try {
|
||||
const parsed = JSON.parse(s.value);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
Object.assign(current, parsed);
|
||||
merged = true;
|
||||
}
|
||||
} catch {}
|
||||
} else if (s.key && s.key.startsWith('applet.pinned:') && s.value) {
|
||||
const route = s.key.slice('applet.pinned:'.length);
|
||||
try {
|
||||
const parsed = JSON.parse(s.value);
|
||||
if (Array.isArray(parsed)) {
|
||||
current[route] = parsed;
|
||||
merged = true;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (merged) {
|
||||
pinnedAppletAutomat.setState({ pinnedByRoute: current, status: 'ready' });
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(current));
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors
|
||||
}
|
||||
|
||||
pinnedAppletAutomat.setState({ status: 'ready' });
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist pinned applets into company settings namespace and localStorage.
|
||||
* @param {string} [route] Optional route updated
|
||||
*/
|
||||
async save(route = null) {
|
||||
const companyId = authAutomat.state.user?.selectedCompanyId;
|
||||
const { pinnedByRoute } = pinnedAppletAutomat.state;
|
||||
|
||||
if (companyId) {
|
||||
const storageKey = `dash_company_${companyId}_applet_pinned`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(pinnedByRoute));
|
||||
} catch (err) {
|
||||
console.warn('[pinnedAppletAutomat] localStorage write failed:', err);
|
||||
}
|
||||
|
||||
// Upsert into company settings namespace 'applet.pinned'
|
||||
try {
|
||||
await settingApi.upsert('applet.pinned', {
|
||||
value: JSON.stringify(pinnedByRoute),
|
||||
frontend: true,
|
||||
});
|
||||
} catch {
|
||||
// Backend key validation fallback
|
||||
}
|
||||
|
||||
// Also upsert route-specific key in namespace
|
||||
if (route) {
|
||||
try {
|
||||
const routeKey = `applet.pinned:${route}`;
|
||||
const routeVal = JSON.stringify(pinnedByRoute[route] || []);
|
||||
await settingApi.upsert(routeKey, {
|
||||
value: routeVal,
|
||||
frontend: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset on logout or company switch.
|
||||
*/
|
||||
reset() {
|
||||
pinnedAppletAutomat.setState(initialState);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Pinned Applet state container.
|
||||
* @type {Automat<PinnedAppletState>}
|
||||
*/
|
||||
pinnedAppletAutomat = new Automat(initialState, actions, { name: 'pinnedApplet' });
|
||||
|
||||
// Subscribe to auth lifecycle
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId;
|
||||
if (auth.status === 'authed' && companyId) {
|
||||
void pinnedAppletAutomat.actions.load();
|
||||
} else if (auth.status === 'anon') {
|
||||
pinnedAppletAutomat.actions.reset();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-load if auth is already established when module loads
|
||||
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
|
||||
void pinnedAppletAutomat.actions.load();
|
||||
}
|
||||
|
||||
export default pinnedAppletAutomat;
|
||||
export const {
|
||||
isPinned,
|
||||
getPinnedForRoute,
|
||||
pinApplet,
|
||||
unpinApplet,
|
||||
togglePin,
|
||||
executeApplet,
|
||||
closeExecution,
|
||||
} = pinnedAppletAutomat.actions;
|
||||
240
src/automats/shipmentAutomat.js
Normal file
240
src/automats/shipmentAutomat.js
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @file shipmentAutomat.js
|
||||
* @description Manages cached and paginated company shipment manifests (Lieferscheine) (`GET /api/shipment`).
|
||||
*
|
||||
* Upstream Triggers:
|
||||
* - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes.
|
||||
* - `sseAutomat`: Triggers `actions.reload()` on `'shipment'` SSE events.
|
||||
*
|
||||
* Actions:
|
||||
* - `load()`: Fetches shipments with current filters.
|
||||
* - `reload()`: Silently re-fetches shipments.
|
||||
* - `setFilter(filter)`: Updates free-text search filter.
|
||||
* - `setStateFilter(state)`: Updates delivery state filter ('', 'new', 'delivered', 'returned').
|
||||
* - `updateState(id, state)`: Calls `PATCH /api/shipment/:id` and updates local cache.
|
||||
* - `deleteShipment(id)`: Calls `DELETE /api/shipment/:id` and removes from local cache.
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import { shipment as shipmentApi } from '../api';
|
||||
import authAutomat from './authAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShipmentSummary
|
||||
* @property {number} id
|
||||
* @property {string} number
|
||||
* @property {'shipment'} type
|
||||
* @property {string} date
|
||||
* @property {string|null} createdAt
|
||||
* @property {number|null} orderId
|
||||
* @property {string|null} orderNumber
|
||||
* @property {number|null} entityId
|
||||
* @property {string|null} customerName
|
||||
* @property {number} positions
|
||||
* @property {number} totalQuantity
|
||||
* @property {number} packageCount
|
||||
* @property {string|null} carrier
|
||||
* @property {string|null} trackingCode
|
||||
* @property {'new'|'delivered'|'returned'} state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ShipmentAutomatState
|
||||
* @property {ShipmentSummary[]|null} items
|
||||
* @property {number} total
|
||||
* @property {number} unfilteredTotal
|
||||
* @property {string} filter
|
||||
* @property {string} stateFilter
|
||||
* @property {number} seekMonths
|
||||
* @property {'idle'|'loading'|'ready'|'failed'} status
|
||||
* @property {Error|string|null} error
|
||||
*/
|
||||
|
||||
const initialState = {
|
||||
items: null,
|
||||
total: 0,
|
||||
unfilteredTotal: 0,
|
||||
filter: '',
|
||||
stateFilter: '',
|
||||
seekMonths: 0,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
let automat;
|
||||
let inflightLoad = null;
|
||||
let debounceTimer = null;
|
||||
|
||||
// #region fetch helper
|
||||
async function fetchShipments(state) {
|
||||
const params = {
|
||||
offset: 0,
|
||||
limit: 100,
|
||||
};
|
||||
if (state.filter && state.filter.trim()) {
|
||||
params.search = state.filter.trim();
|
||||
}
|
||||
if (state.stateFilter) {
|
||||
params.state = state.stateFilter;
|
||||
}
|
||||
if (state.seekMonths) {
|
||||
params.seekMonths = state.seekMonths;
|
||||
}
|
||||
|
||||
const res = await shipmentApi.list(params);
|
||||
return {
|
||||
items: res?.shipments || [],
|
||||
total: res?.total ?? (res?.shipments?.length || 0),
|
||||
unfilteredTotal: res?.unfilteredTotal ?? res?.total ?? (res?.shipments?.length || 0),
|
||||
};
|
||||
}
|
||||
// #endregion
|
||||
|
||||
const actions = {
|
||||
// #region load & reload
|
||||
async load() {
|
||||
if (inflightLoad) return inflightLoad;
|
||||
|
||||
automat.setState((s) => ({
|
||||
status: s.items === null ? 'loading' : s.status,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
inflightLoad = (async () => {
|
||||
try {
|
||||
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
|
||||
automat.setState({
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return items;
|
||||
} catch (err) {
|
||||
automat.setState({
|
||||
status: 'failed',
|
||||
error: err,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
inflightLoad = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inflightLoad;
|
||||
},
|
||||
|
||||
async reload() {
|
||||
try {
|
||||
const { items, total, unfilteredTotal } = await fetchShipments(automat.state);
|
||||
automat.setState({
|
||||
items,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
});
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.warn('[shipmentAutomat] silent reload failed:', err);
|
||||
}
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region filters
|
||||
setFilter(filterText) {
|
||||
const next = typeof filterText === 'string' ? filterText : '';
|
||||
if (automat.state.filter === next) return;
|
||||
|
||||
automat.setState({ filter: next });
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
void automat.actions.load().catch(() => {});
|
||||
}, 200);
|
||||
},
|
||||
|
||||
setStateFilter(stateFilter) {
|
||||
const next = typeof stateFilter === 'string' ? stateFilter : '';
|
||||
if (automat.state.stateFilter === next) return;
|
||||
|
||||
automat.setState({ stateFilter: next });
|
||||
void automat.actions.load().catch(() => {});
|
||||
},
|
||||
|
||||
setSeekMonths(months) {
|
||||
automat.setState({ seekMonths: months });
|
||||
void automat.actions.load().catch(() => {});
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region item mutations
|
||||
patchShipment(id, patch) {
|
||||
automat.setState((s) => {
|
||||
if (!Array.isArray(s.items)) return null;
|
||||
const next = s.items.map((item) => (item.id === id ? { ...item, ...patch } : item));
|
||||
return { items: next };
|
||||
});
|
||||
},
|
||||
|
||||
async updateState(id, newState) {
|
||||
// Optimistic update
|
||||
automat.actions.patchShipment(id, { state: newState });
|
||||
try {
|
||||
await shipmentApi.update(id, { state: newState });
|
||||
void orderAutomat.actions.reload().catch(() => {});
|
||||
} catch (err) {
|
||||
// Rollback on error
|
||||
void automat.actions.reload();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteShipment(id) {
|
||||
await shipmentApi.delete(id);
|
||||
automat.setState((s) => {
|
||||
if (!Array.isArray(s.items)) return null;
|
||||
const next = s.items.filter((item) => item.id !== id);
|
||||
return {
|
||||
items: next,
|
||||
total: Math.max(0, s.total - 1),
|
||||
unfilteredTotal: Math.max(0, s.unfilteredTotal - 1),
|
||||
};
|
||||
});
|
||||
void orderAutomat.actions.reload().catch(() => {});
|
||||
},
|
||||
|
||||
reset() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
inflightLoad = null;
|
||||
automat.setState({ ...initialState });
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
|
||||
automat = new Automat(initialState, actions, { name: 'shipment' });
|
||||
|
||||
// #region authAutomat synchronization
|
||||
let lastCompanyId = null;
|
||||
authAutomat.subscribe((auth) => {
|
||||
const companyId = auth.user?.selectedCompanyId ?? null;
|
||||
if (auth.status === 'anon') {
|
||||
automat.actions.reset();
|
||||
lastCompanyId = null;
|
||||
return;
|
||||
}
|
||||
if (lastCompanyId !== null && companyId !== lastCompanyId) {
|
||||
automat.actions.reset();
|
||||
}
|
||||
if (auth.status === 'authed' && companyId != null) {
|
||||
const isNew = lastCompanyId !== companyId;
|
||||
lastCompanyId = companyId;
|
||||
if (isNew) {
|
||||
void automat.actions.load().catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
// #endregion
|
||||
|
||||
export default automat;
|
||||
@@ -34,6 +34,7 @@ import propertyAutomat from './propertyAutomat';
|
||||
import supplierAutomat from './supplierAutomat';
|
||||
import orderAutomat from './orderAutomat';
|
||||
import quoteAutomat from './quoteAutomat';
|
||||
import shipmentAutomat from './shipmentAutomat';
|
||||
import { handleDraftRequest, handleDraftEvent } from './draftSync';
|
||||
|
||||
|
||||
@@ -253,9 +254,12 @@ function connect() {
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'shipment', () => {
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
void shipmentAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] shipment refresh failed', err);
|
||||
});
|
||||
void orderAutomat.actions.reload().catch((err) => {
|
||||
console.error('[sse] order refresh failed', err);
|
||||
});
|
||||
});
|
||||
|
||||
addTrackedListener(es, 'draft:request', (event) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NAV_SURFACE,
|
||||
} from '../colors';
|
||||
import { ChatFab, ChatDrawer } from './chat';
|
||||
import { PinnedAppletFabs, PinnedAppletDialog } from './applet';
|
||||
|
||||
const BreakpointOverlay = 0 && import.meta.env.DEV
|
||||
? lazy(() => import('./BreakpointOverlay'))
|
||||
@@ -405,6 +406,8 @@ export default function AppShell() {
|
||||
</Box>
|
||||
<ChatFab />
|
||||
<ChatDrawer />
|
||||
<PinnedAppletFabs pathname={pathname} />
|
||||
<PinnedAppletDialog />
|
||||
{BreakpointOverlay && (
|
||||
<Suspense fallback={null}>
|
||||
<BreakpointOverlay />
|
||||
|
||||
149
src/components/applet/PinnedAppletDialog.jsx
Normal file
149
src/components/applet/PinnedAppletDialog.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import pinnedAppletAutomat from '../../automats/pinnedAppletAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
import { vfs as vfsApi } from '../../api';
|
||||
import VfsAppletRunner from '../vfs/VfsAppletRunner';
|
||||
import VfsTextEditor from '../vfs/VfsTextEditor';
|
||||
|
||||
/**
|
||||
* @file PinnedAppletDialog.jsx
|
||||
* @description Dialog modal that executes a pinned applet runner directly on the current route.
|
||||
*/
|
||||
export default class PinnedAppletDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const activeExecution = pinnedAppletAutomat.state.activeExecution;
|
||||
this.state = {
|
||||
activeExecution,
|
||||
content: '',
|
||||
loadingContent: Boolean(activeExecution),
|
||||
loadError: null,
|
||||
viewMode: 'run',
|
||||
files: vfsAutomat.state.files,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubPinned = pinnedAppletAutomat.subscribe(this, (state) => ({
|
||||
activeExecution: state.activeExecution,
|
||||
}));
|
||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||
files: state.files,
|
||||
}));
|
||||
if (this.state.activeExecution) {
|
||||
void this.loadAppletContent(this.state.activeExecution);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevState.activeExecution !== this.state.activeExecution) {
|
||||
if (this.state.activeExecution) {
|
||||
this.setState({ viewMode: 'run' });
|
||||
void this.loadAppletContent(this.state.activeExecution);
|
||||
} else {
|
||||
this.setState({ content: '', loadingContent: false, loadError: null, viewMode: 'run' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubPinned?.();
|
||||
this.unsubVfs?.();
|
||||
}
|
||||
|
||||
loadAppletContent = async (fileName) => {
|
||||
this.setState({ loadingContent: true, loadError: null });
|
||||
try {
|
||||
const text = await vfsApi.getText(fileName);
|
||||
this.setState({ content: text, loadingContent: false });
|
||||
} catch (err) {
|
||||
console.error('[PinnedAppletDialog] failed to load applet content:', err);
|
||||
this.setState({
|
||||
loadError: err?.message || 'Fehler beim Laden des Applets',
|
||||
loadingContent: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleClose = () => {
|
||||
pinnedAppletAutomat.actions.closeExecution();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeExecution, content, loadingContent, loadError, viewMode, files } = this.state;
|
||||
if (!activeExecution) return null;
|
||||
|
||||
const file = files.find((f) => f.name === activeExecution);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(activeExecution)}
|
||||
onClose={this.handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
height: '85vh',
|
||||
maxHeight: 800,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.paper',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{loadingContent ? (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1.5,
|
||||
color: 'text.secondary',
|
||||
p: 4,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={28} />
|
||||
<Typography variant="body2">Lade Applet {activeExecution}…</Typography>
|
||||
</Box>
|
||||
) : loadError ? (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="error" variant="body1">
|
||||
{loadError}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : viewMode === 'run' ? (
|
||||
<VfsAppletRunner
|
||||
fileName={activeExecution}
|
||||
file={file}
|
||||
content={content}
|
||||
loadingContent={false}
|
||||
onClose={this.handleClose}
|
||||
onShowCode={() => this.setState({ viewMode: 'code' })}
|
||||
/>
|
||||
) : (
|
||||
<VfsTextEditor
|
||||
fileName={activeExecution}
|
||||
file={file}
|
||||
content={content}
|
||||
originalContent={content}
|
||||
isDirty={false}
|
||||
loadingContent={false}
|
||||
onClose={this.handleClose}
|
||||
onRunApplet={() => this.setState({ viewMode: 'run' })}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
154
src/components/applet/PinnedAppletFabs.jsx
Normal file
154
src/components/applet/PinnedAppletFabs.jsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import pinnedAppletAutomat, { normalizeRoute } from '../../automats/pinnedAppletAutomat';
|
||||
import vfsAutomat from '../../automats/vfsAutomat';
|
||||
|
||||
/**
|
||||
* @file PinnedAppletFabs.jsx
|
||||
* @description Renders Floating Action Buttons for any applets pinned to the current browser route.
|
||||
* Clicking a FAB triggers the execution dialog for that applet.
|
||||
*/
|
||||
export default class PinnedAppletFabs extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const route = normalizeRoute(props.pathname);
|
||||
this.state = {
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
files: vfsAutomat.state.files,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubPinned = pinnedAppletAutomat.subscribe(this, () => {
|
||||
const route = normalizeRoute(this.props.pathname);
|
||||
return {
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
};
|
||||
});
|
||||
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
|
||||
files: state.files,
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.pathname !== this.props.pathname) {
|
||||
const route = normalizeRoute(this.props.pathname);
|
||||
this.setState({
|
||||
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubPinned?.();
|
||||
this.unsubVfs?.();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { pinnedFileNames, files } = this.state;
|
||||
if (!pinnedFileNames || pinnedFileNames.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
position: 'fixed',
|
||||
bottom: { xs: 20, sm: 28 },
|
||||
right: { xs: 20, sm: 28 },
|
||||
zIndex: theme.zIndex.speedDial || 1050,
|
||||
display: 'flex',
|
||||
flexDirection: 'column-reverse',
|
||||
alignItems: 'flex-end',
|
||||
gap: 1.5,
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
>
|
||||
{pinnedFileNames.map((fileName) => {
|
||||
const file = files.find((f) => f.name === fileName);
|
||||
const baseName = fileName.split('/').pop().replace(/\.applet\.js$/i, '');
|
||||
const title = file?.description || baseName;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={fileName}
|
||||
placement="left"
|
||||
arrow
|
||||
title={
|
||||
<Box sx={{ p: 0.5, maxWidth: 280 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700} sx={{ fontSize: '0.82rem' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ display: 'block', opacity: 0.8, fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
{fileName}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="primary.light"
|
||||
sx={{ display: 'block', mt: 0.5, fontStyle: 'italic', fontWeight: 600 }}
|
||||
>
|
||||
Klicken zum Ausführen
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
overlap="circular"
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
badgeContent={
|
||||
<PushPinIcon
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
transform: 'rotate(25deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{
|
||||
pointerEvents: 'auto',
|
||||
'& .MuiBadge-badge': {
|
||||
bgcolor: 'primary.dark',
|
||||
border: '2px solid #fff',
|
||||
borderRadius: '50%',
|
||||
width: 20,
|
||||
height: 20,
|
||||
minWidth: 20,
|
||||
p: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Fab
|
||||
color="primary"
|
||||
aria-label={`Applet ${title} ausführen`}
|
||||
onClick={() => pinnedAppletAutomat.actions.executeApplet(fileName)}
|
||||
sx={(theme) => ({
|
||||
width: 52,
|
||||
height: 52,
|
||||
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
|
||||
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.25)',
|
||||
transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.08) translateY(-2px)',
|
||||
boxShadow: '0 10px 26px rgba(0, 0, 0, 0.35)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<PlayArrowRoundedIcon sx={{ fontSize: 28 }} />
|
||||
</Fab>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/components/applet/index.js
Normal file
2
src/components/applet/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as PinnedAppletFabs } from './PinnedAppletFabs';
|
||||
export { default as PinnedAppletDialog } from './PinnedAppletDialog';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
@@ -7,16 +7,18 @@ import { PanelTileGrid } from 'dashgui/panels';
|
||||
import { delivery as deliveryApi, quote as quoteApi, order as orderApi, shipment as shipmentApi, payment as paymentApi } from '../../api';
|
||||
import { requireArray, requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { notifyError, notifyErrorMessage } from '../../utils/notify';
|
||||
import withNavigate from '../withNavigate';
|
||||
import ManifestHeader from './ManifestHeader';
|
||||
import ManifestLines from './ManifestLines';
|
||||
import ManifestPdfDialog from './ManifestPdfDialog';
|
||||
import ArticleSearchDialog from './ArticleSearchDialog';
|
||||
import OrderDocumentsTiles from './OrderDocumentsTiles';
|
||||
import OrderShipmentDialog from './OrderShipmentDialog';
|
||||
import artikelAutomat, { getArticle } from '../../automats/artikelAutomat';
|
||||
import entityAutomat from '../../automats/entityAutomat';
|
||||
import supplierAutomat from '../../automats/supplierAutomat';
|
||||
import orderAutomat from '../../automats/orderAutomat';
|
||||
import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat';
|
||||
import { netFromGross, grossFromNet, getArticleGross } from '../../utils/price';
|
||||
import {
|
||||
@@ -95,14 +97,18 @@ class ManifestForm extends PureComponent {
|
||||
dirtyLineFields: initialEdit?.dirtyLineFields || {},
|
||||
dirtySummary: initialEdit?.dirtySummary || [],
|
||||
saving: false,
|
||||
orderCreating: false,
|
||||
partyError: false,
|
||||
pdfUploading: false,
|
||||
pdfCreating: false,
|
||||
pdfDialogOpen: false,
|
||||
shipmentDialogOpen: false,
|
||||
pdfDialogData: null,
|
||||
pdfFileName: '',
|
||||
articleSearchDialogOpen: false,
|
||||
articleSearchLine: null,
|
||||
};
|
||||
this.headerRef = createRef();
|
||||
this._cancelled = false;
|
||||
this.handleRetry = this.handleRetry.bind(this);
|
||||
this.handleRestore = this.handleRestore.bind(this);
|
||||
@@ -122,11 +128,16 @@ class ManifestForm extends PureComponent {
|
||||
this.handleMoveLine = this.handleMoveLine.bind(this);
|
||||
this.handleRemoveAllLines = this.handleRemoveAllLines.bind(this);
|
||||
this.handleSave = this.handleSave.bind(this);
|
||||
this.handleCreateOrder = this.handleCreateOrder.bind(this);
|
||||
this.handleOpenCreateShipment = this.handleOpenCreateShipment.bind(this);
|
||||
this.handleCloseShipmentDialog = this.handleCloseShipmentDialog.bind(this);
|
||||
this.handleShipmentCreated = this.handleShipmentCreated.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);
|
||||
this.isFullyDelivered = this.isFullyDelivered.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -457,10 +468,10 @@ class ManifestForm extends PureComponent {
|
||||
const { mode } = this.props;
|
||||
const { requirePartyBeforeLines } = this.cfg;
|
||||
if (mode === 'create' && requirePartyBeforeLines && partyId == null) {
|
||||
this.setState({ partyId: null, lines: [emptyLine()] });
|
||||
this.setState({ partyId: null, lines: [emptyLine()], partyError: false });
|
||||
return;
|
||||
}
|
||||
this.setState({ partyId });
|
||||
this.setState({ partyId, partyError: false });
|
||||
}
|
||||
|
||||
handleDateChange(date) {
|
||||
@@ -755,10 +766,46 @@ class ManifestForm extends PureComponent {
|
||||
}));
|
||||
}
|
||||
|
||||
isFullyDelivered() {
|
||||
if (this.state.delivered) return true;
|
||||
const lines = this.state.lines || [];
|
||||
const validLines = lines.filter((l) => l.sku && Number(l.amount) > 0);
|
||||
if (validLines.length === 0) return false;
|
||||
|
||||
const shippedBySku = {};
|
||||
for (const sh of this.state.shipments || []) {
|
||||
for (const sl of sh.lines || []) {
|
||||
if (sl.sku) {
|
||||
const k = String(sl.sku).trim().toLowerCase();
|
||||
shippedBySku[k] = (shippedBySku[k] || 0) + (Number(sl.amount) || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validLines.every((l) => {
|
||||
const k = String(l.sku).trim().toLowerCase();
|
||||
return (shippedBySku[k] || 0) >= Number(l.amount);
|
||||
});
|
||||
}
|
||||
|
||||
handleRemoveLine(key) {
|
||||
this.setState((prev) => {
|
||||
const lineToRemove = prev.lines.find((line) => line.key === key);
|
||||
if (lineToRemove && this.props.kind === 'order') {
|
||||
const rawSku = lineToRemove.sku != null ? String(lineToRemove.sku).trim().toLowerCase() : '';
|
||||
const isShipped = Boolean(
|
||||
rawSku &&
|
||||
(prev.shipments || []).some((sh) =>
|
||||
(sh.lines || []).some((sl) => sl.sku && String(sl.sku).trim().toLowerCase() === rawSku)
|
||||
)
|
||||
);
|
||||
if (isShipped) {
|
||||
enqueueSnackbar(`Artikel „${lineToRemove.sku || lineToRemove.name}“ ist bereits auf einem Lieferschein und kann nicht entfernt werden.`, { variant: 'warning' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const next = prev.lines.filter((line) => line.key !== key);
|
||||
return { lines: next.length > 0 ? next : [emptyLine()] };
|
||||
return { lines: next.length > 0 ? next : [emptyLine()], isDirty: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -776,7 +823,29 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
|
||||
handleRemoveAllLines() {
|
||||
this.setState({ lines: [] });
|
||||
this.setState((prev) => {
|
||||
if (this.props.kind === 'order') {
|
||||
const shippedSkus = new Set();
|
||||
for (const sh of prev.shipments || []) {
|
||||
for (const sl of sh.lines || []) {
|
||||
if (sl.sku) shippedSkus.add(String(sl.sku).trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
if (shippedSkus.size > 0) {
|
||||
const remaining = prev.lines.filter((l) => {
|
||||
const raw = l.sku != null ? String(l.sku).trim().toLowerCase() : '';
|
||||
return shippedSkus.has(raw);
|
||||
});
|
||||
if (remaining.length === prev.lines.length) {
|
||||
enqueueSnackbar('Alle Positionen sind bereits auf Lieferscheinen und können nicht gelöscht werden.', { variant: 'warning' });
|
||||
return null;
|
||||
}
|
||||
enqueueSnackbar('Nicht ausgelieferte Positionen wurden entfernt.', { variant: 'info' });
|
||||
return { lines: remaining.length > 0 ? remaining : [emptyLine()], isDirty: true };
|
||||
}
|
||||
}
|
||||
return { lines: [emptyLine()], isDirty: true };
|
||||
});
|
||||
}
|
||||
|
||||
async handlePdfUpload(file) {
|
||||
@@ -945,6 +1014,74 @@ class ManifestForm extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: calls quoteApi.createOrder from ./api/index.js
|
||||
// #region POST /api/quote/:id/order — createOrder
|
||||
async handleCreateOrder(e) {
|
||||
e?.preventDefault?.();
|
||||
e?.stopPropagation?.();
|
||||
const { docId, kind } = this.props;
|
||||
if (this.state.orderCreating || this.state.saving) return;
|
||||
if (docId == null || kind !== 'quote') return;
|
||||
|
||||
if (!this.state.partyId) {
|
||||
this.setState({ partyError: true });
|
||||
notifyErrorMessage(
|
||||
'Für eine Bestellung muss ein Kunde ausgewählt sein. Bitte wählen Sie zuerst einen Kunden aus.',
|
||||
);
|
||||
this.headerRef.current?.focusPartyInput?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.isDirty) {
|
||||
const saved = await this.handleSave();
|
||||
if (!saved) return;
|
||||
}
|
||||
|
||||
this.setState({ orderCreating: true });
|
||||
try {
|
||||
const res = await quoteApi.createOrder(docId, {
|
||||
entityId: this.state.partyId,
|
||||
});
|
||||
requireObject(res, 'POST /api/quote/:id/order');
|
||||
const order = requireObject(res.order, 'order');
|
||||
requireDefined(order.id, 'order.id');
|
||||
|
||||
void orderAutomat.actions.reload();
|
||||
|
||||
enqueueSnackbar(
|
||||
order.number
|
||||
? `Bestellung ${order.number} erfolgreich erstellt.`
|
||||
: 'Bestellung erfolgreich erstellt.',
|
||||
{ variant: 'success' },
|
||||
);
|
||||
|
||||
this.props.navigate(`/auftrag/bestellung/${order.id}`);
|
||||
} catch (err) {
|
||||
if (!this._cancelled) {
|
||||
notifyError(err);
|
||||
this.setState({ orderCreating: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
async handleOpenCreateShipment() {
|
||||
if (this.state.isDirty) {
|
||||
const saved = await this.handleSave();
|
||||
if (!saved) return;
|
||||
}
|
||||
this.setState({ shipmentDialogOpen: true });
|
||||
}
|
||||
|
||||
handleCloseShipmentDialog() {
|
||||
this.setState({ shipmentDialogOpen: false });
|
||||
}
|
||||
|
||||
async handleShipmentCreated() {
|
||||
await this.loadEdit();
|
||||
void orderAutomat.actions.reload();
|
||||
}
|
||||
|
||||
async handleAttachmentDelete(attachment) {
|
||||
if (!attachment) return;
|
||||
const fileId = attachment.fileId ?? attachment.id;
|
||||
@@ -975,8 +1112,8 @@ class ManifestForm extends PureComponent {
|
||||
async handleSave() {
|
||||
const { mode, docId } = this.props;
|
||||
const cfg = this.cfg;
|
||||
if (this.state.saving) return;
|
||||
if (mode === 'edit' && docId == null) return;
|
||||
if (this.state.saving) return false;
|
||||
if (mode === 'edit' && docId == null) return false;
|
||||
|
||||
const requireAtLeastOne = mode === 'create';
|
||||
let body;
|
||||
@@ -999,7 +1136,7 @@ class ManifestForm extends PureComponent {
|
||||
);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
this.setState({ saving: true });
|
||||
@@ -1012,10 +1149,10 @@ class ManifestForm extends PureComponent {
|
||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||
this.draftAutomat?.actions?.reset();
|
||||
await cfg.listAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled) return false;
|
||||
enqueueSnackbar(cfg.createdMsg, { variant: 'success' });
|
||||
this.props.navigate(cfg.editPath(data[cfg.docField].id));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = await api.update(docId, body);
|
||||
@@ -1023,7 +1160,7 @@ class ManifestForm extends PureComponent {
|
||||
requireObject(data[cfg.docField], cfg.docField);
|
||||
requireDefined(data[cfg.docField].id, `${cfg.docField}.id`);
|
||||
await cfg.listAutomat.actions.reload();
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled) return false;
|
||||
const doc = data[cfg.docField];
|
||||
const apiLines = requireArray(doc.lines, `${cfg.docField}.lines`);
|
||||
const savedDoc = {
|
||||
@@ -1051,16 +1188,18 @@ class ManifestForm extends PureComponent {
|
||||
saving: false,
|
||||
});
|
||||
enqueueSnackbar(cfg.savedMsg, { variant: 'success' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (this._cancelled) return;
|
||||
if (this._cancelled) return false;
|
||||
notifyError(err);
|
||||
this.setState({ saving: false });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
renderArticles() {
|
||||
const cfg = this.cfg;
|
||||
const { lines, saving, dirtyLineFields } = this.state;
|
||||
const { lines, saving, dirtyLineFields, shipments } = this.state;
|
||||
|
||||
return (
|
||||
<ManifestLines
|
||||
@@ -1071,6 +1210,7 @@ class ManifestForm extends PureComponent {
|
||||
wide={4}
|
||||
linesColor={cfg.linesColor}
|
||||
dirtyLineFields={dirtyLineFields || {}}
|
||||
shipments={shipments}
|
||||
onLineChange={this.handleLineChange}
|
||||
onSkuBlur={this.handleSkuBlur}
|
||||
onAddLine={this.handleAddLine}
|
||||
@@ -1120,6 +1260,7 @@ class ManifestForm extends PureComponent {
|
||||
<>
|
||||
<PanelTileGrid>
|
||||
<ManifestHeader
|
||||
ref={this.headerRef}
|
||||
kind={kind}
|
||||
headerColor={cfg.headerColor}
|
||||
dateLabel={cfg.dateLabel}
|
||||
@@ -1127,10 +1268,12 @@ class ManifestForm extends PureComponent {
|
||||
parties={parties}
|
||||
partiesLoading={partiesLoading || status === 'loadingParties'}
|
||||
partyId={partyId}
|
||||
partyError={this.state.partyError}
|
||||
date={date}
|
||||
number={number}
|
||||
externalNumber={externalNumber}
|
||||
saving={saving}
|
||||
orderCreating={this.state.orderCreating}
|
||||
pdfUploading={pdfUploading}
|
||||
pdfCreating={this.state.pdfCreating}
|
||||
lines={lines}
|
||||
@@ -1143,6 +1286,9 @@ class ManifestForm extends PureComponent {
|
||||
onPdfUpload={kind === 'delivery' ? this.handlePdfUpload : undefined}
|
||||
onPdfDelete={this.handleAttachmentDelete}
|
||||
onPdfCreate={kind === 'quote' || kind === 'order' ? this.handleCreatePdf : undefined}
|
||||
onCreateOrder={kind === 'quote' && this.props.mode === 'edit' ? this.handleCreateOrder : undefined}
|
||||
onOpenCreateShipment={kind === 'order' && this.props.mode === 'edit' ? this.handleOpenCreateShipment : undefined}
|
||||
deliverDisabled={kind === 'order' && this.isFullyDelivered()}
|
||||
onSave={this.handleSave}
|
||||
onPartyChange={this.handlePartyChange}
|
||||
onDateChange={this.handleDateChange}
|
||||
@@ -1163,9 +1309,12 @@ class ManifestForm extends PureComponent {
|
||||
shipments={this.state.shipments}
|
||||
payments={this.state.payments}
|
||||
attachments={this.state.attachments}
|
||||
deliverDisabled={this.isFullyDelivered()}
|
||||
onPdfCreate={this.handleCreatePdf}
|
||||
pdfCreating={this.state.pdfCreating}
|
||||
onAttachmentDelete={this.handleAttachmentDelete}
|
||||
onOpenCreateShipment={this.handleOpenCreateShipment}
|
||||
onShipmentChanged={this.handleShipmentCreated}
|
||||
navigate={this.props.navigate}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1184,6 +1333,16 @@ class ManifestForm extends PureComponent {
|
||||
onClose={this.handleCloseArticleSearch}
|
||||
onAssign={this.handleAssignArticleForLine}
|
||||
/>
|
||||
<OrderShipmentDialog
|
||||
open={this.state.shipmentDialogOpen}
|
||||
onClose={this.handleCloseShipmentDialog}
|
||||
orderId={this.props.docId}
|
||||
orderNumber={number}
|
||||
externalNumber={externalNumber}
|
||||
lines={lines}
|
||||
shipments={this.state.shipments}
|
||||
onSuccess={this.handleShipmentCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ class ManifestFormLine extends PureComponent {
|
||||
}
|
||||
|
||||
handleRemove() {
|
||||
if (this.props.isOnShipment) return;
|
||||
this.props.onRemove(this.props.line.key);
|
||||
}
|
||||
|
||||
@@ -308,7 +309,8 @@ class ManifestFormLine extends PureComponent {
|
||||
type={type}
|
||||
value={val ?? ''}
|
||||
error={isError}
|
||||
disabled={saving}
|
||||
disabled={saving || (id === 'sku' && Boolean(this.props.isOnShipment))}
|
||||
title={id === 'sku' && this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht geändert werden' : undefined}
|
||||
autoComplete="off"
|
||||
onChange={(e) => onChange(line.key, id, e.target.value)}
|
||||
onBlur={(e) => this.handleBlur(e, id)}
|
||||
@@ -1025,19 +1027,16 @@ class ManifestFormLine extends PureComponent {
|
||||
</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>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label="Zeile entfernen"
|
||||
disabled={saving || Boolean(this.props.isOnShipment)}
|
||||
onClick={this.handleRemove}
|
||||
title={this.props.isOnShipment ? 'Artikel ist bereits auf einem Lieferschein und kann nicht entfernt werden' : 'Zeile entfernen'}
|
||||
sx={{ p: 0.35 }}
|
||||
>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -8,6 +8,8 @@ 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 ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
@@ -36,6 +38,8 @@ class ManifestHeader extends PureComponent {
|
||||
createdParty: null,
|
||||
};
|
||||
this.fileInputRef = createRef();
|
||||
this.partyInputRef = createRef();
|
||||
this.focusPartyInput = this.focusPartyInput.bind(this);
|
||||
this.handlePartyChange = this.handlePartyChange.bind(this);
|
||||
this.handlePartyInputChange = this.handlePartyInputChange.bind(this);
|
||||
this.handlePartyKeyDown = this.handlePartyKeyDown.bind(this);
|
||||
@@ -56,6 +60,13 @@ class ManifestHeader extends PureComponent {
|
||||
this.setState({ partyInput: '', createdParty: null });
|
||||
}
|
||||
}
|
||||
if (!prevProps.partyError && this.props.partyError) {
|
||||
this.partyInputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
focusPartyInput() {
|
||||
this.partyInputRef.current?.focus();
|
||||
}
|
||||
|
||||
handlePartyChange(_event, option) {
|
||||
@@ -237,7 +248,12 @@ class ManifestHeader extends PureComponent {
|
||||
onExternalNumberChange,
|
||||
onSave,
|
||||
onRestore,
|
||||
orderCreating,
|
||||
onCreateOrder,
|
||||
onOpenCreateShipment,
|
||||
deliverDisabled,
|
||||
actionsColor,
|
||||
partyError,
|
||||
docId,
|
||||
ledgerSummary,
|
||||
onOpenBookDialog,
|
||||
@@ -258,7 +274,8 @@ class ManifestHeader extends PureComponent {
|
||||
(this.state.createdParty?.id === partyId ? this.state.createdParty : null);
|
||||
|
||||
const isDateEmpty = !date || String(date).trim() === '';
|
||||
const isPartyEmpty = kind !== 'quote' && (partyId == null || partyId === '');
|
||||
const isPartyEmpty =
|
||||
(kind !== 'quote' || Boolean(partyError)) && (partyId == null || partyId === '');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -342,8 +359,10 @@ class ManifestHeader extends PureComponent {
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={this.partyInputRef}
|
||||
label={dirtyFields?.partyId ? `${partyLabel} *` : partyLabel}
|
||||
error={isPartyEmpty}
|
||||
helperText={Boolean(partyError) && isPartyEmpty ? 'Kunde für Bestellung erforderlich' : undefined}
|
||||
onKeyDown={this.handlePartyKeyDown}
|
||||
slotProps={{
|
||||
...params.slotProps,
|
||||
@@ -455,7 +474,7 @@ class ManifestHeader extends PureComponent {
|
||||
<Button
|
||||
variant="contained"
|
||||
color={isDirty ? 'warning' : 'primary'}
|
||||
disabled={saving}
|
||||
disabled={saving || orderCreating}
|
||||
onClick={onSave}
|
||||
sx={{
|
||||
height: 40,
|
||||
@@ -475,7 +494,7 @@ class ManifestHeader extends PureComponent {
|
||||
color="warning"
|
||||
size="small"
|
||||
startIcon={<RestoreIcon />}
|
||||
disabled={saving}
|
||||
disabled={saving || orderCreating}
|
||||
onClick={onRestore}
|
||||
sx={{
|
||||
height: 34,
|
||||
@@ -523,12 +542,56 @@ class ManifestHeader extends PureComponent {
|
||||
: 'Ins Lager buchen'}
|
||||
</Button>
|
||||
)}
|
||||
{kind === 'quote' && docId != null && onCreateOrder && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color={partyError && !partyId ? 'warning' : 'primary'}
|
||||
startIcon={orderCreating ? <CircularProgress size={16} color="inherit" /> : <ShoppingCartIcon />}
|
||||
disabled={saving || orderCreating || pdfCreating}
|
||||
onClick={onCreateOrder}
|
||||
title={!partyId ? 'Kunde für Bestellung erforderlich' : 'Bestellung aus diesem Angebot erstellen'}
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
width: '100%',
|
||||
px: 1.5,
|
||||
boxSizing: 'border-box',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{orderCreating ? 'Wird erstellt…' : 'Bestellung erstellen'}
|
||||
</Button>
|
||||
)}
|
||||
{kind === 'order' && docId != null && onOpenCreateShipment && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<LocalShippingOutlinedIcon />}
|
||||
disabled={saving || pdfCreating || orderCreating || deliverDisabled}
|
||||
onClick={onOpenCreateShipment}
|
||||
title={deliverDisabled ? 'Alle Positionen bereits ausgeliefert' : 'Lieferschein erstellen'}
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
height: 40,
|
||||
width: '100%',
|
||||
px: 1.5,
|
||||
boxSizing: 'border-box',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
Liefern
|
||||
</Button>
|
||||
)}
|
||||
{(kind === 'quote' || kind === 'order') && onPdfCreate && docId != null && Boolean(number) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
startIcon={pdfCreating ? <CircularProgress size={16} color="inherit" /> : <PictureAsPdfIcon />}
|
||||
disabled={saving || pdfCreating}
|
||||
disabled={saving || pdfCreating || orderCreating}
|
||||
onClick={onPdfCreate}
|
||||
title="PDF erstellen"
|
||||
sx={{
|
||||
|
||||
@@ -25,6 +25,7 @@ class ManifestLines extends PureComponent {
|
||||
saving,
|
||||
linesColor,
|
||||
dirtyLineFields = {},
|
||||
shipments,
|
||||
onLineChange,
|
||||
onAddLine,
|
||||
onRemoveLine,
|
||||
@@ -103,54 +104,67 @@ class ManifestLines extends PureComponent {
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Array.isArray(lines) &&
|
||||
lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const isDelivery = kind === 'delivery';
|
||||
const matched = isDelivery
|
||||
? supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null)
|
||||
: null;
|
||||
const isLineLoading = isDelivery
|
||||
? loadingLineKeys[lineKey] ||
|
||||
(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;
|
||||
{(() => {
|
||||
const shippedSkus = new Set();
|
||||
if (kind === 'order' && Array.isArray(shipments)) {
|
||||
for (const sh of shipments) {
|
||||
for (const sl of sh.lines || []) {
|
||||
if (sl.sku) shippedSkus.add(String(sl.sku).trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ManifestFormLine
|
||||
key={lineKey}
|
||||
index={index}
|
||||
positionIndex={index + 1}
|
||||
kind={kind}
|
||||
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}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
ledgerLine={ledgerLine}
|
||||
onBookLine={onBookLine}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
return Array.isArray(lines) &&
|
||||
lines.map((line, index) => {
|
||||
const lineKey = line.key ?? index;
|
||||
const rawSku = line.sku != null ? String(line.sku).trim() : '';
|
||||
const isOnShipment = kind === 'order' && Boolean(rawSku && shippedSkus.has(rawSku.toLowerCase()));
|
||||
const isDelivery = kind === 'delivery';
|
||||
const matched = isDelivery
|
||||
? supplierArticlesByLineKey[lineKey] ||
|
||||
(rawSku ? supplierArticlesMap[rawSku.toLowerCase()] || supplierArticlesMap[rawSku] : null)
|
||||
: null;
|
||||
const isLineLoading = isDelivery
|
||||
? loadingLineKeys[lineKey] ||
|
||||
(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
|
||||
key={lineKey}
|
||||
index={index}
|
||||
positionIndex={index + 1}
|
||||
kind={kind}
|
||||
line={line.key != null ? line : { ...line, key: index }}
|
||||
columns={columns}
|
||||
saving={saving}
|
||||
dirtyFields={lineDirtyFields}
|
||||
supplierArticle={matched}
|
||||
loading={Boolean(isLineLoading)}
|
||||
supplierArticleBusy={Boolean(busySupplierArticleLineKeys[lineKey])}
|
||||
isOnShipment={isOnShipment}
|
||||
onChange={onLineChange}
|
||||
onRemove={onRemoveLine}
|
||||
onMove={onMoveLine}
|
||||
isFirstLine={index === 0}
|
||||
isLastLine={index === lines.length - 1}
|
||||
onSkuBlur={onSkuBlur}
|
||||
onCreateArticle={onCreateArticle}
|
||||
onSearchArticle={onSearchArticle}
|
||||
onRemoveSupplierArticle={onRemoveSupplierArticle}
|
||||
ledgerLine={ledgerLine}
|
||||
onBookLine={onBookLine}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
@@ -173,14 +187,20 @@ class ManifestLines extends PureComponent {
|
||||
Artikel suchen
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DeleteSweepOutlinedIcon />}
|
||||
disabled={saving || !lines || lines.length === 0}
|
||||
onClick={onRemoveAllLines}
|
||||
>
|
||||
Alle Zeilen entfernen
|
||||
</Button>
|
||||
{(() => {
|
||||
const hasShippedLines = kind === 'order' && Array.isArray(shipments) && shipments.some((sh) => (sh.lines?.length || 0) > 0);
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DeleteSweepOutlinedIcon />}
|
||||
disabled={saving || !lines || lines.length === 0 || hasShippedLines}
|
||||
onClick={onRemoveAllLines}
|
||||
title={hasShippedLines ? 'Auftrag enthält bereits ausgelieferte Positionen' : 'Alle Zeilen entfernen'}
|
||||
>
|
||||
Alle Zeilen entfernen
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
{(totalNet > 0 || totalGross > 0) && (
|
||||
<Box sx={{ ml: 'auto', display: 'flex', alignItems: 'center', gap: 2, pr: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 600, fontSize: '0.8rem' }}>
|
||||
|
||||
@@ -6,13 +6,16 @@ import Chip from '@mui/material/Chip';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import PaymentsOutlinedIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { formatDate } from './manifestConfig';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { typeLabel, typeIcon } from './OrderPaymentList';
|
||||
import ShipmentDetailDialog from './ShipmentDetailDialog';
|
||||
import {
|
||||
GREEN_PANEL,
|
||||
YELLOW_PANEL,
|
||||
@@ -25,20 +28,32 @@ import {
|
||||
* 2. Zahlungen (Payments)
|
||||
*/
|
||||
export class OrderDocumentsTiles extends PureComponent {
|
||||
handleOpenShipment = () => {
|
||||
if (this.props.navigate) {
|
||||
this.props.navigate('/auftrag/ausliefern');
|
||||
}
|
||||
};
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectedShipment: null,
|
||||
};
|
||||
this.handleOpenShipment = this.handleOpenShipment.bind(this);
|
||||
this.handleCloseShipment = this.handleCloseShipment.bind(this);
|
||||
this.handleOpenPayments = this.handleOpenPayments.bind(this);
|
||||
}
|
||||
|
||||
handleOpenPayments = () => {
|
||||
handleOpenShipment(sh) {
|
||||
this.setState({ selectedShipment: sh });
|
||||
}
|
||||
|
||||
handleCloseShipment() {
|
||||
this.setState({ selectedShipment: null });
|
||||
}
|
||||
|
||||
handleOpenPayments() {
|
||||
if (this.props.navigate) {
|
||||
this.props.navigate('/auftrag/zahlungen');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderShipmentsTile() {
|
||||
const { shipments = [], shipped, delivered, navigate } = this.props;
|
||||
const { shipments = [], shipped, delivered, navigate, deliverDisabled } = this.props;
|
||||
|
||||
const statusLabel = delivered
|
||||
? 'Geliefert'
|
||||
@@ -53,7 +68,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
return (
|
||||
<PanelTile wide={1} minHeight={220} color={GREEN_PANEL} title="Lieferscheine">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
|
||||
<LocalShippingOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} />
|
||||
</Stack>
|
||||
@@ -61,7 +76,9 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => navigate?.('/auftrag/ausliefern')}
|
||||
disabled={deliverDisabled}
|
||||
onClick={this.props.onOpenCreateShipment || (() => navigate?.('/auftrag/ausliefern'))}
|
||||
title={deliverDisabled ? 'Alle Positionen bereits ausgeliefert' : 'Lieferschein erstellen'}
|
||||
sx={{ fontWeight: 600, textTransform: 'none', height: 26, fontSize: '0.75rem', px: 1 }}
|
||||
>
|
||||
Liefern
|
||||
@@ -79,7 +96,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
<Card
|
||||
key={sh.id ?? sh.number}
|
||||
variant="outlined"
|
||||
onClick={this.handleOpenShipment}
|
||||
onClick={() => this.handleOpenShipment(sh)}
|
||||
sx={{
|
||||
p: 1.25,
|
||||
bgcolor: WHITE,
|
||||
@@ -95,9 +112,24 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
|
||||
{sh.number}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sh.date ? formatDate(sh.date) : '–'}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{sh.date ? formatDate(sh.date) : '–'}
|
||||
</Typography>
|
||||
{navigate && (
|
||||
<IconButton
|
||||
size="small"
|
||||
title="In Auslieferungen öffnen"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/auftrag/ausliefern?shipmentId=${sh.id}`);
|
||||
}}
|
||||
sx={{ p: 0.25, ml: 0.5, color: 'text.secondary', '&:hover': { color: 'primary.main' } }}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{sh.externalNumber ? (
|
||||
@@ -142,7 +174,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<LocalShippingOutlinedIcon />}
|
||||
onClick={() => navigate?.('/auftrag/ausliefern')}
|
||||
onClick={this.props.onOpenCreateShipment || (() => navigate?.('/auftrag/ausliefern'))}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.75rem' }}
|
||||
>
|
||||
Lieferschein anlegen
|
||||
@@ -166,7 +198,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
return (
|
||||
<PanelTile wide={1} minHeight={220} color={YELLOW_PANEL} title="Zahlungen">
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center' }}>
|
||||
<PaymentsOutlinedIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
<Chip size="small" label={statusLabel} color={statusColor} sx={{ fontWeight: 600, height: 22 }} />
|
||||
</Stack>
|
||||
@@ -239,7 +271,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0, overflow: 'hidden' }}>
|
||||
<Stack direction="row" spacing={0.75} sx={{ alignItems: 'center', minWidth: 0, overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', color: 'text.secondary', flexShrink: 0 }}>
|
||||
{typeIcon(pType, { fontSize: 16 })}
|
||||
</Box>
|
||||
@@ -248,7 +280,7 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ flexShrink: 0, ml: 1 }}>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexShrink: 0, ml: 1 }}>
|
||||
{pay.date ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(pay.date)}
|
||||
@@ -291,6 +323,13 @@ export class OrderDocumentsTiles extends PureComponent {
|
||||
<>
|
||||
{this.renderShipmentsTile()}
|
||||
{this.renderPaymentsTile()}
|
||||
<ShipmentDetailDialog
|
||||
open={Boolean(this.state.selectedShipment)}
|
||||
shipment={this.state.selectedShipment}
|
||||
onClose={this.handleCloseShipment}
|
||||
onNavigate={this.props.navigate}
|
||||
onChanged={this.props.onShipmentChanged}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
465
src/components/manifest/OrderShipmentDialog.jsx
Normal file
465
src/components/manifest/OrderShipmentDialog.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import AppDialog from '../AppDialog';
|
||||
import { order as orderApi } from '../../api';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { todayInputValue } from './manifestConfig';
|
||||
|
||||
// MARK: helper to sum already shipped quantities per SKU across order shipments
|
||||
function computeShippedBySku(shipments) {
|
||||
const map = {};
|
||||
for (const sh of shipments || []) {
|
||||
for (const sl of sh.lines || []) {
|
||||
if (sl.sku) {
|
||||
map[sl.sku] = (map[sl.sku] || 0) + (Number(sl.amount) || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for selecting items and quantities to create a shipment from a sales order.
|
||||
* Calls POST /api/order/:id/shipment.
|
||||
*/
|
||||
export default class OrderShipmentDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { initialAmounts, initialSelected } = this.calculateInitialState(props);
|
||||
this.state = {
|
||||
date: todayInputValue(),
|
||||
externalNumber: '',
|
||||
lineAmounts: initialAmounts,
|
||||
selectedSkus: initialSelected,
|
||||
saving: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
this.handleAmountChange = this.handleAmountChange.bind(this);
|
||||
this.handleToggleSku = this.handleToggleSku.bind(this);
|
||||
this.handleToggleAll = this.handleToggleAll.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleShipAll = this.handleShipAll.bind(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.open && this.props.open) {
|
||||
const { initialAmounts, initialSelected } = this.calculateInitialState(this.props);
|
||||
this.setState({
|
||||
date: todayInputValue(),
|
||||
externalNumber: '',
|
||||
lineAmounts: initialAmounts,
|
||||
selectedSkus: initialSelected,
|
||||
saving: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
calculateInitialState(props) {
|
||||
const shippedBySku = computeShippedBySku(props.shipments);
|
||||
const initialAmounts = {};
|
||||
const initialSelected = new Set();
|
||||
|
||||
for (const l of props.lines || []) {
|
||||
if (!l.sku) continue;
|
||||
const ordered = Number(l.amount) || 0;
|
||||
const shipped = shippedBySku[l.sku] || 0;
|
||||
const rem = Math.max(0, ordered - shipped);
|
||||
if (rem > 0) {
|
||||
initialAmounts[l.sku] = String(rem);
|
||||
initialSelected.add(l.sku);
|
||||
} else {
|
||||
initialAmounts[l.sku] = '0';
|
||||
}
|
||||
}
|
||||
return { initialAmounts, initialSelected };
|
||||
}
|
||||
|
||||
handleToggleSku(sku, remaining) {
|
||||
this.setState((prev) => {
|
||||
const nextSelected = new Set(prev.selectedSkus);
|
||||
const nextAmounts = { ...prev.lineAmounts };
|
||||
if (nextSelected.has(sku)) {
|
||||
nextSelected.delete(sku);
|
||||
} else {
|
||||
nextSelected.add(sku);
|
||||
if (!Number(nextAmounts[sku]) || Number(nextAmounts[sku]) <= 0) {
|
||||
nextAmounts[sku] = String(remaining);
|
||||
}
|
||||
}
|
||||
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
|
||||
});
|
||||
}
|
||||
|
||||
handleToggleAll(openItems) {
|
||||
this.setState((prev) => {
|
||||
const allSelected = openItems.length > 0 && openItems.every((it) => prev.selectedSkus.has(it.sku));
|
||||
const nextSelected = new Set();
|
||||
const nextAmounts = { ...prev.lineAmounts };
|
||||
|
||||
if (!allSelected) {
|
||||
for (const it of openItems) {
|
||||
nextSelected.add(it.sku);
|
||||
if (!Number(nextAmounts[it.sku]) || Number(nextAmounts[it.sku]) <= 0) {
|
||||
nextAmounts[it.sku] = String(it.remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { selectedSkus: nextSelected, lineAmounts: nextAmounts };
|
||||
});
|
||||
}
|
||||
|
||||
handleAmountChange(sku, val) {
|
||||
this.setState((prev) => {
|
||||
const nextAmounts = { ...prev.lineAmounts, [sku]: val };
|
||||
const nextSelected = new Set(prev.selectedSkus);
|
||||
const num = Number(val);
|
||||
if (num > 0) {
|
||||
nextSelected.add(sku);
|
||||
} else {
|
||||
nextSelected.delete(sku);
|
||||
}
|
||||
return { lineAmounts: nextAmounts, selectedSkus: nextSelected };
|
||||
});
|
||||
}
|
||||
|
||||
// MARK: calls orderApi.createShipment from ./api/index.js
|
||||
// #region POST /api/order/:id/shipment — submit selected lines
|
||||
async handleSubmit(e) {
|
||||
e?.preventDefault?.();
|
||||
const { orderId, onClose, onSuccess } = this.props;
|
||||
const { date, externalNumber, lineAmounts, selectedSkus, saving } = this.state;
|
||||
if (saving || !orderId) return;
|
||||
|
||||
const linesToShip = [];
|
||||
for (const sku of selectedSkus) {
|
||||
const amount = Number(lineAmounts[sku]);
|
||||
if (amount > 0) {
|
||||
linesToShip.push({ sku, amount });
|
||||
}
|
||||
}
|
||||
|
||||
if (linesToShip.length === 0) {
|
||||
enqueueSnackbar('Bitte wählen Sie mindestens einen Artikel mit Liefermenge > 0 aus.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ saving: true, error: null });
|
||||
try {
|
||||
const payload = {
|
||||
date: date || undefined,
|
||||
externalNumber: externalNumber.trim() || undefined,
|
||||
lines: linesToShip,
|
||||
};
|
||||
|
||||
const res = await orderApi.createShipment(orderId, payload);
|
||||
const shipment = res?.shipment;
|
||||
const shNum = shipment?.number || '';
|
||||
|
||||
enqueueSnackbar(
|
||||
shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!',
|
||||
{ variant: 'success' },
|
||||
);
|
||||
|
||||
onSuccess?.(shipment);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ saving: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// MARK: calls orderApi.createShipment to ship all remaining lines
|
||||
// #region POST /api/order/:id/shipment — ship all remaining
|
||||
async handleShipAll() {
|
||||
const { orderId, onClose, onSuccess } = this.props;
|
||||
const { date, externalNumber, saving } = this.state;
|
||||
if (saving || !orderId) return;
|
||||
|
||||
this.setState({ saving: true, error: null });
|
||||
try {
|
||||
const payload = {
|
||||
date: date || undefined,
|
||||
externalNumber: externalNumber.trim() || undefined,
|
||||
};
|
||||
|
||||
const res = await orderApi.createShipment(orderId, payload);
|
||||
const shipment = res?.shipment;
|
||||
const shNum = shipment?.number || '';
|
||||
|
||||
enqueueSnackbar(
|
||||
shNum ? `Lieferschein ${shNum} erfolgreich angelegt!` : 'Lieferschein erfolgreich angelegt!',
|
||||
{ variant: 'success' },
|
||||
);
|
||||
|
||||
onSuccess?.(shipment);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ saving: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
render() {
|
||||
const { open, onClose, orderNumber, lines = [], shipments = [] } = this.props;
|
||||
const { date, externalNumber, lineAmounts, selectedSkus, saving, error } = this.state;
|
||||
|
||||
const shippedBySku = computeShippedBySku(shipments);
|
||||
const activeLines = lines.filter((l) => Boolean(l.sku));
|
||||
|
||||
const items = activeLines.map((line) => {
|
||||
const ordered = Number(line.amount) || 0;
|
||||
const shipped = shippedBySku[line.sku] || 0;
|
||||
const remaining = Math.max(0, ordered - shipped);
|
||||
const isSelected = selectedSkus.has(line.sku);
|
||||
const shipAmount = lineAmounts[line.sku] ?? (remaining > 0 ? String(remaining) : '0');
|
||||
return {
|
||||
...line,
|
||||
ordered,
|
||||
shipped,
|
||||
remaining,
|
||||
isSelected,
|
||||
shipAmount,
|
||||
};
|
||||
});
|
||||
|
||||
const openItems = items.filter((it) => it.remaining > 0);
|
||||
const allShipped = activeLines.length > 0 && openItems.length === 0;
|
||||
const isAllSelected = openItems.length > 0 && openItems.every((it) => it.isSelected);
|
||||
const selectedCount = openItems.filter((it) => it.isSelected).length;
|
||||
|
||||
return (
|
||||
<AppDialog open={open} onClose={saving ? undefined : onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<LocalShippingOutlinedIcon color="primary" />
|
||||
<Box>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
Lieferschein erstellen
|
||||
</Typography>
|
||||
{orderNumber ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Zu Auftrag / Bestellung: <strong>{orderNumber}</strong>
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
size="small"
|
||||
sx={{ color: (theme) => theme.palette.grey[500] }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 2.5 }}>
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => this.setState({ error: null })}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{allShipped && (
|
||||
<Alert severity="success" icon={<CheckCircleOutlineOutlinedIcon />} sx={{ mb: 2 }}>
|
||||
Alle Artikel dieses Auftrags wurden bereits vollständig geliefert.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Header controls: date and external reference */}
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
label="Lieferdatum"
|
||||
value={date}
|
||||
disabled={saving}
|
||||
onChange={(e) => this.setState({ date: e.target.value })}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={{ minWidth: 170 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Lieferschein-Nr. / Externe Referenz (optional)"
|
||||
placeholder="z.B. LS-2026-0042"
|
||||
value={externalNumber}
|
||||
disabled={saving}
|
||||
onChange={(e) => this.setState({ externalNumber: e.target.value })}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Items selection section */}
|
||||
<Box sx={{ mb: 1, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Zu liefernde Artikel ({selectedCount} von {openItems.length} ausgewählt)
|
||||
</Typography>
|
||||
{openItems.length > 0 && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
disabled={saving}
|
||||
onClick={() => this.handleToggleAll(openItems)}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, fontSize: '0.8rem' }}
|
||||
>
|
||||
{isAllSelected ? 'Auswahl aufheben' : 'Alle offenen auswählen'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||
<Table size="small">
|
||||
<TableHead sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox" sx={{ width: 44 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={isAllSelected}
|
||||
indeterminate={selectedCount > 0 && !isAllSelected}
|
||||
disabled={saving || openItems.length === 0}
|
||||
onChange={() => this.handleToggleAll(openItems)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>SKU</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>Artikel</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>Bestellt</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>Geliefert</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>Offen</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, width: 130 }}>Liefermenge</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center" sx={{ py: 3, color: 'text.secondary' }}>
|
||||
Keine Artikel im Auftrag vorhanden.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const isClosed = item.remaining <= 0;
|
||||
return (
|
||||
<TableRow
|
||||
key={item.sku}
|
||||
hover={!isClosed}
|
||||
sx={{
|
||||
opacity: isClosed ? 0.55 : 1,
|
||||
bgcolor: isClosed ? 'action.hover' : undefined,
|
||||
}}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={item.isSelected && !isClosed}
|
||||
disabled={saving || isClosed}
|
||||
onChange={() => this.handleToggleSku(item.sku, item.remaining)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600 }}>{item.sku}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{item.name || '–'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">{item.ordered}</TableCell>
|
||||
<TableCell align="right">{item.shipped}</TableCell>
|
||||
<TableCell
|
||||
align="right"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
color: item.remaining > 0 ? 'primary.main' : 'text.disabled',
|
||||
}}
|
||||
>
|
||||
{item.remaining}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{isClosed ? (
|
||||
<Chip size="small" label="Geliefert" color="success" variant="outlined" sx={{ height: 22, fontSize: '0.7rem' }} />
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
disabled={saving || !item.isSelected}
|
||||
value={item.shipAmount}
|
||||
onChange={(e) => this.handleAmountChange(item.sku, e.target.value)}
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
min: 0,
|
||||
max: item.remaining,
|
||||
step: 'any',
|
||||
style: { textAlign: 'right', fontWeight: 600 },
|
||||
},
|
||||
}}
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2, justifyContent: 'space-between' }}>
|
||||
<Button variant="outlined" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
{!allShipped && openItems.length > 0 && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={saving}
|
||||
onClick={this.handleShipAll}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
Alles liefern
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={saving || allShipped || selectedCount === 0}
|
||||
startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <LocalShippingOutlinedIcon />}
|
||||
onClick={this.handleSubmit}
|
||||
sx={{ textTransform: 'none', fontWeight: 700 }}
|
||||
>
|
||||
{saving ? 'Wird erstellt…' : 'Lieferschein erstellen'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</DialogActions>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
531
src/components/manifest/ShipmentDetailDialog.jsx
Normal file
531
src/components/manifest/ShipmentDetailDialog.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { PureComponent } 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 DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import AppDialog from '../AppDialog';
|
||||
import { shipment as shipmentApi, shipping as shippingApi } from '../../api';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { formatDate } from './manifestConfig';
|
||||
import orderAutomat from '../../automats/orderAutomat';
|
||||
import shipmentAutomat from '../../automats/shipmentAutomat';
|
||||
import { SURFACE_RAISED, WHITE } from '../../colors';
|
||||
|
||||
const CARRIERS = ['DHL', 'UPS', 'DPD', 'GLS', 'Hermes', 'Spedition', 'Selbstabholung'];
|
||||
|
||||
// MARK: <AddPackageDialog/>
|
||||
class AddPackageDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
carrier: 'DHL',
|
||||
trackingCode: '',
|
||||
type: 'parcel',
|
||||
state: 'draft',
|
||||
saving: false,
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
const { shipment, onClose, onSuccess } = this.props;
|
||||
const { carrier, trackingCode, type, state } = this.state;
|
||||
if (!shipment?.id) return;
|
||||
|
||||
this.setState({ saving: true });
|
||||
try {
|
||||
await shippingApi.create(shipment.id, {
|
||||
carrier: carrier.trim() || undefined,
|
||||
trackingCode: trackingCode.trim() || undefined,
|
||||
type,
|
||||
state,
|
||||
});
|
||||
enqueueSnackbar('Paket angelegt.', { variant: 'success' });
|
||||
await Promise.all([
|
||||
orderAutomat.actions.reload(),
|
||||
shipmentAutomat.actions.reload(),
|
||||
]);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ saving: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, onClose, shipment } = this.props;
|
||||
const { carrier, trackingCode, type, state, saving } = this.state;
|
||||
|
||||
return (
|
||||
<AppDialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 2 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
Paket zu Lieferschein hinzufügen
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Lieferschein: <strong>{shipment?.number}</strong>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Versanddienstleister (Carrier)"
|
||||
size="small"
|
||||
value={carrier}
|
||||
onChange={(e) => this.setState({ carrier: e.target.value })}
|
||||
>
|
||||
{CARRIERS.map((c) => (
|
||||
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Tracking-Code / Sendungsnummer"
|
||||
size="small"
|
||||
value={trackingCode}
|
||||
onChange={(e) => this.setState({ trackingCode: e.target.value })}
|
||||
placeholder="z.B. 00340434234234234"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label="Paketart"
|
||||
size="small"
|
||||
value={type}
|
||||
onChange={(e) => this.setState({ type: e.target.value })}
|
||||
>
|
||||
<MenuItem value="parcel">Paket (Parcel)</MenuItem>
|
||||
<MenuItem value="pallet">Palette (Pallet)</MenuItem>
|
||||
<MenuItem value="letter">Brief / Dokument</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
select
|
||||
label="Status"
|
||||
size="small"
|
||||
value={state}
|
||||
onChange={(e) => this.setState({ state: e.target.value })}
|
||||
helperText={state === 'shipped' ? 'Bucht bei Auswahl direkt das Lager ab.' : undefined}
|
||||
>
|
||||
<MenuItem value="draft">Entwurf (draft)</MenuItem>
|
||||
<MenuItem value="packed">Gepackt (packed)</MenuItem>
|
||||
<MenuItem value="shipped">Versendet (shipped — Lagerabbuchung)</MenuItem>
|
||||
</TextField>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1.5 }}>
|
||||
<Button variant="outlined" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button variant="contained" onClick={this.handleSubmit} disabled={saving}>
|
||||
{saving ? 'Wird gespeichert…' : 'Paket anlegen'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal to view and manage an existing Lieferschein (Shipment) directly from the Order page.
|
||||
*/
|
||||
export default class ShipmentDetailDialog extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
shipment: props.shipment || null,
|
||||
loading: false,
|
||||
error: null,
|
||||
addPackageDialogOpen: false,
|
||||
busyAction: false,
|
||||
};
|
||||
this.handleRefresh = this.handleRefresh.bind(this);
|
||||
this.handleDeletePackage = this.handleDeletePackage.bind(this);
|
||||
this.handleMarkPackageShipped = this.handleMarkPackageShipped.bind(this);
|
||||
this.handleStateChange = this.handleStateChange.bind(this);
|
||||
this.handleNavigateFull = this.handleNavigateFull.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.open && this.props.shipment?.id) {
|
||||
void this.handleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!prevProps.open && this.props.open) {
|
||||
this.setState({ shipment: this.props.shipment, error: null });
|
||||
if (this.props.shipment?.id) {
|
||||
void this.handleRefresh();
|
||||
}
|
||||
} else if (prevProps.shipment?.id !== this.props.shipment?.id && this.props.shipment?.id) {
|
||||
this.setState({ shipment: this.props.shipment });
|
||||
void this.handleRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: calls shipmentApi.get from ./api/index.js
|
||||
// #region GET /api/shipment/:id — refresh details
|
||||
async handleRefresh() {
|
||||
const shipmentId = this.props.shipment?.id;
|
||||
if (!shipmentId) return;
|
||||
|
||||
this.setState({ loading: true, error: null });
|
||||
try {
|
||||
const res = await shipmentApi.get(shipmentId);
|
||||
const enriched = res?.shipment || res;
|
||||
this.setState({ shipment: enriched, loading: false });
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
this.setState({ error: err?.message || String(err), loading: false });
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
async handleDeletePackage(pkg) {
|
||||
if (!window.confirm(`Paket „#${pkg.number || pkg.id}“ wirklich löschen?`)) return;
|
||||
this.setState({ busyAction: true });
|
||||
try {
|
||||
await shippingApi.delete(pkg.id);
|
||||
enqueueSnackbar('Paket gelöscht.', { variant: 'success' });
|
||||
await Promise.all([
|
||||
orderAutomat.actions.reload(),
|
||||
shipmentAutomat.actions.reload(),
|
||||
]);
|
||||
this.props.onChanged?.();
|
||||
await this.handleRefresh();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busyAction: false });
|
||||
}
|
||||
}
|
||||
|
||||
async handleMarkPackageShipped(pkg) {
|
||||
this.setState({ busyAction: true });
|
||||
try {
|
||||
await shippingApi.update(pkg.id, { state: 'shipped' });
|
||||
enqueueSnackbar('Paket als versendet markiert (Lagerbestand abgebucht).', { variant: 'success' });
|
||||
await Promise.all([
|
||||
orderAutomat.actions.reload(),
|
||||
shipmentAutomat.actions.reload(),
|
||||
]);
|
||||
this.props.onChanged?.();
|
||||
await this.handleRefresh();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busyAction: false });
|
||||
}
|
||||
}
|
||||
|
||||
async handleStateChange(newState) {
|
||||
const shipmentId = this.state.shipment?.id;
|
||||
if (!shipmentId) return;
|
||||
|
||||
this.setState({ busyAction: true });
|
||||
try {
|
||||
await shipmentApi.update(shipmentId, { state: newState });
|
||||
this.setState((prev) => ({
|
||||
shipment: { ...prev.shipment, state: newState },
|
||||
}));
|
||||
const label = newState === 'delivered' ? 'Geliefert' : newState === 'returned' ? 'Retoure' : 'Neu';
|
||||
enqueueSnackbar(`Lieferstatus geändert: ${label}`, { variant: 'success' });
|
||||
await Promise.all([
|
||||
orderAutomat.actions.reload(),
|
||||
shipmentAutomat.actions.reload(),
|
||||
]);
|
||||
this.props.onChanged?.();
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busyAction: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleNavigateFull() {
|
||||
const { shipment, onClose, onNavigate } = this.props;
|
||||
onClose?.();
|
||||
if (onNavigate && shipment?.id) {
|
||||
onNavigate(`/auftrag/ausliefern?shipmentId=${shipment.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, onClose } = this.props;
|
||||
const { shipment, loading, error, addPackageDialogOpen, busyAction } = this.state;
|
||||
|
||||
if (!shipment) return null;
|
||||
|
||||
const shLines = Array.isArray(shipment.lines) ? shipment.lines : [];
|
||||
const shPackages = Array.isArray(shipment.shippings) ? shipment.shippings : [];
|
||||
const hasShippedPackages = shPackages.some((p) => p.state === 'shipped');
|
||||
|
||||
return (
|
||||
<AppDialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}>
|
||||
<LocalShippingIcon color="primary" sx={{ fontSize: 28 }} />
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{shipment.number || 'Lieferschein'}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
color={hasShippedPackages ? 'success' : 'default'}
|
||||
label={hasShippedPackages ? 'Im Versand' : 'Bereitstellung'}
|
||||
sx={{ fontWeight: 600, height: 20, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{shipment.date ? `Datum: ${formatDate(shipment.date)}` : ''}
|
||||
{shipment.externalNumber ? ` • Ref: ${shipment.externalNumber}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Lieferstatus"
|
||||
value={shipment.state || 'new'}
|
||||
onChange={(e) => this.handleStateChange(e.target.value)}
|
||||
disabled={busyAction}
|
||||
sx={{ minWidth: 130 }}
|
||||
>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="delivered">Geliefert</MenuItem>
|
||||
<MenuItem value="returned">Retoure</MenuItem>
|
||||
</TextField>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{ color: (theme) => theme.palette.grey[500] }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent dividers sx={{ p: 2.5, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
{error && (
|
||||
<Alert severity="error" onClose={() => this.setState({ error: null })}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Shipped Article Positions */}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
Gelieferte Artikel-Positionen ({shLines.length})
|
||||
</Typography>
|
||||
<TableContainer sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1.5, bgcolor: WHITE }}>
|
||||
<Table size="small">
|
||||
<TableHead sx={{ bgcolor: SURFACE_RAISED }}>
|
||||
<TableRow>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.75 }}>SKU</TableCell>
|
||||
<TableCell sx={{ fontWeight: 600, py: 0.75 }}>Bezeichnung</TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600, py: 0.75 }}>Menge</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{shLines.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} align="center" sx={{ py: 2, color: 'text.secondary' }}>
|
||||
{loading ? 'Lädt Positionen…' : 'Keine Positionen auf diesem Lieferschein.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
shLines.map((line, idx) => (
|
||||
<TableRow key={line.id || idx}>
|
||||
<TableCell sx={{ py: 0.75, fontWeight: 500 }}>{line.sku}</TableCell>
|
||||
<TableCell sx={{ py: 0.75 }}>{line.name || '–'}</TableCell>
|
||||
<TableCell align="right" sx={{ py: 0.75, fontWeight: 700 }}>
|
||||
{line.amount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
|
||||
{/* Shipping Packages / Tracking */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
Pakete & Sendungsverfolgung ({shPackages.length})
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => this.setState({ addPackageDialogOpen: true })}
|
||||
disabled={busyAction}
|
||||
sx={{ textTransform: 'none', fontWeight: 600, height: 28, fontSize: '0.75rem' }}
|
||||
>
|
||||
Paket hinzufügen
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{shPackages.length === 0 ? (
|
||||
<Box sx={{ p: 2.5, textAlign: 'center', bgcolor: WHITE, borderRadius: 1.5, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Inventory2Icon sx={{ color: 'text.secondary', fontSize: 28, mb: 0.5 }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Noch keine Pakete erfasst.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{shPackages.map((pkg) => {
|
||||
const isShipped = pkg.state === 'shipped';
|
||||
return (
|
||||
<Box
|
||||
key={pkg.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1.5,
|
||||
bgcolor: WHITE,
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Chip size="small" label={`#${pkg.number || pkg.id}`} sx={{ fontWeight: 700 }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{pkg.carrier || 'DHL'} ({pkg.type === 'pallet' ? 'Palette' : 'Paket'})
|
||||
</Typography>
|
||||
{pkg.trackingCode ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'primary.main' }}>
|
||||
{pkg.trackingCode}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(pkg.trackingCode);
|
||||
enqueueSnackbar('Tracking-Code kopiert!', { variant: 'info' });
|
||||
}}
|
||||
sx={{ p: 0.25 }}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 13 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Ohne Tracking
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isShipped ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color="success"
|
||||
icon={<CheckCircleOutlineOutlinedIcon />}
|
||||
label="Versendet (Lager abgebucht)"
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
color="success"
|
||||
disabled={busyAction}
|
||||
onClick={() => this.handleMarkPackageShipped(pkg)}
|
||||
sx={{ textTransform: 'none', py: 0.25, px: 1, height: 26, fontSize: '0.75rem' }}
|
||||
>
|
||||
Als versendet buchen
|
||||
</Button>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={busyAction}
|
||||
onClick={() => this.handleDeletePackage(pkg)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2, justifyContent: 'space-between' }}>
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Schließen
|
||||
</Button>
|
||||
|
||||
{this.props.onNavigate && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<OpenInNewIcon />}
|
||||
onClick={this.handleNavigateFull}
|
||||
sx={{ textTransform: 'none', fontWeight: 600 }}
|
||||
>
|
||||
In Auslieferungen öffnen
|
||||
</Button>
|
||||
)}
|
||||
</DialogActions>
|
||||
|
||||
{addPackageDialogOpen && (
|
||||
<AddPackageDialog
|
||||
open={addPackageDialogOpen}
|
||||
shipment={shipment}
|
||||
onClose={() => this.setState({ addPackageDialogOpen: false })}
|
||||
onSuccess={async () => {
|
||||
this.props.onChanged?.();
|
||||
await this.handleRefresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AppDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ export { default as TextEditorPanel } from './TextEditorPanel';
|
||||
export { default as PdfViewerPanel } from './PdfViewerPanel';
|
||||
export { default as ArticleSearchDialog } from './ArticleSearchDialog';
|
||||
export { default as BookDeliveryDialog } from './BookDeliveryDialog';
|
||||
export { default as OrderShipmentDialog } from './OrderShipmentDialog';
|
||||
export { default as ShipmentDetailDialog } from './ShipmentDetailDialog';
|
||||
export { default as OrderDocumentsTiles } from './OrderDocumentsTiles';
|
||||
export { withManifestId } from './withManifestId';
|
||||
export {
|
||||
|
||||
@@ -14,11 +14,16 @@ import CodeRoundedIcon from '@mui/icons-material/CodeRounded';
|
||||
import ChatBubbleOutlineRoundedIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
|
||||
import InputRoundedIcon from '@mui/icons-material/InputRounded';
|
||||
import OutputRoundedIcon from '@mui/icons-material/OutputRounded';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import VfsFileIcon from './VfsFileIcon';
|
||||
import VfsAppletForm from './VfsAppletForm';
|
||||
import { parseAppletSchemas, buildAppletDefaults, parseCsvRows } from './vfsUtils';
|
||||
import { vfs as vfsApi } from '../../api';
|
||||
import pinnedAppletAutomat, { normalizeRoute } from '../../automats/pinnedAppletAutomat';
|
||||
|
||||
/**
|
||||
* @file VfsAppletRunner.jsx
|
||||
@@ -46,9 +51,26 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
stdout: '',
|
||||
durationMs: null,
|
||||
truncated: false,
|
||||
isPinned: Boolean(
|
||||
props.fileName &&
|
||||
pinnedAppletAutomat.actions.isPinned(props.fileName, window.location.pathname)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsubPinned = pinnedAppletAutomat.subscribe(this, () => ({
|
||||
isPinned: Boolean(
|
||||
this.props.fileName &&
|
||||
pinnedAppletAutomat.actions.isPinned(this.props.fileName, window.location.pathname)
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsubPinned?.();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.content !== this.props.content || prevProps.fileName !== this.props.fileName) {
|
||||
const { inputSchema, outputColumns, description } = parseAppletSchemas(this.props.content || '');
|
||||
@@ -66,10 +88,27 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
stdout: '',
|
||||
durationMs: null,
|
||||
truncated: false,
|
||||
isPinned: Boolean(
|
||||
this.props.fileName &&
|
||||
pinnedAppletAutomat.actions.isPinned(this.props.fileName, window.location.pathname)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleTogglePin = () => {
|
||||
const { fileName } = this.props;
|
||||
if (!fileName) return;
|
||||
const currentRoute = normalizeRoute(window.location.pathname);
|
||||
const nextPinned = pinnedAppletAutomat.actions.togglePin(fileName, currentRoute);
|
||||
this.setState({ isPinned: nextPinned });
|
||||
if (nextPinned) {
|
||||
enqueueSnackbar(`Applet an „${currentRoute}“ angepinnt`, { variant: 'success' });
|
||||
} else {
|
||||
enqueueSnackbar(`Applet von „${currentRoute}“ gelöst`, { variant: 'info' });
|
||||
}
|
||||
};
|
||||
|
||||
handleInputChange = (nextValues) => {
|
||||
this.setState({ inputValues: nextValues });
|
||||
};
|
||||
@@ -248,6 +287,7 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
stdout,
|
||||
durationMs,
|
||||
truncated,
|
||||
isPinned,
|
||||
} = this.state;
|
||||
const description = stateDescription || file?.description;
|
||||
|
||||
@@ -308,6 +348,32 @@ export default class VfsAppletRunner extends PureComponent {
|
||||
Code
|
||||
</Button>
|
||||
)}
|
||||
{fileName && (
|
||||
<Tooltip
|
||||
title={
|
||||
isPinned
|
||||
? `Von dieser Route (${normalizeRoute(window.location.pathname)}) lösen`
|
||||
: `An diese Route (${normalizeRoute(window.location.pathname)}) anpinnen`
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
variant={isPinned ? 'contained' : 'outlined'}
|
||||
color={isPinned ? 'primary' : 'inherit'}
|
||||
startIcon={isPinned ? <PushPinIcon /> : <PushPinOutlinedIcon />}
|
||||
onClick={this.handleTogglePin}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
...(isPinned ? { boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)' } : {}),
|
||||
}}
|
||||
>
|
||||
{isPinned ? 'Angepinnt' : 'Anpinnen'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onMentionInChat && (
|
||||
<IconButton size="small" onClick={onMentionInChat} aria-label="In Chat erwähnen" sx={{ p: 0.6 }}>
|
||||
<ChatBubbleOutlineRoundedIcon fontSize="small" color="primary" />
|
||||
|
||||
@@ -669,7 +669,7 @@ class ChunkCacheMonitor extends PureComponent {
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<IconButton size="small" onClick={() => this.setState({ selectedChunk: null })}>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
@@ -832,7 +832,7 @@ class ChunkCacheMonitor extends PureComponent {
|
||||
bgcolor: isOldest ? 'rgba(237, 108, 2, 0.04)' : 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Stack direction="row" spacing={1.5} sx={{ minWidth: 0, flex: 1, alignItems: 'center' }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`#${entry.lruRank}`}
|
||||
@@ -856,7 +856,7 @@ class ChunkCacheMonitor extends PureComponent {
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||
{new Date(entry.accessedAt).toLocaleTimeString()}
|
||||
</Typography>
|
||||
|
||||
@@ -231,7 +231,7 @@ class SseMonitor extends PureComponent {
|
||||
key={entry.id}
|
||||
sx={{ px: 1.25, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="baseline">
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'baseline' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||
{new Date(entry.time).toLocaleTimeString()}
|
||||
</Typography>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function ActionLogPanel({ logOpen, log, logRef, onToggle, onCopy,
|
||||
|
||||
return (
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'divider', flexShrink: 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ px: 1.5, py: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ px: 1.5, py: 0.5, alignItems: 'center' }}>
|
||||
<ButtonBase
|
||||
component="div"
|
||||
onClick={onToggle}
|
||||
|
||||
@@ -79,7 +79,7 @@ export default function AutomatEntry({ automat, isExpanded, flash, onToggle, onC
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
|
||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexGrow: 1 }}>
|
||||
Aktionen ({actionNames.length})
|
||||
</Typography>
|
||||
|
||||
Reference in New Issue
Block a user