Compare commits

...

2 Commits

Author SHA1 Message Date
seb
5e13b8f9cd no redux 2026-09-22 07:20:31 +02:00
seb
536e7b1e2a u 2026-09-21 20:21:57 +02:00
46 changed files with 5428 additions and 2476 deletions

View File

@@ -0,0 +1,207 @@
---
name: backend
description: >-
Reference and schema documentation for LibDashStore backend service and database models.
Use this skill whenever working with backend data models, database schema definitions (articles, categories, companies, entities, manifests, orders, POS terminals), or calling and integrating with LibDashStore API methods and namespaces.
---
# LibDashStore API
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each method is an async function that takes a `pool` (mssql connection pool) at construction time and returns a callable function. Methods are grouped by the namespace they are exposed under.
## Database Schema
> **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default
- **article**: `id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_father:b=0, fatherarticle_id?->article, fathersort:i=0, created_at:dt, updated_at:dt, UQ(fatherarticle_id, fathersort)`
- **articleean**: `id PK, article_id->article, ean:s(50) UQ`
- **articlehistory**: `id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt`
- **articlelist**: `id PK`
- **articlelistarticle**: `id PK, articlelist_id->articlelist, article_id->article, count:i=1, sort:i=0, UQ(articlelist_id, article_id), UQ(articlelist_id, sort)`
- **articlepic**: `id PK, article_id->article, pic_id->pic, sort:i=0, UQ(article_id, pic_id), UQ(article_id, sort)`
- **articlesupplier**: `id PK, article_id->article, entity_id->entity, price_id?->price, sku?:s(100), stock?:f, pu:f=1, sort:i=0, UQ(article_id, entity_id), UQ(article_id, sort)`
- **articletext**: `id PK, article_id->article, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(article_id, language_id)`
- **brand**: `id PK, company_id->company, name:s(255), gsprpinfo?:txt, UQ(company_id, name)`
- **brandpic**: `id PK, brand_id->brand, pic_id->pic, sort:i=0, UQ(brand_id, pic_id), UQ(brand_id, sort)`
- **category**: `id PK, parent_id?->category, sort:i=0`
- **categoryarticle**: `id PK, category_id->category, article_id->article, UQ(category_id, article_id)`
- **categorypic**: `id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)`
- **categorytext**: `id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)`
- **company**: `id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup`
- **companysetting**: `id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)`
- **companyuser**: `id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)`
- **customergroup**: `id PK, name:s(255), sort:i=0, company_id?->company`
- **entity**: `id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)`
- **entityaddress**: `id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0`
- **entitycontact**: `id PK, entity_id->entity, contacttype:s(50), contact:s(255), sort:i=0`
- **entityhistory**: `id PK, entity_id->entity, companyuser_id?->companyuser, date:dt, text:txt`
- **file**: `id PK, hash:s(64), mimetype:s(255), name:s(255), size:bi, created:dt, modified:dt, content:bin, thumbavif?:bin, thumbx?:i, thumby?:i, meta?:txt`
- **google_auth**: `id PK, email:s(320) UQ, picture?:s(2048), picturebinary?:bin, sub?:s(255) UQ, created_at:dt, selected_company?->company`
- **language**: `id PK, iso639:s(10) UQ`
- **ledger**: `id PK, manifestarticle_id->manifestarticle, transaction_type:s(100), amount:f, transaction_text:txt, hash_version:i, prev_hash?:s(128) [append-only]`
- **manifest**: `id PK, company_id->company, number:s(100), type:s(100), entity_id?->entity, external_number?:s(100), date:dt`
- **manifestarticle**: `id PK, manifest_id->manifest, sku?:s(100), name?:s(255), pu:f=1, amount:f, net:f, comment?:txt, rebate:f=0, taxrate:f=0`
- **manifestattachment**: `id PK, manifest_id->manifest, file_id->file, sort:i=0, UQ(manifest_id, sort)`
- **meta**: `key:s(255) PK, value:txt`
- **numbers**: `id PK, company_id->company, type:s(100), prefix:s(50)='', padding:i=0, next:i=1, UQ(company_id, type)`
- **optionslist**: `id PK`
- **optionslistarticle**: `id PK, optionslist_id->optionslist, article_id->article, count:i=1, sort:i=0, mandated:b=0, UQ(optionslist_id, article_id), UQ(optionslist_id, sort)`
- **pic**: `id PK, w:i, h:i, size:bi, hash:s(64) UQ, thumbw:i, thumbh:i, thumbhash:s(64), source:txt, fullbinary:bin, thumbbinary:bin`
- **posterminal**: `id PK, company_id->company, name:s(255), token?:s(255) UQ, pairingpin:s(50)`
- **posterminalcategory**: `id PK, posterminal_id->posterminal, category_id->category, UQ(posterminal_id, category_id)`
- **price**: `id PK, tax_id->tax, article_id->article, minmultiplier:d(18,4)=1, customergroup_id->customergroup, net:f`
- **syncpos**: `id PK, company_id->company, paircode:s(50) UQ, token?:s(255) UQ`
- **tax**: `id PK, rate:d(18,4) UQ`
- **unit**: `id PK, company_id->company, si:s(50), basesi:s(50), basesifactor:f, UQ(company_id, si)`
- **unittext**: `id PK, unit_id->unit, language_id->language, symbol:s(50), name:s(255), UQ(unit_id, language_id)`
## Table of Contents
- [companyuser](API.companyuser.md)
- `add(companyId: number, googleAuthId: number): Promise<number>`
- `delete(id: number): Promise<void>`
- `getByEmail(email: string): Promise<Array<number>>`
- [company](API.company.md)
- `create(input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number, googleAuthId: number}): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>`
- `delete(companyId: number): Promise<void>`
- `getById(id: number, tx?: object): Promise<object|undefined>`
- `getByName(name: string, tx?: object): Promise<object|undefined>`
- `list(googleAuthId: number): Promise<Array<object>>`
- `update(id: number, input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number}): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>`
- [category](API.category.md)
- `addImage(googleAuthId: number, categoryId: number, image: Buffer): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}): Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>`
- `delete(input: {googleAuthId: number, id: number}): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number): Promise<Array<object>>`
- `getTreeForCompany(googleAuthId: number): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>`
- `move(input: {googleAuthId: number, id: number, parentId: number}): Promise<{id: number, parentId: number, sort: number}>`
- `order(input: {googleAuthId: number, parentId: number, categoryIds: Array<number>}): Promise<{categoryIds: Array<number>}>`
- `rename(input: {googleAuthId: number, id: number, name: string}): Promise<{id: number, name: string, slug: string}>`
- [article](API.article.md)
- `addImage(googleAuthId: number, sku: string, image: Buffer): Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}): Promise<{id: number, parentId: number, name: string, sku: string}>`
- `delete(googleAuthId: number, sku: string): Promise<void>`
- `deleteImage(googleAuthId: number, sku: string, fullHash: string): Promise<void>`
- `getBySku(googleAuthId: number, sku: string): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {name: string, thumbW: number, thumbH: number, thumbHash: string}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>`
- `getHistoryBySku(googleAuthId: number, sku: string): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `importRows(input: {googleAuthId: number, rows: Array<object>}): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>`
- `listExport(googleAuthId: number, categoryId?: number|null): Promise<Array<object>>`
- `listSearchIndex(googleAuthId: number): Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>`
- `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array<string>}): Promise<{fullHashes: Array<string>}>`
- `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null): Promise<object>`
- `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array<number>): Promise<object>`
- `setEansBySku(googleAuthId: number, sku: string, eans: Array<string>): Promise<object>`
- `setFatherBySku(googleAuthId: number, sku: string, fatherSku: string|null): Promise<object>`
- `setPricesBySku(googleAuthId: number, sku: string, prices: Array<{price: number, tax: number, minmultiplier?: number}>): Promise<object>`
- `setWeightBySku(googleAuthId: number, sku: string, weight: number): Promise<object>`
- `updateBySku(googleAuthId: number, sku: string, patch: {name?: string, unitSi?: string, unitamount?: number}): Promise<object>`
- [lang](API.lang.md)
- `create(iso639: string, tx?: object): Promise<number>`
- `delete(id: number): Promise<void>`
- `ensure(iso639: string, tx?: object): Promise<number>`
- `getByIso639(iso639: string, tx?: object): Promise<{id: number, iso639: string}>`
- `list(): Promise<Array<{id: number, iso639: string}>>`
- [tax](API.tax.md)
- `create(rate: number, tx?: object): Promise<number>`
- `ensure(rate: number, tx?: object): Promise<number>`
- `getByRate(rate: number, tx?: object): Promise<{id: number, rate: number}>`
- `list(): Promise<Array<{id: number, rate: number}>>`
- [unit](API.unit.md)
- `delete(googleAuthId: number, si: string): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>>`
- `upsert(googleAuthId: number, input: {si: string, basesi: string, basesifactor: number, symbol: string, name: string}): Promise<{id: number, si: string, basesi: string, basesifactor: number, symbol: string, name: string}>`
- [companysetting](API.companysetting.md)
- `get(googleAuthId: number, key: string, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}|undefined>`
- `list(googleAuthId: number, options?: {frontendOnly?: boolean, maskWriteonly?: boolean}): Promise<Array<{key: string, value: string, frontend: boolean, writeonly: boolean}>>`
- `set(googleAuthId: number, input: {key: string, value: string, frontend?: boolean, writeonly?: boolean}): Promise<{key: string, value: string, frontend: boolean, writeonly: boolean}>`
- [brand](API.brand.md)
- `delete(googleAuthId: number, id: number): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, gsprpinfo: string|null}>>`
- `upsert(googleAuthId: number, input: {name: string, gsprpinfo?: string}): Promise<{id: number, name: string, gsprpinfo: string|null}>`
- [customergroup](API.customergroup.md)
- `create(googleAuthId: number, input: {name: string}): Promise<{id: number, name: string, sort: number}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, sort: number}>>`
- `order(googleAuthId: number, input: {customergroupIds: Array<number>}): Promise<{customergroupIds: Array<number>}>`
- `update(googleAuthId: number, id: number, input: {name: string}): Promise<{id: number, name: string, sort: number}>`
- [entity](API.entity.md)
- `create(googleAuthId: number, input: {customergroupId: number, name: string, firstName?: string, number?: string}): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- `isNumberFree(googleAuthId: number, number: string, options?: {excludeEntityId?: number}): Promise<boolean>`
- `list(googleAuthId: number, options?: {customergroupId?: number, role?: string}): Promise<Array<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>>`
- `peekNextNumber(googleAuthId: number): Promise<string>`
- `update(googleAuthId: number, id: number, patch: {name?: string, firstName?: string, number?: string}): Promise<{id: number, customergroupId: number, firstName: string|null, name: string, number: string, createdAt: string, updatedAt: string}>`
- [entityaddress](API.entityaddress.md)
- `create(googleAuthId: number, entityId: number, input: {addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>`
- `delete(googleAuthId: number, entityId: number, id: number): Promise<void>`
- `list(googleAuthId: number, entityId: number): Promise<Array<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>>`
- `update(googleAuthId: number, entityId: number, id: number, patch: {addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string, sort?: number}): Promise<{id: number, entityId: number, firstName: string|null, name: string|null, street: string|null, streetNumber: string|null, zip: string|null, city: string|null, county: string|null, addressType: string, sort: number}>`
- [entitycontact](API.entitycontact.md)
- `create(googleAuthId: number, entityId: number, input: {contactType: string, contact: string, sort?: number}): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>`
- `delete(googleAuthId: number, entityId: number, id: number): Promise<void>`
- `list(googleAuthId: number, entityId: number): Promise<Array<{id: number, entityId: number, contactType: string, contact: string, sort: number}>>`
- `update(googleAuthId: number, entityId: number, id: number, patch: object): Promise<{id: number, entityId: number, contactType: string, contact: string, sort: number}>`
- [supplier](API.supplier.md)
- `create(googleAuthId: number, input: {name: string}): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, createdAt: string, updatedAt: string}>>`
- `update(googleAuthId: number, id: number, input: {name: string}): Promise<{id: number, name: string, createdAt: string, updatedAt: string}>`
- [articlesupplier](API.articlesupplier.md)
- `create(googleAuthId: number, sku: string, input: {entityId: number, sku?: string, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>`
- `delete(googleAuthId: number, sku: string, id: number): Promise<void>`
- `listByArticleSku(googleAuthId: number, sku: string): Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>>`
- `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string): Promise<Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null, article: {id: number, sku: string, name: string|null, slug: string|null, photos: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>`
- `order(googleAuthId: number, sku: string, input: {ids: Array<number>}): Promise<{ids: Array<number>}>`
- `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}): Promise<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>`
- [delivery](API.delivery.md)
- `attachments(): void`
- `create(googleAuthId: number, body: {date: string, supplierId: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `list(googleAuthId: number): Promise<Array<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string}>>`
- `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, supplierId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- [quote](API.quote.md)
- `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `list(googleAuthId: number): Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>`
- `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- [order](API.order.md)
- `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `delete(googleAuthId: number, id: number): Promise<void>`
- `getById(googleAuthId: number, id: number): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- `list(googleAuthId: number): Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>`
- `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>`
- [manifestattachment](API.manifestattachment.md)
- `add(googleAuthId: number, manifestId: number, input: {mimetype: string, name: string, content: Buffer, sort?: number, size?: number, created?: Date|string, modified?: Date|string, hash?: string, thumbavif?: Buffer, thumbx?: number, thumby?: number, meta?: object}): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>`
- `getById(googleAuthId: number, manifestId: number, id: number): Promise<{id: number, manifestId: number, fileId: number, sort: number, hash: string, mimetype: string, name: string, size: number, created: Date, modified: Date, content: Buffer, thumbavif: Buffer|null, thumbx: number|null, thumby: number|null, meta: object|null}>`
- `listByManifestId(): void`
- `order(googleAuthId: number, manifestId: number, input: {ids?: Array<number>}): Promise<{ids: Array<number>}>`
- `remove(googleAuthId: number, manifestId: number, id: number): Promise<void>`
- [posterminal](API.posterminal.md)
- `delete(googleAuthId: number, id: number): Promise<void>`
- `list(googleAuthId: number): Promise<Array<{id: number, name: string, token: string, pairingpin: string}>>`
- `setOff(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array<number>}>`
- `setOffAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array<number>}>`
- `setOn(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array<number>}>`
- `setOnAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array<number>}>`
- `upsert(googleAuthId: number, input: {name: string, pairingpin: string, token?: string, id?: number}): Promise<{id: number, name: string, token: string, pairingpin: string}>`
- [google_auth](API.google_auth.md)
- `add(email: string): Promise<number>`
- `getByEmail(email: string): Promise<object|undefined>`
- `getById(googleAuthId: number): Promise<object|undefined>`
- `listAllowedEmails(): Promise<Array<{id: number, email: string}>>`
- `removeAllowedEmail(email: string): Promise<void>`
- `setProfile(googleAuthId: number, input: {picture?: string, pictureBinary?: Buffer, sub?: string}): Promise<void>`
- `setSelectedCompany(googleAuthId: number, companyId: number): Promise<void>`
- [pic](API.pic.md)
- `getImageByHash(googleAuthId: number, fullHash: string): Promise<{buffer: Buffer, w: number, h: number, size: number, contentType: string}>`
- `getThumbByHash(googleAuthId: number, fullHash: string): Promise<{buffer: Buffer, w: number, h: number, contentType: string}>`
- [numbers](API.numbers.md)
- `list(googleAuthId: number): Promise<Array<{id: number, type: string, prefix: string, padding: number, next: number}>>`
- `update(googleAuthId: number, id: number, input: {prefix?: string, padding?: number, next?: number}): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>`

536
AGENTS.md Normal file
View File

