Compare commits
13 Commits
56274ecb1d
...
082771b9c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
082771b9c3 | ||
|
|
d04a5f104f | ||
|
|
9a2419e115 | ||
|
|
782bc17bd1 | ||
|
|
8a042ef9f2 | ||
|
|
ac677dd5e3 | ||
|
|
2800fb64e0 | ||
|
|
9b47190d47 | ||
|
|
1bbeeaeff4 | ||
|
|
14ae017b64 | ||
|
|
645a9153a0 | ||
|
|
e9190f1ea9 | ||
|
|
5e62be468f |
@@ -7,7 +7,7 @@ description: >-
|
|||||||
|
|
||||||
# Dash API Routes Overview (All)
|
# Dash API Routes Overview (All)
|
||||||
|
|
||||||
Overview of all 192 API endpoints across all modules.
|
Overview of all 206 API endpoints across all modules.
|
||||||
|
|
||||||
### Notation
|
### Notation
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
### Reusable types
|
### Reusable types
|
||||||
|
|
||||||
- `T1` = `{ article: T2 }`
|
- `T1` = `{ article: T2 }`
|
||||||
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T32, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T36[], suppliers: T12[] }`
|
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T35[], suppliers: T12[] }`
|
||||||
- `T3` = `{ delivery: T4 }`
|
- `T3` = `{ delivery: T4 }`
|
||||||
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
|
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
|
||||||
- `T5` = `{ labeltemplate: T6 }`
|
- `T5` = `{ labeltemplate: T6 }`
|
||||||
@@ -45,13 +45,13 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
|
||||||
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
|
||||||
- `T29` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
|
- `T29` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
|
||||||
- `T30` = `{ id: int, sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
- `T30` = `{ article: T33 }`
|
||||||
- `T31` = `{ article: T34 }`
|
- `T31` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
||||||
- `T32` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
|
- `T32` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- `T33` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- `T33` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
||||||
- `T34` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
|
- `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
||||||
- `T35` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
|
- `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
||||||
- `T36` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
|
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
|
||||||
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
|
||||||
- `T38` = `{ id: int, attributeId: int, value: str, sort: int }`
|
- `T38` = `{ id: int, attributeId: int, value: str, sort: int }`
|
||||||
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
|
||||||
@@ -64,9 +64,18 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
|
|
||||||
### Routes
|
### Routes
|
||||||
|
|
||||||
|
#### List accounts — `GET /account`
|
||||||
|
_none_
|
||||||
|
- Returns: `200 { accounts: { id: int, companyId: int, type: str, name: str }[] }`
|
||||||
|
#### Create account — `POST /account`
|
||||||
|
- Params: `body: { type: str, name: str }`
|
||||||
|
- Returns: `201 { account: { id: int, companyId: int, type: str, name: str } }`
|
||||||
|
#### Delete account — `DELETE /account/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
#### Create article in category — `POST /article`
|
#### Create article in category — `POST /article`
|
||||||
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
|
||||||
- Returns: `201 T31`
|
- Returns: `201 T30`
|
||||||
#### Export articles as ZIP — `GET /article/export.zip`
|
#### Export articles as ZIP — `GET /article/export.zip`
|
||||||
- Params: `query: categoryId?: int`
|
- Params: `query: categoryId?: int`
|
||||||
- Returns: `200 zip`
|
- Returns: `200 zip`
|
||||||
@@ -102,10 +111,10 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
- Returns: `200 { children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- 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`
|
#### 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 }`
|
- Params: `sku: str, body: { attributeValueId?: int, value?: str, name?: str }`
|
||||||
- Returns: `201 T31`
|
- Returns: `201 T30`
|
||||||
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
|
#### 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 }`
|
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
|
||||||
- Returns: `200 { article: T34, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
|
- Returns: `200 { article: T33, 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}`
|
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
|
||||||
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
|
||||||
- Returns: `200 { success: bool, deleted: bool }`
|
- Returns: `200 { success: bool, deleted: bool }`
|
||||||
@@ -132,7 +141,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
|
|||||||
- Returns: `200 { options: { id: int, articleId: int, sku: str, name: str, count: int, sort: int, mandated: bool }[] }`
|
- 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`
|
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
|
||||||
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
- Params: `sku: str, body: { isParent: bool, attributeNameId?: int }`
|
||||||
- Returns: `200 T31`
|
- Returns: `200 T30`
|
||||||
#### Replace POS prices by SKU (net > gross; price=net, tax=taxRate %) — `PUT /article/{sku}/prices`
|
#### 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 }[] }`
|
- Params: `sku: str, body: { prices: { net?: num, gross?: num, price?: num, taxRate?: num, tax?: num, minmultiplier?: num }[] }`
|
||||||
- Returns: `200 T1`
|
- Returns: `200 T1`
|
||||||
@@ -201,31 +210,34 @@ _none_
|
|||||||
- Returns: `200 { attributevalue: T38 }`
|
- Returns: `200 { attributevalue: T38 }`
|
||||||
#### Login with Google ID token — `POST /auth/login`
|
#### Login with Google ID token — `POST /auth/login`
|
||||||
- Params: `body: { idToken: str }`
|
- Params: `body: { idToken: str }`
|
||||||
- Returns: `200 { message: str, user: T35 }`
|
- Returns: `200 { message: str, user: T34 }`
|
||||||
#### Logout — `POST /auth/logout`
|
#### Logout — `POST /auth/logout`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { message: str }`
|
- Returns: `200 { message: str }`
|
||||||
#### Current user — `GET /auth/me`
|
#### Current user — `GET /auth/me`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { user: T35, googleClientId: str }`
|
- Returns: `200 { user: T34, googleClientId: str }`
|
||||||
#### Set selected company — `PUT /auth/selected-company`
|
#### Set selected company — `PUT /auth/selected-company`
|
||||||
- Params: `body: { companyId: int }`
|
- Params: `body: { companyId: int }`
|
||||||
- Returns: `200 { user: T35 }`
|
- Returns: `200 { user: T34 }`
|
||||||
#### List brands — `GET /brand`
|
#### List brands — `GET /brand`
|
||||||
_none_
|
- Params: `query: offset?: int, limit?: int, includeSmallThumbs?: bool`
|
||||||
- Returns: `200 { brands: { id: int, name: str, pics: T30[], gsprpinfo?: str|null }[] }`
|
- Returns: `200 { brands: { id: int, name: str, 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 }`
|
||||||
#### Upsert brand — `PUT /brand`
|
#### Create brand — `POST /brand`
|
||||||
- Params: `body: { name: str, gsprpinfo?: str }`
|
- Params: `body: { name: str, gsprpinfo?: str }`
|
||||||
- Returns: `200 { brand: { id: int, name: str, pics: T30[], gsprpinfo?: str|null } }`
|
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }`
|
||||||
#### Delete brand — `DELETE /brand/{id}`
|
#### Delete brand — `DELETE /brand/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
|
#### Rename brand — `PATCH /brand/{id}`
|
||||||
|
- Params: `id: int, body: { name?: str, gsprpinfo?: str }`
|
||||||
|
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
|
||||||
#### Delete brand image — `DELETE /brand/{id}/image`
|
#### Delete brand image — `DELETE /brand/{id}/image`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T30[] } }`
|
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }`
|
||||||
#### Add or replace brand image — `POST /brand/{id}/image`
|
#### Add or replace brand image — `POST /brand/{id}/image`
|
||||||
- Params: `id: int, image/avif`
|
- Params: `id: int, image/avif`
|
||||||
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T30[] } }`
|
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }`
|
||||||
#### Create category — `POST /category`
|
#### Create category — `POST /category`
|
||||||
- Params: `body: { parentId: int, name: str }`
|
- Params: `body: { parentId: int, name: str }`
|
||||||
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
|
- Returns: `201 { category: { id: int, parentId: int|null, sort: int, name: str } }`
|
||||||
@@ -270,10 +282,10 @@ _none_
|
|||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
#### List companies — `GET /company`
|
#### List companies — `GET /company`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { companies: T33[] }`
|
- Returns: `200 { companies: T32[] }`
|
||||||
#### Create company — `POST /company`
|
#### Create company — `POST /company`
|
||||||
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `201 { company: T33, user: T35 }`
|
- Returns: `201 { company: T32, user: T34 }`
|
||||||
#### Get company history — `GET /company/history`
|
#### Get company history — `GET /company/history`
|
||||||
- Params: `query: limit?: int`
|
- Params: `query: limit?: int`
|
||||||
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
|
||||||
@@ -297,7 +309,7 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### Update company — `PUT /company/{id}`
|
#### Update company — `PUT /company/{id}`
|
||||||
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
|
||||||
- Returns: `200 { company: T33 }`
|
- Returns: `200 { company: T32 }`
|
||||||
#### List customer groups — `GET /customergroup`
|
#### List customer groups — `GET /customergroup`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
- Returns: `200 { customergroups: { id: int, name: str, sort: int, editable: bool }[] }`
|
||||||
@@ -322,6 +334,15 @@ _none_
|
|||||||
#### Parse document (SSE) — `POST /delivery/parse`
|
#### Parse document (SSE) — `POST /delivery/parse`
|
||||||
- Params: `form: { file: str }`
|
- Params: `form: { file: str }`
|
||||||
- Returns: `200 SSE`
|
- Returns: `200 SSE`
|
||||||
|
|
||||||
|
- Events:
|
||||||
|
- `status`: `{ "message": "…" }`
|
||||||
|
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
|
||||||
|
- `tool_call`: `{ "name": "output_manifest" }`
|
||||||
|
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
|
||||||
|
- `progress`: `ManifestResult`
|
||||||
|
- `result`: `ManifestResult`
|
||||||
|
- `error`: `{ "message": "…" }`
|
||||||
#### Delete delivery — `DELETE /delivery/{id}`
|
#### Delete delivery — `DELETE /delivery/{id}`
|
||||||
- Params: `id: int`
|
- Params: `id: int`
|
||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
@@ -379,6 +400,22 @@ _none_
|
|||||||
#### Subscribe to update signals (SSE) — `GET /events`
|
#### Subscribe to update signals (SSE) — `GET /events`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 SSE`
|
- Returns: `200 SSE`
|
||||||
|
|
||||||
|
- Events:
|
||||||
|
- `category`: `{}`
|
||||||
|
- `articles`: `{}`
|
||||||
|
- `article-<sku>`: `{}`
|
||||||
|
- `stock`: `{}`
|
||||||
|
- `ledger`: `{}`
|
||||||
|
- `attributename`: `{}`
|
||||||
|
- `propertyname`: `{}`
|
||||||
|
- `labeltemplate`: `{}`
|
||||||
|
- `printer`: `{}`
|
||||||
|
- `warehouse`: `{}`
|
||||||
|
- `vfs`: `{}`
|
||||||
|
- `modelconversation`: `{}`
|
||||||
|
- `modelconversation-<id>`: `{}`
|
||||||
|
- `brand`: `{ sender, brandId }`
|
||||||
#### Database health check — `GET /health`
|
#### Database health check — `GET /health`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { ok: bool }`
|
- Returns: `200 { ok: bool }`
|
||||||
@@ -443,8 +480,8 @@ _none_
|
|||||||
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
|
- Params: `id: int, body: { prefix?: str, padding?: int, next?: int }`
|
||||||
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
|
- Returns: `200 { number: { id: int, type: str, prefix: str, padding: int, next: int } }`
|
||||||
#### List orders — `GET /order`
|
#### List orders — `GET /order`
|
||||||
_none_
|
- Params: `query: offset?: int, limit?: int`
|
||||||
- Returns: `200 { orders: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }`
|
- Returns: `200 { orders: T11[], total: int, offset: int, limit: int }`
|
||||||
#### Create order — `POST /order`
|
#### Create order — `POST /order`
|
||||||
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
|
- Params: `body: { number: str, date: str, lines: T25[], entityId?: int|null }`
|
||||||
- Returns: `201 T7`
|
- Returns: `201 T7`
|
||||||
@@ -457,6 +494,24 @@ _none_
|
|||||||
#### Update order — `PATCH /order/{id}`
|
#### Update order — `PATCH /order/{id}`
|
||||||
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
|
- Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T25[] }`
|
||||||
- Returns: `200 T7`
|
- Returns: `200 T7`
|
||||||
|
#### List payments of an order — `GET /order/{id}/payment`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { payments: { id: int, accountId: int, manifestId: int, type: str, amount: num }[] }`
|
||||||
|
#### Add payment to an order — `POST /order/{id}/payment`
|
||||||
|
- Params: `id: int, body: { accountId: int, type: str, amount: num }`
|
||||||
|
- Returns: `201 { payment: { id: int, accountId: int, manifestId: int, type: str, amount: num } }`
|
||||||
|
#### List shippings of an order — `GET /order/{id}/shipping`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `200 { shippings: { id: int, manifestId: int, number: str, type: str, state: str }[] }`
|
||||||
|
#### Create shipping for an order — `POST /order/{id}/shipping`
|
||||||
|
- Params: `id: int, body: { type: str, state: str }`
|
||||||
|
- Returns: `201 { shipping: { id: int, manifestId: int, number: str, type: str, state: str } }`
|
||||||
|
#### Delete package — `DELETE /package/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
#### Delete payment — `DELETE /payment/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
#### List POS terminals — `GET /posterminal`
|
#### List POS terminals — `GET /posterminal`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { posterminals: T10[] }`
|
- Returns: `200 { posterminals: T10[] }`
|
||||||
@@ -550,6 +605,24 @@ _none_
|
|||||||
#### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images`
|
#### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images`
|
||||||
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
|
- Params: `body: { q: str, gl?: str, hl?: str, num?: int, preferSource?: bool }`
|
||||||
- Returns: `200 SSE`
|
- Returns: `200 SSE`
|
||||||
|
|
||||||
|
- Events:
|
||||||
|
- `images`: `{ "images": [ { "id": 0, "url": "…", "width": 1000, "height": 800 } ] }`
|
||||||
|
- `thumb`: `{ "id": 0, "dataUrl": "data:image/webp;base64,…" }`
|
||||||
|
- `done`: `{ "total": 100 }`
|
||||||
|
- `error`: `{ "error": "…" }`
|
||||||
|
#### Delete shipping — `DELETE /shipping/{id}`
|
||||||
|
- Params: `id: int`
|
||||||
|
- Returns: `204 void`
|
||||||
|
#### 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 }[] }`
|
||||||
|
#### Create package for a shipping — `POST /shipping/{id}/package`
|
||||||
|
- Params: `id: int, body: { entityaddressId: int, type: str, state: str, details?: str }`
|
||||||
|
- Returns: `201 { package: { id: int, shippingId: int, entityaddressId: int, number: str, type: str, state: str, details?: str|null } }`
|
||||||
|
#### Small thumb image by hash — `GET /smallthumb/{fullHash}`
|
||||||
|
- Params: `fullHash: str`
|
||||||
|
- Returns: `200 avif`
|
||||||
#### Adjust stock manually — `POST /stock/adjust`
|
#### 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 }[] }`
|
- 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: T15[] }`
|
- 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: T15[] }`
|
||||||
@@ -579,10 +652,10 @@ _none_
|
|||||||
- Returns: `204 void`
|
- Returns: `204 void`
|
||||||
#### List units — `GET /unit`
|
#### List units — `GET /unit`
|
||||||
_none_
|
_none_
|
||||||
- Returns: `200 { units: T32[] }`
|
- Returns: `200 { units: T31[] }`
|
||||||
#### Upsert unit — `PUT /unit`
|
#### Upsert unit — `PUT /unit`
|
||||||
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
- Params: `body: { si: str, basesi: str, basesifactor: num, symbol: str, name: str }`
|
||||||
- Returns: `200 { unit: T32 }`
|
- Returns: `200 { unit: T31 }`
|
||||||
#### JTL-POS category synchronization — `GET /v1/category`
|
#### JTL-POS category synchronization — `GET /v1/category`
|
||||||
- Params: `query: lastChangedCategory?: str, limit?: int`
|
- Params: `query: lastChangedCategory?: str, limit?: int`
|
||||||
- Returns: `200 {}[]`
|
- Returns: `200 {}[]`
|
||||||
|
|||||||
100
src/api/index.js
100
src/api/index.js
@@ -21,25 +21,39 @@ function readableHeaders(headers) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||||
|
const CLIENT_ID_HEADER = 'X-Client-Id';
|
||||||
|
|
||||||
|
function getStoredClientId() {
|
||||||
|
try {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientIdHeaders() {
|
||||||
|
const cid = getStoredClientId();
|
||||||
|
return cid ? { [CLIENT_ID_HEADER]: cid } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMutatingMethod(method) {
|
||||||
|
const upper = String(method || 'GET').toUpperCase();
|
||||||
|
return upper !== 'GET' && upper !== 'HEAD';
|
||||||
|
}
|
||||||
|
|
||||||
async function http(path, { method = 'GET', body } = {}) {
|
async function http(path, { method = 'GET', body } = {}) {
|
||||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||||
const headers = {};
|
const headers = {};
|
||||||
if (!isFormData) {
|
if (!isFormData) {
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
if (isMutatingMethod(method)) {
|
||||||
|
Object.assign(headers, clientIdHeaders());
|
||||||
|
}
|
||||||
const url = new URL(path, window.location.origin).href;
|
const url = new URL(path, window.location.origin).href;
|
||||||
|
|
||||||
logApi('request', {
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
url,
|
|
||||||
origin: window.location.origin,
|
|
||||||
credentials: 'include',
|
|
||||||
// HttpOnly cookies are invisible here; this only shows JS-readable cookies.
|
|
||||||
documentCookie: document.cookie || '(empty)',
|
|
||||||
hasBody: Boolean(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
@@ -47,18 +61,6 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
|
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseHeaders = readableHeaders(res.headers);
|
|
||||||
// Browsers hide Set-Cookie from JS; if it's missing here that proves nothing.
|
|
||||||
logApi('response', {
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
ok: res.ok,
|
|
||||||
responseHeaders,
|
|
||||||
documentCookieAfter: document.cookie || '(empty)',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = res.statusText;
|
let message = res.statusText;
|
||||||
let data;
|
let data;
|
||||||
@@ -78,12 +80,6 @@ async function http(path, { method = 'GET', body } = {}) {
|
|||||||
}
|
}
|
||||||
if (res.status === 204) return null;
|
if (res.status === 204) return null;
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
logApi('ok-body', {
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
|
|
||||||
hasUser: Boolean(json?.user),
|
|
||||||
});
|
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +98,7 @@ async function httpBinary(path, { method = 'POST', body, contentType, headers: e
|
|||||||
|
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
method,
|
method,
|
||||||
headers: { 'Content-Type': contentType, ...(extraHeaders || {}) },
|
headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
@@ -842,22 +838,42 @@ export const category = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Company-scoped brands (selectedCompanyId from session).
|
* Company-scoped brands (selectedCompanyId from session).
|
||||||
* Shape: Brand = { id, name, gsprpinfo | null }
|
* Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] }
|
||||||
*/
|
*/
|
||||||
export const brand = {
|
export const brand = {
|
||||||
/** GET /api/brand → { brands: Brand[] } · 400 No company selected */
|
/**
|
||||||
list() {
|
* GET /api/brand → { brands: Brand[], total, offset, limit }
|
||||||
return http('/api/brand');
|
* @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params]
|
||||||
|
*/
|
||||||
|
list(params) {
|
||||||
|
const offset = Number(params?.offset ?? 0);
|
||||||
|
const limit = Number(params?.limit ?? 0);
|
||||||
|
const includeSmallThumbs = params?.includeSmallThumbs;
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset));
|
||||||
|
if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit));
|
||||||
|
if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false');
|
||||||
|
const qs = query.toString();
|
||||||
|
return http(qs ? `/api/brand?${qs}` : '/api/brand');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/brand
|
* POST /api/brand
|
||||||
* Body: { name, gsprpinfo? } — upsert by name for the selected company.
|
* Body: { name, gsprpinfo? } — insert for the selected company.
|
||||||
* Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand.
|
* Empty name ⇒ 400 Invalid brand.
|
||||||
* → 200 { brand: Brand }
|
* → 201 { brand: Brand } · 409 Brand already exists
|
||||||
*/
|
*/
|
||||||
upsert(body) {
|
create(body) {
|
||||||
return http('/api/brand', { method: 'PUT', body });
|
return http('/api/brand', { method: 'POST', body });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/brand/:id
|
||||||
|
* Body: { name, gsprpinfo? } — rename / update info.
|
||||||
|
* → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found
|
||||||
|
*/
|
||||||
|
rename(id, body) {
|
||||||
|
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1170,6 +1186,7 @@ export const delivery = {
|
|||||||
formData.append('file', file, file.name);
|
formData.append('file', file, file.name);
|
||||||
const res = await fetch('/api/delivery/parse', {
|
const res = await fetch('/api/delivery/parse', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { ...clientIdHeaders() },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
signal,
|
signal,
|
||||||
@@ -1264,6 +1281,7 @@ export const serper = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...clientIdHeaders(),
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ q, gl, hl, num, preferSource }),
|
body: JSON.stringify({ q, gl, hl, num, preferSource }),
|
||||||
@@ -1355,6 +1373,7 @@ export const serper = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...clientIdHeaders(),
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
@@ -2165,6 +2184,7 @@ export const vfs = {
|
|||||||
}
|
}
|
||||||
const res = await fetch('/api/vfs/upload', {
|
const res = await fetch('/api/vfs/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { ...clientIdHeaders() },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,16 +5,24 @@
|
|||||||
* Upstream Triggers:
|
* Upstream Triggers:
|
||||||
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
|
||||||
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
* `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`.
|
||||||
* - UI / Settings: Mutating actions (`upsert`, `remove`, `addImage`, `deleteImage`).
|
* - `sseAutomat`: Subscribes to the single `'brand'` event carrying
|
||||||
|
* `{ sender, brandId }` — any create/rename/delete/picture change triggers
|
||||||
|
* `actions.reload()`, which silently re-fetches the list. Subscribed views
|
||||||
|
* (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then
|
||||||
|
* re-render from the updated state. This is the reload pattern. Events sent
|
||||||
|
* by this tab (matching `sender`) are ignored.
|
||||||
|
* - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { brand as brandApi } from '../api';
|
import { brand as brandApi } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
|
import sseAutomat, { getSseClientId } from './sseAutomat';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} BrandState
|
* @typedef {Object} BrandState
|
||||||
* @property {Array<object>} brands
|
* @property {Array<object>} brands All brands (full list, decoupled from page UI).
|
||||||
|
* @property {number} total Unpaged brand count.
|
||||||
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
* @property {'idle'|'loading'|'succeeded'|'failed'} status
|
||||||
* @property {string|null} error
|
* @property {string|null} error
|
||||||
*/
|
*/
|
||||||
@@ -22,6 +30,7 @@ import authAutomat from './authAutomat';
|
|||||||
/** @type {BrandState} */
|
/** @type {BrandState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
brands: [],
|
brands: [],
|
||||||
|
total: 0,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -34,36 +43,101 @@ function sortBrands(brands) {
|
|||||||
|
|
||||||
let brandAutomat;
|
let brandAutomat;
|
||||||
|
|
||||||
|
/** In-flight `load()`/`reload()` promise so concurrent callers share one request. */
|
||||||
|
let inflightLoad = null;
|
||||||
|
|
||||||
|
/** Max page size the API accepts; full list is fetched by paging through. */
|
||||||
|
const FETCH_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
async function fetchAllBrands() {
|
||||||
|
let offset = 0;
|
||||||
|
let total = Infinity;
|
||||||
|
const collected = [];
|
||||||
|
for (;;) {
|
||||||
|
const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE });
|
||||||
|
if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0);
|
||||||
|
const page = Array.isArray(res.brands) ? res.brands : [];
|
||||||
|
collected.push(...page);
|
||||||
|
offset += page.length;
|
||||||
|
if (page.length === 0 || collected.length >= total) break;
|
||||||
|
}
|
||||||
|
return { brands: sortBrands(collected), total: Number.isFinite(total) ? total : collected.length };
|
||||||
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
/** Load all brands for the selected company. */
|
/** Load all brands for the selected company (pages through the API). */
|
||||||
async load() {
|
async load() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
brandAutomat.setState({ status: 'loading', error: null });
|
brandAutomat.setState({ status: 'loading', error: null });
|
||||||
|
inflightLoad = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await brandApi.list();
|
const { brands, total } = await fetchAllBrands();
|
||||||
const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []);
|
brandAutomat.setState({ brands, total, status: 'succeeded' });
|
||||||
brandAutomat.setState({ brands, status: 'succeeded' });
|
|
||||||
return brands;
|
return brands;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
|
||||||
throw err;
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a brand (create or update by name).
|
* Silently re-fetches all brands while keeping the current state
|
||||||
|
* visible (no loading flash). Subscribed views re-render from the updated
|
||||||
|
* state — this is the reload pattern for SSE-driven refreshes.
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>} Fresh brands.
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
if (inflightLoad) return inflightLoad;
|
||||||
|
inflightLoad = (async () => {
|
||||||
|
try {
|
||||||
|
const { brands, total } = await fetchAllBrands();
|
||||||
|
brandAutomat.setState({ brands, total, status: 'succeeded', error: null });
|
||||||
|
return brands;
|
||||||
|
} catch (err) {
|
||||||
|
brandAutomat.setState({ error: err?.message ?? String(err) });
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
inflightLoad = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflightLoad;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a brand (insert). 409 Brand already exists on duplicate name.
|
||||||
* @param {{ name: string, gsprpinfo?: string }} body
|
* @param {{ name: string, gsprpinfo?: string }} body
|
||||||
*/
|
*/
|
||||||
async upsert(body) {
|
async create(body) {
|
||||||
const res = await brandApi.upsert(body);
|
const res = await brandApi.create(body);
|
||||||
const row = res.brand;
|
const row = res.brand;
|
||||||
const brands = sortBrands([
|
const brands = sortBrands([
|
||||||
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
|
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
|
||||||
row,
|
row,
|
||||||
]);
|
]);
|
||||||
brandAutomat.setState({ brands });
|
brandAutomat.setState({ brands, total: brands.length });
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a brand. Returns `{ id, name, gsprpinfo, touchedArticleCount }`.
|
||||||
|
* @param {number} id
|
||||||
|
* @param {{ name?: string, gsprpinfo?: string }} body
|
||||||
|
*/
|
||||||
|
async rename(id, body) {
|
||||||
|
const res = await brandApi.rename(id, body);
|
||||||
|
const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo };
|
||||||
|
const brands = sortBrands(
|
||||||
|
brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)),
|
||||||
|
);
|
||||||
|
brandAutomat.setState({ brands });
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a brand by id.
|
* Delete a brand by id.
|
||||||
* @param {number} id
|
* @param {number} id
|
||||||
@@ -71,7 +145,7 @@ const actions = {
|
|||||||
async remove(id) {
|
async remove(id) {
|
||||||
await brandApi.delete(id);
|
await brandApi.delete(id);
|
||||||
const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
|
const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
|
||||||
brandAutomat.setState({ brands });
|
brandAutomat.setState({ brands, total: brands.length });
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,4 +193,21 @@ authAutomat.subscribe((auth) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SSE: single `brand` event with `{ sender, brandId }` payload for every
|
||||||
|
// create/rename/delete/picture change. Silently reloads the list; subscribed
|
||||||
|
// views re-render from state. Own events are skipped.
|
||||||
|
sseAutomat.actions.on('brand', (event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event?.data ?? '{}');
|
||||||
|
if (payload && typeof payload.sender === 'string' && payload.sender) {
|
||||||
|
try {
|
||||||
|
if (payload.sender === getSseClientId()) return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unparseable payload: fall through to reload.
|
||||||
|
}
|
||||||
|
void brandAutomat.actions.reload().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
export default brandAutomat;
|
export default brandAutomat;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { Automat } from 'react-automat';
|
import { Automat } from 'react-automat';
|
||||||
import { modelconversation as modelconversationApi } from '../api';
|
import { modelconversation as modelconversationApi } from '../api';
|
||||||
import authAutomat from './authAutomat';
|
import authAutomat from './authAutomat';
|
||||||
|
import { getSseClientId } from './sseAutomat';
|
||||||
import {
|
import {
|
||||||
attachUserEnv,
|
attachUserEnv,
|
||||||
getCurrentRoute,
|
getCurrentRoute,
|
||||||
@@ -110,6 +111,23 @@ let companyReconnectTimer = null;
|
|||||||
let conversationStream = null;
|
let conversationStream = null;
|
||||||
let activeStreamConversationId = null;
|
let activeStreamConversationId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the company-wide SSE stream (/api/events). Called on logout so no
|
||||||
|
* stale stream survives; reconnected on relogin via `ensureCompanyEventsConnected()`.
|
||||||
|
*/
|
||||||
|
function disconnectCompanyEvents() {
|
||||||
|
if (companyReconnectTimer) {
|
||||||
|
clearTimeout(companyReconnectTimer);
|
||||||
|
companyReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (companyEventSource) {
|
||||||
|
try {
|
||||||
|
companyEventSource.close();
|
||||||
|
} catch {}
|
||||||
|
companyEventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events.
|
* Connect to company-wide SSE stream (/api/events) to listen for 'modelconversation' events.
|
||||||
*/
|
*/
|
||||||
@@ -128,7 +146,10 @@ function ensureCompanyEventsConnected() {
|
|||||||
companyReconnectTimer = null;
|
companyReconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = new EventSource('/api/events', { withCredentials: true });
|
const url = new URL('/api/events', window.location.origin);
|
||||||
|
const cid = getSseClientId();
|
||||||
|
if (cid) url.searchParams.set('cid', cid);
|
||||||
|
const es = new EventSource(url.href, { withCredentials: true });
|
||||||
companyEventSource = es;
|
companyEventSource = es;
|
||||||
|
|
||||||
es.addEventListener('modelconversation', () => {
|
es.addEventListener('modelconversation', () => {
|
||||||
@@ -1362,28 +1383,24 @@ const actions = {
|
|||||||
*/
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
closeConversationStream();
|
closeConversationStream();
|
||||||
if (companyEventSource) {
|
disconnectCompanyEvents();
|
||||||
try {
|
|
||||||
companyEventSource.close();
|
|
||||||
} catch {}
|
|
||||||
companyEventSource = null;
|
|
||||||
}
|
|
||||||
if (companyReconnectTimer) {
|
|
||||||
clearTimeout(companyReconnectTimer);
|
|
||||||
companyReconnectTimer = null;
|
|
||||||
}
|
|
||||||
chatAutomat.setState(initialState);
|
chatAutomat.setState(initialState);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
chatAutomat = new Automat(initialState, actions, { name: 'chat' });
|
chatAutomat = new Automat(initialState, actions, { name: 'chat' });
|
||||||
|
|
||||||
// Listen to auth changes: reset on logout or company change
|
// Listen to auth changes: disconnect on logout, reconnect on relogin/company change.
|
||||||
|
let chatWasAuthed = false;
|
||||||
authAutomat.subscribe((auth) => {
|
authAutomat.subscribe((auth) => {
|
||||||
const companyId = auth.user?.selectedCompanyId;
|
const companyId = auth.user?.selectedCompanyId;
|
||||||
|
const isAuthed = auth.status === 'authed' && companyId != null;
|
||||||
|
const relogin = isAuthed && !chatWasAuthed;
|
||||||
|
chatWasAuthed = isAuthed;
|
||||||
if (auth.status === 'anon' || !companyId) {
|
if (auth.status === 'anon' || !companyId) {
|
||||||
|
disconnectCompanyEvents();
|
||||||
chatAutomat.actions.reset();
|
chatAutomat.actions.reset();
|
||||||
} else if (auth.status === 'authed' && companyId) {
|
} else if (relogin || (auth.status === 'authed' && companyId)) {
|
||||||
if (chatAutomat.state.sseEstablished) {
|
if (chatAutomat.state.sseEstablished) {
|
||||||
ensureCompanyEventsConnected();
|
ensureCompanyEventsConnected();
|
||||||
void chatAutomat.actions.loadConversations(true);
|
void chatAutomat.actions.loadConversations(true);
|
||||||
|
|||||||
@@ -37,15 +37,51 @@ import supplierAutomat from './supplierAutomat';
|
|||||||
* @typedef {Object} SseState
|
* @typedef {Object} SseState
|
||||||
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
* @property {'disconnected'|'connecting'|'connected'|'error'} status Connection state.
|
||||||
* @property {string|null} companyId Company the stream is bound to.
|
* @property {string|null} companyId Company the stream is bound to.
|
||||||
|
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
|
||||||
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const EVENT_HISTORY_LIMIT = 200;
|
const EVENT_HISTORY_LIMIT = 200;
|
||||||
|
|
||||||
|
const SSE_CLIENT_ID_KEY = 'sse.cid';
|
||||||
|
|
||||||
|
function createClientId() {
|
||||||
|
try {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tab-scoped SSE client id, creating and persisting it on first use.
|
||||||
|
* Created when the automat module is evaluated so every `EventSource` in this
|
||||||
|
* tab shares the same `cid`.
|
||||||
|
*
|
||||||
|
* @returns {string} Client id.
|
||||||
|
*/
|
||||||
|
export function getSseClientId() {
|
||||||
|
try {
|
||||||
|
const existing = window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
const id = createClientId();
|
||||||
|
window.sessionStorage.setItem(SSE_CLIENT_ID_KEY, id);
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
if (!getSseClientId.fallback) getSseClientId.fallback = createClientId();
|
||||||
|
return getSseClientId.fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Created once per tab at automat creation time and stored to sessionStorage.
|
||||||
|
const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null;
|
||||||
|
|
||||||
/** @type {SseState} */
|
/** @type {SseState} */
|
||||||
const initialState = {
|
const initialState = {
|
||||||
status: 'disconnected',
|
status: 'disconnected',
|
||||||
companyId: null,
|
companyId: null,
|
||||||
|
clientId: sseClientId,
|
||||||
events: [],
|
events: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,10 +133,16 @@ function addTrackedListener(es, type, handler) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
if (!eventSource) return;
|
if (eventSource) {
|
||||||
|
console.log('[sse] disconnect');
|
||||||
|
try {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
|
} catch {}
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
|
}
|
||||||
|
if (sseAutomat.state.status !== 'disconnected') {
|
||||||
sseAutomat.setState({ status: 'disconnected' });
|
sseAutomat.setState({ status: 'disconnected' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,10 +189,12 @@ function connect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL('/api/events', window.location.origin).href;
|
const url = new URL('/api/events', window.location.origin);
|
||||||
|
if (sseClientId) url.searchParams.set('cid', sseClientId);
|
||||||
|
console.log(`[sse] connecting`);
|
||||||
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
sseAutomat.setState({ status: 'connecting', companyId: String(companyId) });
|
||||||
|
|
||||||
const es = new EventSource(url, { withCredentials: true });
|
const es = new EventSource(url.href, { withCredentials: true });
|
||||||
|
|
||||||
// Category tree changed: refresh the tree and every open category view.
|
// Category tree changed: refresh the tree and every open category view.
|
||||||
addTrackedListener(es, 'category', () => {
|
addTrackedListener(es, 'category', () => {
|
||||||
@@ -314,12 +358,19 @@ export function invalidateArticle(sku) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconnect when the selected company changes; disconnect on logout.
|
// Reconnect when the selected company changes; disconnect on logout.
|
||||||
|
// On logout the stream must close; on relogin (anon -> authed with a company)
|
||||||
|
// the stream is re-established even if the company id matches the previous one.
|
||||||
|
let wasAuthed = false;
|
||||||
authAutomat.subscribe((auth) => {
|
authAutomat.subscribe((auth) => {
|
||||||
const companyId =
|
const companyId =
|
||||||
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
auth.status === 'authed' && auth.user?.selectedCompanyId != null
|
||||||
? String(auth.user.selectedCompanyId)
|
? String(auth.user.selectedCompanyId)
|
||||||
: null;
|
: null;
|
||||||
if (companyId === sseAutomat.state.companyId) return;
|
const isAuthed = companyId != null;
|
||||||
|
const relogin = isAuthed && !wasAuthed;
|
||||||
|
wasAuthed = isAuthed;
|
||||||
|
|
||||||
|
if (!relogin && companyId === sseAutomat.state.companyId) return;
|
||||||
|
|
||||||
if (companyId == null) {
|
if (companyId == null) {
|
||||||
disconnect();
|
disconnect();
|
||||||
@@ -327,7 +378,7 @@ authAutomat.subscribe((auth) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A different company means a different stream scope.
|
// A different company (or a fresh login) means a different stream scope.
|
||||||
disconnect();
|
disconnect();
|
||||||
sseAutomat.setState({ companyId });
|
sseAutomat.setState({ companyId });
|
||||||
connect();
|
connect();
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export default class ArticleViewBrand extends PureComponent {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
brandId = existing.id;
|
brandId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
await brandAutomat.actions.upsert({ name });
|
await brandAutomat.actions.create({ name });
|
||||||
const nextBrands = await brandAutomat.actions.load();
|
const nextBrands = await brandAutomat.actions.load();
|
||||||
this.setState({ brands: nextBrands });
|
this.setState({ brands: nextBrands });
|
||||||
brandId = nextBrands.find(
|
brandId = nextBrands.find(
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent } from 'react';
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import ListItem from '@mui/material/ListItem';
|
import ListItem from '@mui/material/ListItem';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
|
||||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
import CopyrightIcon from '@mui/icons-material/Copyright';
|
|
||||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
import BrandThumb from './BrandThumb';
|
import BrandThumb from './BrandThumb';
|
||||||
|
import BrandImageSearchDialog from './BrandImageSearchDialog';
|
||||||
|
|
||||||
class ArtikelMarke extends PureComponent {
|
class ArtikelMarke extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
draft: props.brand?.name ?? '',
|
draft: props.brand?.name ?? '',
|
||||||
|
logoOpen: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +35,27 @@ class ArtikelMarke extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleBlur = () => {
|
handleBlur = () => {
|
||||||
const { onRename, brand } = this.props;
|
const { brand } = this.props;
|
||||||
const draft = this.state.draft.trim();
|
const draft = this.state.draft.trim();
|
||||||
if (!draft) {
|
if (!draft) {
|
||||||
this.setState({ draft: brand?.name ?? '' });
|
this.setState({ draft: brand?.name ?? '' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draft !== brand?.name) {
|
if (draft !== brand?.name) {
|
||||||
onRename?.(brand, draft);
|
void this.renameBrand(brand, draft);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async renameBrand(brand, name) {
|
||||||
|
try {
|
||||||
|
await brandAutomat.actions.rename(brand.id, { name });
|
||||||
|
enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
this.setState({ draft: brand?.name ?? '' });
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleKeyDown = (event) => {
|
handleKeyDown = (event) => {
|
||||||
const { brand } = this.props;
|
const { brand } = this.props;
|
||||||
if (event.key === 'Enter') {
|
if (event.key === 'Enter') {
|
||||||
@@ -57,30 +70,52 @@ class ArtikelMarke extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleSearchLogo = () => {
|
handleSearchLogo = () => {
|
||||||
const { onSearchLogo, brand } = this.props;
|
this.setState({ logoOpen: true });
|
||||||
onSearchLogo?.(brand);
|
};
|
||||||
|
|
||||||
|
handleLogoClose = () => {
|
||||||
|
this.setState({ logoOpen: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDelete = () => {
|
handleDelete = () => {
|
||||||
const { onDelete, brand } = this.props;
|
const { brand } = this.props;
|
||||||
onDelete?.(brand);
|
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await brandAutomat.actions.remove(brand.id);
|
||||||
|
enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { brand, disabled, sx } = this.props;
|
const { brand, disabled, sx } = this.props;
|
||||||
const { draft } = this.state;
|
const { draft, logoOpen } = this.state;
|
||||||
const name = brand?.name ?? '';
|
const name = brand?.name ?? '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
disableGutters
|
disableGutters
|
||||||
sx={{ gap: 1, py: 0.75, alignItems: 'center', ...sx }}
|
sx={{ gap: 1, py: 0.75, alignItems: 'center', width: '100%', minWidth: 0, ...sx }}
|
||||||
>
|
>
|
||||||
<BrandThumb
|
<BrandThumb
|
||||||
brand={brand}
|
brand={brand}
|
||||||
size={22}
|
size={40}
|
||||||
fallback={<CopyrightIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />}
|
fallback={
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Logo für „${name}" suchen`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={this.handleSearchLogo}
|
||||||
|
sx={{ width: 40, height: 40, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<ImageSearchIcon sx={{ fontSize: 24 }} />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SingleLineTextEdit
|
<SingleLineTextEdit
|
||||||
size="small"
|
size="small"
|
||||||
label=""
|
label=""
|
||||||
@@ -89,34 +124,26 @@ class ArtikelMarke extends PureComponent {
|
|||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.handleBlur}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
sx={{ flex: 1 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
<Tooltip title="Logo suchen">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
|
||||||
aria-label={`Logo für „${name}" suchen`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={this.handleSearchLogo}
|
|
||||||
>
|
|
||||||
<ImageSearchIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Löschen">
|
|
||||||
<span>
|
|
||||||
<IconButton
|
|
||||||
edge="end"
|
|
||||||
size="small"
|
|
||||||
aria-label={`„${name}" löschen`}
|
aria-label={`„${name}" löschen`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={this.handleDelete}
|
onClick={this.handleDelete}
|
||||||
|
sx={{ width: 40, height: 40, flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<DeleteOutlinedIcon fontSize="small" />
|
<DeleteOutlinedIcon sx={{ fontSize: 24 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
{logoOpen ? (
|
||||||
|
<BrandImageSearchDialog
|
||||||
|
open
|
||||||
|
brand={brand}
|
||||||
|
onClose={this.handleLogoClose}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/components/brand/ArtikelMarkeNew.jsx
Normal file
61
src/components/brand/ArtikelMarkeNew.jsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||||
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
|
import { notifyError } from '../../utils/notify';
|
||||||
|
|
||||||
|
// Own component so typing a new brand name never rerenders ArtikelMarkenPage
|
||||||
|
// (and its FillSpace measurement). Holds the draft locally and performs the
|
||||||
|
// automat mutation itself — no callbacks.
|
||||||
|
class ArtikelMarkeNew extends PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { newName: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
const name = this.state.newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const row = await brandAutomat.actions.create({ name });
|
||||||
|
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
|
||||||
|
this.setState({ newName: '' });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { rowRef } = this.props;
|
||||||
|
const { newName } = this.state;
|
||||||
|
return (
|
||||||
|
<Box ref={rowRef} sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, pb: 2 }}>
|
||||||
|
<SingleLineTextEdit
|
||||||
|
size="small"
|
||||||
|
label="Neue Marke"
|
||||||
|
value={newName}
|
||||||
|
onChange={(next) => this.setState({ newName: next })}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
void this.submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
onClick={() => void this.submit()}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArtikelMarkeNew;
|
||||||
@@ -19,7 +19,7 @@ class ArtikelMarkenTable extends PureComponent {
|
|||||||
const isBusy = disabled || busyKey != null;
|
const isBusy = disabled || busyKey != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List dense disablePadding sx={sx}>
|
<List dense disablePadding sx={{ width: '100%', minWidth: 0, ...sx }}>
|
||||||
{brands.length === 0 ? (
|
{brands.length === 0 ? (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
|
|||||||
@@ -1,90 +1,39 @@
|
|||||||
|
import { PureComponent } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import ThumbHashImage, { getThumbHashDimensions } from '../article/ThumbHashImage';
|
import ThumbHashImage from '../article/ThumbHashImage';
|
||||||
import brandAutomat from '../../automats/brandAutomat';
|
|
||||||
import {
|
import {
|
||||||
SHADOW_THUMB_RING,
|
SHADOW_THUMB_RING,
|
||||||
TINT_ROW_HOVER,
|
TINT_ROW_HOVER,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
/**
|
export default class BrandThumb extends PureComponent {
|
||||||
* Extracts thumbHash from various brand representations:
|
render() {
|
||||||
* - Direct thumbHash / thumbhash property
|
const {
|
||||||
* - Brand with pics array (e.g. brand.pics[0].thumbHash)
|
|
||||||
* - Brand by name or id looked up in brandAutomat.state.brands
|
|
||||||
*/
|
|
||||||
export function extractBrandThumbInfo(brand, thumbHashOverride) {
|
|
||||||
if (thumbHashOverride) {
|
|
||||||
const dims = getThumbHashDimensions(thumbHashOverride);
|
|
||||||
return {
|
|
||||||
thumbHash: thumbHashOverride,
|
|
||||||
thumbW: dims?.w ?? null,
|
|
||||||
thumbH: dims?.h ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!brand) return null;
|
|
||||||
|
|
||||||
// Direct thumbHash on brand object
|
|
||||||
let hash = brand.thumbHash ?? brand.thumbhash;
|
|
||||||
let thumbW = brand.thumbW ?? brand.thumbw;
|
|
||||||
let thumbH = brand.thumbH ?? brand.thumbh;
|
|
||||||
|
|
||||||
// Check pics array
|
|
||||||
if (!hash && Array.isArray(brand.pics) && brand.pics.length > 0) {
|
|
||||||
const firstPic = brand.pics[0];
|
|
||||||
hash = firstPic?.thumbHash ?? firstPic?.thumbhash;
|
|
||||||
thumbW = firstPic?.thumbW ?? firstPic?.thumbw;
|
|
||||||
thumbH = firstPic?.thumbH ?? firstPic?.thumbh;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look up in brandAutomat
|
|
||||||
if (!hash) {
|
|
||||||
const brands = brandAutomat.state?.brands;
|
|
||||||
if (Array.isArray(brands)) {
|
|
||||||
const id = typeof brand === 'object' ? brand.id : (typeof brand === 'number' ? brand : null);
|
|
||||||
const name = typeof brand === 'object' ? brand.name : (typeof brand === 'string' ? brand : null);
|
|
||||||
const found = brands.find(
|
|
||||||
(b) =>
|
|
||||||
(id != null && b.id === id) ||
|
|
||||||
(name && String(b.name || '').trim().toLowerCase() === String(name).trim().toLowerCase())
|
|
||||||
);
|
|
||||||
if (found) {
|
|
||||||
hash = found.thumbHash ?? found.thumbhash ?? found.pics?.[0]?.thumbHash ?? found.pics?.[0]?.thumbhash;
|
|
||||||
thumbW = found.thumbW ?? found.thumbw ?? found.pics?.[0]?.thumbW ?? found.pics?.[0]?.thumbw;
|
|
||||||
thumbH = found.thumbH ?? found.thumbh ?? found.pics?.[0]?.thumbH ?? found.pics?.[0]?.thumbh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hash) return null;
|
|
||||||
|
|
||||||
if (thumbW == null || thumbH == null) {
|
|
||||||
const dims = getThumbHashDimensions(hash);
|
|
||||||
if (dims) {
|
|
||||||
thumbW = dims.w;
|
|
||||||
thumbH = dims.h;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { thumbHash: hash, thumbW, thumbH };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BrandThumb({
|
|
||||||
brand,
|
brand,
|
||||||
thumbHash: thumbHashProp,
|
|
||||||
size = 20,
|
size = 20,
|
||||||
fallback = null,
|
fallback = null,
|
||||||
sx,
|
sx,
|
||||||
}) {
|
} = this.props;
|
||||||
const info = extractBrandThumbInfo(brand, thumbHashProp);
|
const pic = brand?.pics?.[0];
|
||||||
|
|
||||||
if (!info?.thumbHash) {
|
if (!pic?.thumbHash) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { thumbHash, thumbW, thumbH } = info;
|
const { thumbHash, thumbW, thumbH } = pic;
|
||||||
const ratio = (thumbW && thumbH) ? (thumbW / thumbH) : 1;
|
const ratio = thumbW && thumbH ? thumbW / thumbH : 1;
|
||||||
const calcWidth = Math.round(size * Math.max(0.6, Math.min(ratio, 2.5)));
|
// Contain-fit inside a square `size x size` box, preserving aspect ratio.
|
||||||
|
let innerW = size;
|
||||||
|
let innerH = size;
|
||||||
|
if (Number.isFinite(ratio) && ratio > 0) {
|
||||||
|
if (ratio >= 1) {
|
||||||
|
innerW = size;
|
||||||
|
innerH = Math.max(1, Math.round(size / ratio));
|
||||||
|
} else {
|
||||||
|
innerH = size;
|
||||||
|
innerW = Math.max(1, Math.round(size * ratio));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -94,26 +43,40 @@ export default function BrandThumb({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
height: size,
|
height: size,
|
||||||
width: calcWidth,
|
width: size,
|
||||||
minWidth: Math.round(size * 0.6),
|
minWidth: size,
|
||||||
maxWidth: Math.round(size * 2.5),
|
maxWidth: size,
|
||||||
borderRadius: size >= 24 ? '4px' : '3px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
lineHeight: 0,
|
lineHeight: 0,
|
||||||
my: 0,
|
my: 0,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
width: innerW,
|
||||||
|
height: innerH,
|
||||||
|
borderRadius: size >= 24 ? '4px' : '3px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0,
|
||||||
|
lineHeight: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
bgcolor: TINT_ROW_HOVER,
|
bgcolor: TINT_ROW_HOVER,
|
||||||
boxShadow: SHADOW_THUMB_RING,
|
boxShadow: SHADOW_THUMB_RING,
|
||||||
...sx,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ThumbHashImage
|
<ThumbHashImage
|
||||||
thumbHash={thumbHash}
|
thumbHash={thumbHash}
|
||||||
width={calcWidth}
|
width={innerW}
|
||||||
height={size}
|
height={innerH}
|
||||||
|
sx={{ width: innerW, height: innerH, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb';
|
export { default as BrandThumb } from './BrandThumb';
|
||||||
export { default as BrandLabel } from './BrandLabel';
|
export { default as BrandLabel } from './BrandLabel';
|
||||||
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';
|
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';
|
||||||
export { default as ArtikelMarke } from './ArtikelMarke';
|
export { default as ArtikelMarke } from './ArtikelMarke';
|
||||||
|
export { default as ArtikelMarkeNew } from './ArtikelMarkeNew';
|
||||||
export { default as ArtikelMarkenTable } from './ArtikelMarkenTable';
|
export { default as ArtikelMarkenTable } from './ArtikelMarkenTable';
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ import CloseIcon from '@mui/icons-material/Close';
|
|||||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||||
import sseAutomat from '../automats/sseAutomat';
|
import sseAutomat, { getSseClientId } from '../automats/sseAutomat';
|
||||||
|
|
||||||
const PANEL_WIDTH = 420;
|
const PANEL_WIDTH = 420;
|
||||||
const PANEL_HEIGHT = 380;
|
const PANEL_HEIGHT = 380;
|
||||||
|
const RAW_EVENT_LIMIT = 500;
|
||||||
const pulseAnimationA = keyframes`
|
const pulseAnimationA = keyframes`
|
||||||
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
|
||||||
35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); }
|
35% { transform: scale(1.16); box-shadow: 0 0 0 8px rgba(255, 255, 255, 0.55); }
|
||||||
@@ -42,11 +43,15 @@ class SseMonitor extends PureComponent {
|
|||||||
this.state = {
|
this.state = {
|
||||||
status: sseAutomat.state.status,
|
status: sseAutomat.state.status,
|
||||||
companyId: sseAutomat.state.companyId,
|
companyId: sseAutomat.state.companyId,
|
||||||
events: sseAutomat.state.events,
|
events: [],
|
||||||
open: false,
|
open: false,
|
||||||
position: null,
|
position: null,
|
||||||
eventPulse: 0,
|
eventPulse: 0,
|
||||||
};
|
};
|
||||||
|
this.nextRawId = 0;
|
||||||
|
this.rawAbort = null;
|
||||||
|
this.rawReconnectTimer = null;
|
||||||
|
this.rawBuffer = '';
|
||||||
this.panelRef = createRef();
|
this.panelRef = createRef();
|
||||||
this.handleToggle = this.handleToggle.bind(this);
|
this.handleToggle = this.handleToggle.bind(this);
|
||||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||||
@@ -61,14 +66,15 @@ class SseMonitor extends PureComponent {
|
|||||||
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
|
this.unsubscribe = sseAutomat.subscribe(this, (state) => ({
|
||||||
status: state.status,
|
status: state.status,
|
||||||
companyId: state.companyId,
|
companyId: state.companyId,
|
||||||
events: state.events,
|
|
||||||
}));
|
}));
|
||||||
window.addEventListener('keydown', this.handleKeyDown);
|
window.addEventListener('keydown', this.handleKeyDown);
|
||||||
|
this.startRawStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
window.removeEventListener('keydown', this.handleKeyDown);
|
window.removeEventListener('keydown', this.handleKeyDown);
|
||||||
|
this.stopRawStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
@@ -77,6 +83,96 @@ class SseMonitor extends PureComponent {
|
|||||||
if (currentEventId != null && currentEventId !== previousEventId) {
|
if (currentEventId != null && currentEventId !== previousEventId) {
|
||||||
this.setState((state) => ({ eventPulse: state.eventPulse + 1 }));
|
this.setState((state) => ({ eventPulse: state.eventPulse + 1 }));
|
||||||
}
|
}
|
||||||
|
if (prevState.companyId !== this.state.companyId) {
|
||||||
|
this.startRawStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushRawEvent(type, data) {
|
||||||
|
const entry = {
|
||||||
|
id: ++this.nextRawId,
|
||||||
|
type: type || 'message',
|
||||||
|
data: data ?? '',
|
||||||
|
lastEventId: '',
|
||||||
|
time: Date.now(),
|
||||||
|
};
|
||||||
|
this.setState((state) => ({
|
||||||
|
events: [...state.events.slice(-(RAW_EVENT_LIMIT - 1)), entry],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRawChunk(text) {
|
||||||
|
this.rawBuffer += text;
|
||||||
|
let boundary;
|
||||||
|
while ((boundary = this.rawBuffer.indexOf('\n\n')) !== -1) {
|
||||||
|
const frame = this.rawBuffer.slice(0, boundary);
|
||||||
|
this.rawBuffer = this.rawBuffer.slice(boundary + 2);
|
||||||
|
let type = '';
|
||||||
|
const dataLines = [];
|
||||||
|
let comment = '';
|
||||||
|
for (const line of frame.split('\n')) {
|
||||||
|
if (line.startsWith(':')) {
|
||||||
|
comment = line.slice(1).trim();
|
||||||
|
} else if (line.startsWith('event:')) {
|
||||||
|
type = line.slice(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
dataLines.push(line.slice(5).trimStart());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No filtering: show every frame, including heartbeats and
|
||||||
|
// events no automat listens for (brand, modelconversation, ...).
|
||||||
|
if (!type && comment) {
|
||||||
|
this.pushRawEvent(comment, '');
|
||||||
|
} else if (type || dataLines.length > 0) {
|
||||||
|
this.pushRawEvent(type || 'message', dataLines.join('\n'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopRawStream() {
|
||||||
|
if (this.rawReconnectTimer) {
|
||||||
|
clearTimeout(this.rawReconnectTimer);
|
||||||
|
this.rawReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.rawAbort) {
|
||||||
|
try {
|
||||||
|
this.rawAbort.abort();
|
||||||
|
} catch {}
|
||||||
|
this.rawAbort = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRawStream() {
|
||||||
|
this.stopRawStream();
|
||||||
|
this.rawBuffer = '';
|
||||||
|
const controller = new AbortController();
|
||||||
|
this.rawAbort = controller;
|
||||||
|
try {
|
||||||
|
const url = new URL('/api/events', window.location.origin);
|
||||||
|
try {
|
||||||
|
const cid = getSseClientId();
|
||||||
|
if (cid) url.searchParams.set('cid', `${cid}-monitor`);
|
||||||
|
} catch {}
|
||||||
|
const res = await fetch(url.href, {
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: { Accept: 'text/event-stream' },
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error(`SSE monitor HTTP ${res.status}`);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
this.handleRawChunk(decoder.decode(value, { stream: true }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
this.pushRawEvent('error', String(err?.message ?? err));
|
||||||
|
}
|
||||||
|
if (!controller.signal.aborted && !this.rawReconnectTimer) {
|
||||||
|
this.rawReconnectTimer = setTimeout(() => this.startRawStream(), 3000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(event) {
|
handleKeyDown(event) {
|
||||||
@@ -94,7 +190,7 @@ class SseMonitor extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleClear() {
|
handleClear() {
|
||||||
sseAutomat.actions.clearEvents();
|
this.setState({ events: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePointerDown(event) {
|
handlePointerDown(event) {
|
||||||
|
|||||||
@@ -1,153 +1,57 @@
|
|||||||
import { PureComponent } from 'react';
|
import { PureComponent, createRef } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import BackendError from '../../components/BackendError';
|
import BackendError from '../../components/BackendError';
|
||||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
import FillSpace from 'dashgui/FillSpace';
|
||||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||||
import { backendErrorFrom } from '../../utils/backendError';
|
import { backendErrorFrom } from '../../utils/backendError';
|
||||||
import { notifyError } from '../../utils/notify';
|
import { notifyError } from '../../utils/notify';
|
||||||
import { setLastArticleSku } from '../../automats';
|
import { setLastArticleSku } from '../../automats';
|
||||||
import brandAutomat from '../../automats/brandAutomat';
|
import brandAutomat from '../../automats/brandAutomat';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
|
||||||
import {
|
import {
|
||||||
BrandImageSearchDialog,
|
|
||||||
ArtikelMarke,
|
ArtikelMarke,
|
||||||
ArtikelMarkenTable,
|
ArtikelMarkeNew,
|
||||||
} from '../../components/brand';
|
} from '../../components/brand';
|
||||||
import {
|
import {
|
||||||
PURPLE_PANEL,
|
PURPLE_PANEL,
|
||||||
} from '../../colors';
|
} from '../../colors';
|
||||||
|
|
||||||
|
|
||||||
class ArtikelMarkenPage extends PureComponent {
|
class ArtikelMarkenPage extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
status: brandAutomat.state.status === 'succeeded' ? 'ready' : 'loading',
|
status: brandAutomat.state.status,
|
||||||
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null,
|
||||||
brands: brandAutomat.state.brands || [],
|
brands: brandAutomat.state.brands,
|
||||||
newName: '',
|
|
||||||
busyKey: null,
|
|
||||||
searchBrand: null,
|
|
||||||
};
|
};
|
||||||
this._cancelled = false;
|
this.createRowRef = createRef();
|
||||||
this.handleCreate = this.handleCreate.bind(this);
|
|
||||||
this.handleRetry = this.handleRetry.bind(this);
|
this.handleRetry = this.handleRetry.bind(this);
|
||||||
this.loadBrands = this.loadBrands.bind(this);
|
|
||||||
this.handleRename = this.handleRename.bind(this);
|
|
||||||
this.handleDelete = this.handleDelete.bind(this);
|
|
||||||
this.handleSearchLogo = this.handleSearchLogo.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
setLastArticleSku(null);
|
setLastArticleSku(null);
|
||||||
this._cancelled = false;
|
|
||||||
|
|
||||||
this.unsub = brandAutomat.subscribe((state) => {
|
this.unsub = brandAutomat.subscribe(this, (state) => ({
|
||||||
if (this._cancelled) return;
|
brands: state.brands,
|
||||||
if (state.status === 'succeeded') {
|
status: state.status,
|
||||||
this.applyBrands(state.brands);
|
error: state.error ? backendErrorFrom({ message: state.error }) : null,
|
||||||
} else if (state.status === 'failed') {
|
}));
|
||||||
this.setState({ status: 'failed', error: backendErrorFrom({ message: state.error }) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (brandAutomat.state.status !== 'succeeded') {
|
|
||||||
void this.loadBrands();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
this._cancelled = true;
|
|
||||||
this.unsub?.();
|
this.unsub?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
applyBrands(brands) {
|
|
||||||
this.setState({
|
|
||||||
status: 'ready',
|
|
||||||
error: null,
|
|
||||||
brands,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadBrands() {
|
|
||||||
if (brandAutomat.state.status !== 'succeeded') {
|
|
||||||
this.setState({ status: 'loading', error: null });
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const list = await brandAutomat.actions.load();
|
|
||||||
if (this._cancelled) return;
|
|
||||||
this.applyBrands(list);
|
|
||||||
} catch (err) {
|
|
||||||
if (this._cancelled) return;
|
|
||||||
this.setState({ status: 'failed', error: backendErrorFrom(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleRetry() {
|
handleRetry() {
|
||||||
void this.loadBrands();
|
// The automat owns fetching; just ask it to load again.
|
||||||
}
|
void brandAutomat.actions.load().catch((err) => notifyError(err));
|
||||||
|
|
||||||
handleSearchLogo(brand) {
|
|
||||||
this.setState({ searchBrand: brand });
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleCreate() {
|
|
||||||
const name = this.state.newName.trim();
|
|
||||||
if (!name || this.state.busyKey != null) return;
|
|
||||||
|
|
||||||
this.setState({ busyKey: 'new' });
|
|
||||||
try {
|
|
||||||
const row = await brandAutomat.actions.upsert({ name });
|
|
||||||
if (this._cancelled) return;
|
|
||||||
this.setState({ newName: '', busyKey: null });
|
|
||||||
enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
if (this._cancelled) return;
|
|
||||||
notifyError(err);
|
|
||||||
this.setState({ busyKey: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleRename(brand, draft) {
|
|
||||||
const nextName = String(draft ?? '').trim();
|
|
||||||
if (!nextName || nextName === brand.name || this.state.busyKey != null) return;
|
|
||||||
|
|
||||||
this.setState({ busyKey: brand.id });
|
|
||||||
try {
|
|
||||||
await brandAutomat.actions.upsert({ name: nextName });
|
|
||||||
if (this._cancelled) return;
|
|
||||||
this.setState({ busyKey: null });
|
|
||||||
enqueueSnackbar('Marke gespeichert.', { variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
if (this._cancelled) return;
|
|
||||||
notifyError(err);
|
|
||||||
this.setState({ busyKey: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleDelete(brand) {
|
|
||||||
if (this.state.busyKey != null) return;
|
|
||||||
if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return;
|
|
||||||
|
|
||||||
this.setState({ busyKey: brand.id });
|
|
||||||
try {
|
|
||||||
await brandAutomat.actions.remove(brand.id);
|
|
||||||
if (this._cancelled) return;
|
|
||||||
this.setState({ busyKey: null });
|
|
||||||
enqueueSnackbar('Marke gelöscht.', { variant: 'success' });
|
|
||||||
} catch (err) {
|
|
||||||
if (this._cancelled) return;
|
|
||||||
notifyError(err);
|
|
||||||
this.setState({ busyKey: null });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { status, error, brands, newName, busyKey } = this.state;
|
const { status, error, brands } = this.state;
|
||||||
const busy = busyKey != null;
|
|
||||||
|
|
||||||
if (status === 'loading') {
|
if (status === 'loading' || status === 'idle') {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
@@ -161,50 +65,23 @@ class ArtikelMarkenPage extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelTileGrid>
|
<PanelTileGrid>
|
||||||
<PanelTile wide={2} minHeight={280} color={PURPLE_PANEL}>
|
<PanelTile wide={2} minHeight={0} color={PURPLE_PANEL} sx={{ pb: 0 }}>
|
||||||
<ArtikelMarkenTable
|
<FillSpace
|
||||||
brands={brands}
|
aboveRef={[]}
|
||||||
busyKey={busyKey}
|
belowRef={this.createRowRef}
|
||||||
onRename={this.handleRename}
|
reserveBottom={32}
|
||||||
onSearchLogo={this.handleSearchLogo}
|
minPageSize={8}
|
||||||
onDelete={this.handleDelete}
|
minHeight={0}
|
||||||
|
items={brands}
|
||||||
|
renderItem={ArtikelMarke}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
<ArtikelMarkeNew rowRef={this.createRowRef} />
|
||||||
<SingleLineTextEdit
|
|
||||||
size="small"
|
|
||||||
label="Neue Marke"
|
|
||||||
value={newName}
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(next) => this.setState({ newName: next })}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
void this.handleCreate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
size="small"
|
|
||||||
disabled={busy || !newName.trim()}
|
|
||||||
onClick={() => void this.handleCreate()}
|
|
||||||
>
|
|
||||||
{busyKey === 'new' ? '…' : 'Hinzufügen'}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</PanelTile>
|
</PanelTile>
|
||||||
|
|
||||||
<BrandImageSearchDialog
|
|
||||||
open={Boolean(this.state.searchBrand)}
|
|
||||||
brand={this.state.searchBrand}
|
|
||||||
onClose={() => this.setState({ searchBrand: null })}
|
|
||||||
/>
|
|
||||||
</PanelTileGrid>
|
</PanelTileGrid>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ArtikelMarke, ArtikelMarkenTable };
|
export { ArtikelMarke, ArtikelMarkeNew };
|
||||||
export default ArtikelMarkenPage;
|
export default ArtikelMarkenPage;
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-whi
|
|||||||
|
|
||||||
let encodeAvif;
|
let encodeAvif;
|
||||||
|
|
||||||
|
function clientIdHeaders() {
|
||||||
|
try {
|
||||||
|
const cid = window.sessionStorage.getItem('sse.cid');
|
||||||
|
return cid ? { 'X-Client-Id': cid } : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getAvifEncoder() {
|
async function getAvifEncoder() {
|
||||||
if (!encodeAvif) {
|
if (!encodeAvif) {
|
||||||
const { default: encode } = await import('@jsquash/avif/encode');
|
const { default: encode } = await import('@jsquash/avif/encode');
|
||||||
@@ -319,7 +328,10 @@ export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) {
|
|||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/serper/image', {
|
const resp = await fetch('/api/serper/image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...clientIdHeaders(),
|
||||||
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Everything is created through the existing dashback API:
|
* Everything is created through the existing dashback API:
|
||||||
* - categories: POST /api/category ({ parentId, name })
|
* - categories: POST /api/category ({ parentId, name })
|
||||||
* - brands: PUT /api/brand ({ name }) → { brand: { id } }
|
* - brands: POST /api/brand ({ name }) → { brand: { id } }
|
||||||
* - articles: POST /api/article ({ categoryId, name }) → { article: { sku } }
|
* - articles: POST /api/article ({ categoryId, name }) → { article: { sku } }
|
||||||
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
|
* - price: PUT /api/article/:sku/prices ({ prices: [{ price, tax }] })
|
||||||
* - brand: PUT /api/article/:sku/brand ({ brandId })
|
* - brand: PUT /api/article/:sku/brand ({ brandId })
|
||||||
@@ -111,8 +111,8 @@ function createEnsureCategory(cache) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upsert a brand by name → brandId.
|
* Ensure a brand by name → brandId.
|
||||||
* PUT /api/brand upserts by name (same name ⇒ update, otherwise create).
|
* POST /api/brand inserts (409 Brand already exists on duplicate).
|
||||||
*/
|
*/
|
||||||
function createEnsureBrand(cache) {
|
function createEnsureBrand(cache) {
|
||||||
return async function ensureBrand(name) {
|
return async function ensureBrand(name) {
|
||||||
@@ -121,8 +121,8 @@ function createEnsureBrand(cache) {
|
|||||||
const key = clean.toLowerCase();
|
const key = clean.toLowerCase();
|
||||||
if (cache.brandByKey.has(key)) return cache.brandByKey.get(key);
|
if (cache.brandByKey.has(key)) return cache.brandByKey.get(key);
|
||||||
|
|
||||||
const res = await brand.upsert({ name: clean });
|
const res = await brand.create({ name: clean });
|
||||||
requireObject(res, 'PUT /api/brand');
|
requireObject(res, 'POST /api/brand');
|
||||||
requireObject(res.brand, 'brand');
|
requireObject(res.brand, 'brand');
|
||||||
requireDefined(res.brand.id, 'brand.id');
|
requireDefined(res.brand.id, 'brand.id');
|
||||||
cache.brandByKey.set(key, res.brand.id);
|
cache.brandByKey.set(key, res.brand.id);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*
|
*
|
||||||
* All work is done through the existing dashback API:
|
* All work is done through the existing dashback API:
|
||||||
* - categories → POST /api/category
|
* - categories → POST /api/category
|
||||||
* - brands → PUT /api/brand
|
* - brands → POST /api/brand
|
||||||
* - articles → POST /api/article (+ price / brand / ean / categories PUTs)
|
* - articles → POST /api/article (+ price / brand / ean / categories PUTs)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user