This commit is contained in:
seb
2026-10-15 20:15:19 +02:00
parent 8e783a571f
commit beed294cb0
26 changed files with 3165 additions and 906 deletions

View File

@@ -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`

View File

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

View File

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

View File

@@ -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`);

View File

@@ -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';
/**
* ============================================================================

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

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

View File

@@ -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) => {

View File

@@ -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 />

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

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

View File

@@ -0,0 +1,2 @@
export { default as PinnedAppletFabs } from './PinnedAppletFabs';
export { default as PinnedAppletDialog } from './PinnedAppletDialog';

View File

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

View File

@@ -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>

View File

@@ -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={{

View File

@@ -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' }}>

View File

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

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

View 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

View File

@@ -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 {

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>