This commit is contained in:
seb
2026-09-21 20:21:57 +02:00
parent 2046f96768
commit 536e7b1e2a
20 changed files with 3047 additions and 672 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;
}
```

711
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",
@@ -1611,6 +1612,28 @@
"node": ">=6.9.0"
}
},
"node_modules/@cacheable/memory": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
"integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
"license": "MIT",
"dependencies": {
"@cacheable/utils": "^2.5.0",
"@keyv/bigmap": "^1.3.1",
"hookified": "^1.15.1",
"keyv": "^5.6.0"
}
},
"node_modules/@cacheable/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
"license": "MIT",
"dependencies": {
"hashery": "^1.5.1",
"keyv": "^5.6.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -1757,6 +1780,166 @@
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
"license": "MIT",
"dependencies": {
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
}
},
"node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint-community/regexpp": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
"license": "MIT",
"engines": {
"node": "^12.0.0 || ^14.0.0 || >=16.0.0"
}
},
"node_modules/@eslint/config-array": {
"version": "0.23.5",
"resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
"integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/object-schema": "^3.0.5",
"debug": "^4.3.1",
"minimatch": "^10.2.4"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
"integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
"integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/json-schema": "^7.0.15"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/object-schema": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
"integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
"integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^1.2.1",
"levn": "^0.4.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
"integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
"license": "Apache-2.0",
"engines": {
"node": ">=12.22"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@humanwhocodes/retry": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
@@ -1839,6 +2022,28 @@
"integrity": "sha512-0R5UL1ZLHUT+carjVikcE1QfA+kfNQ2YamYyGVRmhfh4zttU5EY3bQBGxPIPtY2xIAw1P4Kgyxm2xrceRw1r2w==",
"license": "Apache-2.0"
},
"node_modules/@keyv/bigmap": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
"integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
"license": "MIT",
"dependencies": {
"hashery": "^1.4.0",
"hookified": "^1.15.0"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"keyv": "^5.6.0"
}
},
"node_modules/@keyv/serialize": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
"integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
"license": "MIT"
},
"node_modules/@mui/core-downloads-tracker": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.4.0.tgz",
@@ -2961,11 +3166,22 @@
"node": ">=12"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/parse-json": {
@@ -3053,7 +3269,6 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -3062,6 +3277,15 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-jsx": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
"integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -3248,7 +3472,6 @@
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
@@ -3271,7 +3494,6 @@
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -3321,6 +3543,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cacheable": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
"integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
"license": "MIT",
"dependencies": {
"@cacheable/memory": "^2.2.0",
"@cacheable/utils": "^2.5.0",
"hookified": "^1.15.0",
"keyv": "^5.6.0",
"qified": "^0.10.1"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3534,7 +3769,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -3646,6 +3880,12 @@
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -3952,6 +4192,227 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint": {
"version": "10.11.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz",
"integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==",
"license": "MIT",
"workspaces": [
"packages/*"
],
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2",
"@eslint/config-array": "^0.23.5",
"@eslint/config-helpers": "^0.7.0",
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.3",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.4.2",
"@types/estree": "^1.0.6",
"ajv": "^6.14.0",
"cross-spawn": "^7.0.6",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^9.1.2",
"eslint-visitor-keys": "^5.0.1",
"espree": "^11.2.0",
"esquery": "^1.7.0",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "11.1.5 || >11.1.6 <12",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"json-stable-stringify-without-jsonify": "^1.0.1",
"minimatch": "^10.2.5",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://eslint.org/donate"
},
"peerDependencies": {
"jiti": "*"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
}
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"license": "BSD-2-Clause",
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/eslint/node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"license": "BSD-2-Clause",
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"license": "BSD-3-Clause",
"dependencies": {
"estraverse": "^5.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
"integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
"license": "BSD-2-Clause",
"dependencies": {
"estraverse": "^5.2.0"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -3963,7 +4424,6 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.10.0"
@@ -3986,14 +4446,18 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"license": "MIT"
},
"node_modules/fast-uri": {
@@ -4031,6 +4495,15 @@
}
}
},
"node_modules/file-entry-cache": {
"version": "11.1.5",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
"integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
"license": "MIT",
"dependencies": {
"flat-cache": "^6.1.23"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -4091,6 +4564,23 @@
"node": ">=8"
}
},
"node_modules/flat-cache": {
"version": "6.1.23",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
"integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
"license": "MIT",
"dependencies": {
"cacheable": "^2.5.0",
"flatted": "^3.4.2",
"hookified": "^1.15.0"
}
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"license": "ISC"
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -4317,6 +4807,18 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/globalthis": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
@@ -4434,6 +4936,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hashery": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
"integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
"license": "MIT",
"dependencies": {
"hookified": "^1.15.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -4461,6 +4975,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/hookified": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
"integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
"license": "MIT"
},
"node_modules/idb": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
@@ -4468,6 +4988,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.18",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
@@ -4494,6 +5023,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4665,6 +5203,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-finalizationregistry": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz",
@@ -4711,6 +5258,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz",
@@ -4950,7 +5509,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
@@ -5018,6 +5576,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -5054,6 +5618,15 @@
"node": ">=0.10.0"
}
},
"node_modules/keyv": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
"integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
"license": "MIT",
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -5064,6 +5637,19 @@
"node": ">=6"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
"integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1",
"type-check": "~0.4.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -5409,7 +5995,6 @@
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
@@ -5456,6 +6041,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.56",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz",
@@ -5550,6 +6141,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"license": "MIT",
"dependencies": {
"deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6",
"levn": "^0.4.1",
"prelude-ls": "^1.2.1",
"type-check": "^0.4.0",
"word-wrap": "^1.2.5"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/own-keys": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz",
@@ -5649,7 +6257,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5659,7 +6266,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5781,6 +6387,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
"license": "MIT",
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/pretty-bytes": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
@@ -5811,6 +6426,33 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qified": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
"integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
"license": "MIT",
"dependencies": {
"hookified": "^2.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/qified/node_modules/hookified": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
"integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -6354,7 +6996,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -6367,7 +7008,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6762,6 +7402,18 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"license": "MIT",
"dependencies": {
"prelude-ls": "^1.2.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
@@ -6980,6 +7632,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
@@ -7108,7 +7769,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7216,6 +7876,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/workbox-background-sync": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz",
@@ -7511,6 +8180,18 @@
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -23,6 +23,7 @@
"@reduxjs/toolkit": "^2.12.0",
"@shopify/draggable": "^1.2.1",
"dashgui": "file:../dashgui",
"eslint": "^10.11.0",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",

View File

@@ -2,7 +2,7 @@ import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient';
import articleAutomat from '../store/articleAutomat';
import artikelAutomat from '../automats/artikelAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
@@ -75,8 +75,6 @@ export class StorageManager extends ApiClient {
#events = null;
/** @type {ReturnType<typeof createCache> | null} */
#articleIndexCache = null;
/** @type {Map<string, object>} sku → article detail */
#articlesBySku = new Map();
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
@@ -92,39 +90,41 @@ export class StorageManager extends ApiClient {
this.watchArticleSuppliers(sku, onSuppliers);
}
/** Settled article detail for sku, or undefined. */
/** Cached article detail from the shared artikelAutomat, or null. */
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) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
this.#articlesBySku.set(key, article);
// Update automat as well
articleAutomat.actions.setArticle(article);
const stored = artikelAutomat.actions.setArticle(article);
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) {
for (const onArticle of watchers) onArticle(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>`).
* @param {string} sku
@@ -335,11 +335,11 @@ export class StorageManager extends ApiClient {
#onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length);
// Update automat on SSE article events
void articleAutomat.actions.loadArticle(sku).catch((err) => notifyError(err));
// Refresh the shared cache; silently when nothing is watching this 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);
if (historyWatchers) {
@@ -360,9 +360,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) {
const current = this.#articlesBySku.get(String(sku));
const current = this.getArticle(sku);
if (!current) return;
this.setArticle({ ...current, name });
}

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

@@ -3,4 +3,13 @@ export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
export { default as navigationAutomat } 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 artikelAutomat } from './artikelAutomat';
export {
getArticle,
hasArticle,
selectArticle,
createArticleSelector,
} from './artikelAutomat';

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