@@ -0,0 +1,536 @@
# AGENTS.md — Dashfront Developer & Agent Guide
> **Audience**: AI Coding Agents & Pair-Programmers working on `dashfront`.
> **Workspace**: `/home/seb/src/dashfront`
> **Last Updated**: 2026-09-21
---
## Table of Contents
1. [System Overview & Ecosystem Map](#1-system-overview--ecosystem-map)
2. [Technology Stack & Key Libraries](#2-technology-stack--key-libraries)
3. [Directory Layout](#3-directory-layout)
4. [State Management Conventions](#4-state-management-conventions)
5. [Backend Communication & Authentication Rules](#5-backend-communication--authentication-rules)
6. [Route & Navigation Architecture](#6-route--navigation-architecture)
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
8. [Critical Agent Rules & Common Pitfalls](#8-critical-agent-rules--common-pitfalls)
9. [Deep Dive: The Automats (`react-automat`)](#9-deep-dive-the-automats-react-automat)
10. [Deep Dive: Redux Toolkit Architecture](#10-deep-dive-redux-toolkit-architecture)
11. [`dashgui` Component Library Integration](#11-dashgui-component-library-integration)
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
14. [Real-time SSE Signals & Invalidation](#14-real-time-sse-signals--invalidation)
15. [Developer Tooling & Debugging](#15-developer-tooling--debugging)
16. [Agent Cheatsheet & Code Recipes](#16-agent-cheatsheet--code-recipes)
---
## 1. System Overview & Ecosystem Map
`dashfront` is the React web client and Progressive Web App (PWA) for the Dash ERP/POS system. It is part of a multi-repository workspace under `/home/seb/src/`:
```
/home/seb/src/
├── dashfront/ # THIS REPO: Vite + React 19 web/PWA client (port 4209 HTTP, 4308 HTTPS)
├── dashback/ # Backend Express REST/SSE API server (port 4208)
├── dashdbschema/ # LibDashStore (MSSQL data layer, migrations, schema graph, API docs)
├── dashgui/ # Reusable custom UI component library (file:../dashgui)
└── automat/ # react-automat state management library (file:../automat or npm)
```
### End-to-End Data & Execution Flow
```yaml
# Machine-readable execution flow:
flow:
- step: 1_client
component: dashfront
runtime: React 19 (PureComponent & Function Components)
state_layers:
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat]
redux_slices: [company, category, brand, unit, posterminal]
transport: src/api (http(), ApiClient)
egress:
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
proxy: Vite dev server (/api -> http://localhost:4208)
target: dashback
- step: 2_api_gateway
component: dashback
runtime: Express 5 API server (src/api/routes/*)
security:
auth_middleware: Cookie auth middleware (validates JWT in HTTP-only "token" cookie)
context_injection:
user_auth_id: req.user.googleAuthId
tenant_company_id: req.selectedCompanyId
events: Dispatches real-time SSE mutation events to subscribed clients via /api/events
egress:
interface: In-process method calls (store.<namespace>.<method>())
target: dashdbschema
- step: 3_data_layer
component: dashdbschema
runtime: LibDashStore (src/store/*)
storage: MSSQL connection pool, migrations, and SQL transactions
```
---
## 2. Technology Stack & Key Libraries
| Technology | Version / Spec | Notes |
|---|---|---|
| **Build & Dev** | Vite 8 (`type: module`) | Dev server on `4209`, HTTPS on `4308`, proxies `/api` → `http://localhost:4208` |
| **Framework** | React 19 (`react`, `react-dom`) | Uses `PureComponent` (with Automats & dashgui) and modern function components |
| **Routing** | `react-router-dom` v7 | Configured in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) with HMR router preservation |
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#c5ddd8` in `src/colors.js` |
| **Shared GUI** | `dashgui` (`file:../dashgui`) | `PanelTile`, `RecordList`, `Tile`, `SingleLineTextEdit`, `ElevatedPill` |
| **State Management** | `react-automat` + Redux Toolkit | Automats for reactive domain models; Redux for category tree & session bootstrap |
| **Notifications** | `notistack` | `useSnackbar()` / `<SnackbarProvider>` in [src/main.jsx](file:///home/seb/src/dashfront/src/main.jsx) |
| **Media & Images** | `@jsquash/avif`, `@jsquash/resize`, `thumbhash` | Client-side AVIF encoding and instant thumbnail hash generation |
| **Documents** | `pdfconvert-wasm` | In-browser PDF processing for supplier delivery invoices & receipts |
| **Drag & Drop** | `@shopify/draggable`, `sortablejs` | Category re-ordering, article assignments, nested tree nodes |
---
## 3. Directory Layout
```
src/
├── api/ # Backend communication layer
│ ├── index.js # Canonical API module exports (article, category, delivery, etc.)
│ ├── ApiClient.js # Object-oriented API client (this.article, this.delivery, etc.)
│ ├── StorageManager.js # Client-side cache / local storage management
│ └── storageManagerContext.js # React context provider for StorageManager
├── automats/ # react-automat stores with fine-grained subscriptions
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
│ ├── authAutomat.js # Authentication state, company list, company selection
│ ├── deliveryEditorAutomat.js # Multi-line delivery receipt / invoice editor & draft state
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
│ ├── userSettingsAutomat.js # User preferences (viewer positions, layout settings)
│ └── index.js # Barrel exports for all automats and helper selectors
├── components/ # Shared UI components
│ ├── AppShell.jsx # Main application frame (top bar, sidebar, nav buttons)
│ ├── SectionLayout.jsx # Secondary navigation header for sections (/artikel, /lieferung...)
│ ├── AuthBootstrap.jsx # Session bootstrapper, Google One Tap, company setup gate
│ ├── LoginPage.jsx # Google One Tap / credentials login interface
│ ├── UserMenu.jsx # User avatar, company switcher dropdown, logout action
│ ├── BackendError.jsx # Graceful error banner for backend communication failures
│ ├── article/ # Article tiles, editors, dialogs, photos, price tiers
│ ├── category/ # Category navigation, dialogs, and picker components
│ ├── manifest/ # Manifest / delivery line inspection widgets
│ ├── settings/ # Terminal, unit, and brand settings dialogs
│ └── tiles/ # Tile components extending dashgui base Tile
├── devtools/ # Dev-only debug tools
│ └── AutomatInspector.jsx # Docked overlay inspecting all registered window.__AUTOMATS__
├── pages/ # Top-level view routes
│ ├── artikel/ # Article index, category browser, warehouse, SKU detail editor
│ ├── lieferung/ # Delivery list, new delivery editor, supplier manager, edit page
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
│ └── infos/ # System and company information page
├── store/ # Redux Toolkit store (legacy & tree slice)
│ ├── index.js # configureStore with dev logger & listener middleware
│ ├── authSlice.js # Redux auth state (session bootstrap, company selection)
│ ├── companySlice.js # Redux company details (tax rates, rootCategoryId)
│ ├── categorySlice.js # Redux category tree, breadcrumbs, category articles
│ ├── brandSlice.js # Redux brand list and lookup
│ ├── posterminalSlice.js # Redux POS terminals configuration
│ └── unitSlice.js # Redux unit of measure definitions
├── theme/ # MUI theme definitions (light/dark palette, typography, components)
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
├── appRouter.jsx # react-router-dom route definitions
├── nav.js # Navigation items (NAV_ITEMS, SUB_NAV, badges, active path matchers)
├── colors.js # Shared color constants (MAIN_BACKGROUND)
├── main.jsx # React root mount, providers, dev SW unregistration
└── App.jsx # Main router provider wrapper
```
---
## 4. State Management Conventions
In `dashfront`, two state management solutions coexist harmoniously:
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
- **Skill reference**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md).
- Lives outside the React lifecycle in module scope (`src/automats/*.js`).
- Designed for React `PureComponent` to bypass unnecessary React reconciliations.
- **Pattern**:
```jsx
import { PureComponent } from 'react';
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
class ArticleView extends PureComponent {
constructor(props) {
super(props);
// 1. Synchronous read in constructor — zero blank flash!
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
// 2. Subscribe with fine-grained selector
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
componentWillUnmount() {
// 3. Always unsubscribe
this.unsub?.();
}
render() {
const { article } = this.state;
if (!article) return <div>Lädt...</div>;
return <div>{article.name}</div>;
}
}
```
### B. Redux Toolkit (`src/store/`)
- Used for static/relational trees: `company`, hierarchical `category` tree, `brand`, `posterminal`, and `unit`.
- Uses `connect(mapStateToProps, mapDispatchToProps)` to connect to `PureComponent` classes or functional hooks where appropriate.
- Coordinated via `createListenerMiddleware`: when `authAutomat` emits an auth change (`auth/sessionEstablished` or `auth/companyChanged`), listeners automatically dispatch loads for company info, brands, POS terminals, and units.
---
## 5. Backend Communication & Authentication Rules
### Authentication & Cookies
1. **HTTP-only cookie**: The session token is stored in an HTTP-only cookie (`token`).
2. **`credentials: 'include'`**: Every `fetch` request MUST set `credentials: 'include'`. The `http()` helper in [src/api/index.js](file:///home/seb/src/dashfront/src/api/index.js) does this automatically.
3. **Never pass `googleAuthId`**: The frontend NEVER passes `googleAuthId` in request bodies or query parameters. The backend middleware extracts the authenticated user directly from the cookie session!
4. **Session Company**: Multi-tenancy is scoped to the user's selected company. When a user switches companies (`PUT /api/auth/selected-company`), the backend updates the cookie session. All subsequent calls operate on that company.
### API Entry Point
Import API namespaces from `src/api`:
```js
import { article, category, delivery, supplier, entity, company } from '../api';
// Examples:
const { article: data } = await article.getBySku('SKU-1001');
const { deliveries } = await delivery.list();
const { suppliers } = await supplier.list();
```
---
## 6. Route & Navigation Architecture
- Main navigation sections are defined in [src/nav.js](file:///home/seb/src/dashfront/src/nav.js):
- `/artikel` — Articles overview, SKU editor, Warehouse stock
- `/kategorien/*` — Drag-and-drop category tree
- `/lieferung` — Inbound deliveries, New delivery editor, Suppliers
- `/auftrag` — Quotes (`/auftrag`) and Orders (`/auftrag/bestellung`)
- `/kunde` — Customers, Customer groups
- `/infos` — System settings & company info
- **Router Singleton**:
- Defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx).
- Preserved across Vite HMR via `import.meta.hot.data.router` to prevent mid-session resets when hot-reloading code.
- **Dynamic Badges**:
- `DeliveryNavIcon` and `DeliveryNeuIcon` in `src/nav.js` subscribe to `deliveryEditorAutomat` to show live uncommitted draft line badges in real time.
---
## 7. Developer Workflows & Commands
Run commands from the repository root `/home/seb/src/dashfront`:
```bash
# Start Vite development server (proxies /api -> http://localhost:4208)
npm run dev
# Start development server with local HTTPS (uses certs/key.pem and certs/cert.pem)
npm run dev:https
# Run full PWA production build + preview (to test service worker / offline caching)
npm run dev:pwa
# Generate local self-signed TLS certificates
npm run cert
# Production build
npm run build
# Preview production build
npm run preview
```
> [!NOTE]
> When testing camera-based barcode scanning or Progressive Web App features on mobile devices or local network IP addresses, start the server using `npm run dev:https`. Web browsers require a secure context (HTTPS) to grant access to media devices and service workers.
---
## 8. Critical Agent Rules & Common Pitfalls
1. **Do not create dummy/mock backends**: `dashback` runs locally at `http://localhost:4208`. All `/api/*` routes are handled by it.
2. **Keep `dashgui` links working**: `dashgui` is imported via `file:../dashgui`. Vite has deduplication configured (`dedupe: ['react', 'react-dom', ...]` in `vite.config.js`) to avoid dual-instance React conflicts.
3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_father: 1` or `childCount > 0`). The backend returns HTTP 400.
4. **Prices and Multipliers**:
- Article prices are tiered by `minmultiplier` and `customergroup_id`.
- Prices sent to the backend are net prices (`net`).
5. **Binary / Image Handling**:
- Image uploads convert source images into AVIF variants client-side.
- Frontend components use `thumbhash` for instant blur placeholders while loading full images via `/api/image/{fullHash}` or `/api/thumb/{fullHash}`.
6. **No Hooks in PureComponents**: Never call React hooks (`useState`, `useSelector`, `useEffect`) inside `PureComponent` classes. Automats and `connect()` provide direct reactive subscription without hooks.
7. **Always Unsubscribe**: Every `automat.subscribe(this, ...)` in `componentDidMount()` must have its cleanup callback called in `componentWillUnmount()`.
---
## 9. Deep Dive: The Automats (`react-automat`)
All domain Automats are located in [src/automats/](file:///home/seb/src/dashfront/src/automats/). Each automat exposes an observable state container with explicit action methods:
### 1. `artikelAutomat` ([src/automats/artikelAutomat.js](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js))
- **Role**: Cache and reactive distributor for article detail payloads (`GET /api/article/:sku`).
- **Inflight Deduplication**: Uses an internal `Map<string, Promise<object>>` so concurrent UI tiles or navigation transitions requesting the same SKU share a single HTTP call.
- **Key Actions**:
- `loadArticle(sku)`: Fetches if missing, resolves immediately if cached.
- `reloadArticle(sku)`: Silently re-fetches to update cache (used on SSE mutation events).
- `setArticle(article)`: Updates cache after mutation responses.
- `setArticleName(sku, name)`: Optimistic immediate rename.
- `clearArticle(sku)` / `clear()`: Invalidates cache.
- **Helper Selectors**: `getArticle(sku)`, `hasArticle(sku)`, `createArticleSelector(sku)`, `selectArticle(sku)`.
### 2. `authAutomat` ([src/automats/authAutomat.js](file:///home/seb/src/dashfront/src/automats/authAutomat.js))
- **Role**: Manages user authentication lifecycle and multi-tenant company selection.
- **State**: `{ user: AuthUser | null, googleClientId: string | null, status: 'loading' | 'anon' | 'authed' }`.
- **Bridge to Redux**: Calls `emit(action)` registered via `setAuthEventDispatcher(store.dispatch)`. Emits:
- `auth/sessionEstablished`
- `auth/companyChanged`
- `auth/loggedOut`
- **Actions**: `bootstrapSession(session)`, `switchCompany(companyId)`, `logout()`.
### 3. `deliveryEditorAutomat` ([src/automats/deliveryEditorAutomat.js](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js))
- **Role**: State engine for the multi-line inbound delivery receiver and invoice reconciler ([src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx)).
- **Features**:
- Line items editor (`lines`, `createEmptyLineitem`).
- Supplier article auto-matching via `buildSupplierArticlesMap(articles)` indexing by supplier SKU, internal SKU, and EANs.
- PDF/Receipt attachments parsing with OCR integration.
- Reactive draft indicators (provides draft counts for badges in `src/nav.js`).
### 4. `supplierAutomat` ([src/automats/supplierAutomat.js](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js))
- **Role**: Caches supplier directory list and maps articles to their corresponding supplier catalogs (`GET /api/supplier`).
### 5. `navigationAutomat` ([src/automats/navigationAutomat.js](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js))
- **Role**: Remembers last active subpaths and last visited article SKU. When a user navigates between the main sidebar sections (e.g. from `/lieferung` to `/artikel` and back), it restores the exact active SKU or delivery document instead of resetting to the root list.
### 6. `userSettingsAutomat` ([src/automats/userSettingsAutomat.js](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js))
- **Role**: Stores client-side user layout preferences such as viewer panel coordinates and drawer positions.
---
## 10. Deep Dive: Redux Toolkit Architecture
Redux Toolkit manages static, relational, and configuration data in [src/store/](file:///home/seb/src/dashfront/src/store/):
### Slices
1. **`categorySlice`**: Hierarchical category tree structure, category breadcrumbs, article-category memberships, category reordering (`moveCategory`, `reorderCategories`).
2. **`companySlice`**: Active company configuration, tax rates (standard, reduced), root category ID, company legal details.
3. **`brandSlice`**: Catalog brands and manufacturer list.
4. **`posterminalSlice`**: Registered point-of-sale hardware terminals.
5. **`unitSlice`**: Allowed units of measure (Stück, kg, Liter, etc.).
### Automatic Bootstrap with Listener Middleware
In [src/store/index.js](file:///home/seb/src/dashfront/src/store/index.js), `createListenerMiddleware` coordinates data loading automatically when auth events fire:
```js
companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
```
When `auth/sessionEstablished` or `auth/companyChanged` occurs, these listeners trigger thunks to reload company tax settings, brands, units, and POS terminals without boilerplate in React views.
### Redux Dev Logger
In development mode (`import.meta.env.DEV`), `reduxDevLogger` intercepts actions from `auth/`, `company/`, `category/`, `brand/`, `posterminal/`, and `unit/`, logging structured before/after counts to the browser console.
---
## 11. `dashgui` Component Library Integration
`dashgui` is a shared, high-performance UI library linked from `file:../dashgui`:
### Core Components
- **`PanelTile`**: Structured card container featuring standard header styling, action slots, collapsible state, and clean borders.
- **`RecordList`**: High-performance list component optimized for large dataset rendering with custom row click, hover, and selection handlers.
- **`Tile`**: Base container element for building responsive dashboard grids.
- **`SingleLineTextEdit`**: Inline editable text field with debounced autosave, blur commit, and escape rollback.
- **`ElevatedPill`**: Compact visual badges for status, tags, and category chips.
### React 19 & Deduplication Warning
Because `dashgui` is symlinked from `../dashgui`, it has its own `node_modules`. To prevent duplicate React, Emotion, or MUI instances from causing runtime context errors (`Cannot read properties of null (reading 'useContext')`), [vite.config.js](file:///home/seb/src/dashfront/vite.config.js) enforces `dedupe`:
```js
resolve: {
dedupe: [
'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime',
'react-router', 'react-router-dom', '@emotion/react', '@emotion/styled',
'@mui/material', '@mui/icons-material'
]
}
```
---
## 12. Application Views & Route Hierarchy
The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) and styled with [src/components/AppShell.jsx](file:///home/seb/src/dashfront/src/components/AppShell.jsx):
```
/
├── /artikel
│ ├── index → ArtikelUebersichtPage (Search & filter grid)
│ ├── /kategorien/* → ArtikelKategorienPage (Interactive category tree)
│ ├── /lager → ArtikelLagerPage (Warehouse stock overview)
│ └── /:sku/:tab? → ArtikelSkuPage (SKU detail: info, photos, prices, suppliers, warehouse)
├── /lieferung
│ ├── index → LieferungListePage (Inbound delivery list)
│ ├── /neu → LieferungNeuPage (Receipt editor & OCR reconciliation)
│ ├── /lieferanten → LieferungLieferantenPage (Supplier directory)
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
├── /auftrag
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
│ ├── /neu → AuftragAngebotNeuPage (Create quote)
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
│ └── /bestellung/:id → AuftragBestellungEditPage (Edit order)
├── /kunde
│ ├── index → KundeListePage (Customer directory)
│ ├── /neu → KundeNeuPage (Create customer)
│ ├── /gruppen → KundeGruppenPage (Customer groups & price tiers)
│ └── /:entityId → KundeEditPage (Customer profile & address editor)
└── /infos → InfosPage (Company information & POS setup)
```
---
## 13. Media, Images & Document Pipelines
### Client-Side AVIF Conversion
To minimize backend CPU usage and reduce bandwidth:
- When a user selects or drops an article photo, the frontend processes the image in-browser using `@jsquash/avif` and `@jsquash/resize`.
- Images are scaled to target variant dimensions and compressed into AVIF format before transmission to `/api/article/:sku/photo`.
### ThumbHash Placeholders
- For instant visual feedback without layout shift, article photos generate a 25-byte binary hash using `thumbhash`.
- [src/components/article/ThumbHashImage.jsx](file:///home/seb/src/dashfront/src/components/article/ThumbHashImage.jsx) renders the blur preview instantly while the full image loads asynchronously from `/api/thumb/{hash}` or `/api/image/{hash}`.
### PDF & Document Ingestion
- [src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx) utilizes `pdfconvert-wasm` to process supplier PDF delivery notes and invoices directly inside the browser.
- Extracted pages are rendered to canvas, displayed for line-by-line inspection, and passed to OCR recognition to populate delivery line items.
---
## 14. Real-time SSE Signals & Invalidation
The frontend connects to the backend Server-Sent Events stream at `/api/events`:
- **Architecture**: When mutations occur on `dashback` (whether triggered by this client, another terminal, or the POS), SSE events are broadcast with payload information (e.g. `{ type: 'article-update', sku: '1001' }`).
- **Reactive Cache Refresh**:
- `artikelAutomat.actions.reloadArticle(sku)` re-fetches the updated article silently, updating subscribers without flicker.
- Category updates trigger Redux category slice refreshes.
- Delivery and order updates trigger automatic list reloads.
---
## 15. Developer Tooling & Debugging
### AutomatInspector ([src/devtools/AutomatInspector.jsx](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx))
- Docked overlay on the right side of the screen, active only in development mode (`import.meta.env.DEV`).
- Auto-discovers all running automats registered in `window.__AUTOMATS__`.
- Allows developers and agents to:
- Inspect full state JSON trees in real time.
- View subscriber counts and registered actions.
- Trigger actions and test state changes without code modifications.
### Vite API Proxy Logging
[vite.config.js](file:///home/seb/src/dashfront/vite.config.js) includes automatic proxy logging for all `/api` requests and responses. HTTP method, path, request payloads, response statuses, and timing are formatted directly in the Vite terminal.
### TLS / HTTPS Development
For testing hardware barcode scanners, camera video feeds, and PWA offline modes:
```bash
npm run cert # Generates local certs in ./certs/
npm run dev:https # Runs Vite HTTPS server on port 4308
```
---
## 16. Agent Cheatsheet & Code Recipes
### Recipe 1: Consuming an Automat in a React PureComponent
```jsx
import { PureComponent } from 'react';
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
export class ArticleTitle extends PureComponent {
constructor(props) {
super(props);
// 1. Read synchronously to avoid blank flash
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
// 2. Subscribe with fine-grained selector
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
componentDidUpdate(prevProps) {
// 3. Handle prop changes cleanly
if (prevProps.sku !== this.props.sku) {
this.setState({ article: getArticle(this.props.sku) });
this.unsub?.();
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
}
componentWillUnmount() {
// 4. Always cleanup
this.unsub?.();
}
render() {
const { article } = this.state;
return <h1>{article?.name ?? 'Lädt...'}</h1>;
}
}
```
### Recipe 2: Calling the API and Notifying Errors
```jsx
import { article } from '../api';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
import { artikelAutomat } from '../automats';
async function handleSavePrice(sku, netPrice) {
try {
const res = await article.updatePrice(sku, { net: netPrice });
artikelAutomat.actions.setArticle(res.article);
enqueueSnackbar('Preis erfolgreich aktualisiert', { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
```
### Recipe 3: Calculating Gross Prices with Company Tax Rates
```jsx
// Prices in the database and API are NET prices.
// Gross prices shown to end users must apply company tax rates from Redux companySlice:
export function calculateGrossPrice(netPrice, taxRatePercent) {
const net = Number(netPrice) || 0;
const rate = Number(taxRatePercent) || 0;
return Math.round(net * (1 + rate / 100) * 100) / 100;
}
```

466
NGINX.md Normal file
View File

@@ -0,0 +1,466 @@
# NGINX Configuration Guide for Dashfront & Dashback
This document provides a production-tested, complete NGINX reverse-proxy and static hosting guide for **Dashfront** (Vite + React 19 PWA) and **Dashback** (Express REST + SSE API).
---
## Table of Contents
1. [Architecture & Request Flow](#1-architecture--request-flow)
2. [Key Technical Requirements](#2-key-technical-requirements)
3. [Production Configuration (`dashfront.conf`)](#3-production-configuration-dashfrontconf)
4. [Detailed Breakdown of Critical Directives](#4-detailed-breakdown-of-critical-directives)
- [PWA & Cache Invalidation Strategy](#pwa--cache-invalidation-strategy)
- [SPA Routing (`try_files`)](#spa-routing-try_files)
- [Server-Sent Events (SSE) Buffering & Timeouts](#server-sent-events-sse-buffering--timeouts)
- [File Uploads & Request Body Limits](#file-uploads--request-body-limits)
- [WebAssembly (WASM) MIME Types](#webassembly-wasm-mime-types)
- [Camera & Hardware Permissions](#camera--hardware-permissions)
5. [Alternative: Remote Vite Dev Server Proxy](#5-alternative-remote-vite-dev-server-proxy)
6. [Deployment & Operations Runbook](#6-deployment--operations-runbook)
7. [Troubleshooting Matrix](#7-troubleshooting-matrix)
---
## 1. Architecture & Request Flow
In production, NGINX acts as both the high-performance static web server for Dashfront assets and the secure reverse proxy for the Dashback Express service.
```
┌──────────────────────────────────────────────┐
│ CLIENT │
│ (Desktop Browser, Tablet, Mobile PWA) │
└──────────────────────┬───────────────────────┘
│ HTTPS (:443)
▼
┌──────────────────────────────────────────────┐
│ NGINX │
│ TLS Termination, Gzip, Caching, Auth │
└──────────────┬────────────────┬──────────────┘
│ │
/assets/*, /index.html │ │ /api/*
/sw.js, static files │ │ (REST & SSE)
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Static Build Dir │ │ Dashback │
│ `dashfront/dist` │ │ Express API │
│ │ │ (localhost:4208) │
└───────────────────┘ └───────────────────┘
```
| Path | Handled By | Cache Policy | Notes |
|---|---|---|---|
| `/index.html` & `/` | NGINX static file | `no-cache, no-store, must-revalidate` | Always fresh so users get new script hashes |
| `/sw.js`, `/registerSW.js` | NGINX static file | `no-cache, no-store, must-revalidate` | **Must never cache**; avoids bricking PWA updates |
| `/workbox-*.js` | NGINX static file | `no-cache, no-store, must-revalidate` | Workbox runtime loader |
| `/manifest.webmanifest` | NGINX static file | `public, max-age=3600` | PWA manifest definition |
| `/assets/*` | NGINX static file | `public, max-age=31536000, immutable` | Content-hashed JS, CSS, WASM, and workers |
| `/api/events` | Dashback (`:4208`) | `no-cache, no-buffering` | Server-Sent Events (SSE) live push notifications |
| `/api/*` | Dashback (`:4208`) | Dynamic / Backend controlled | REST endpoints, auth cookies, image & PDF uploads |
| All other paths | NGINX `try_files` | Fallback to `/index.html` | React Router HTML5 History API deep linking |
---
## 2. Key Technical Requirements
1. **PWA Lifecycles**: A Progressive Web App checks for updates by fetching `sw.js`. If NGINX caches `sw.js` or `index.html`, client devices can remain stuck on obsolete bundles indefinitely.
2. **SSE Streaming**: Real-time push updates at `/api/events` require NGINX proxy buffering and proxy caching to be turned **OFF**, with a 24-hour read timeout to prevent arbitrary 60-second connection drops.
3. **Payload Limits**: Inbound delivery invoices (PDF receipts scanned client-side) and high-resolution article photos (AVIF) require a request body limit of at least `50m`–`100m` (matching Dashback's `BODY_LIMIT`).
4. **Cookie Authentication**: Dashback issues HTTP-only `token` session cookies. NGINX must pass the original `Host`, `X-Real-IP`, and `X-Forwarded-Proto https` headers so Express sets and retains cookies correctly.
5. **Camera Access (HTTPS)**: Mobile barcode scanning requires a secure origin (HTTPS) and appropriate `Permissions-Policy` response headers.
---
## 3. Production Configuration (`dashfront.conf`)
Place this configuration file at `/etc/nginx/sites-available/dashfront` (or adapt it to your existing reverse proxy structure):
```nginx
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
# Map Connection header for WebSocket upgrades (if proxying dev server or WebSockets)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dash.example.com; # <-- Replace with your domain or server IP
# Let's Encrypt / ACME challenge directory
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
# NGINX >= 1.25.1 uses 'http2 on;'. For older NGINX, use: listen 443 ssl http2;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dash.example.com; # <-- Replace with your domain or server IP
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Static File Root ---
# Point directly to the dist/ directory created by `npm run build`
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads ---
# Supports large delivery PDF uploads and client-encoded AVIF photos
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Allow camera access for the built-in barcode scanner
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
# Clear Connection header to maintain persistent HTTP/1.1 stream
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering and caching completely for immediate push
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Prevent premature disconnections; keep SSE open for 24 hours
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Retain cookies across domain/path
proxy_cookie_path / /;
# Streaming uploads (PDF receipts, AVIF photos)
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
# Service worker scripts MUST NEVER be cached by HTTP cache
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
# PWA web manifest and browser icons
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
# Vite content-hashed assets: JS, CSS, WASM, Web Workers
# (Example: /assets/index-CutpK3vd.js, /assets/avif_enc-Co4TcJko.wasm)
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Ensure WebAssembly MIME type is correctly set
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
# index.html must never be cached so new release deployments take effect immediately
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SPA routing: Serve existing static file or directory; otherwise fallback to /index.html
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## 4. Detailed Breakdown of Critical Directives
### PWA & Cache Invalidation Strategy
PWA update lifecycles depend on byte-for-byte comparisons of `sw.js` when the user visits the page:
1. **`sw.js` and `registerSW.js`**:
```nginx
add_header Cache-Control "no-cache, no-store, must-revalidate";
```
If NGINX serves `sw.js` with a 30-day cache header, browser service worker checks will read from disk cache and never detect new application releases.
2. **`index.html`**:
Must also have `no-cache, no-store, must-revalidate`. When a new build is run, Vite produces chunks with new hashes (e.g., `index-AbCd12.js`). If `index.html` is cached, users continue loading old references that may no longer exist on disk.
3. **`/assets/`**:
Because Vite / Rolldown generates content-addressed filenames (hashes change whenever contents change), these files are completely immutable and safe to cache for 1 year (`max-age=31536000, immutable`).
### SPA Routing (`try_files`)
Dashfront uses React Router with HTML5 History API pushState:
- Direct visits to URLs like `/artikel/10001`, `/kategorien/12`, `/lieferung/neu`, or `/kunde` do not correspond to files on disk.
- The directive:
```nginx
try_files $uri $uri/ /index.html;
```
instructs NGINX to first check if a matching static file exists (e.g. `/favicon.svg` or `/assets/...`). If not found, it internally rewrites the request to `/index.html` without returning an HTTP 404, allowing React Router to resolve the route in-browser.
### Server-Sent Events (SSE) Buffering & Timeouts
The backend provides real-time model invalidation via `GET /api/events`. By default, NGINX buffers HTTP responses. If buffering is enabled:
1. Events sent by Dashback stay in NGINX's internal memory buffer until the buffer fills up (often several kilobytes), causing clients to miss instant UI updates.
2. NGINX will close idle proxy connections after `proxy_read_timeout` (default 60s).
The dedicated `/api/events` block fixes both issues:
```nginx
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_set_header Connection "";
proxy_read_timeout 86400s;
```
### File Uploads & Request Body Limits
In Dashfront:
- Inbound deliveries support multi-page PDF invoices and OCR processing (`pdfconvert-wasm`).
- Article management supports client-side AVIF image generation.
- The default NGINX limit is `1m` (`client_max_body_size 1m;`), which causes HTTP 413 (Payload Too Large) errors on invoice uploads.
- Setting:
```nginx
client_max_body_size 100m;
proxy_request_buffering off;
```
allows uploads up to 100 megabytes and streams the body directly to Express without waiting for NGINX to buffer the entire file on disk.
### WebAssembly (WASM) MIME Types
Dashfront uses WebAssembly for:
- `@jsquash/avif` (image encoding)
- `@jsquash/resize` (squoosh resizing)
- `pdfconvert-wasm` (PDF page extraction and rasterization)
Modern browsers require the HTTP header `Content-Type: application/wasm` for streaming compilation (`WebAssembly.instantiateStreaming`). If missing, NGINX may default to `application/octet-stream`. The WASM block guarantees the correct MIME header:
```nginx
location ~* \.wasm$ {
default_type application/wasm;
}
```
### Camera & Hardware Permissions
The article lookup and delivery receipt features allow scanning barcodes using device cameras. Web browsers enforce two strict rules for camera access:
1. **Secure Origin**: The page must be served over HTTPS or `localhost`. Plain HTTP over local IP addresses (e.g., `http://192.168.1.100/`) will automatically disable `navigator.mediaDevices`.
2. **Permissions Policy**: The header `Permissions-Policy "camera=(self)"` ensures the browser allows Dashfront to access connected webcams or smartphone cameras.
---
## 5. Alternative: Remote Vite Dev Server Proxy
If you want to access the live **Vite development server** (running on `localhost:4209` with HMR) through NGINX from a smartphone, tablet, or external domain:
```nginx
# Map connection upgrade for Vite HMR WebSockets
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name dev-dash.example.com;
ssl_certificate /etc/letsencrypt/live/dev-dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev-dash.example.com/privkey.pem;
client_max_body_size 100m;
# 1. API Calls -> Dashback (Port 4208)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 86400s;
}
# 2. Vite Dev Server & HMR -> Vite (Port 4209)
location / {
proxy_pass http://127.0.0.1:4209;
proxy_http_version 1.1;
# Crucial for Vite Hot Module Replacement (HMR) WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
}
}
```
> [!NOTE]
> When proxying to Vite dev server, ensure `server.allowedHosts = true` or add your domain to `allowedHosts` in [vite.config.js](file:///home/seb/src/dashfront/vite.config.js).
---
## 6. Deployment & Operations Runbook
### Step 1: Build Dashfront for Production
On the development machine or build server:
```bash
cd /home/seb/src/dashfront
npm run build
```
This compiles optimized bundles and service worker files into `dashfront/dist`.
### Step 2: Install and Verify NGINX Configuration
```bash
# 1. Copy config file to sites-available
sudo cp deploy/nginx-dashfront.conf /etc/nginx/sites-available/dashfront
# 2. Enable site by creating a symlink
sudo ln -s /etc/nginx/sites-available/dashfront /etc/nginx/sites-enabled/
# 3. Test configuration syntax
sudo nginx -t
# 4. Reload NGINX without dropping active connections
sudo systemctl reload nginx
```
### Step 3: Obtain TLS Certificates with Certbot
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d dash.example.com
```
### Step 4: Ensure Filesystem Permissions
NGINX worker processes run as the `www-data` user by default. Ensure the directory path to `dist/` is readable:
```bash
# Allow www-data execution on parent path components
chmod 755 /home/seb /home/seb/src /home/seb/src/dashfront /home/seb/src/dashfront/dist
```
Or deploy build artifacts into a standard web directory:
```bash
sudo mkdir -p /var/www/dashfront
sudo rsync -av --delete /home/seb/src/dashfront/dist/ /var/www/dashfront/dist/
sudo chown -R www-data:www-data /var/www/dashfront
```
---
## 7. Troubleshooting Matrix
| Symptom | Cause | Solution |
|---|---|---|
| **HTTP 404 on page reload** on sub-routes (`/artikel`, `/kunde`) | Missing SPA fallback in NGINX | Ensure `try_files $uri $uri/ /index.html;` is present in `location /`. |
| **PWA does not update** after deployment | `sw.js` or `index.html` cached in browser | Verify `Cache-Control: no-cache, no-store, must-revalidate` on `sw.js`, `registerSW.js`, and `index.html`. |
| **HTTP 413 (Payload Too Large)** when uploading invoice PDF or image | Default NGINX upload limit is 1MB | Add `client_max_body_size 100m;` to the `server` or `location /api/` block. |
| **Live SSE events disconnect every 60s** | Default `proxy_read_timeout` timeout | Set `proxy_read_timeout 86400s;` and `proxy_buffering off;` on `/api/events`. |
| **Login loop / Cookie not sent** | Secure cookie rejected on plain HTTP, or missing proxy headers | Access via HTTPS; ensure `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` are set. |
| **Barcode camera scanner disabled** | Page accessed over insecure HTTP or missing permissions policy | Use HTTPS; check that `Permissions-Policy "camera=(self)"` header is present. |
| **WASM compilation error** (`incorrect response MIME type`) | NGINX serving `.wasm` as `application/octet-stream` | Add `default_type application/wasm;` for `\.wasm$` extensions. |
| **Vite HMR disconnects immediately** (Dev proxy) | Missing WebSocket upgrade headers | Ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection $connection_upgrade;` are configured. |

View File

@@ -24,6 +24,8 @@ Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
To test the full PWA precache (production build + preview, no HMR), use To test the full PWA precache (production build + preview, no HMR), use
`npm run dev:pwa` instead. `npm run dev:pwa` instead.
For production deployment with NGINX, see [NGINX.md](file:///home/seb/src/dashfront/NGINX.md).
Dashback must be running and your Google account must be authorized on the Dashback must be running and your Google account must be authorized on the
backend. backend.

168
deploy/nginx-dashfront.conf Normal file
View File

@@ -0,0 +1,168 @@
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dash.example.com; # <-- Replace with your domain or server IP
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dash.example.com; # <-- Replace with your domain or server IP
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Static File Root ---
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads (PDF receipts, AVIF photos) ---
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location / {
try_files $uri $uri/ /index.html;
}
}

1163
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,15 +20,14 @@
"@jsquash/resize": "^2.1.1", "@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.4.0", "@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0", "@mui/material": "^9.4.0",
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1", "@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui", "dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"notistack": "^3.0.2", "notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0", "pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0", "react": "^19.3.0",
"react-automat": "^0.1.0", "react-automat": "file:../automat",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"react-router-dom": "^7.18.4", "react-router-dom": "^7.18.4",
"sortablejs": "^1.15.7", "sortablejs": "^1.15.7",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"

View File

@@ -1,8 +1,14 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice'; import { parseCategoryTreeResponse } from '../utils/categoryUtils';
import { ApiClient } from './ApiClient'; import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat'; import artikelAutomat from '../automats/artikelAutomat';
import companyAutomat from '../automats/companyAutomat';
import brandAutomat from '../automats/brandAutomat';
import unitAutomat from '../automats/unitAutomat';
import authAutomat from '../automats/authAutomat';
import categoryAutomat from '../automats/categoryAutomat';
/** /**
* Stale-while-revalidate cache: readers keep the current promise; update() * Stale-while-revalidate cache: readers keep the current promise; update()
@@ -75,8 +81,6 @@ export class StorageManager extends ApiClient {
#events = null; #events = null;
/** @type {ReturnType<typeof createCache> | null} */ /** @type {ReturnType<typeof createCache> | null} */
#articleIndexCache = null; #articleIndexCache = null;
/** @type {Map<string, object>} sku → article detail */
#articlesBySku = new Map();
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */ /** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map(); #articleWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → history refreshers */ /** @type {Map<string, Set<() => void>>} sku → history refreshers */
@@ -92,39 +96,41 @@ export class StorageManager extends ApiClient {
this.watchArticleSuppliers(sku, onSuppliers); this.watchArticleSuppliers(sku, onSuppliers);
} }
/** Settled article detail for sku, or undefined. */ /** Cached article detail from the shared artikelAutomat, or null. */
getArticle(sku) { getArticle(sku) {
return this.#articlesBySku.get(String(sku)); return artikelAutomat.state.bySku[String(sku)] ?? null;
} }
/** Keep local article detail in sync after GET/PATCH/PUT responses. */ /**
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
* notify any StorageManager-level watchers.
*/
setArticle(article) { setArticle(article) {
requireObject(article, 'article'); requireObject(article, 'article');
requireDefined(article.sku, 'article.sku'); requireDefined(article.sku, 'article.sku');
const key = String(article.sku); const key = String(article.sku);
this.#articlesBySku.set(key, article); const stored = artikelAutomat.actions.setArticle(article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key); const watchers = this.#articleWatchers.get(key);
if (watchers) {
for (const onArticle of watchers) onArticle(stored);
}
return stored;
}
/**
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
* @param {string} sku
*/
async loadArticle(sku) {
const article = await artikelAutomat.actions.loadArticle(sku);
const watchers = this.#articleWatchers.get(String(sku));
if (watchers) { if (watchers) {
for (const onArticle of watchers) onArticle(article); for (const onArticle of watchers) onArticle(article);
} }
return article; return article;
} }
/**
* GET /api/article/{sku}, cache detail, return article.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await this.article.get(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
return this.setArticle(res.article);
}
/** /**
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`). * Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
* @param {string} sku * @param {string} sku
@@ -172,20 +178,25 @@ export class StorageManager extends ApiClient {
* @returns {{ categories: object, rootCategoryId: unknown } | null} * @returns {{ categories: object, rootCategoryId: unknown } | null}
*/ */
getCategoryState() { getCategoryState() {
const treeData = this.categoryTree; const company = this.company;
const companies = this.companies;
const session = this.authMe;
if (treeData == null || companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
const company = companies.find((c) => c.id === companyId);
if (company == null) return null; if (company == null) return null;
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId); const treeData = this.categoryTree;
if (treeData != null) {
// Legacy: tree was loaded via the old cache mechanism
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
return {
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
};
}
// New path: use categoryAutomat state directly
const catState = categoryAutomat.state;
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
return { return {
categories: parsed.categories, categories: catState.categories,
rootCategoryId: parsed.rootCategoryId, rootCategoryId: catState.rootCategoryId,
}; };
} }
@@ -335,11 +346,11 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => { #onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length); const sku = event.type.slice('article-'.length);
// Update automat on SSE article events // Refresh the shared cache; silently when nothing is watching this SKU.
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
if (this.#articleWatchers.has(sku)) { if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err)); void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
} else {
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
} }
const historyWatchers = this.#historyWatchers.get(sku); const historyWatchers = this.#historyWatchers.get(sku);
if (historyWatchers) { if (historyWatchers) {
@@ -360,9 +371,9 @@ export class StorageManager extends ApiClient {
); );
} }
/** Patch local article detail name if already cached; no-op otherwise. */ /** Patch cached article detail name if already cached; no-op otherwise. */
setArticleName(sku, name) { setArticleName(sku, name) {
const current = this.#articlesBySku.get(String(sku)); const current = this.getArticle(sku);
if (!current) return; if (!current) return;
this.setArticle({ ...current, name }); this.setArticle({ ...current, name });
} }
@@ -375,13 +386,6 @@ export class StorageManager extends ApiClient {
#initCaches() { #initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe()); const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const articleIndexCache = this.#articleIndexCache;
const brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries()); const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () => const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(), this.#loadCustomergroups(),
@@ -391,6 +395,19 @@ export class StorageManager extends ApiClient {
const quoteCache = createCache('GET /quote', () => this.#loadQuotes()); const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('GET /order', () => this.#loadOrders()); const orderCache = createCache('GET /order', () => this.#loadOrders());
// articleIndex is auth-gated — create a deferred cache that only loads after auth
this.#articleIndexCache = this.#createDeferredCache(
'GET /articleIndex',
() => this.#loadArticleIndex(),
);
const articleIndexCache = this.#articleIndexCache;
// category tree: deferred, reloaded via categoryAutomat
const categoryTreeCache = this.#createDeferredCache(
'GET /category/tree',
() => this.#loadCategoryTree(),
);
Object.defineProperties(this, { Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise }, authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update }, updateAuthMe: { value: authMeCache.update },
@@ -400,29 +417,68 @@ export class StorageManager extends ApiClient {
articleIndexPromise: { get: () => articleIndexCache.promise }, articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update }, updateArticleIndex: { value: articleIndexCache.update },
brandPromise: { get: () => brandCache.promise }, // ── Automat bridges (brand, unit, company, posterminal) ──────────────────
updateBrand: { value: brandCache.update }, /** Brand list from brandAutomat (may be empty until authed). */
/** Settled GET /brand list, or undefined. */ brands: { get: () => brandAutomat.state.brands },
brands: { get: () => brandCache.value }, /** Promise that resolves when brands are first loaded. */
brandPromise: {
get: () => brandAutomat.state.status === 'succeeded'
? Promise.resolve(brandAutomat.state.brands)
: new Promise((resolve) => {
const unsub = brandAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
});
}),
},
/** Reload brands from API. */
updateBrand: { value: () => brandAutomat.actions.load() },
unitPromise: { get: () => unitCache.promise }, /** Unit list from unitAutomat. */
updateUnit: { value: unitCache.update }, units: { get: () => unitAutomat.state.units },
/** Settled GET /unit list, or undefined. */ /** Promise that resolves when units are first loaded. */
units: { get: () => unitCache.value }, unitPromise: {
get: () => unitAutomat.state.status === 'succeeded'
? Promise.resolve(unitAutomat.state.units)
: new Promise((resolve) => {
const unsub = unitAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
});
}),
},
/** Reload units from API. */
updateUnit: { value: () => unitAutomat.actions.load() },
companyPromise: { get: () => companyCache.promise }, /**
updateCompany: { value: companyCache.update }, * Current company object from companyAutomat, or null.
/** Settled GET /company list, or undefined. */ * Previously: companies array from GET /api/company.
companies: { get: () => companyCache.value }, * Consumers that used companies.find(c => c.id === companyId) can use .company directly.
*/
company: { get: () => companyAutomat.state.current ?? null },
/** Promise that resolves when company data is loaded. */
companyPromise: {
get: () => companyAutomat.state.status === 'succeeded'
? Promise.resolve(companyAutomat.state.current)
: new Promise((resolve, reject) => {
const unsub = companyAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload company data from API. */
updateCompany: { value: () => companyAutomat.actions.load() },
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
// Return a single-element array so .find() still works.
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
// ── Category tree bridge ─────────────────────────────────────────────────
categoryTreePromise: { get: () => categoryTreeCache.promise }, categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update }, updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */ /** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value }, categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise }, // ── Remaining non-auth-gated caches ─────────────────────────────────────
updatePosterminal: { value: posterminalCache.update },
deliveryPromise: { get: () => deliveryCache.promise }, deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update }, updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */ /** Settled GET /delivery list, or undefined. */
@@ -456,8 +512,63 @@ export class StorageManager extends ApiClient {
/** Close and reopen GET /api/events (required after selected-company change). */ /** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() }, reconnectEvents: { value: () => this.#reconnectEvents() },
}); });
// Trigger articleIndex load once auth is established
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
if (!articleIndexCache.value) {
void articleIndexCache.update?.().catch(() => {});
}
}
});
} }
/**
* Create a cache that does NOT load immediately but waits for an explicit trigger.
* Differs from createCache in that the initial load promise is not started.
*/
#createDeferredCache(label, load) {
let current;
let resolveInitial;
let rejectInitial;
let promise = new Promise((res, rej) => {
resolveInitial = res;
rejectInitial = rej;
});
async function update() {
try {
const data = await load();
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
rejectInitial?.(err);
rejectInitial = null;
throw err;
}
}
function replace(data) {
current = data;
promise = Promise.resolve(data);
resolveInitial?.(data);
resolveInitial = null;
return data;
}
return {
update,
replace,
get promise() { return promise; },
get value() { return current; },
};
}
/** /**
* GET /api/events — SSE signals for the selected company. * GET /api/events — SSE signals for the selected company.
* Stream is bound at connect time; reconnect after company switch. * Stream is bound at connect time; reconnect after company switch.

View File

@@ -0,0 +1,190 @@
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';
/**
* @typedef {Object} ArtikelState
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
*/
/** @type {ArtikelState} */
const initialState = {
bySku: {},
};
/**
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
*
* @type {Map<string, Promise<object>>}
*/
const inflight = new Map();
/** @type {Automat<ArtikelState>} */
let artikelAutomat;
const actions = {
/**
* Loads an article into the shared cache, deduplicating concurrent callers.
* Resolves with the cached article when it is already present.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Loaded article detail.
*/
loadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) {
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
}
const cached = artikelAutomat.state.bySku[key];
if (cached) return Promise.resolve(cached);
const pending = inflight.get(key);
if (pending) return pending;
const request = articleApi
.getBySku(key)
.then((res) => {
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, request);
return request;
},
/**
* Silently re-fetches an article, keeping the cached copy until the new one
* arrives. Use for SSE invalidation and pull-to-refresh.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Fresh article detail.
*/
async reloadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
const res = await articleApi.getBySku(key);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
},
/**
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
* response so every subscriber sees the same object.
*
* @param {object} article Article detail with at least a `sku`.
* @returns {object} The stored article.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
return article;
},
/**
* Patches the cached article name without a round-trip (optimistic update).
*
* @param {string} sku Article SKU.
* @param {string} name New name.
* @returns {object|null} Updated article, or `null` when not cached.
*/
setArticleName(sku, name) {
const key = String(sku);
const current = artikelAutomat.state.bySku[key];
if (!current) return null;
return artikelAutomat.actions.setArticle({ ...current, name });
},
/**
* Drops an article from the cache (e.g. after deletion), forcing the next
* `loadArticle` to hit the backend.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
clearArticle(sku) {
const key = String(sku);
if (!(key in artikelAutomat.state.bySku)) return;
const bySku = { ...artikelAutomat.state.bySku };
delete bySku[key];
artikelAutomat.setState({ bySku });
},
/**
* Forgets every cached article.
*
* @returns {void}
*/
clear() {
artikelAutomat.setState({ bySku: {} });
},
};
/**
* Shared cache for article detail payloads. State lives in module scope so
* navigating between articles (and remounting the view) reuses the cache
* instead of re-fetching.
*
* @type {Automat<ArtikelState>}
*/
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
/**
* Reads a cached article synchronously.
*
* @param {string} sku Article SKU.
* @returns {object|null} Cached article detail, or `null`.
*/
export function getArticle(sku) {
if (sku == null) return null;
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/**
* Whether an article detail payload is currently cached.
*
* @param {string} sku Article SKU.
* @returns {boolean} `true` when cached.
*/
export function hasArticle(sku) {
return getArticle(sku) != null;
}
/**
* Builds a selector for one SKU, so a component can subscribe to just that
* article instead of every cache change.
*
* @param {string} sku Article SKU.
* @returns {(state: ArtikelState) => object|null} Selector function.
*/
export function createArticleSelector(sku) {
const key = sku == null ? null : String(sku);
return (state) => (key == null ? null : state.bySku[key] ?? null);
}
/**
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
*
* @param {string} sku Article SKU.
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
*/
export function selectArticle(sku) {
return artikelAutomat.select(createArticleSelector(sku));
}
export default artikelAutomat;

View File

@@ -0,0 +1,81 @@
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} BrandState
* @property {Array<object>} brands
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {BrandState} */
const initialState = {
brands: [],
status: 'idle',
error: null,
};
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
let brandAutomat;
const actions = {
/** Load all brands for the selected company. */
async load() {
brandAutomat.setState({ status: 'loading', error: null });
try {
const res = await brandApi.list();
const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []);
brandAutomat.setState({ brands, status: 'succeeded' });
return brands;
} catch (err) {
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert a brand (create or update by name).
* @param {{ name: string, gsprpinfo?: string }} body
*/
async upsert(body) {
const res = await brandApi.upsert(body);
const row = res.brand;
const brands = sortBrands([
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
row,
]);
brandAutomat.setState({ brands });
return row;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
brandAutomat.setState(initialState);
},
};
/**
* Global brand state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<BrandState>}
*/
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void brandAutomat.actions.load();
} else if (auth.status === 'anon') {
brandAutomat.actions.reset();
}
});
export default brandAutomat;

View File

@@ -0,0 +1,490 @@
import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
ROOT_CATEGORY_NAME,
} from '../utils/categoryUtils';
/**
* @typedef {Object} CategoryState
* @property {number|string|null} companyId
* @property {string|null} languageId
* @property {Record<string,object>|null} categories id → node map
* @property {number|string|null} rootCategoryId
* @property {Array<object>} rootArticles
* @property {number|string|null} currentCategoryId
* @property {Array<object>} articles
* @property {number} articlesTotal
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {'idle'|'loading'|'succeeded'|'failed'} articlesStatus
* @property {string|null} error
*/
/** @type {CategoryState} */
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
let categoryAutomat;
// ─── Pure helpers ──────────────────────────────────────────────────────────────
function articlesForCategory(state, categoryId) {
if (String(categoryId) === String(state.rootCategoryId)) {
return state.rootArticles;
}
return [];
}
// ─── Actions ───────────────────────────────────────────────────────────────────
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
const company = companyAutomat.state.current;
if (!company) return;
const { id: companyId, rootCategoryId: companyRootId } = company;
if (!companyRootId) return;
categoryAutomat.setState({ treeStatus: 'loading', error: null });
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
currentCategoryId: parsed.rootCategoryId,
rootArticles: parsed.rootArticles,
articles: parsed.rootArticles,
articlesTotal: parsed.rootArticles.length,
articlesStatus: 'succeeded',
error: null,
});
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
},
/**
* Load articles for a non-root category.
* @param {number|string} categoryId
*/
async loadArticles(categoryId) {
categoryAutomat.setState({ articlesStatus: 'loading', error: null });
try {
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
categoryAutomat.setState({
articlesStatus: 'succeeded',
articles,
articlesTotal: articles.length,
});
} catch (err) {
categoryAutomat.setState({
articlesStatus: 'failed',
articles: [],
articlesTotal: 0,
error: err?.message ?? String(err),
});
throw err;
}
},
/** Navigate to a category by id. */
navigateTo(categoryId) {
const { rootCategoryId, rootArticles } = categoryAutomat.state;
const isRoot = String(categoryId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: categoryId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
},
/** Navigate up to the parent category. */
navigateBack() {
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
const current = categories?.[String(currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(rootCategoryId) || categories?.[String(parentId)]) {
const isRoot = String(parentId) === String(rootCategoryId);
categoryAutomat.setState({
currentCategoryId: parentId,
error: null,
articles: isRoot ? rootArticles : [],
articlesTotal: isRoot ? rootArticles.length : 0,
articlesStatus: isRoot ? 'succeeded' : 'idle',
});
}
},
/** Toggle rename/edit mode for the category grid. */
setRenameMode(enabled) {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
},
// ─── Category mutations ────────────────────────────────────────────────────
/**
* Create a single category.
* @param {{ parentId: number|string, name: string }} param0
*/
async createCategory({ parentId, name }) {
const res = await categoryApi.create({ parentId, name });
const node = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
categoryAutomat.setState({
categories: { ...(categories ?? {}), [String(node.id)]: node },
});
return node;
},
/**
* Create multiple categories under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createCategories({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await categoryApi.create({ parentId, name });
created.push(normalizeCategoryNode(res.category));
}
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
categoryAutomat.setState({
categories: { ...(categoryAutomat.state.categories ?? {}), ...patch },
});
return created;
},
/**
* Create a single article.
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
if (String(created.parentId) === String(currentCategoryId)) {
patch.articles = [...articles, created];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(rootCategoryId)) {
patch.rootArticles = [...rootArticles, created];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
/**
* Create multiple articles under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createArticles({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
const { currentCategoryId, rootCategoryId, articles, rootArticles } = categoryAutomat.state;
const patch = {};
const forCurrent = created.filter((a) => String(a.parentId) === String(currentCategoryId));
if (forCurrent.length > 0) {
patch.articles = [...articles, ...forCurrent];
patch.articlesTotal = patch.articles.length;
patch.articlesStatus = 'succeeded';
}
const forRoot = created.filter((a) => String(a.parentId) === String(rootCategoryId));
if (forRoot.length > 0) {
patch.rootArticles = [...rootArticles, ...forRoot];
}
if (Object.keys(patch).length) categoryAutomat.setState(patch);
return created;
},
/**
* Rename a category.
* @param {{ categoryId: number|string, name: string }} param0
*/
async renameCategory({ categoryId, name }) {
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const res = await categoryApi.updateName(categoryId, trimmed);
const updated = normalizeCategoryNode(res.category);
const key = String(updated.id);
const { categories, rootArticles } = categoryAutomat.state;
categoryAutomat.setState({
categories: categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null,
rootArticles: rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name: updated.name }
: row,
),
});
return updated;
},
/**
* Reorder sibling categories.
* @param {{ categoryIds: number[] }} param0
*/
async reorderCategories({ categoryIds }) {
const { currentCategoryId, categories } = categoryAutomat.state;
const res = await categoryApi.reorder(currentCategoryId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(currentCategoryId);
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
const patched = { ...categories };
for (const node of Object.values(patched)) {
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
}
}
categoryAutomat.setState({ categories: patched });
return ordered;
},
/**
* Move a category under a new parent.
* @param {{ categoryId: number|string, parentId: number|string }} param0
*/
async moveCategory({ categoryId, parentId }) {
const res = await categoryApi.move(categoryId, parentId);
const moved = res.category;
const { categories } = categoryAutomat.state;
if (!categories || !moved) return;
const key = String(moved.id);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
},
});
return moved;
},
/**
* Delete a category by id.
* @param {number|string} categoryId
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories, currentCategoryId, rootCategoryId, rootArticles } = categoryAutomat.state;
if (!categories) return;
const next = { ...categories };
delete next[String(categoryId)];
const patch = { categories: next };
if (String(currentCategoryId) === String(categoryId)) {
patch.currentCategoryId = rootCategoryId;
patch.articles = rootArticles;
patch.articlesTotal = rootArticles.length;
patch.articlesStatus = 'succeeded';
}
categoryAutomat.setState(patch);
return categoryId;
},
/**
* Toggle the POS flag on a category.
* @param {{ categoryId: number|string, pos: boolean }} param0
*/
async toggleCategoryPos({ categoryId, pos }) {
const res = await categoryApi.updatePos(categoryId, pos);
const updated = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
if (!categories) return;
const key = String(updated.id);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], pos: updated.pos } },
});
return updated;
},
/**
* Upload an image for a category.
* @param {{ categoryId: number|string, file: Blob }} param0
*/
async uploadCategoryImage({ categoryId, file }) {
const res = await categoryApi.uploadImage(categoryId, file);
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
categoryAutomat.setState({
categories: { ...categories, [key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash } },
});
return res.category;
},
/**
* Delete the image for a category.
* @param {number|string} categoryId
*/
async deleteCategoryImage(categoryId) {
const res = await categoryApi.deleteImage(categoryId);
const { id } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
categoryAutomat.setState({
categories: {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
},
});
return res.category;
},
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from both article lists (used after article deletion). */
removeArticleBySku(sku) {
categoryAutomat.setState({
articles: categoryAutomat.state.articles.filter((r) => r.sku !== sku),
articlesTotal: categoryAutomat.state.articles.filter((r) => r.sku !== sku).length,
rootArticles: categoryAutomat.state.rootArticles.filter((r) => r.sku !== sku),
});
},
/**
* Patch article fields and category membership in local lists.
* @param {{ sku: string, categoryIds?: number[], [key: string]: any }} payload
*/
patchArticleInLists({ sku, categoryIds, ...fields }) {
if (sku == null) return;
const { articles, rootArticles, rootCategoryId, currentCategoryId } = categoryAutomat.state;
const patchList = (list) =>
list.map((row) => (row.sku !== sku ? row : { ...row, ...fields }));
let nextArticles = patchList(articles);
let nextRoot = patchList(rootArticles);
let nextArticlesTotal = nextArticles.length;
if (categoryIds !== undefined) {
const newIds = categoryIds ?? [];
const articleObj = nextArticles.find((r) => r.sku === sku) ?? nextRoot.find((r) => r.sku === sku);
if (rootCategoryId != null) {
const inRoot = newIds.some((id) => String(id) === String(rootCategoryId));
const wasInRoot = nextRoot.some((r) => r.sku === sku);
if (!inRoot) nextRoot = nextRoot.filter((r) => r.sku !== sku);
else if (!wasInRoot && articleObj) nextRoot = [...nextRoot, articleObj];
}
if (currentCategoryId != null) {
const inCurrent = newIds.some((id) => String(id) === String(currentCategoryId));
const wasInCurrent = nextArticles.some((r) => r.sku === sku);
if (!inCurrent) { nextArticles = nextArticles.filter((r) => r.sku !== sku); }
else if (!wasInCurrent && articleObj) { nextArticles = [...nextArticles, articleObj]; }
nextArticlesTotal = nextArticles.length;
}
}
categoryAutomat.setState({
articles: nextArticles,
articlesTotal: nextArticlesTotal,
rootArticles: nextRoot,
});
},
/**
* Patch posterminalIds on a category node.
* @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0
*/
patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) {
const { categories } = categoryAutomat.state;
const cat = categories?.[String(categoryId)];
if (!cat) return;
const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : [];
const next = enabled
? current.includes(terminalId) ? current : [...current, terminalId]
: current.filter((id) => id !== terminalId);
categoryAutomat.setState({
categories: {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
},
});
},
/** Reset state to idle (logout / company switch). */
reset() {
categoryAutomat.setState(initialState);
},
};
/**
* Global category tree state container.
* Loads automatically when companyAutomat has a company with a rootCategoryId.
*
* @type {Automat<CategoryState>}
*/
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// Load tree whenever the company data changes (or first becomes available)
let _lastCompanyId = null;
companyAutomat.subscribe((company) => {
const id = company.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Reset tree on logout
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Re-export pure helpers for consumers that previously imported from categorySlice
export {
ROOT_CATEGORY_NAME,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
export default categoryAutomat;

View File

@@ -0,0 +1,96 @@
import { Automat } from 'react-automat';
import { company as companyApi } from '../api';
import authAutomat from './authAutomat';
import { normalizeCompany } from '../utils/companyForm';
/**
* @typedef {Object} CompanyState
* @property {object|null} current Normalized company object for the selected company.
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {CompanyState} */
const initialState = {
current: null,
status: 'idle',
error: null,
};
let companyAutomat;
const actions = {
/**
* Load the company data for the currently selected company.
* @returns {Promise<object|null>}
*/
async load() {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
companyAutomat.setState({ status: 'loading', error: null });
try {
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Update company details and refresh cached data.
* @param {object} payload
*/
async update(payload) {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
companyAutomat.setState({ status: 'loading', error: null });
try {
await companyApi.update(companyId, payload);
const data = await companyApi.getById(companyId);
const normalized = normalizeCompany(data.company, payload);
companyAutomat.setState({ current: normalized, status: 'succeeded' });
return normalized;
} catch (err) {
companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/** Reset to idle (called on logout / company switch). */
reset() {
companyAutomat.setState(initialState);
},
};
/**
* Global company state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<CompanyState>}
*/
companyAutomat = new Automat(initialState, actions, { name: 'company' });
// Subscribe to auth changes — load when authed, reset when logged out / anon
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
// Selectors (plain functions for use outside React)
export const selectCurrentCompany = () => companyAutomat.state.current;
export const selectCompanyId = () => companyAutomat.state.current?.id ?? null;
export const selectCompanyStatus = () => companyAutomat.state.status;
export const selectCompanyName = () => companyAutomat.state.current?.name;
export const selectCompanyIso639 = () => companyAutomat.state.current?.iso639;
export const selectCompanyTaxrate = () => companyAutomat.state.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = () => companyAutomat.state.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = () => companyAutomat.state.current?.rootCategoryId ?? null;
export default companyAutomat;

View File

@@ -1,6 +1,54 @@
export * from './deliveryEditorAutomat'; export * from './deliveryEditorAutomat';
export { default as authAutomat } from './authAutomat'; export { default as authAutomat } from './authAutomat';
export * from './authAutomat'; export * from './authAutomat';
export { default as navigationAutomat } from './navigationAutomat'; export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as supplierAutomat } from './supplierAutomat'; export { default as supplierAutomat } from './supplierAutomat';
export { default as artikelAutomat } from './artikelAutomat';
export {
getArticle,
hasArticle,
selectArticle,
createArticleSelector,
} from './artikelAutomat';
export { default as companyAutomat } from './companyAutomat';
export {
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
selectCompanyName,
selectCompanyIso639,
selectCompanyTaxrate,
selectCompanyReducedTaxrate,
selectCompanyRootCategoryId,
} from './companyAutomat';
export { default as brandAutomat } from './brandAutomat';
export { default as unitAutomat } from './unitAutomat';
export { default as posterminalAutomat } from './posterminalAutomat';
export {
selectPosterminals,
selectPosterminalStatus,
selectHasPosterminals,
} from './posterminalAutomat';
export { default as categoryAutomat } from './categoryAutomat';
export {
ROOT_CATEGORY_NAME,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
} from './categoryAutomat';

View File

@@ -0,0 +1,142 @@
import { Automat } from 'react-automat';
import { posterminal as posterminalApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} PosterminalState
* @property {Array<object>} terminals
* @property {number|string|null} companyId
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PosterminalState} */
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
let posterminalAutomat;
const actions = {
/** Load all posterminals for the selected company. */
async load() {
const companyId = companyAutomat.state.current?.id;
if (!companyId) return [];
posterminalAutomat.setState({ status: 'loading', error: null });
try {
const res = await posterminalApi.list();
const terminals = Array.isArray(res.posterminals) ? res.posterminals : [];
posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' });
return terminals;
} catch (err) {
posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Create a new posterminal.
* @param {{ name: string }} param0
*/
async create({ name }) {
const res = await posterminalApi.create({ name });
const terminal = res.posterminal;
posterminalAutomat.setState({
terminals: [...posterminalAutomat.state.terminals, terminal],
});
return terminal;
},
/**
* Delete a posterminal.
* @param {number} terminalId
*/
async delete(terminalId) {
await posterminalApi.delete(terminalId);
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId),
});
},
/**
* Enable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async enableAll({ terminalId }) {
const res = await posterminalApi.enableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable all categories on a terminal.
* @param {{ terminalId: number }} param0
*/
async disableAll({ terminalId }) {
const res = await posterminalApi.disableAllCategories(terminalId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Enable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async enableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.enableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/**
* Disable a specific category on a terminal.
* @param {{ terminalId: number, categoryId: number }} param0
*/
async disableCategory({ terminalId, categoryId }) {
const res = await posterminalApi.disableCategory(terminalId, categoryId);
patchTerminalCategories(terminalId, res.categoryIds);
return res;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
posterminalAutomat.setState(initialState);
},
};
function patchTerminalCategories(terminalId, categoryIds) {
posterminalAutomat.setState({
terminals: posterminalAutomat.state.terminals.map((t) =>
t.id === terminalId ? { ...t, categoryIds } : t,
),
});
}
/**
* Global posterminal state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<PosterminalState>}
*/
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void posterminalAutomat.actions.load();
} else if (auth.status === 'anon') {
posterminalAutomat.actions.reset();
}
});
// Selectors
export const selectPosterminals = () => posterminalAutomat.state.terminals;
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
export default posterminalAutomat;

View File

@@ -0,0 +1,94 @@
import { Automat } from 'react-automat';
import { unit as unitApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} UnitState
* @property {Array<object>} units
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {UnitState} */
const initialState = {
units: [],
status: 'idle',
error: null,
};
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
let unitAutomat;
const actions = {
/** Load all units for the selected company. */
async load() {
unitAutomat.setState({ status: 'loading', error: null });
try {
const res = await unitApi.list();
const units = sortUnits(Array.isArray(res.units) ? res.units : []);
unitAutomat.setState({ units, status: 'succeeded' });
return units;
} catch (err) {
unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert (create or update) a unit.
* @param {object} body
*/
async upsert(body) {
const res = await unitApi.upsert(body);
const row = res.unit;
const units = sortUnits([
...unitAutomat.state.units.filter((u) => u.si !== row.si),
row,
]);
unitAutomat.setState({ units });
return units;
},
/**
* Delete a unit by SI symbol.
* @param {string} si
*/
async delete(si) {
await unitApi.delete(si);
unitAutomat.setState({
units: unitAutomat.state.units.filter((u) => u.si !== si),
});
},
/** Reset to idle state (called on logout / company switch). */
reset() {
unitAutomat.setState(initialState);
},
};
/**
* Global unit state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<UnitState>}
*/
unitAutomat = new Automat(initialState, actions, { name: 'unit' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void unitAutomat.actions.load();
} else if (auth.status === 'anon') {
unitAutomat.actions.reset();
}
});
export default unitAutomat;

View File

@@ -0,0 +1,48 @@
import { Automat } from 'react-automat';
/**
* @typedef {'top'|'right'|'below'} ViewerPosition
*/
/**
* @typedef {Object} UserSettingsState
* @property {ViewerPosition} viewerPosition Preferred docking position of the
* attachment viewer (text editor / PDF preview) relative to the article list.
*/
/** @type {UserSettingsState} */
const initialState = {
viewerPosition: 'top',
};
/**
* Persisted per-user UI preferences. Unlike the other automats this one is
* backed by IndexedDB (`persist: true`) so settings survive reloads and
* sessions.
*
* @type {Automat<UserSettingsState>}
*/
const userSettingsAutomat = new Automat(
initialState,
{
/**
* Set the docking position of the attachment viewer.
*
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
* @returns {void}
*/
setViewerPosition(position) {
if (position !== 'top' && position !== 'right' && position !== 'below') {
return;
}
userSettingsAutomat.setState({ viewerPosition: position });
},
},
{
name: 'userSettings',
persist: true,
}
);
export default userSettingsAutomat;
export const { setViewerPosition } = userSettingsAutomat.actions;

View File

@@ -1,6 +1,5 @@
import { useState, useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Outlet, useLocation } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Popover from '@mui/material/Popover'; import Popover from '@mui/material/Popover';
import MenuList from '@mui/material/MenuList'; import MenuList from '@mui/material/MenuList';
@@ -14,13 +13,7 @@ import { enqueueSnackbar } from 'notistack';
import PageLayout from './PageLayout'; import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav'; import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
import { isKategorienPath } from '../pages/artikel/categoryRoute'; import { isKategorienPath } from '../pages/artikel/categoryRoute';
import { import categoryAutomat from '../automats/categoryAutomat';
selectRenameMode,
setRenameMode,
selectCategoryMap,
selectCategoryRootId,
loadCategoryTree,
} from '../store/categorySlice';
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill'; import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
import { article, category } from '../api'; import { article, category } from '../api';
import { importArtikelCsv } from '../utils/importArtikel'; import { importArtikelCsv } from '../utils/importArtikel';
@@ -37,15 +30,31 @@ function triggerDownload(blob, filename) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function selectCatSlice(s) {
return {
renameMode: s.renameMode,
categories: s.categories,
rootCategoryId: s.rootCategoryId,
};
}
export default function SectionLayout({ sectionPath }) { export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const dispatch = useDispatch();
const renameMode = useSelector(selectRenameMode);
const categories = useSelector(selectCategoryMap);
const rootCategoryId = useSelector(selectCategoryRootId);
const importInputRef = useRef(null); const importInputRef = useRef(null);
const [extrasAnchor, setExtrasAnchor] = useState(null); const [extrasAnchor, setExtrasAnchor] = useState(null);
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [catState, setCatState] = useState(() => selectCatSlice(categoryAutomat.state));
useEffect(() => {
// Automat supports callback subscriptions: (state, partial) => void
const unsub = categoryAutomat.subscribe((state) => {
setCatState(selectCatSlice(state));
}, selectCatSlice);
return unsub;
}, []);
const { renameMode, categories, rootCategoryId } = catState;
const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? ''; const title = NAV_ITEMS.find((item) => item.path === sectionPath)?.label ?? '';
const subNav = SUB_NAV[sectionPath]; const subNav = SUB_NAV[sectionPath];
const subSegment = resolveActiveSub(pathname, sectionPath); const subSegment = resolveActiveSub(pathname, sectionPath);
@@ -109,7 +118,7 @@ export default function SectionLayout({ sectionPath }) {
}, },
}); });
if (result.ok > 0 && rootCategoryId != null) { if (result.ok > 0 && rootCategoryId != null) {
await dispatch(loadCategoryTree()).unwrap(); await categoryAutomat.actions.loadTree();
} }
if (result.ok > 0) { if (result.ok > 0) {
enqueueSnackbar( enqueueSnackbar(
@@ -136,7 +145,7 @@ export default function SectionLayout({ sectionPath }) {
<ToggleElevatedPill <ToggleElevatedPill
label="Edit" label="Edit"
checked={renameMode} checked={renameMode}
onToggle={(checked) => dispatch(setRenameMode(checked))} onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
/> />
<ActionElevatedPill <ActionElevatedPill
label="Extras" label="Extras"

View File

@@ -6,7 +6,7 @@ import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
} from '../../pages/artikel/categoryRoute'; } from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat'; import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import ArticleTileTab from './ArticleTileTab'; import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile'; import ArticlePhotoTile from './ArticlePhotoTile';
@@ -26,17 +26,10 @@ class ArticleView extends PureComponent {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
const sku = props.sku; this.state = { article: getArticle(props.sku) };
const currentArticle = articleAutomat.state[sku] ?? null;
this.state = { article: currentArticle };
this.handleArticleDeleted = this.handleArticleDeleted.bind(this); this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
if (!currentArticle) { this.ensureArticle(props.sku);
articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
} }
componentDidMount() { componentDidMount() {
@@ -45,23 +38,24 @@ class ArticleView extends PureComponent {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) { if (prevProps.sku !== this.props.sku) {
const currentArticle = articleAutomat.state[this.props.sku] ?? null; this.setState({ article: getArticle(this.props.sku) });
this.setState({ article: currentArticle }); this.ensureArticle(this.props.sku);
if (!currentArticle) {
articleAutomat.actions.loadArticle(this.props.sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.subscribeToArticle(); this.subscribeToArticle();
} }
} }
/** Loads through the shared cache; resolves instantly when already cached. */
ensureArticle(sku) {
if (!sku) return;
artikelAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
subscribeToArticle() { subscribeToArticle() {
if (this.unsub) this.unsub(); if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku); const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({ this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: selector(state), article: selector(state),
})); }));
} }

View File

@@ -14,7 +14,7 @@ import CheckIcon from '@mui/icons-material/Check';
import FolderIcon from '@mui/icons-material/Folder'; import FolderIcon from '@mui/icons-material/Folder';
import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { getCategoryChildren, categoryDisplayName } from '../../store/categorySlice'; import { getCategoryChildren, categoryDisplayName } from '../../utils/categoryUtils';
function CategoryNode({ function CategoryNode({
categories, categories,
categoryId, categoryId,

View File

@@ -1,10 +1,6 @@
import { Component } from 'react'; import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles'; import { ActionTile } from '../tiles';
import { import categoryAutomat from '../../automats/categoryAutomat';
createArticles as createArticlesThunk,
createArticle as createArticleThunk,
} from '../../store/categorySlice';
import ArticleNameDialog from './ArticleNameDialog'; import ArticleNameDialog from './ArticleNameDialog';
class NewArticleTile extends Component { class NewArticleTile extends Component {
@@ -28,15 +24,16 @@ class NewArticleTile extends Component {
const { parentId } = this.props; const { parentId } = this.props;
const list = (names ?? []).filter(Boolean); const list = (names ?? []).filter(Boolean);
if (list.length <= 1) { if (list.length <= 1) {
const article = await this.props const article = await categoryAutomat.actions.createArticle({
.createArticle({ parentId, name: list[0] ?? '' }) parentId,
.unwrap(); name: list[0] ?? '',
});
if (article) { if (article) {
this.props.onOpenArticle?.(article); this.props.onOpenArticle?.(article);
} }
return; return;
} }
const created = await this.props.createArticles({ parentId, names: list }).unwrap(); const created = await categoryAutomat.actions.createArticles({ parentId, names: list });
if (created?.[0]) { if (created?.[0]) {
this.props.onOpenArticle?.(created[0]); this.props.onOpenArticle?.(created[0]);
} }
@@ -62,9 +59,4 @@ class NewArticleTile extends Component {
} }
} }
const mapDispatchToProps = { export default NewArticleTile;
createArticle: createArticleThunk,
createArticles: createArticlesThunk,
};
export default connect(null, mapDispatchToProps)(NewArticleTile);

View File

@@ -2,7 +2,7 @@ import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels'; import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { StorageManagerContext } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { getCategoryBreadcrumbs } from '../../../store/categorySlice'; import { getCategoryBreadcrumbs } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton } from '@mui/material'; import { Box, Chip, IconButton } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CategoryPickerDialog from '../CategoryPickerDialog'; import CategoryPickerDialog from '../CategoryPickerDialog';

View File

@@ -169,12 +169,7 @@ function priceDraftValid(draft) {
function companyFromStore(store) { function companyFromStore(store) {
if (!store) return null; if (!store) return null;
const companies = store.companies; return store.company ?? null;
const session = store.authMe;
if (companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
return companies.find((c) => c.id === companyId) ?? null;
} }
class PriceAmountField extends Component { class PriceAmountField extends Component {
@@ -395,18 +390,12 @@ export default class ArticleViewPrice extends PureComponent {
async load() { async load() {
const store = this.context; const store = this.context;
try { try {
const [unitList, companies, session] = await Promise.all([ const [unitList, company] = await Promise.all([
store.unitPromise, store.unitPromise,
store.companyPromise, store.companyPromise,
store.authMePromise,
]); ]);
const companyId = requireDefined(session.user?.selectedCompanyId, 'selectedCompanyId');
const current = requireDefined(
companies.find((c) => c.id === companyId),
'company',
);
if (!this._cancelled) { if (!this._cancelled) {
this.setState({ units: unitList, company: current }); this.setState({ units: unitList, company: company ?? null });
} }
} catch (err) { } catch (err) {
if (!this._cancelled) notifyError(err); if (!this._cancelled) notifyError(err);

View File

@@ -3,6 +3,7 @@ import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import { StorageManagerContext } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { artikelAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
export default class ArticleSettingsTab extends Component { export default class ArticleSettingsTab extends Component {
@@ -24,6 +25,7 @@ export default class ArticleSettingsTab extends Component {
this.setState({ busy: true }); this.setState({ busy: true });
try { try {
await this.context.article.delete(articleData.sku); await this.context.article.delete(articleData.sku);
artikelAutomat.actions.clearArticle(articleData.sku);
onArticleDeleted?.(articleData); onArticleDeleted?.(articleData);
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

View File

@@ -1,10 +1,6 @@
import { Component } from 'react'; import { Component } from 'react';
import { connect } from 'react-redux';
import { ActionTile } from '../tiles'; import { ActionTile } from '../tiles';
import { import categoryAutomat from '../../automats/categoryAutomat';
createCategories as createCategoriesThunk,
createCategory as createCategoryThunk,
} from '../../store/categorySlice';
import CategoryNameDialog from './CategoryNameDialog'; import CategoryNameDialog from './CategoryNameDialog';
class NewCategoryTile extends Component { class NewCategoryTile extends Component {
@@ -28,10 +24,10 @@ class NewCategoryTile extends Component {
const { parentId } = this.props; const { parentId } = this.props;
const list = (names ?? []).filter(Boolean); const list = (names ?? []).filter(Boolean);
if (list.length <= 1) { if (list.length <= 1) {
await this.props.createCategory({ parentId, name: list[0] ?? '' }).unwrap(); await categoryAutomat.actions.createCategory({ parentId, name: list[0] ?? '' });
return; return;
} }
await this.props.createCategories({ parentId, names: list }).unwrap(); await categoryAutomat.actions.createCategories({ parentId, names: list });
} }
render() { render() {
@@ -55,9 +51,4 @@ class NewCategoryTile extends Component {
} }
} }
const mapDispatchToProps = { export default NewCategoryTile;
createCategory: createCategoryThunk,
createCategories: createCategoriesThunk,
};
export default connect(null, mapDispatchToProps)(NewCategoryTile);

View File

@@ -8,8 +8,43 @@ import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import SyncIcon from '@mui/icons-material/Sync'; import SyncIcon from '@mui/icons-material/Sync';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles'; import { tileBaseSx, TILE_SIZE } from 'dashgui/tiles';
import { generateTextThumbnail } from '../../utils/textThumbnail';
export class Attachment extends PureComponent { export class Attachment extends PureComponent {
state = {
generatedThumbnail: null,
};
componentDidMount() {
this.ensureThumbnail();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.ensureThumbnail();
}
}
async ensureThumbnail() {
const { attachment } = this.props;
if (!attachment || attachment.thumbnail) return;
const isCsv = attachment.type === 'text/csv' || attachment.name?.toLowerCase().endsWith('.csv');
const isText = attachment.type?.startsWith('text/') || attachment.name?.toLowerCase().endsWith('.txt') || isCsv;
if (!isText) return;
const source = attachment.text || attachment.file;
if (!source) return;
try {
const thumb = await generateTextThumbnail(source);
if (thumb && this.props.attachment === attachment) {
this.setState({ generatedThumbnail: thumb });
}
} catch (err) {
console.error('Fehler beim Generieren des Text-Thumbnails:', err);
}
}
handleRescan = (e) => { handleRescan = (e) => {
e?.stopPropagation?.(); e?.stopPropagation?.();
const { attachment, onRescan } = this.props; const { attachment, onRescan } = this.props;
@@ -56,7 +91,7 @@ export class Attachment extends PureComponent {
render() { render() {
const { attachment, disabled, sx } = this.props; const { attachment, disabled, sx } = this.props;
const thumbnail = attachment?.thumbnail; const thumbnail = attachment?.thumbnail || this.state.generatedThumbnail;
const name = attachment?.name || 'Dokument'; const name = attachment?.name || 'Dokument';
const size = attachment?.size; const size = attachment?.size;
const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf'); const isPdf = attachment?.type === 'application/pdf' || name.toLowerCase().endsWith('.pdf');

View File

@@ -33,10 +33,11 @@ class ManifestLines extends PureComponent {
onSearchArticle, onSearchArticle,
onRemoveSupplierArticle, onRemoveSupplierArticle,
busySupplierArticleLineKeys = {}, busySupplierArticleLineKeys = {},
wide = 3,
} = this.props; } = this.props;
return ( return (
<PanelTile wide={3} minHeight={280} color={linesColor} title="Artikel"> <PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
<Stack spacing={1.5}> <Stack spacing={1.5}>
<TableContainer> <TableContainer>
<Table size="small" stickyHeader> <Table size="small" stickyHeader>

View File

@@ -0,0 +1,296 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline PDF viewer rendered as a panel tile, mirroring TextEditorPanel's
* "window" behaviour: it can sit above, right of, or below the article list,
* and stays sticky when docked to the right. Mount it only while a PDF
* attachment is selected; unmounting closes it.
*/
export class PdfViewerPanel extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
url: null,
loading: false,
error: null,
};
this.handleOpenExternal = this.handleOpenExternal.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadUrl();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.revokeUrl();
this.loadUrl();
}
}
componentWillUnmount() {
this.revokeUrl();
}
revokeUrl() {
if (this._objectUrl) {
URL.revokeObjectURL(this._objectUrl);
this._objectUrl = null;
}
}
async loadUrl() {
const { attachment, docId } = this.props;
if (!attachment) return;
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this._objectUrl = URL.createObjectURL(attachment.file);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
}
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true, error: null });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
this._objectUrl = URL.createObjectURL(blob);
this.setState({ url: this._objectUrl, loading: false, error: null });
return;
} catch (err) {
console.error('Fehler beim Laden der PDF-Datei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false, error: 'Fehler beim Laden der Datei' });
return;
}
}
this.setState({ url: null, loading: false, error: null });
}
handleOpenExternal() {
const { url } = this.state;
if (!url) return;
window.open(url, '_blank');
}
handleDownload() {
const { attachment } = this.props;
const { url } = this.state;
if (!url) return;
const link = document.createElement('a');
link.href = url;
link.download = attachment?.name || 'Dokument.pdf';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
render() {
const {
onClose,
attachment,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) return null;
const { url, loading, error } = this.state;
const filename = attachment?.name || 'Dokument.pdf';
return (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<PictureAsPdfIcon color="error" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
PDF-Vorschau
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
bgcolor: '#ffffff',
overflow: 'hidden',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
{error ? (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
}}
>
<Typography color="text.secondary">{error}</Typography>
</Box>
) : (
url && (
<Box
component="iframe"
title={filename}
src={url}
sx={{ flex: 1, width: '100%', border: 'none' }}
/>
)
)}
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<OpenInNewIcon />}
onClick={this.handleOpenExternal}
disabled={loading || !url}
>
In neuem Tab
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !url}
>
Download
</Button>
</Box>
<Button size="small" onClick={onClose}>
Schließen
</Button>
</Box>
</Box>
</PanelTile>
);
}
}
export default PdfViewerPanel;

View File

@@ -1,340 +0,0 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
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 TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import { enqueueSnackbar } from 'notistack';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
export class TextEditorDialog extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
content: '',
initialContent: '',
loading: false,
};
this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment || (this.props.open && !prevProps.open)) {
if (this.props.open) {
this.loadContent();
} else {
this.setState({ content: '', initialContent: '', loading: false });
}
}
}
async loadContent() {
const { attachment, open, docId, onContentLoad } = this.props;
if (!open || !attachment) return;
if (typeof attachment.text === 'string') {
this.setState({
content: attachment.text,
initialContent: attachment.text,
loading: false,
});
return;
}
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
loading: false,
});
}
handleChange(e) {
this.setState({ content: e.target.value });
}
handleSave() {
const { onSave, onClose } = this.props;
const { content } = this.state;
onSave?.(content);
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
onClose?.();
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.state.content);
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
} catch {
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
render() {
const { open, onClose, attachment, readOnly = false } = this.props;
if (!open) return null;
const { content, initialContent, loading } = this.state;
const filename = attachment?.name || 'Dokument.txt';
const isDirty = content !== initialContent;
const linesCount = content ? content.split('\n').length : 0;
const charCount = content ? content.length : 0;
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="lg"
fullWidth
slotProps={{
paper: {
sx: {
height: '85vh',
maxHeight: 850,
display: 'flex',
flexDirection: 'column',
},
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 2.5,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<TextSnippetOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{linesCount} Zeilen · {charCount} Zeichen
</Typography>
</Box>
{isDirty && (
<Chip label="Ungespeichert" size="small" color="warning" sx={{ height: 20, fontSize: '0.7rem' }} />
)}
</Box>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent
sx={{
p: 2,
flex: 1,
display: 'flex',
flexDirection: 'column',
bgcolor: '#fafafa',
position: 'relative',
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(250, 250, 250, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
inputProps={{
wrap: 'off',
spellCheck: false,
}}
slotProps={{
htmlInput: {
wrap: 'off',
spellCheck: false,
},
input: {
readOnly,
sx: {
height: '100%',
alignItems: 'flex-start',
fontFamily: '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.72rem',
lineHeight: 1.45,
tabSize: 4,
bgcolor: '#ffffff',
p: 1.5,
'& textarea': {
height: '100% !important',
whiteSpace: 'pre !important',
overflowWrap: 'normal !important',
wordWrap: 'normal !important',
wordBreak: 'normal !important',
overflowX: 'auto !important',
overflowY: 'auto !important',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
},
},
},
}}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
'& .MuiInputBase-root': {
flex: 1,
},
}}
/>
</DialogContent>
<DialogActions
sx={{
px: 2.5,
py: 1.5,
borderTop: '1px solid',
borderColor: 'divider',
display: 'flex',
justifyContent: 'space-between',
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</Button>
{!readOnly && (
<Button
size="small"
variant="contained"
startIcon={<SaveOutlinedIcon />}
onClick={this.handleSave}
disabled={loading || !isDirty}
>
Speichern
</Button>
)}
</Box>
</DialogActions>
</Dialog>
);
}
}
export default TextEditorDialog;

View File

@@ -0,0 +1,381 @@
import { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import TextSnippetOutlinedIcon from '@mui/icons-material/TextSnippetOutlined';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined';
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import { enqueueSnackbar } from 'notistack';
import { PanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../api/storageManagerContext';
import { delivery as deliveryApi } from '../../api';
/**
* Inline text-file viewer/editor rendered as a full-width panel tile below the
* manifest header ("Kopfdaten"). Mount it only while a text attachment is
* selected; unmounting closes it.
*/
export class TextEditorPanel extends PureComponent {
static contextType = StorageManagerContext;
constructor(props) {
super(props);
this.state = {
content: '',
initialContent: '',
loading: false,
};
this.handleChange = this.handleChange.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleCopy = this.handleCopy.bind(this);
this.handleDownload = this.handleDownload.bind(this);
}
componentDidMount() {
this.loadContent();
}
componentDidUpdate(prevProps) {
if (this.props.attachment !== prevProps.attachment) {
this.loadContent();
}
}
async loadContent() {
const { attachment, docId, onContentLoad } = this.props;
if (!attachment) return;
if (typeof attachment.text === 'string') {
this.setState({
content: attachment.text,
initialContent: attachment.text,
loading: false,
});
return;
}
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
this.setState({ loading: true });
try {
const text = await attachment.file.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
return;
} catch (err) {
console.error('Fehler beim Lesen der Textdatei:', err);
this.setState({ loading: false });
}
}
// Attachment has no text and empty file — load from API (GET /delivery/{id}/file/{fileId})
const effectiveDocId = docId ?? attachment.manifestId;
const fileId = attachment.fileId ?? attachment.id;
if (effectiveDocId != null && fileId != null) {
this.setState({ loading: true });
try {
const getFile =
this.context?.delivery?.getFile?.bind(this.context.delivery) ||
deliveryApi.getFile;
const blob = await getFile(effectiveDocId, fileId);
const text = await blob.text();
this.setState({
content: text,
initialContent: text,
loading: false,
});
onContentLoad?.(attachment, text, blob);
return;
} catch (err) {
console.error('Fehler beim Laden der Textdatei:', err);
enqueueSnackbar('Fehler beim Laden der Datei', { variant: 'error' });
this.setState({ loading: false });
return;
}
}
this.setState({
content: '',
initialContent: '',
loading: false,
});
}
handleChange(e) {
this.setState({ content: e.target.value });
}
handleSave() {
const { onSave, onClose } = this.props;
const { content } = this.state;
onSave?.(content);
enqueueSnackbar('Textdatei gespeichert', { variant: 'success' });
onClose?.();
}
async handleCopy() {
try {
await navigator.clipboard.writeText(this.state.content);
enqueueSnackbar('In die Zwischenablage kopiert', { variant: 'info' });
} catch {
enqueueSnackbar('Kopieren fehlgeschlagen', { variant: 'error' });
}
}
handleDownload() {
const { attachment } = this.props;
const { content } = this.state;
if (!content) return;
const filename = attachment?.name || 'Dokument.txt';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
render() {
const {
onClose,
attachment,
readOnly = false,
wide = 3,
position = 'top',
onPositionChange,
height = '60vh',
} = this.props;
if (!attachment) return null;
const { content, initialContent, loading } = this.state;
const filename = attachment?.name || 'Dokument.txt';
const isDirty = content !== initialContent;
const linesCount = content ? content.split('\n').length : 0;
const charCount = content ? content.length : 0;
return (
<PanelTile
wide={wide}
minHeight={320}
color="#f8fafc"
sx={{ height, minHeight: 320 }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
mb: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<TextSnippetOutlinedIcon color="primary" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" fontWeight={600} noWrap>
{filename}
</Typography>
<Typography variant="caption" color="text.secondary">
{linesCount} Zeilen · {charCount} Zeichen
</Typography>
</Box>
{isDirty && (
<Chip
label="Ungespeichert"
size="small"
color="warning"
sx={{ height: 20, fontSize: '0.7rem' }}
/>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title="Über der Artikelliste">
<IconButton
size="small"
color={position === 'top' ? 'primary' : 'default'}
aria-label="Über der Artikelliste"
onClick={() => onPositionChange?.('top')}
>
<VerticalAlignTopIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Rechts neben der Artikelliste">
<IconButton
size="small"
color={position === 'right' ? 'primary' : 'default'}
aria-label="Rechts neben der Artikelliste"
onClick={() => onPositionChange?.('right')}
>
<ViewSidebarIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Unter der Artikelliste">
<IconButton
size="small"
color={position === 'below' ? 'primary' : 'default'}
aria-label="Unter der Artikelliste"
onClick={() => onPositionChange?.('below')}
>
<VerticalAlignBottomIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} sx={{ color: 'text.secondary' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
</Box>
<Box
sx={{
position: 'relative',
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
{loading && (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'rgba(248, 250, 252, 0.7)',
zIndex: 2,
}}
>
<CircularProgress size={36} />
</Box>
)}
<TextField
multiline
fullWidth
value={content}
disabled={loading}
onChange={this.handleChange}
slotProps={{
htmlInput: {
wrap: 'off',
spellCheck: false,
},
input: {
readOnly,
sx: {
height: '100%',
alignItems: 'flex-start',
fontFamily:
'"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.72rem',
lineHeight: 1.45,
tabSize: 4,
bgcolor: '#ffffff',
p: 1.5,
'& textarea': {
height: '100% !important',
whiteSpace: 'pre !important',
overflowWrap: 'normal !important',
wordWrap: 'normal !important',
wordBreak: 'normal !important',
overflowX: 'auto !important',
overflowY: 'auto !important',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
},
},
},
}}
sx={{
flex: 1,
display: 'flex',
flexDirection: 'column',
'& .MuiInputBase-root': {
flex: 1,
},
}}
/>
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
gap: 1,
mt: 1.5,
flexShrink: 0,
}}
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopyIcon />}
onClick={this.handleCopy}
disabled={loading || !content}
>
Kopieren
</Button>
<Button
size="small"
variant="outlined"
startIcon={<DownloadOutlinedIcon />}
onClick={this.handleDownload}
disabled={loading || !content}
>
Download
</Button>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button size="small" onClick={onClose}>
{readOnly ? 'Schließen' : 'Abbrechen'}
</Button>
{!readOnly && (
<Button
size="small"
variant="contained"
startIcon={<SaveOutlinedIcon />}
onClick={this.handleSave}
disabled={loading || !isDirty}
>
Speichern
</Button>
)}
</Box>
</Box>
</Box>
</PanelTile>
);
}
}
export default TextEditorPanel;

View File

@@ -5,7 +5,8 @@ export { default as ManifestLines } from './ManifestLines';
export { default as ManifestFormLine } from './ManifestFormLine'; export { default as ManifestFormLine } from './ManifestFormLine';
export { default as Attachment } from './Attachment'; export { default as Attachment } from './Attachment';
export { default as AttachmentList } from './AttachmentList'; export { default as AttachmentList } from './AttachmentList';
export { default as TextEditorDialog } from './TextEditorDialog'; export { default as TextEditorPanel } from './TextEditorPanel';
export { default as PdfViewerPanel } from './PdfViewerPanel';
export { default as ArticleSearchDialog } from './ArticleSearchDialog'; export { default as ArticleSearchDialog } from './ArticleSearchDialog';
export { withManifestId } from './withManifestId'; export { withManifestId } from './withManifestId';
export { export {

View File

@@ -341,10 +341,18 @@ export function collectFilesForUpload(attachments = [], { onlyNew = false } = {}
} }
} }
let thumbavif = null;
if (attachment.thumbnail) {
thumbavif = attachment.thumbnail.includes(',')
? attachment.thumbnail.split(',')[1]
: attachment.thumbnail;
}
files.push({ files.push({
file: fileToUpload, file: fileToUpload,
name: attachment.name || fileToUpload.name, name: attachment.name || fileToUpload.name,
mimetype: attachment.type || fileToUpload.type, mimetype: attachment.type || fileToUpload.type,
thumbavif,
}); });
} }
return files; return files;
@@ -389,6 +397,10 @@ export async function buildManifestBody(
const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate }); const filesToUpload = collectFilesForUpload(form.attachments, { onlyNew: isUpdate });
if (filesToUpload.length > 0) { if (filesToUpload.length > 0) {
const formData = new FormData(); const formData = new FormData();
body.filesMeta = filesToUpload.map((item) => ({
name: item.name,
thumbavif: item.thumbavif ?? null,
}));
formData.append('data', JSON.stringify(body)); formData.append('data', JSON.stringify(body));
for (const item of filesToUpload) { for (const item of filesToUpload) {
formData.append('files', item.file, item.name); formData.append('files', item.file, item.name);

View File

@@ -1,9 +1,8 @@
import { PureComponent } from 'react'; import { PureComponent, createRef } from 'react';
import Badge from '@mui/material/Badge'; import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase'; import ButtonBase from '@mui/material/ButtonBase';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Fab from '@mui/material/Fab'; import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
@@ -12,6 +11,7 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import BugReportIcon from '@mui/icons-material/BugReport'; import BugReportIcon from '@mui/icons-material/BugReport';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ClearIcon from '@mui/icons-material/Clear';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
@@ -162,11 +162,175 @@ export function formatState(value) {
} }
} }
/** Maximum number of retained action log entries (ring buffer). */
const MAX_LOG = 300;
/** Marks action functions that already carry the logging proxy. */
const LOGGED = Symbol('automatInspectorLogged');
/** Compact single-line rendering limit for args/results in the log. */
const LOG_VALUE_LIMIT = 240;
/** /**
* Floating dev button plus overlay listing all registered Automats with live * Ring buffer of captured action calls, oldest first. Kept at module scope so
* state, readiness flags, subscriber counts and available actions. * entries survive component re-renders (and are shared by every inspector).
* *
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number}>} * @type {Array<{id: number, time: Date, automat: string, action: string, args: string, status: string, duration: number|null, result: string, error: string|null}>}
*/
const actionLog = [];
/** Monotonic id source for log entries. */
let logSeq = 0;
/** @type {Set<() => void>} Listeners notified when the log buffer changes. */
const logListeners = new Set();
/** Notifies every log listener, swallowing individual errors. */
function notifyLog() {
for (const listener of logListeners) {
try {
listener();
} catch {
/* listeners are dev-only; never let one break logging */
}
}
}
/**
* Appends an entry to the ring buffer and notifies listeners.
*
* @param {object} entry Log entry.
*/
function pushLog(entry) {
actionLog.push(entry);
if (actionLog.length > MAX_LOG) actionLog.splice(0, actionLog.length - MAX_LOG);
notifyLog();
}
/**
* Renders a value as a compact single-line string for the log.
*
* @param {*} value Value to summarize.
* @param {number} [limit] Maximum length before truncation.
* @returns {string} Compact representation.
*/
function compact(value, limit = LOG_VALUE_LIMIT) {
const text = formatState(value).replace(/\s+/g, ' ').trim();
return text.length > limit ? `${text.slice(0, limit)} …` : text;
}
/**
* Formats a timestamp as `HH:MM:SS.mmm`.
*
* @param {Date} date Timestamp.
* @returns {string} Formatted time.
*/
function formatTime(date) {
const pad = (n, len = 2) => String(n).padStart(len, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
}
/**
* Wraps every action of an automat with a logging proxy that records call
* arguments, duration, return value and errors. Idempotent: already wrapped
* actions are left untouched, and the original function is always invoked with
* the caller's `this` and arguments.
*
* @param {import('react-automat').Automat} automat Automat instance.
*/
function installActionLogging(automat) {
const actions = automat.actions;
if (!actions) return;
for (const key of Object.keys(actions)) {
const original = actions[key];
if (typeof original !== 'function' || original[LOGGED]) continue;
const wrapped = function loggedAction(...args) {
const started = performance.now();
const entry = {
id: ++logSeq,
time: new Date(),
automat: automat.name ?? '(anonym)',
action: key,
args: compact(args),
status: 'running',
duration: null,
result: '',
error: null,
};
pushLog(entry);
const settle = (status, payload, err) => {
entry.status = status;
entry.duration = performance.now() - started;
if (status === 'error') entry.error = err?.message ?? String(err);
else entry.result = compact(payload);
notifyLog();
};
let result;
try {
result = original.apply(this, args);
} catch (err) {
settle('error', undefined, err);
throw err;
}
if (result && typeof result.then === 'function') {
return result.then(
(value) => {
settle('ok', value);
return value;
},
(err) => {
settle('error', undefined, err);
throw err;
},
);
}
settle('ok', result);
return result;
};
try {
Object.defineProperty(wrapped, 'name', { value: key });
} catch {
/* name is cosmetic */
}
wrapped[LOGGED] = true;
actions[key] = wrapped;
}
}
/** Marks the registry Map once its `set` has been wrapped. */
const WATCHED = Symbol('automatInspectorWatched');
/**
* Wraps the live registry's `set` so automats created after mount get their
* actions instrumented immediately instead of on the next poll tick.
*/
function watchRegistry() {
const registry = window.__AUTOMATS__;
if (!(registry instanceof Map) || registry[WATCHED]) return;
const originalSet = registry.set.bind(registry);
registry.set = (name, automat) => {
const result = originalSet(name, automat);
installActionLogging(automat);
return result;
};
registry[WATCHED] = true;
}
/**
* Floating dev button plus a docked side panel listing all registered Automats
* with live state, readiness flags, subscriber counts and available actions.
*
* The panel is rendered in-flow (not as an overlay), so the page stays fully
* interactive while the action log is watched live. It relies on being placed
* as a flex sibling of the app content (see `main.jsx`).
*
* @augments {PureComponent<{}, {open: boolean, names: Array<string>, expanded: Object<string, boolean>, query: string, revision: number, logOpen: boolean, log: Array<object>}>}
*/ */
class AutomatInspector extends PureComponent { class AutomatInspector extends PureComponent {
constructor(props) { constructor(props) {
@@ -177,11 +341,15 @@ class AutomatInspector extends PureComponent {
expanded: {}, expanded: {},
query: '', query: '',
revision: 0, revision: 0,
logOpen: false,
log: [],
}; };
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */ /** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
this.unsubs = new Map(); this.unsubs = new Map();
this.frame = null; this.frame = null;
this.logFrame = null;
this.logRef = createRef();
this.handleToggle = this.handleToggle.bind(this); this.handleToggle = this.handleToggle.bind(this);
this.handleClose = this.handleClose.bind(this); this.handleClose = this.handleClose.bind(this);
@@ -193,24 +361,35 @@ class AutomatInspector extends PureComponent {
this.handleKeyDown = this.handleKeyDown.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this);
this.syncRegistry = this.syncRegistry.bind(this); this.syncRegistry = this.syncRegistry.bind(this);
this.scheduleUpdate = this.scheduleUpdate.bind(this); this.scheduleUpdate = this.scheduleUpdate.bind(this);
this.scheduleLogUpdate = this.scheduleLogUpdate.bind(this);
this.handleToggleLog = this.handleToggleLog.bind(this);
this.handleClearLog = this.handleClearLog.bind(this);
this.handleCopyLog = this.handleCopyLog.bind(this);
} }
componentDidMount() { componentDidMount() {
this.syncRegistry(); this.syncRegistry();
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS); this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
window.addEventListener('keydown', this.handleKeyDown); window.addEventListener('keydown', this.handleKeyDown);
logListeners.add(this.scheduleLogUpdate);
this.scheduleLogUpdate();
} }
componentWillUnmount() { componentWillUnmount() {
window.clearInterval(this.registryTimer); window.clearInterval(this.registryTimer);
window.removeEventListener('keydown', this.handleKeyDown); window.removeEventListener('keydown', this.handleKeyDown);
logListeners.delete(this.scheduleLogUpdate);
this.unsubscribeAll(); this.unsubscribeAll();
if (this.frame) cancelAnimationFrame(this.frame); if (this.frame) cancelAnimationFrame(this.frame);
if (this.logFrame) cancelAnimationFrame(this.logFrame);
} }
/** Mirrors the registry keys into state and keeps subscriptions in sync. */ /** Mirrors the registry keys into state and keeps subscriptions in sync. */
syncRegistry() { syncRegistry() {
const names = Array.from(getRegistry().keys()).sort((a, b) => a.localeCompare(b)); watchRegistry();
const registry = getRegistry();
for (const automat of registry.values()) installActionLogging(automat);
const names = Array.from(registry.keys()).sort((a, b) => a.localeCompare(b));
const changed = const changed =
names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]); names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]);
if (changed) this.setState({ names }); if (changed) this.setState({ names });
@@ -249,6 +428,58 @@ class AutomatInspector extends PureComponent {
}); });
} }
/**
* Copies the module-level log ring buffer into state, coalesced per frame so
* bursts of action calls do not cause a render each. Skipped while the drawer
* is closed; opening it re-syncs via `handleToggle`.
*/
scheduleLogUpdate() {
if (!this.state.open || this.logFrame) return;
this.logFrame = requestAnimationFrame(() => {
this.logFrame = null;
this.setState({ log: actionLog.slice() });
});
}
componentDidUpdate(prevProps, prevState) {
const grew = this.state.log.length > prevState.log.length;
const opened = this.state.logOpen && !prevState.logOpen;
if (this.state.logOpen && (grew || opened)) {
const node = this.logRef.current;
if (node) node.scrollTop = node.scrollHeight;
}
}
handleToggleLog() {
this.setState((prev) => ({ logOpen: !prev.logOpen, log: actionLog.slice() }));
}
handleClearLog() {
actionLog.length = 0;
notifyLog();
}
/** Copies the whole log buffer to the clipboard as plain text. */
async handleCopyLog() {
try {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API nicht verfügbar (sichere Origin erforderlich).');
}
const text = actionLog
.map(
(entry) =>
`${formatTime(entry.time)} ${entry.automat}.${entry.action}(${entry.args})` +
`${entry.status === 'error' ? ` ! ${entry.error}` : ` → ${entry.result}`}` +
`${entry.duration != null ? ` [${Math.round(entry.duration)}ms]` : ''}`,
)
.join('\n');
await navigator.clipboard.writeText(text);
enqueueSnackbar(`Log kopiert (${actionLog.length} Einträge)`, { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
handleKeyDown(event) { handleKeyDown(event) {
if (!event.altKey || !event.shiftKey) return; if (!event.altKey || !event.shiftKey) return;
if (event.key?.toLowerCase() !== 'a') return; if (event.key?.toLowerCase() !== 'a') return;
@@ -262,6 +493,7 @@ class AutomatInspector extends PureComponent {
if (open) { if (open) {
this.syncRegistry(); this.syncRegistry();
this.subscribeToAll(); this.subscribeToAll();
this.scheduleLogUpdate();
} else { } else {
this.unsubscribeAll(); this.unsubscribeAll();
} }
@@ -434,8 +666,116 @@ class AutomatInspector extends PureComponent {
); );
} }
render() { /**
const { open, names, expanded, query } = this.state; * Renders one captured action call as a single log line.
*
* @param {object} entry Log entry.
* @returns {React.ReactElement} Log line.
*/
renderLogLine(entry) {
const color =
entry.status === 'error' ? 'error.main' : entry.status === 'running' ? 'text.secondary' : 'success.main';
return (
<Box key={entry.id} component="div">
<Box component="span" sx={{ color: 'text.disabled' }}>
{formatTime(entry.time)}{' '}
</Box>
<Box component="span" sx={{ fontWeight: 600 }}>
{entry.automat}.{entry.action}
</Box>
<Box component="span" sx={{ color: 'text.secondary' }}>
({entry.args})
</Box>
{entry.status === 'error' ? (
<Box component="span" sx={{ color: 'error.main' }}>
{' '}
✗ {entry.error}
</Box>
) : entry.status === 'ok' ? (
<Box component="span" sx={{ color: 'text.disabled' }}>
{' '}
→ {entry.result}
</Box>
) : null}{' '}
<Box component="span" sx={{ color, ml: 0.5 }}>
{entry.status === 'running'
? '…'
: `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`}
</Box>
</Box>
);
}
/** Bottom-docked, collapsible log of every captured automat action call. */
renderLog() {
const { logOpen, log } = this.state;
const errorCount = log.filter((entry) => entry.status === 'error').length;
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 }}>
<ButtonBase
component="div"
onClick={this.handleToggleLog}
sx={{ flexGrow: 1, display: 'flex', alignItems: 'center', gap: 0.5, justifyContent: 'flex-start', py: 0.5 }}
>
{logOpen ? <ExpandMoreIcon fontSize="small" /> : <ChevronRightIcon fontSize="small" />}
<Typography variant="caption" sx={{ fontWeight: 600 }}>
Action-Log
</Typography>
<Chip size="small" variant="outlined" label={log.length} />
{errorCount > 0 ? <Chip size="small" color="error" label={`${errorCount} Fehler`} /> : null}
</ButtonBase>
<Tooltip title="Log als Text kopieren">
<span>
<IconButton size="small" onClick={this.handleCopyLog} disabled={log.length === 0}>
<ContentCopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Log leeren">
<span>
<IconButton size="small" onClick={this.handleClearLog} disabled={log.length === 0}>
<ClearIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
{logOpen ? (
<Box
ref={this.logRef}
component="div"
sx={{
m: 0,
mx: 1.5,
mb: 1,
p: 1,
borderRadius: 1,
bgcolor: 'action.hover',
fontFamily: 'monospace',
fontSize: 11,
lineHeight: 1.6,
maxHeight: '40vh',
overflow: 'auto',
}}
>
{log.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Noch keine Action-Aufrufe protokolliert.
</Typography>
) : (
log.map((entry) => this.renderLogLine(entry))
)}
</Box>
) : null}
</Box>
);
}
/** Docked side panel with the automat list, filter and action log. */
renderPanel() {
const { names, expanded, query } = this.state;
const registry = getRegistry(); const registry = getRegistry();
const needle = query.trim().toLowerCase(); const needle = query.trim().toLowerCase();
const entries = names const entries = names
@@ -444,91 +784,108 @@ class AutomatInspector extends PureComponent {
const allExpanded = names.length > 0 && names.every((name) => expanded[name]); const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
return ( return (
<> <Box
<Badge component="aside"
badgeContent={names.length} aria-label="Automat-Inspector"
color="secondary" sx={{
overlap="circular" width: { xs: '100%', sm: 560 },
invisible={names.length === 0} maxWidth: '100%',
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }} flexShrink: 0,
display: 'flex',
flexDirection: 'column',
position: 'sticky',
top: 0,
alignSelf: 'flex-start',
height: '100vh',
borderLeft: '1px solid',
borderColor: 'divider',
bgcolor: 'background.paper',
zIndex: (theme) => theme.zIndex.appBar,
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
> >
<Tooltip title="Automat-Inspector (Alt+Shift+A)"> <BugReportIcon fontSize="small" color="primary" />
<Fab <Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
size="small" Automat-Inspector
color="primary" </Typography>
aria-label="Automat-Inspector umschalten" <Chip size="small" label={`${names.length} registriert`} />
aria-pressed={open} <Tooltip title="Registry neu einlesen">
onClick={this.handleToggle} <IconButton size="small" onClick={this.handleRefresh}>
> <RefreshIcon fontSize="small" />
<BugReportIcon fontSize="small" /> </IconButton>
</Fab>
</Tooltip> </Tooltip>
</Badge> <Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
<IconButton size="small" onClick={this.handleToggleAll}>
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Drawer <Box sx={{ px: 2, py: 1 }}>
anchor="right" <TextField
open={open} size="small"
onClose={this.handleClose} fullWidth
slotProps={{ placeholder="Name filtern …"
transition: { timeout: 0 }, value={query}
paper: { onChange={this.handleQueryChange}
sx: { />
width: { xs: '100%', sm: 560 }, </Box>
maxWidth: '100%',
display: 'flex', <Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
flexDirection: 'column', {entries.length === 0 ? (
}, <Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
}, {names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
>
<BugReportIcon fontSize="small" color="primary" />
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
Automat-Inspector
</Typography> </Typography>
<Chip size="small" label={`${names.length} registriert`} /> ) : (
<Tooltip title="Registry neu einlesen"> <Stack spacing={1}>
<IconButton size="small" onClick={this.handleRefresh}> {entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
<RefreshIcon fontSize="small" /> </Stack>
</IconButton> )}
</Tooltip> </Box>
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
<IconButton size="small" onClick={this.handleToggleAll}>
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Schließen">
<IconButton size="small" onClick={this.handleClose}>
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ px: 2, py: 1 }}> {this.renderLog()}
<TextField </Box>
size="small" );
fullWidth }
placeholder="Name filtern …"
value={query}
onChange={this.handleQueryChange}
/>
</Box>
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}> render() {
{entries.length === 0 ? ( const { open, names } = this.state;
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'} return (
</Typography> <>
) : ( {open ? null : (
<Stack spacing={1}>{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}</Stack> <Badge
)} badgeContent={names.length}
</Box> color="secondary"
</Drawer> overlap="circular"
invisible={names.length === 0}
sx={{ position: 'fixed', right: 16, bottom: 16, zIndex: (theme) => theme.zIndex.modal + 1 }}
>
<Tooltip title="Automat-Inspector (Alt+Shift+A)">
<Fab
size="small"
color="primary"
aria-label="Automat-Inspector umschalten"
aria-pressed={open}
onClick={this.handleToggle}
>
<BugReportIcon fontSize="small" />
</Fab>
</Tooltip>
</Badge>
)}
{open ? this.renderPanel() : null}
</> </>
); );
} }

View File

@@ -1,13 +1,12 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
import { SnackbarProvider } from 'notistack'; import { SnackbarProvider } from 'notistack';
import AuthBootstrap from './components/AuthBootstrap'; import AuthBootstrap from './components/AuthBootstrap';
import { StorageManagerProvider } from './api/storageManagerContext'; import { StorageManagerProvider } from './api/storageManagerContext';
import App from './App'; import App from './App';
import { store } from './store';
import { getActiveTheme } from './theme'; import { getActiveTheme } from './theme';
import './automats/supplierAutomat'; import './automats/supplierAutomat';
import './index.css'; import './index.css';
@@ -33,25 +32,29 @@ if (import.meta.env.DEV) {
} }
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<Provider store={store}> <ThemeProvider theme={getActiveTheme()}>
<ThemeProvider theme={getActiveTheme()}> <CssBaseline />
<CssBaseline /> <SnackbarProvider
<SnackbarProvider maxSnack={3}
maxSnack={3} autoHideDuration={2000}
autoHideDuration={2000} anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} >
> <StorageManagerProvider>
<StorageManagerProvider> <AuthBootstrap>
<AuthBootstrap> {/* Flex row so the dev inspector docks beside the app instead of
<App /> overlaying it; the app content shrinks while the panel is open. */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', minHeight: '100vh' }}>
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
<App />
</Box>
{AutomatInspector ? ( {AutomatInspector ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<AutomatInspector /> <AutomatInspector />
</Suspense> </Suspense>
) : null} ) : null}
</AuthBootstrap> </Box>
</StorageManagerProvider> </AuthBootstrap>
</SnackbarProvider> </StorageManagerProvider>
</ThemeProvider> </SnackbarProvider>
</Provider>, </ThemeProvider>,
); );

View File

@@ -3,7 +3,7 @@ import {
categorySlug, categorySlug,
getCategoryBreadcrumbs, getCategoryBreadcrumbs,
getCategoryChildren, getCategoryChildren,
} from '../../store/categorySlice'; } from '../../utils/categoryUtils';
export { ROOT_CATEGORY_NAME, categorySlug }; export { ROOT_CATEGORY_NAME, categorySlug };

View File

@@ -1,5 +1,4 @@
import { Component, createRef } from 'react'; import { Component, PureComponent, createRef } from 'react';
import { connect } from 'react-redux';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
@@ -19,41 +18,49 @@ import {
import { TILE_SIZE } from 'dashgui/tiles'; import { TILE_SIZE } from 'dashgui/tiles';
import withCategoryRoute from './withCategoryRoute'; import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute'; import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice';
import { selectPosterminals, loadPosterminals } from '../../store/posterminalSlice';
import { import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
resolveCategoryIdFromSlugPath, resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME, ROOT_CATEGORY_NAME,
} from './categoryRoute'; } from './categoryRoute';
import { import categoryAutomat from '../../automats/categoryAutomat';
categoryDisplayName, import posterminalAutomat from '../../automats/posterminalAutomat';
deleteCategory, import companyAutomat from '../../automats/companyAutomat';
moveCategory,
loadCategoryArticles,
loadCategoryTree,
navigateToCategory,
renameCategory,
reorderCategories,
selectCanNavigateBack,
selectCategoryArticles,
selectCategoryArticlesStatus,
selectCategoryBreadcrumbs,
selectCategoryMap,
selectCategoryError,
selectCategoryTreeStatus,
selectChildCategories,
selectRenameMode,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
} from '../../store/categorySlice';
import {
enableTerminalCategory,
disableTerminalCategory,
} from '../../store/posterminalSlice';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku } from '../../automats';
import { categoryDisplayName, getCategoryBreadcrumbs, getCategoryChildren } from '../../utils/categoryUtils';
// ─── Helper selectors for this page ────────────────────────────────────────────
function selectFromCategory(s) {
const { categories, rootCategoryId, currentCategoryId, articles, articlesTotal, renameMode, treeStatus, articlesStatus, error } = s;
const breadcrumbs = getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId);
const childCategories = getCategoryChildren(categories, currentCategoryId);
const canGoBack =
currentCategoryId != null &&
rootCategoryId != null &&
String(currentCategoryId) !== String(rootCategoryId) &&
categories?.[String(currentCategoryId)]?.parentId != null &&
categories[String(currentCategoryId)].parentId !== 0;
return {
categories,
rootCategoryId,
treeStatus,
articlesStatus,
error,
currentCategoryId,
breadcrumbs,
childCategories,
articles,
articlesTotal,
canGoBack,
renameMode,
};
}
function selectTerminals(s) {
return { terminals: s.terminals };
}
const gridSx = { const gridSx = {
display: 'grid', display: 'grid',
@@ -61,10 +68,15 @@ const gridSx = {
gap: 2, gap: 2,
}; };
class ArtikelKategorienPage extends Component { class ArtikelKategorienPage extends PureComponent {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { drafts: {} }; this.state = {
drafts: {},
...selectFromCategory(categoryAutomat.state),
...selectTerminals(posterminalAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
};
this.gridRef = createRef(); this.gridRef = createRef();
this.sortableModule = null; this.sortableModule = null;
this.droppableModule = null; this.droppableModule = null;
@@ -74,6 +86,11 @@ class ArtikelKategorienPage extends Component {
componentDidMount() { componentDidMount() {
setLastArticleSku(null); setLastArticleSku(null);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
companyId: s.current?.id ?? null,
}));
this.ensureTreeLoaded(); this.ensureTreeLoaded();
this.ensurePosterminalsLoaded(); this.ensurePosterminalsLoaded();
this.syncCategoryFromUrl(); this.syncCategoryFromUrl();
@@ -82,37 +99,36 @@ class ArtikelKategorienPage extends Component {
this.syncDroppable(); this.syncDroppable();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevState) {
if (this.props.companyId !== prevProps.companyId) { // Note: PureComponent — comparisons use this.state not this.props
if (this.state.companyId !== prevState.companyId) {
this.ensureTreeLoaded(); this.ensureTreeLoaded();
} }
if (this.state.treeStatus === 'succeeded' && prevState.treeStatus !== 'succeeded') {
if (
this.props.treeStatus === 'succeeded' &&
prevProps.treeStatus !== 'succeeded'
) {
this.syncCategoryFromUrl(); this.syncCategoryFromUrl();
this.ensureArticlesLoaded(); this.ensureArticlesLoaded();
} }
if ( if (
this.props.treeStatus === 'succeeded' && this.state.treeStatus === 'succeeded' &&
this.props.categorySlugPath !== prevProps.categorySlugPath this.props.categorySlugPath !== (this._prevSlugPath ?? this.props.categorySlugPath)
) { ) {
this.syncCategoryFromUrl(); this.syncCategoryFromUrl();
} }
this._prevSlugPath = this.props.categorySlugPath;
if (this.props.currentCategoryId !== prevProps.currentCategoryId) { if (this.state.currentCategoryId !== prevState.currentCategoryId) {
this.ensureArticlesLoaded(); this.ensureArticlesLoaded();
} }
if (this.state.renameMode !== prevState.renameMode) {
if (this.props.renameMode !== prevProps.renameMode) {
this.syncSortable(); this.syncSortable();
this.syncDroppable(); this.syncDroppable();
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._unsubCat?.();
this._unsubTerm?.();
this._unsubCompany?.();
this.sortableModule?.destroy(); this.sortableModule?.destroy();
this.droppableModule?.destroy(); this.droppableModule?.destroy();
} }
@@ -122,19 +138,21 @@ class ArtikelKategorienPage extends Component {
if (!el) return; if (!el) return;
if (!this.sortableModule) { if (!this.sortableModule) {
this.sortableModule = new CategorySortable(el, { this.sortableModule = new CategorySortable(el, {
onReorder: this.props.reorderCategories, onReorder: ({ categoryIds }) =>
categoryAutomat.actions.reorderCategories({ categoryIds }),
}); });
} }
if (!this.droppableModule) { if (!this.droppableModule) {
this.droppableModule = new CategoryDroppable(el, { this.droppableModule = new CategoryDroppable(el, {
onMove: this.props.moveCategory, onMove: ({ categoryId, parentId }) =>
categoryAutomat.actions.moveCategory({ categoryId, parentId }),
}); });
} }
}; };
syncSortable = () => { syncSortable = () => {
this.#ensureModules(); this.#ensureModules();
if (this.props.renameMode) { if (this.state.renameMode) {
this.sortableModule?.destroy(); this.sortableModule?.destroy();
this.sortableModule?.activate(); this.sortableModule?.activate();
} else { } else {
@@ -144,7 +162,7 @@ class ArtikelKategorienPage extends Component {
syncDroppable = () => { syncDroppable = () => {
this.#ensureModules(); this.#ensureModules();
if (this.props.renameMode) { if (this.state.renameMode) {
this.droppableModule?.destroy(); this.droppableModule?.destroy();
this.droppableModule?.activate(); this.droppableModule?.activate();
} else { } else {
@@ -157,33 +175,32 @@ class ArtikelKategorienPage extends Component {
}; };
ensureTreeLoaded = () => { ensureTreeLoaded = () => {
const { companyId, treeStatus } = this.props; const { companyId, treeStatus } = this.state;
if (!companyId || treeStatus !== 'idle') return; if (!companyId || treeStatus !== 'idle') return;
this.props.loadCategoryTree(); void categoryAutomat.actions.loadTree();
}; };
ensurePosterminalsLoaded = () => { ensurePosterminalsLoaded = () => {
const { companyId } = this.props; const { companyId } = this.state;
if (!companyId) return; if (!companyId) return;
this.props.loadPosterminals(); void posterminalAutomat.actions.load();
}; };
ensureArticlesLoaded = () => { ensureArticlesLoaded = () => {
const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.props; const { currentCategoryId, articlesStatus, rootCategoryId, treeStatus } = this.state;
if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return; if (treeStatus !== 'succeeded' || !currentCategoryId || articlesStatus !== 'idle') return;
if (String(currentCategoryId) === String(rootCategoryId)) return; if (String(currentCategoryId) === String(rootCategoryId)) return;
this.props.loadCategoryArticles(currentCategoryId); void categoryAutomat.actions.loadArticles(currentCategoryId);
}; };
syncCategoryFromUrl = () => { syncCategoryFromUrl = () => {
const { const {
categories, categories,
rootCategoryId, rootCategoryId,
categorySlugPath,
currentCategoryId, currentCategoryId,
treeStatus, treeStatus,
navigate, } = this.state;
} = this.props; const { categorySlugPath, navigate } = this.props;
if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return; if (treeStatus !== 'succeeded' || !categories || rootCategoryId == null) return;
@@ -206,13 +223,13 @@ class ArtikelKategorienPage extends Component {
} }
if (resolvedId !== currentCategoryId) { if (resolvedId !== currentCategoryId) {
this.props.navigateToCategory(resolvedId); categoryAutomat.actions.navigateTo(resolvedId);
} }
}; };
navigateToCategoryById = (categoryId) => { navigateToCategoryById = (categoryId) => {
const { categories, rootCategoryId, navigate } = this.props; const { categories, rootCategoryId } = this.state;
navigate(buildCategoryPath(categories, rootCategoryId, categoryId)); this.props.navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
}; };
handleDraftChange(categoryId, value) { handleDraftChange(categoryId, value) {
@@ -228,10 +245,7 @@ class ArtikelKategorienPage extends Component {
return; return;
} }
try { try {
await this.props.renameCategory({ await categoryAutomat.actions.renameCategory({ categoryId, name: trimmed });
categoryId,
name: trimmed,
}).unwrap();
enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' }); enqueueSnackbar('Kategorie umbenannt.', { variant: 'success' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
@@ -240,7 +254,7 @@ class ArtikelKategorienPage extends Component {
handleDeleteCategory = async (category) => { handleDeleteCategory = async (category) => {
try { try {
await this.props.deleteCategory(category.id).unwrap(); await categoryAutomat.actions.deleteCategory(category.id);
enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' }); enqueueSnackbar('Kategorie gelöscht.', { variant: 'success' });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);
@@ -248,18 +262,18 @@ class ArtikelKategorienPage extends Component {
}; };
handleRetryTree = () => { handleRetryTree = () => {
if (this.props.companyId) this.props.loadCategoryTree(); if (this.state.companyId) void categoryAutomat.actions.loadTree();
}; };
handleRetryArticles = () => { handleRetryArticles = () => {
const { currentCategoryId } = this.props; const { currentCategoryId } = this.state;
if (currentCategoryId) { if (currentCategoryId) {
this.props.loadCategoryArticles(currentCategoryId); void categoryAutomat.actions.loadArticles(currentCategoryId);
} }
}; };
handleNavigateBack = () => { handleNavigateBack = () => {
const { breadcrumbs } = this.props; const { breadcrumbs } = this.state;
if (breadcrumbs.length < 2) return; if (breadcrumbs.length < 2) return;
this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id); this.navigateToCategoryById(breadcrumbs[breadcrumbs.length - 2].id);
}; };
@@ -283,7 +297,8 @@ class ArtikelKategorienPage extends Component {
articles, articles,
renameMode, renameMode,
terminals, terminals,
} = this.props; drafts,
} = this.state;
if (!companyId) { if (!companyId) {
return ( return (
@@ -347,8 +362,8 @@ class ArtikelKategorienPage extends Component {
{childCategories.map((item, index) => { {childCategories.map((item, index) => {
const label = categoryDisplayName(item.name, item.id, rootCategoryId); const label = categoryDisplayName(item.name, item.id, rootCategoryId);
const draftName = const draftName =
this.state.drafts[item.id] !== undefined drafts[item.id] !== undefined
? this.state.drafts[item.id] ? drafts[item.id]
: label; : label;
const editProps = renameMode const editProps = renameMode
? { ? {
@@ -359,13 +374,12 @@ class ArtikelKategorienPage extends Component {
terminals, terminals,
categoryPosterminalIds: item.posterminalIds || [], categoryPosterminalIds: item.posterminalIds || [],
onTerminalToggle: (terminalId, enabled) => { onTerminalToggle: (terminalId, enabled) => {
const thunk = enabled const action = enabled
? this.props.enableTerminalCategory ? posterminalAutomat.actions.enableCategory
: this.props.disableTerminalCategory; : posterminalAutomat.actions.disableCategory;
void thunk({ terminalId, categoryId: item.id }) void action({ terminalId, categoryId: item.id })
.unwrap()
.then(() => { .then(() => {
this.props.patchCategoryPosterminalIds({ categoryAutomat.actions.patchCategoryPosterminalIds({
categoryId: item.id, categoryId: item.id,
terminalId, terminalId,
enabled, enabled,
@@ -382,10 +396,9 @@ class ArtikelKategorienPage extends Component {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
try { try {
// Prepare image (resize and encode to AVIF)
const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto'); const { prepareArticlePhoto } = await import('../../utils/prepareArticlePhoto');
const avifFile = await prepareArticlePhoto(file); const avifFile = await prepareArticlePhoto(file);
await this.props.uploadCategoryImage({ await categoryAutomat.actions.uploadCategoryImage({
categoryId: item.id, categoryId: item.id,
file: avifFile, file: avifFile,
}); });
@@ -396,7 +409,7 @@ class ArtikelKategorienPage extends Component {
input.click(); input.click();
}, },
onPhotoDelete: () => { onPhotoDelete: () => {
void this.props.deleteCategoryImage(item.id) void categoryAutomat.actions.deleteCategoryImage(item.id)
.catch(notifyError); .catch(notifyError);
}, },
nameInput: ( nameInput: (
@@ -478,36 +491,4 @@ class ArtikelKategorienPage extends Component {
} }
} }
const mapStateToProps = (state) => ({ export default withCategoryRoute(ArtikelKategorienPage);
companyId: selectCompanyId(state),
categories: selectCategoryMap(state),
rootCategoryId: state.category.rootCategoryId,
treeStatus: selectCategoryTreeStatus(state),
articlesStatus: selectCategoryArticlesStatus(state),
error: selectCategoryError(state),
currentCategoryId: state.category.currentCategoryId,
breadcrumbs: selectCategoryBreadcrumbs(state),
childCategories: selectChildCategories(state),
articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state),
terminals: selectPosterminals(state),
});
const mapDispatchToProps = {
loadCategoryTree,
loadCategoryArticles,
loadPosterminals,
navigateToCategory,
renameCategory,
deleteCategory,
moveCategory,
reorderCategories,
enableTerminalCategory,
disableTerminalCategory,
patchCategoryPosterminalIds,
uploadCategoryImage,
deleteCategoryImage,
};
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -1,8 +1,25 @@
import { Component } from 'react'; import { Component } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { StorageManagerContext } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { ArticleView } from '../../components/article'; import { ArticleView } from '../../components/article';
import {
artikelAutomat,
createArticleSelector,
getArticle,
setLastArticleSku,
} from '../../automats';
import withSkuRoute from './withSkuRoute'; import withSkuRoute from './withSkuRoute';
import { setLastArticleSku } from '../../automats';
/** Full-screen loading state while the shared article cache resolves. */
function ArticleLoading() {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress size={32} />
</Box>
);
}
class ArtikelSkuPage extends Component { class ArtikelSkuPage extends Component {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -10,43 +27,66 @@ class ArtikelSkuPage extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
ready: article: getArticle(props.sku),
context.getArticle(props.sku) != null && ready: context.getCategoryState() != null,
context.getCategoryState() != null, error: null,
}; };
} }
componentDidMount() { componentDidMount() {
this._mounted = true;
this.installEscape(); this.installEscape();
setLastArticleSku(this.props.sku); setLastArticleSku(this.props.sku);
if (!this.state.ready) void this.ensureArticle(); this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
void this.ensureArticle();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) { if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku); setLastArticleSku(this.props.sku);
const ready = this.setState({ article: getArticle(this.props.sku), error: null });
this.context.getArticle(this.props.sku) != null && this.unsub?.();
this.context.getCategoryState() != null; this.unsub = artikelAutomat.subscribe(this, (state) => ({
if (ready !== this.state.ready) this.setState({ ready }); article: createArticleSelector(this.props.sku)(state),
if (!ready) void this.ensureArticle(); }));
void this.ensureArticle();
} }
} }
componentWillUnmount() { componentWillUnmount() {
this._mounted = false;
this.removeEscape(); this.removeEscape();
this.unsub?.();
this.unsub = null;
} }
/**
* Delegates the article fetch to the shared artikelAutomat cache; also waits
* for the category tree/company/session the view tiles depend on.
*/
async ensureArticle() { async ensureArticle() {
await Promise.all([ const sku = this.props.sku;
this.context.loadArticle(this.props.sku), if (!sku) return;
this.context.categoryTreePromise, try {
this.context.companyPromise, await Promise.all([
this.context.authMePromise, artikelAutomat.actions.loadArticle(sku),
]); this.context.categoryTreePromise,
this.setState({ ready: true }); this.context.companyPromise,
this.context.authMePromise,
]);
if (this._mounted) this.setState({ ready: true });
} catch (error) {
if (this._mounted) this.setState({ error });
}
} }
handleRetry = () => {
this.setState({ error: null });
void this.ensureArticle();
};
installEscape() { installEscape() {
this._onKeyDown = (e) => { this._onKeyDown = (e) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
@@ -65,7 +105,12 @@ class ArtikelSkuPage extends Component {
render() { render() {
const { sku } = this.props; const { sku } = this.props;
if (!this.state.ready) return null; const { article, ready, error } = this.state;
if (error) {
return <BackendError error={error} sx={{ mt: 2 }} onRetry={this.handleRetry} />;
}
if (!article || !ready) return <ArticleLoading />;
return <ArticleView sku={sku} />; return <ArticleView sku={sku} />;
} }
} }

View File

@@ -20,7 +20,8 @@ import {
emptyLine, emptyLine,
lineFromApi, lineFromApi,
manifestConfig, manifestConfig,
TextEditorDialog, TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog, ArticleSearchDialog,
} from '../../components/manifest'; } from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog'; import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
@@ -30,9 +31,10 @@ import { requireArray, requireDefined, requireObject } from '../../utils/apiShap
import { backendErrorFrom } from '../../utils/backendError'; import { backendErrorFrom } from '../../utils/backendError';
import { notifyError } from '../../utils/notify'; import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail'; import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { generateTextThumbnail } from '../../utils/textThumbnail';
import { extractPdfText } from '../../utils/pdfText'; import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats'; import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import { delivery as deliveryApi } from '../../api'; import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent { class LieferungNeuPage extends PureComponent {
@@ -54,6 +56,9 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '', pdfFileName: '',
textEditorOpen: false, textEditorOpen: false,
textEditorAttachment: null, textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition,
pdfViewerOpen: false,
pdfViewerAttachment: null,
articleSearchDialogOpen: false, articleSearchDialogOpen: false,
articleSearchLine: null, articleSearchLine: null,
unmatchedSupplierName: null, unmatchedSupplierName: null,
@@ -77,6 +82,8 @@ class LieferungNeuPage extends PureComponent {
this.handleAbortParse = this.handleAbortParse.bind(this); this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.bind(this); this.handleAttachmentClick = this.handleAttachmentClick.bind(this);
this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this); this.handleCloseTextEditor = this.handleCloseTextEditor.bind(this);
this.handleTextEditorPositionChange = this.handleTextEditorPositionChange.bind(this);
this.handleClosePdfViewer = this.handleClosePdfViewer.bind(this);
this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this); this.handleSaveTextEditor = this.handleSaveTextEditor.bind(this);
this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this); this.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this); this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
@@ -90,6 +97,9 @@ class LieferungNeuPage extends PureComponent {
componentDidMount() { componentDidMount() {
this.unsubscribe = this.automat.subscribe(this); this.unsubscribe = this.automat.subscribe(this);
this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({
viewerPosition: state.viewerPosition,
}));
this._cancelled = false; this._cancelled = false;
if (this.props.docId != null) { if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`); setLastDeliveryPath(`/lieferung/${this.props.docId}`);
@@ -131,6 +141,7 @@ class LieferungNeuPage extends PureComponent {
this._skuDebounceTimers = null; this._skuDebounceTimers = null;
} }
this.unsubscribe?.(); this.unsubscribe?.();
this._settingsUnsub?.();
} }
async loadPartiesOnly() { async loadPartiesOnly() {
@@ -467,12 +478,19 @@ class LieferungNeuPage extends PureComponent {
const text = await extractPdfText(file, { layout: true }); const text = await extractPdfText(file, { layout: true });
const txtName = file.name.replace(/\.pdf$/i, '') + '.txt'; const txtName = file.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const txtAttachment = { const txtAttachment = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: txtFile, file: txtFile,
name: txtFile.name, name: txtFile.name,
size: txtFile.size, size: txtFile.size,
type: 'text/plain', type: 'text/plain',
thumbnail: txtThumbnail,
text, text,
parentPdfId: pdfId, parentPdfId: pdfId,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
@@ -489,6 +507,12 @@ class LieferungNeuPage extends PureComponent {
} catch (err) { } catch (err) {
console.error('Fehler beim Lesen der Datei:', err); console.error('Fehler beim Lesen der Datei:', err);
} }
let thumbnail = null;
try {
thumbnail = await generateTextThumbnail(text || file);
} catch (thumbErr) {
console.error('Fehler beim Erstellen des Text-Thumbnails:', thumbErr);
}
const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const attachmentId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const textAttachment = { const textAttachment = {
id: attachmentId, id: attachmentId,
@@ -496,6 +520,7 @@ class LieferungNeuPage extends PureComponent {
name: file.name, name: file.name,
size: file.size, size: file.size,
type: isCsv ? 'text/csv' : 'text/plain', type: isCsv ? 'text/csv' : 'text/plain',
thumbnail,
text, text,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
}; };
@@ -527,6 +552,13 @@ class LieferungNeuPage extends PureComponent {
const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt'; const txtName = attachment.name.replace(/\.pdf$/i, '') + '.txt';
const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' }); const txtFile = new File([text], txtName, { type: 'text/plain;charset=utf-8' });
let txtThumbnail = null;
try {
txtThumbnail = await generateTextThumbnail(text);
} catch (thumbErr) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', thumbErr);
}
const existingTxt = this.state.attachments?.find( const existingTxt = this.state.attachments?.find(
(a) => a.parentPdfId === attachment.id || a.name === txtName, (a) => a.parentPdfId === attachment.id || a.name === txtName,
); );
@@ -536,6 +568,7 @@ class LieferungNeuPage extends PureComponent {
file: txtFile, file: txtFile,
size: txtFile.size, size: txtFile.size,
text, text,
thumbnail: txtThumbnail || existingTxt.thumbnail,
isDirty: true, isDirty: true,
}); });
} else { } else {
@@ -545,6 +578,7 @@ class LieferungNeuPage extends PureComponent {
name: txtFile.name, name: txtFile.name,
size: txtFile.size, size: txtFile.size,
type: 'text/plain', type: 'text/plain',
thumbnail: txtThumbnail,
text, text,
parentPdfId: attachment.id, parentPdfId: attachment.id,
uploadedAt: new Date().toISOString(), uploadedAt: new Date().toISOString(),
@@ -862,30 +896,21 @@ class LieferungNeuPage extends PureComponent {
this.setState({ this.setState({
textEditorOpen: true, textEditorOpen: true,
textEditorAttachment: attachment, textEditorAttachment: attachment,
pdfViewerOpen: false,
pdfViewerAttachment: null,
}); });
return; return;
} }
if (isPdf) { if (isPdf) {
if ( this.setState({
(attachment.file instanceof Blob || attachment.file instanceof File) && pdfViewerOpen: true,
attachment.file.size > 0 pdfViewerAttachment: attachment,
) { textEditorOpen: false,
const url = URL.createObjectURL(attachment.file); textEditorAttachment: null,
window.open(url, '_blank'); });
return; void this.ensureAttachmentFile(attachment);
} return;
const docId = this.props.docId ?? attachment.manifestId ?? this.state.id;
const fileId = attachment.fileId ?? attachment.id;
if (docId != null && fileId != null) {
window.open(
`/api/delivery/${encodeURIComponent(docId)}/file/${encodeURIComponent(fileId)}`,
'_blank',
);
void this.ensureAttachmentFile(attachment);
return;
}
} }
if (attachment.file instanceof Blob || attachment.file instanceof File) { if (attachment.file instanceof Blob || attachment.file instanceof File) {
@@ -913,7 +938,89 @@ class LieferungNeuPage extends PureComponent {
}); });
} }
handleSaveTextEditor(newText) { handleClosePdfViewer() {
this.setState({
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
}
handleTextEditorPositionChange(position) {
setViewerPosition(position);
}
renderViewerPanels(wide) {
const {
textEditorOpen,
textEditorAttachment,
pdfViewerOpen,
pdfViewerAttachment,
viewerPosition,
} = this.state;
const docId = this.props.docId ?? this.state.id;
const panels = [];
if (textEditorOpen && textEditorAttachment) {
panels.push(
<TextEditorPanel
key="text"
docId={docId}
attachment={textEditorAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>,
);
}
if (pdfViewerOpen && pdfViewerAttachment) {
panels.push(
<PdfViewerPanel
key="pdf"
docId={docId}
attachment={pdfViewerAttachment}
position={viewerPosition}
wide={wide}
height={panels.length === 0 ? '60vh' : '40vh'}
onPositionChange={this.handleTextEditorPositionChange}
onClose={this.handleClosePdfViewer}
/>,
);
}
if (panels.length === 0) return null;
return (
<Box
sx={{
gridColumn: { xs: '1 / -1', sm: wide >= 2 ? 'span 2' : 'span 1', md: `span ${wide}` },
display: 'flex',
flexDirection: 'column',
gap: 2,
minWidth: 0,
...(viewerPosition === 'right'
? { position: 'sticky', top: 16, alignSelf: 'flex-start' }
: {}),
}}
>
{panels}
</Box>
);
}
isViewerOpen() {
return Boolean(
(this.state.textEditorOpen && this.state.textEditorAttachment) ||
(this.state.pdfViewerOpen && this.state.pdfViewerAttachment),
);
}
async handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment; const attachment = this.state.textEditorAttachment;
if (!attachment) return; if (!attachment) return;
@@ -921,11 +1028,19 @@ class LieferungNeuPage extends PureComponent {
type: 'text/plain;charset=utf-8', type: 'text/plain;charset=utf-8',
}); });
let thumbnail = attachment.thumbnail;
try {
thumbnail = await generateTextThumbnail(newText);
} catch (err) {
console.error('Fehler beim Aktualisieren des Text-Thumbnails:', err);
}
this.automat.actions.updateAttachment({ this.automat.actions.updateAttachment({
id: attachment.id, id: attachment.id,
file: newFile, file: newFile,
size: newFile.size, size: newFile.size,
text: newText, text: newText,
thumbnail,
isDirty: true, isDirty: true,
}); });
@@ -1067,6 +1182,7 @@ class LieferungNeuPage extends PureComponent {
onNumberChange={this.handleNumberChange} onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange} onExternalNumberChange={this.handleExternalNumberChange}
/> />
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
{partyId == null && (!lines || lines.length === 0) ? ( {partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0"> <PanelTile wide={3} minHeight={160} color="#e2e8f0">
{this.state.unmatchedSupplierName ? ( {this.state.unmatchedSupplierName ? (
@@ -1120,6 +1236,11 @@ class LieferungNeuPage extends PureComponent {
columns={DELIVERY_LINE_COLUMNS} columns={DELIVERY_LINE_COLUMNS}
lines={lines} lines={lines}
saving={saving} saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 3
}
linesColor={cfg.linesColor} linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap} supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey} supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
@@ -1137,6 +1258,8 @@ class LieferungNeuPage extends PureComponent {
/> />
</> </>
)} )}
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(3)}
{notes && notes.length > 0 && ( {notes && notes.length > 0 && (
<PanelTile wide={3} color="#fffbeb"> <PanelTile wide={3} color="#fffbeb">
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}> <Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
@@ -1162,14 +1285,6 @@ class LieferungNeuPage extends PureComponent {
onAbort={this.handleAbortParse} onAbort={this.handleAbortParse}
/> />
<TextEditorDialog
open={this.state.textEditorOpen}
docId={this.props.docId ?? this.state.id}
attachment={this.state.textEditorAttachment}
onClose={this.handleCloseTextEditor}
onSave={this.handleSaveTextEditor}
onContentLoad={this.handleTextEditorContentLoaded}
/>
<ArticleSearchDialog <ArticleSearchDialog
open={this.state.articleSearchDialogOpen} open={this.state.articleSearchDialogOpen}
line={this.state.articleSearchLine} line={this.state.articleSearchLine}

View File

@@ -1,124 +0,0 @@
import { Automat, fetchJson } from 'react-automat';
import { article } from '../api/index';
import { requireObject, requireDefined } from '../utils/apiShape';
/**
* Article automat store.
* Registered as window.artikel via the name option.
* State shape: { [sku: string]: article | null }
*
* Usage in PureComponent:
* ```jsx
* import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
*
* class ArticleView extends PureComponent {
* constructor(props) {
* super(props);
* const sku = props.sku;
* const currentArticle = articleAutomat.state[sku] ?? null;
* this.state = { article: currentArticle };
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(sku).catch(console.error);
* }
* }
*
* componentDidMount() {
* this.subscribeToArticle();
* }
*
* componentDidUpdate(prevProps) {
* if (prevProps.sku !== this.props.sku) {
* const currentArticle = articleAutomat.state[this.props.sku] ?? null;
* this.setState({ article: currentArticle });
*
* if (!currentArticle) {
* articleAutomat.actions.loadArticle(this.props.sku).catch(console.error);
* }
*
* this.subscribeToArticle();
* }
* }
*
* subscribeToArticle() {
* if (this.unsub) this.unsub();
* const selector = createArticleSelector(this.props.sku);
* this.unsub = articleAutomat.subscribe(this, (state) => ({
* article: selector(state),
* }));
* }
*
* componentWillUnmount() {
* if (this.unsub) {
* this.unsub();
* this.unsub = null;
* }
* }
*
* render() {
* const { article } = this.state;
* if (!article) return null;
* return <div>{article.name}</div>;
* }
* }
* ```
*/
const articleAutomat = new Automat(
{},
{
/**
* Load article by SKU and update the store.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await article.getBySku(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
articleAutomat.setState({
[sku]: res.article,
});
return res.article;
},
/**
* Update article state after a successful mutation.
* @param {object} article
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
articleAutomat.setState({
[article.sku]: article,
});
},
/**
* Clear article from store (e.g., after deletion).
* @param {string} sku
*/
clearArticle(sku) {
articleAutomat.setState({
[sku]: null,
});
},
},
{
name: 'artikel',
}
);
/**
* Create a selector function for a specific SKU.
* This avoids closure issues when the SKU changes.
* @param {string} sku - The article SKU
* @returns {(state: object) => object|null} Selector function
*/
export function createArticleSelector(sku) {
return (state) => state[sku] ?? null;
}
export default articleAutomat;

View File

@@ -1,94 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { brand as brandApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import { catchBackendError } from '../utils/backendError';
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
export const loadBrands = createAsyncThunk('brand/load', async (_, { rejectWithValue }) => {
try {
const res = await brandApi.list();
requireObject(res, 'GET /api/brand');
return requireArray(res.brands, 'brands');
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
});
export const upsertBrand = createAsyncThunk(
'brand/upsert',
async (body, { rejectWithValue }) => {
try {
const res = await brandApi.upsert(body);
requireObject(res, 'PUT /api/brand');
requireObject(res.brand, 'brand');
return res.brand;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
brands: [],
status: 'idle',
error: null,
};
const brandSlice = createSlice({
name: 'brand',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadBrands.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadBrands.fulfilled, (state, action) => {
state.status = 'succeeded';
state.brands = sortBrands(action.payload);
})
.addCase(loadBrands.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertBrand.fulfilled, (state, action) => {
const row = action.payload;
state.brands = sortBrands([
...state.brands.filter((b) => b.id !== row.id),
row,
]);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectBrands = (state) => state.brand.brands;
export const selectBrandsStatus = (state) => state.brand.status;
export const selectBrandsError = (state) => state.brand.error;
export function brandLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadBrands());
}
},
});
}
export default brandSlice.reducer;

View File

@@ -1,696 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { category, article } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { AUTH_LOGGED_OUT } from '../automats/authAutomat';
import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
requireObject(item, 'category');
requireDefined(item.id, 'category.id');
return {
...item,
id: item.id,
parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null,
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
};
}
/** Backend: { languageId, tree: [...], articles: [] } — rootCategoryId aus Company */
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
requireObject(data, 'category/tree');
const list = requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
requireDefined(rootCategoryId, 'rootCategoryId');
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
return {
rootCategoryId,
categories,
rootArticles: data.articles,
languageId: data.languageId,
};
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
export function categorySlug(node, rootCategoryId) {
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return node.slug || null;
}
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
if (!categories || currentCategoryId == null) return [];
const crumbs = [];
const seen = new Set();
let id = currentCategoryId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = categories[String(id)];
if (!node) break;
crumbs.unshift({
id: node.id,
name: categoryDisplayName(node.name, node.id, rootCategoryId),
slug: categorySlug(node, rootCategoryId),
});
const parentId = node.parentId;
if (parentId == null || parentId === 0) break;
if (String(parentId) === String(id)) break;
id = parentId;
}
return crumbs;
}
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
const companyRootId = getState().company.current?.rootCategoryId;
requireDefined(companyRootId, 'company.rootCategoryId');
const data = await category.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
return {
companyId,
...parsed,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
async (categoryId, { rejectWithValue }) => {
try {
const data = await category.getArticles(categoryId);
requireObject(data, `category/${categoryId}/articles`);
const articles = requireArray(data.articles, `category/${categoryId}/articles.articles`);
return { categoryId, articles };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const createCategory = createAsyncThunk(
'category/create',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create several categories under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every category is created
* even if a later one fails; the first error is reported.
* Returns the array of created (normalized) categories.
*/
export const createCategories = createAsyncThunk(
'category/createCategories',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await category.create({ parentId, name });
requireObject(data.category, 'POST /api/category.category');
created.push(normalizeCategoryNode(data.category));
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created categories so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const createArticle = createAsyncThunk(
'category/createArticle',
async ({ parentId, name }, { rejectWithValue }) => {
try {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
return data.article;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create several articles under the same parent in one go (bulk add).
* Runs the single-create API sequentially so every article is created
* even if a later one fails; the first error is reported.
* Returns the array of created articles.
*/
export const createArticles = createAsyncThunk(
'category/createArticles',
async ({ parentId, names }, { rejectWithValue }) => {
const list = (names ?? [])
.map((n) => String(n ?? '').trim())
.filter(Boolean);
if (list.length === 0) {
return rejectWithValue({ message: 'Keine Namen übergeben.' });
}
const created = [];
try {
for (const name of list) {
const data = await article.create({ parentId, name });
requireObject(data.article, 'POST /api/article.article');
requireDefined(data.article.id, 'article.id');
created.push(data.article);
}
return created;
} catch (err) {
const normalized = catchBackendError(rejectWithValue)(err);
// Attach already-created articles so the UI can still update optimistically.
return normalized && created.length > 0
? rejectWithValue({ ...normalized, created })
: normalized;
}
},
);
export const renameCategory = createAsyncThunk(
'category/rename',
async ({ categoryId, name }, { rejectWithValue }) => {
try {
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const data = await category.updateName(categoryId, trimmed);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Reorder sibling categories (new order of children under the current parent).
* `PUT /api/category/order` → 200 { categoryIds: number[] }
*/
export const reorderCategories = createAsyncThunk(
'category/reorder',
async ({ categoryIds }, { rejectWithValue, getState }) => {
try {
const ids = requireArray(categoryIds, 'categoryIds');
const parentId = getState().category.currentCategoryId;
requireDefined(parentId, 'currentCategoryId');
const data = await category.reorder(parentId, ids);
requireObject(data, 'PUT /api/category/order');
requireArray(data.categoryIds, 'categoryIds');
return { parentId, categoryIds: requireArray(data.categoryIds, 'categoryIds') };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Move a category under a new parent (drop into subcategory).
* Backend: PATCH /api/category/:id Body: { parentId } → { category }.
* (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.)
*/
export const moveCategory = createAsyncThunk(
'category/move',
async ({ categoryId, parentId }, { rejectWithValue }) => {
try {
const data = await category.move(categoryId, parentId);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk(
'category/delete',
async (categoryId, { rejectWithValue }) => {
try {
await category.delete(categoryId);
return categoryId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Toggle the POS (Point of Sale) flag on a category.
* Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }.
* PROPOSED — backend needs to implement this field.
*/
export const toggleCategoryPos = createAsyncThunk(
'category/togglePos',
async ({ categoryId, pos }, { rejectWithValue }) => {
try {
const data = await category.updatePos(categoryId, pos);
requireObject(data.category, 'PATCH /api/category/:id.category (pos)');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* POST /api/category/:id/image → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
export const uploadCategoryImage = createAsyncThunk(
'category/uploadImage',
async ({ categoryId, file }, { rejectWithValue }) => {
try {
const data = await category.uploadImage(categoryId, file);
requireObject(data.category, 'POST /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* DELETE /api/category/:id/image → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
export const deleteCategoryImage = createAsyncThunk(
'category/deleteImage',
async (categoryId, { rejectWithValue }) => {
try {
const data = await category.deleteImage(categoryId);
requireObject(data.category, 'DELETE /api/category/:id/image');
return data.category;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
renameMode: false,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
const categorySlice = createSlice({
name: 'category',
initialState,
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
state.error = null;
if (String(action.payload) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) {
state.currentCategoryId = parentId;
state.error = null;
if (String(parentId) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
}
},
resetCategoryState() {
return initialState;
},
patchCategoryPosterminalIds(state, action) {
const { categoryId, terminalId, enabled } = action.payload;
const cat = state.categories?.[String(categoryId)];
if (!cat) return;
if (!Array.isArray(cat.posterminalIds)) cat.posterminalIds = [];
if (enabled && !cat.posterminalIds.includes(terminalId)) {
cat.posterminalIds.push(terminalId);
} else if (!enabled) {
cat.posterminalIds = cat.posterminalIds.filter((id) => id !== terminalId);
}
},
setRenameMode(state, action) {
state.renameMode = Boolean(action.payload);
},
removeArticleBySku(state, action) {
const sku = action.payload;
state.articles = state.articles.filter((row) => row.sku !== sku);
state.articlesTotal = state.articles.length;
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
},
/** Keep category tile list in sync with article detail (e.g. after photo upload or category change). */
patchArticleInLists(state, action) {
const { sku, categoryIds, ...fields } = action.payload;
if (sku == null) return;
// Merge fields into existing article in both lists
const patchList = (list) =>
list.map((row) => {
if (row.sku !== sku) return row;
return { ...row, ...fields };
});
state.articles = patchList(state.articles);
state.rootArticles = patchList(state.rootArticles);
if (categoryIds === undefined) return;
const newCategoryIds = categoryIds ?? [];
// Helper: find the article object in either list
const findArticle = (list) => list.find((row) => row.sku === sku);
const articleObj = findArticle(state.articles) || findArticle(state.rootArticles);
if (!articleObj) return;
// Handle root category membership
if (state.rootCategoryId != null) {
const inRootCategory = newCategoryIds.some(
(id) => String(id) === String(state.rootCategoryId),
);
const alreadyInRoot = state.rootArticles.some((row) => row.sku === sku);
if (!inRootCategory) {
state.rootArticles = state.rootArticles.filter((row) => row.sku !== sku);
} else if (!alreadyInRoot) {
state.rootArticles = [...state.rootArticles, articleObj];
}
}
// Handle current category membership
if (state.currentCategoryId != null) {
const inCurrentCategory = newCategoryIds.some(
(id) => String(id) === String(state.currentCategoryId),
);
const alreadyInCurrent = state.articles.some((row) => row.sku === sku);
if (!inCurrentCategory) {
state.articles = state.articles.filter((row) => row.sku !== sku);
state.articlesTotal = state.articles.length;
} else if (!alreadyInCurrent) {
state.articles = [...state.articles, articleObj];
state.articlesTotal = state.articles.length;
}
}
},
},
extraReducers: (builder) => {
builder
.addCase(loadCategoryTree.pending, (state) => {
state.treeStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
state.languageId = action.payload.languageId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
})
.addCase(loadCategoryTree.rejected, (state, action) => {
state.treeStatus = 'failed';
state.error = resolveBackendError(action);
})
.addCase(loadCategoryArticles.pending, (state) => {
state.articlesStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
state.articles = action.payload.articles;
state.articlesTotal = action.payload.articles.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
state.articles = [];
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
.addCase(createCategory.fulfilled, (state, action) => {
if (!state.categories) state.categories = {};
state.categories[String(action.payload.id)] = action.payload;
})
.addCase(createCategories.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
if (!state.categories) state.categories = {};
for (const item of createdList) {
if (item == null || item.id == null) continue;
state.categories[String(item.id)] = item;
}
})
.addCase(renameCategory.fulfilled, (state, action) => {
const { id, name } = action.payload;
if (id == null || name == null) return;
const key = String(id);
const existing = state.categories?.[key];
if (existing) {
state.categories[key] = { ...existing, name };
} else if (state.categories) {
state.categories[key] = action.payload;
}
state.rootArticles = state.rootArticles.map((row) =>
row?.categoryId != null && String(row.categoryId) === key
? { ...row, name }
: row,
);
})
.addCase(reorderCategories.fulfilled, (state, action) => {
const { parentId, categoryIds } = action.payload;
if (!state.categories) return;
const parentKey = String(parentId);
const idToSort = new Map(
categoryIds.map((id, index) => [String(id), index]),
);
for (const node of Object.values(state.categories)) {
if (
node.parentId != null &&
String(node.parentId) === parentKey &&
idToSort.has(String(node.id))
) {
state.categories[String(node.id)] = {
...node,
sort: idToSort.get(String(node.id)),
};
}
}
})
.addCase(moveCategory.fulfilled, (state, action) => {
const moved = action.payload;
if (moved == null || state.categories == null) return;
const existing = state.categories[String(moved.id)] ?? {};
// Der Move-Response enthält nur { id, parentId, sort } — keinen Namen.
// parentId/sort werden übernommen, der bestehende Name bleibt erhalten
// (das Backend hat ihn beim Move nicht verändert).
state.categories[String(moved.id)] = {
...existing,
parentId: moved.parentId,
sort: moved.sort,
};
})
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;
if (String(created.parentId) === String(state.currentCategoryId)) {
state.articles = [...state.articles, created];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
if (String(created.parentId) === String(state.rootCategoryId)) {
state.rootArticles = [...state.rootArticles, created];
}
})
.addCase(createArticles.fulfilled, (state, action) => {
const createdList = action.payload;
if (!Array.isArray(createdList)) return;
const added = createdList.filter((item) => item != null);
if (added.length === 0) return;
if (added.some((item) => String(item.parentId) === String(state.currentCategoryId))) {
state.articles = [
...state.articles,
...added.filter((item) =>
String(item.parentId) === String(state.currentCategoryId),
),
];
state.articlesTotal = state.articles.length;
state.articlesStatus = 'succeeded';
}
const rootAdded = added.filter((item) =>
String(item.parentId) === String(state.rootCategoryId),
);
if (rootAdded.length > 0) {
state.rootArticles = [...state.rootArticles, ...rootAdded];
}
})
.addCase(deleteCategory.fulfilled, (state, action) => {
if (!state.categories) return;
delete state.categories[String(action.payload)];
if (String(state.currentCategoryId) === String(action.payload)) {
state.currentCategoryId = state.rootCategoryId;
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
}
})
.addCase(toggleCategoryPos.fulfilled, (state, action) => {
const updated = action.payload;
if (updated == null || state.categories == null) return;
const key = String(updated.id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, pos: updated.pos };
}
})
.addCase(uploadCategoryImage.fulfilled, (state, action) => {
const { id, fullHash, thumbW, thumbH, thumbHash } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, fullHash, thumbW, thumbH, thumbHash };
}
})
.addCase(deleteCategoryImage.fulfilled, (state, action) => {
const { id } = action.payload;
if (id == null || state.categories == null) return;
const key = String(id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = {
...existing,
fullHash: null,
thumbW: null,
thumbH: null,
thumbHash: null,
};
}
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return {
...initialState,
companyId: nextCompanyId,
};
}
})
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const {
navigateToCategory,
navigateBack,
resetCategoryState,
setRenameMode,
removeArticleBySku,
patchArticleInLists,
patchCategoryPosterminalIds,
} = categorySlice.actions;
export const selectRenameMode = (state) => state.category.renameMode;
export const selectCategoryMap = (state) => state.category.categories;
export const selectCurrentCategoryId = (state) => state.category.currentCategoryId;
export const selectCategoryRootId = (state) => state.category.rootCategoryId;
export const selectCategoryTreeStatus = (state) => state.category.treeStatus;
export const selectCategoryArticles = (state) => state.category.articles;
export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus;
export const selectCategoryError = (state) => state.category.error;
export const selectCurrentCategory = (state) => {
const id = state.category.currentCategoryId;
if (id == null) return null;
return state.category.categories?.[String(id)] ?? null;
};
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
export const selectCategoryBreadcrumbs = (state) =>
getCategoryBreadcrumbs(
state.category.categories,
state.category.currentCategoryId,
state.category.rootCategoryId,
);
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;
if (String(currentCategoryId) === String(rootCategoryId)) return false;
const parentId = categories?.[String(currentCategoryId)]?.parentId;
return parentId != null && parentId !== 0;
};
export default categorySlice.reducer;

View File

@@ -1,98 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { normalizeCompany } from '../utils/companyForm';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async () => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
},
);
export const updateCompany = createAsyncThunk(
'company/update',
async (payload) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
return normalizeCompany(data.company, payload);
},
);
const initialState = {
current: null,
status: 'idle',
error: null,
};
const companySlice = createSlice({
name: 'company',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadCurrentCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(loadCurrentCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(updateCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(updateCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(updateCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(AUTH_LOGGED_OUT, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
});
},
});
export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name;
export const selectCompanyIso639 = (state) => state.company.current?.iso639;
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
export function companyLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadCurrentCompany());
}
},
});
}
export default companySlice.reducer;

View File

@@ -1,55 +0,0 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import { setAuthEventDispatcher } from '../automats/authAutomat';
import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
import unitReducer, { unitLoadListener } from './unitSlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
function reduxDevLogger() {
return (storeApi) => (next) => (action) => {
if (
!import.meta.env.DEV ||
(!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') &&
!action.type?.startsWith('category/') &&
!action.type?.startsWith('brand/') &&
!action.type?.startsWith('posterminal/') &&
!action.type?.startsWith('unit/'))
) {
return next(action);
}
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
company: after.company.current,
brands: after.brand.brands.length,
posterminals: after.posterminal.terminals.length,
units: after.unit.units.length,
});
return result;
};
}
export const store = configureStore({
reducer: {
company: companyReducer,
category: categoryReducer,
brand: brandReducer,
posterminal: posterminalReducer,
unit: unitReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});
setAuthEventDispatcher(store.dispatch);

View File

@@ -1,267 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { posterminal } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { loadCurrentCompany } from './companySlice';
/**
* Load all posterminals for the selected company.
* GET /api/posterminal → { posterminals: [...] }
*/
export const loadPosterminals = createAsyncThunk(
'posterminal/load',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
requireDefined(companyId, 'company.id');
const data = await posterminal.list();
requireObject(data, 'GET /api/posterminal');
const list = requireArray(data.posterminals, 'posterminals');
return { companyId, terminals: list };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create a new posterminal.
* PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } }
*/
export const createPosterminal = createAsyncThunk(
'posterminal/create',
async ({ name }, { rejectWithValue }) => {
try {
const data = await posterminal.create({ name });
requireObject(data, 'PUT /api/posterminal');
requireObject(data.posterminal, 'posterminal');
requireDefined(data.posterminal.id, 'posterminal.id');
return data.posterminal;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Delete a posterminal.
* DELETE /api/posterminal/:id → 204
*/
export const deletePosterminal = createAsyncThunk(
'posterminal/delete',
async ({ terminalId }, { rejectWithValue }) => {
try {
await posterminal.delete(terminalId);
return terminalId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable all categories on a terminal.
* PUT /api/posterminal/:id/all → { id, categoryIds }
*/
export const enableAllTerminalCategories = createAsyncThunk(
'posterminal/enableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableAllCategories(terminalId);
requireObject(data, 'PUT /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable all categories on a terminal.
* DELETE /api/posterminal/:id/all → { id, categoryIds }
*/
export const disableAllTerminalCategories = createAsyncThunk(
'posterminal/disableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableAllCategories(terminalId);
requireObject(data, 'DELETE /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable a specific category on a terminal.
* PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const enableTerminalCategory = createAsyncThunk(
'posterminal/enableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableCategory(terminalId, categoryId);
requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable a specific category on a terminal.
* DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const disableTerminalCategory = createAsyncThunk(
'posterminal/disableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableCategory(terminalId, categoryId);
requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
terminals: [],
status: 'idle',
error: null,
};
const posterminalSlice = createSlice({
name: 'posterminal',
initialState,
reducers: {
resetPosterminalState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
// Load
.addCase(loadPosterminals.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadPosterminals.fulfilled, (state, action) => {
state.status = 'succeeded';
state.companyId = action.payload.companyId;
state.terminals = action.payload.terminals;
})
.addCase(loadPosterminals.rejected, (state, action) => {
state.status = 'failed';
state.error = resolveBackendError(action);
})
// Create
.addCase(createPosterminal.pending, (state) => {
state.error = null;
})
.addCase(createPosterminal.fulfilled, (state, action) => {
state.terminals.push(action.payload);
})
.addCase(createPosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Delete
.addCase(deletePosterminal.fulfilled, (state, action) => {
state.terminals = state.terminals.filter(
(t) => t.id !== action.payload,
);
})
.addCase(deletePosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable all categories
.addCase(enableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable all categories
.addCase(disableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable single category
.addCase(enableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable single category
.addCase(disableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Reset on company change
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return { ...initialState, companyId: nextCompanyId, status: 'idle' };
}
})
// Reset on logout
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const { resetPosterminalState } = posterminalSlice.actions;
/**
* Auto-load posterminals when the session/company is established.
* Mirrors brandLoadListener / unitLoadListener pattern.
*/
export function posterminalLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
console.log('[posterminalLoadListener]', action.type, { companyId });
if (companyId) {
api.dispatch(loadPosterminals());
}
},
});
}
// Selectors
export const selectPosterminals = (state) => state.posterminal.terminals;
export const selectPosterminalStatus = (state) => state.posterminal.status;
export const selectPosterminalError = (state) => state.posterminal.error;
export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0;
export default posterminalSlice.reducer;

View File

@@ -1,92 +0,0 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { unit as unitApi } from '../api';
import authAutomat, {
AUTH_COMPANY_CHANGED,
AUTH_LOGGED_OUT,
AUTH_SESSION_ESTABLISHED,
} from '../automats/authAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
function sortUnits(units) {
return [...units].sort((a, b) => {
const left = a.symbol ?? a.si ?? '';
const right = b.symbol ?? b.si ?? '';
return String(left).localeCompare(String(right), 'de');
});
}
export const loadUnits = createAsyncThunk('unit/load', async () => {
const res = await unitApi.list();
requireObject(res, 'GET /api/unit');
return requireArray(res.units, 'units');
});
export const upsertUnit = createAsyncThunk('unit/upsert', async (body) => {
const res = await unitApi.upsert(body);
requireObject(res, 'PUT /api/unit');
requireObject(res.unit, 'unit');
return res.unit;
});
export const deleteUnit = createAsyncThunk('unit/delete', async (si) => {
await unitApi.delete(si);
return si;
});
const initialState = {
units: [],
status: 'idle',
error: null,
};
const unitSlice = createSlice({
name: 'unit',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadUnits.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadUnits.fulfilled, (state, action) => {
state.status = 'succeeded';
state.units = sortUnits(action.payload);
})
.addCase(loadUnits.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(upsertUnit.fulfilled, (state, action) => {
const row = action.payload;
state.units = sortUnits([
...state.units.filter((u) => u.si !== row.si),
row,
]);
})
.addCase(deleteUnit.fulfilled, (state, action) => {
state.units = state.units.filter((u) => u.si !== action.payload);
})
.addCase(AUTH_COMPANY_CHANGED, () => initialState)
.addCase(AUTH_LOGGED_OUT, () => initialState);
},
});
export const selectUnits = (state) => state.unit.units;
export const selectUnitsStatus = (state) => state.unit.status;
export const selectUnitsError = (state) => state.unit.error;
export function unitLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
action.type === AUTH_SESSION_ESTABLISHED || action.type === AUTH_COMPANY_CHANGED,
effect: async (_action, api) => {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadUnits());
}
},
});
}
export default unitSlice.reducer;

111
src/utils/categoryUtils.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* Pure category tree helper functions.
* These operate on plain category maps and have no side-effects.
*/
export const ROOT_CATEGORY_NAME = 'Home';
/**
* @param {object} item
* @returns {object}
*/
export function normalizeCategoryNode(item) {
return {
...item,
id: item.id,
parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null,
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
};
}
/**
* Parse the raw GET /api/category/tree response body into normalized state.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function parseCategoryTreeResponse(data, companyRootCategoryId) {
const list = Array.isArray(data.tree) ? data.tree : [];
const rootCategoryId = data.rootCategoryId != null ? data.rootCategoryId : companyRootCategoryId;
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
return {
rootCategoryId,
categories,
rootArticles: Array.isArray(data.articles) ? data.articles : [],
languageId: data.languageId,
};
}
/**
* Display name for a category node (root → "Home").
*/
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
/**
* URL slug for a category node (root → "Home").
*/
export function categorySlug(node, rootCategoryId) {
if (rootCategoryId != null && String(node.id) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return node.slug || null;
}
/**
* Direct children of `parentId`, sorted by `sort` then `id`.
*/
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => item.parentId != null && String(item.parentId) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
/**
* Breadcrumb trail from root to `currentCategoryId`.
*/
export function getCategoryBreadcrumbs(categories, currentCategoryId, rootCategoryId) {
if (!categories || currentCategoryId == null) return [];
const crumbs = [];
const seen = new Set();
let id = currentCategoryId;
while (id != null && !seen.has(String(id))) {
seen.add(String(id));
const node = categories[String(id)];
if (!node) break;
crumbs.unshift({
id: node.id,
name: categoryDisplayName(node.name, node.id, rootCategoryId),
slug: categorySlug(node, rootCategoryId),
});
const parentId = node.parentId;
if (parentId == null || parentId === 0) break;
if (String(parentId) === String(id)) break;
id = parentId;
}
return crumbs;
}
/**
* Map of category.id → category node from a raw tree response.
* @param {object} data
* @param {number|string|null} companyRootCategoryId
*/
export function normalizeCategoryMap(data, companyRootCategoryId) {
return parseCategoryTreeResponse(data, companyRootCategoryId).categories;
}

144
src/utils/textThumbnail.js Normal file
View File

@@ -0,0 +1,144 @@
let encodeAvif = null;
async function getAvifEncoder() {
if (!encodeAvif) {
const { default: encode } = await import('@jsquash/avif/encode');
encodeAvif = encode;
}
return encodeAvif;
}
/**
* Converts an ArrayBuffer to a base64 string safely without stack overflow.
* @param {ArrayBuffer} buffer
* @returns {string}
*/
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(
null,
bytes.subarray(i, i + chunkSize),
);
}
return btoa(binary);
}
/**
* Renders text onto a 2D canvas styled like a document page thumbnail.
* @param {string} text
* @param {number} [width=200]
* @param {number} [height=280]
* @returns {HTMLCanvasElement}
*/
export function renderTextToCanvas(text = '', width = 200, height = 280) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return canvas;
// Background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Subtle border
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
// Top header accent line
ctx.fillStyle = '#cbd5e1';
ctx.fillRect(10, 8, 30, 2);
// Render text lines
ctx.fillStyle = '#334155';
ctx.font = '8px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const paddingX = 10;
const startY = 22;
const lineHeight = 11;
const maxLines = Math.floor((height - startY - 8) / lineHeight);
const maxWidth = width - paddingX * 2;
const rawLines = String(text || '').split(/\r?\n/);
const formattedLines = [];
for (const line of rawLines) {
if (formattedLines.length >= maxLines) break;
const clean = line.replace(/\t/g, ' ');
if (!clean.trim()) {
formattedLines.push('');
continue;
}
// Wrap or clip long lines
let current = '';
for (let i = 0; i < clean.length; i++) {
const candidate = current + clean[i];
if (ctx.measureText(candidate).width > maxWidth) {
formattedLines.push(current);
current = clean[i];
if (formattedLines.length >= maxLines) break;
} else {
current = candidate;
}
}
if (current && formattedLines.length < maxLines) {
formattedLines.push(current);
}
}
for (let idx = 0; idx < formattedLines.length; idx++) {
const y = startY + idx * lineHeight;
ctx.fillText(formattedLines[idx], paddingX, y);
}
return canvas;
}
/**
* Generates an AVIF thumbnail data URL for text content.
* @param {string|File|Blob} textSource
* @param {{ width?: number, height?: number, quality?: number }} [options]
* @returns {Promise<string|null>} Base64 Data URL (data:image/avif;base64,... or data:image/png;base64,...)
*/
export async function generateTextThumbnail(textSource, { width = 200, height = 280, quality = 70 } = {}) {
let text = '';
if (textSource instanceof Blob) {
text = await textSource.text();
} else if (typeof textSource === 'string') {
text = textSource;
} else if (textSource != null) {
text = String(textSource);
}
if (typeof document === 'undefined') {
return null;
}
const canvas = renderTextToCanvas(text, width, height);
const ctx = canvas.getContext('2d');
if (!ctx) return null;
try {
const imageData = ctx.getImageData(0, 0, width, height);
const encoder = await getAvifEncoder();
const avifBuffer = await encoder(imageData, {
quality,
speed: 9,
});
const base64 = arrayBufferToBase64(avifBuffer);
return `data:image/avif;base64,${base64}`;
} catch (err) {
console.warn('generateTextThumbnail: @jsquash/avif encode failed, falling back to PNG:', err);
try {
return canvas.toDataURL('image/png');
} catch {
return null;
}
}
}