@@ -6,7 +6,7 @@ import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import articleAutomat, { createArticleSelector } from '../../store/articleAutomat';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import ArticleTileTab from './ArticleTileTab';
import ArticlePhotoTile from './ArticlePhotoTile';
@@ -26,17 +26,10 @@ class ArticleView extends PureComponent {
constructor(props, context) {
super(props, context);
const sku = props.sku;
const currentArticle = articleAutomat.state[sku] ?? null;
this.state = { article: currentArticle };
this.state = { article: getArticle(props.sku) };
this.handleArticleDeleted = this.handleArticleDeleted.bind(this);
if (!currentArticle) {
articleAutomat.actions.loadArticle(sku).catch((err) => {
console.error('Failed to load article:', err);
});
}
this.ensureArticle(props.sku);
}
componentDidMount() {
@@ -45,23 +38,24 @@ class ArticleView extends PureComponent {
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((err) => {
console.error('Failed to load article:', err);
});
}
this.setState({ article: getArticle(this.props.sku) });
this.ensureArticle(this.props.sku);
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() {
if (this.unsub) this.unsub();
const selector = createArticleSelector(this.props.sku);
this.unsub = articleAutomat.subscribe(this, (state) => ({
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: selector(state),
}));
}

View File

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

View File

@@ -33,10 +33,11 @@ class ManifestLines extends PureComponent {
onSearchArticle,
onRemoveSupplierArticle,
busySupplierArticleLineKeys = {},
wide = 3,
} = this.props;
return (
<PanelTile wide={3} minHeight={280} color={linesColor} title="Artikel">
<PanelTile wide={wide} minHeight={280} color={linesColor} title="Artikel">
<Stack spacing={1.5}>
<TableContainer>
<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 Attachment } from './Attachment';
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 { withManifestId } from './withManifestId';
export {

View File

@@ -1,9 +1,8 @@
import { PureComponent } from 'react';
import { PureComponent, createRef } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
@@ -12,6 +11,7 @@ import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import BugReportIcon from '@mui/icons-material/BugReport';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import ClearIcon from '@mui/icons-material/Clear';
import CloseIcon from '@mui/icons-material/Close';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
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
* state, readiness flags, subscriber counts and available actions.
* Ring buffer of captured action calls, oldest first. Kept at module scope so
* 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 {
constructor(props) {
@@ -177,11 +341,15 @@ class AutomatInspector extends PureComponent {
expanded: {},
query: '',
revision: 0,
logOpen: false,
log: [],
};
/** @type {Map<string, () => void>} Unsubscribe functions per automat name. */
this.unsubs = new Map();
this.frame = null;
this.logFrame = null;
this.logRef = createRef();
this.handleToggle = this.handleToggle.bind(this);
this.handleClose = this.handleClose.bind(this);
@@ -193,24 +361,35 @@ class AutomatInspector extends PureComponent {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.syncRegistry = this.syncRegistry.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() {
this.syncRegistry();
this.registryTimer = window.setInterval(this.syncRegistry, REGISTRY_POLL_MS);
window.addEventListener('keydown', this.handleKeyDown);
logListeners.add(this.scheduleLogUpdate);
this.scheduleLogUpdate();
}
componentWillUnmount() {
window.clearInterval(this.registryTimer);
window.removeEventListener('keydown', this.handleKeyDown);
logListeners.delete(this.scheduleLogUpdate);
this.unsubscribeAll();
if (this.frame) cancelAnimationFrame(this.frame);
if (this.logFrame) cancelAnimationFrame(this.logFrame);
}
/** Mirrors the registry keys into state and keeps subscriptions in sync. */
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 =
names.length !== this.state.names.length || names.some((name, i) => name !== this.state.names[i]);
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) {
if (!event.altKey || !event.shiftKey) return;
if (event.key?.toLowerCase() !== 'a') return;
@@ -262,6 +493,7 @@ class AutomatInspector extends PureComponent {
if (open) {
this.syncRegistry();
this.subscribeToAll();
this.scheduleLogUpdate();
} else {
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 needle = query.trim().toLowerCase();
const entries = names
@@ -444,41 +784,23 @@ class AutomatInspector extends PureComponent {
const allExpanded = names.length > 0 && names.every((name) => expanded[name]);
return (
<>
<Badge
badgeContent={names.length}
color="secondary"
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>
<Drawer
anchor="right"
open={open}
onClose={this.handleClose}
slotProps={{
transition: { timeout: 0 },
paper: {
sx: {
<Box
component="aside"
aria-label="Automat-Inspector"
sx={{
width: { xs: '100%', sm: 560 },
maxWidth: '100%',
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
@@ -525,10 +847,45 @@ class AutomatInspector extends PureComponent {
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
</Typography>
) : (
<Stack spacing={1}>{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}</Stack>
<Stack spacing={1}>
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
</Stack>
)}
</Box>
</Drawer>
{this.renderLog()}
</Box>
);
}
render() {
const { open, names } = this.state;
return (
<>
{open ? null : (
<Badge
badgeContent={names.length}
color="secondary"
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,5 +1,6 @@
import { createRoot } from 'react-dom/client';
import { lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { Provider } from 'react-redux';
@@ -43,12 +44,18 @@ createRoot(document.getElementById('root')).render(
>
<StorageManagerProvider>
<AuthBootstrap>
{/* Flex row so the dev inspector docks beside the app instead of
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 ? (
<Suspense fallback={null}>
<AutomatInspector />
</Suspense>
) : null}
</Box>
</AuthBootstrap>
</StorageManagerProvider>
</SnackbarProvider>

View File

@@ -1,8 +1,25 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { StorageManagerContext } from '../../api/storageManagerContext';
import BackendError from '../../components/BackendError';
import { ArticleView } from '../../components/article';
import {
artikelAutomat,
createArticleSelector,
getArticle,
setLastArticleSku,
} from '../../automats';
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 {
static contextType = StorageManagerContext;
@@ -10,42 +27,65 @@ class ArtikelSkuPage extends Component {
constructor(props, context) {
super(props, context);
this.state = {
ready:
context.getArticle(props.sku) != null &&
context.getCategoryState() != null,
article: getArticle(props.sku),
ready: context.getCategoryState() != null,
error: null,
};
}
componentDidMount() {
this._mounted = true;
this.installEscape();
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) {
if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku);
const ready =
this.context.getArticle(this.props.sku) != null &&
this.context.getCategoryState() != null;
if (ready !== this.state.ready) this.setState({ ready });
if (!ready) void this.ensureArticle();
this.setState({ article: getArticle(this.props.sku), error: null });
this.unsub?.();
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
void this.ensureArticle();
}
}
componentWillUnmount() {
this._mounted = false;
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() {
const sku = this.props.sku;
if (!sku) return;
try {
await Promise.all([
this.context.loadArticle(this.props.sku),
artikelAutomat.actions.loadArticle(sku),
this.context.categoryTreePromise,
this.context.companyPromise,
this.context.authMePromise,
]);
this.setState({ ready: true });
if (this._mounted) this.setState({ ready: true });
} catch (error) {
if (this._mounted) this.setState({ error });
}
}
handleRetry = () => {
this.setState({ error: null });
void this.ensureArticle();
};
installEscape() {
this._onKeyDown = (e) => {
@@ -65,7 +105,12 @@ class ArtikelSkuPage extends Component {
render() {
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} />;
}
}

View File

@@ -20,7 +20,8 @@ import {
emptyLine,
lineFromApi,
manifestConfig,
TextEditorDialog,
TextEditorPanel,
PdfViewerPanel,
ArticleSearchDialog,
} from '../../components/manifest';
import ManifestPdfDialog from '../../components/manifest/ManifestPdfDialog';
@@ -32,7 +33,7 @@ import { notifyError } from '../../utils/notify';
import { generatePdfThumbnail } from '../../utils/pdfThumbnail';
import { extractPdfText } from '../../utils/pdfText';
import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat';
import { setLastDeliveryPath } from '../../automats';
import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats';
import { delivery as deliveryApi } from '../../api';
class LieferungNeuPage extends PureComponent {
@@ -54,6 +55,9 @@ class LieferungNeuPage extends PureComponent {
pdfFileName: '',
textEditorOpen: false,
textEditorAttachment: null,
viewerPosition: userSettingsAutomat.state.viewerPosition,
pdfViewerOpen: false,
pdfViewerAttachment: null,
articleSearchDialogOpen: false,
articleSearchLine: null,
unmatchedSupplierName: null,
@@ -77,6 +81,8 @@ class LieferungNeuPage extends PureComponent {
this.handleAbortParse = this.handleAbortParse.bind(this);
this.handleAttachmentClick = this.handleAttachmentClick.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.handleTextEditorContentLoaded = this.handleTextEditorContentLoaded.bind(this);
this.ensureAttachmentFile = this.ensureAttachmentFile.bind(this);
@@ -90,6 +96,9 @@ class LieferungNeuPage extends PureComponent {
componentDidMount() {
this.unsubscribe = this.automat.subscribe(this);
this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({
viewerPosition: state.viewerPosition,
}));
this._cancelled = false;
if (this.props.docId != null) {
setLastDeliveryPath(`/lieferung/${this.props.docId}`);
@@ -131,6 +140,7 @@ class LieferungNeuPage extends PureComponent {
this._skuDebounceTimers = null;
}
this.unsubscribe?.();
this._settingsUnsub?.();
}
async loadPartiesOnly() {
@@ -862,31 +872,22 @@ class LieferungNeuPage extends PureComponent {
this.setState({
textEditorOpen: true,
textEditorAttachment: attachment,
pdfViewerOpen: false,
pdfViewerAttachment: null,
});
return;
}
if (isPdf) {
if (
(attachment.file instanceof Blob || attachment.file instanceof File) &&
attachment.file.size > 0
) {
const url = URL.createObjectURL(attachment.file);
window.open(url, '_blank');
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',
);
this.setState({
pdfViewerOpen: true,
pdfViewerAttachment: attachment,
textEditorOpen: false,
textEditorAttachment: null,
});
void this.ensureAttachmentFile(attachment);
return;
}
}
if (attachment.file instanceof Blob || attachment.file instanceof File) {
if (attachment.file.size > 0) {
@@ -913,6 +914,88 @@ class LieferungNeuPage extends PureComponent {
});
}
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),
);
}
handleSaveTextEditor(newText) {
const attachment = this.state.textEditorAttachment;
if (!attachment) return;
@@ -1067,6 +1150,7 @@ class LieferungNeuPage extends PureComponent {
onNumberChange={this.handleNumberChange}
onExternalNumberChange={this.handleExternalNumberChange}
/>
{this.state.viewerPosition === 'top' && this.renderViewerPanels(3)}
{partyId == null && (!lines || lines.length === 0) ? (
<PanelTile wide={3} minHeight={160} color="#e2e8f0">
{this.state.unmatchedSupplierName ? (
@@ -1120,6 +1204,11 @@ class LieferungNeuPage extends PureComponent {
columns={DELIVERY_LINE_COLUMNS}
lines={lines}
saving={saving}
wide={
this.state.viewerPosition === 'right' && this.isViewerOpen()
? 2
: 3
}
linesColor={cfg.linesColor}
supplierArticlesMap={this.state.supplierArticlesMap}
supplierArticlesByLineKey={this.state.supplierArticlesByLineKey}
@@ -1137,6 +1226,8 @@ class LieferungNeuPage extends PureComponent {
/>
</>
)}
{this.state.viewerPosition === 'right' && this.renderViewerPanels(2)}
{this.state.viewerPosition === 'below' && this.renderViewerPanels(3)}
{notes && notes.length > 0 && (
<PanelTile wide={3} color="#fffbeb">
<Typography variant="subtitle2" fontWeight={700} color="warning.dark" sx={{ mb: 1.5 }}>
@@ -1162,14 +1253,6 @@ class LieferungNeuPage extends PureComponent {
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
open={this.state.articleSearchDialogOpen}
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;