u
This commit is contained in:
207
.agents/skills/backend/SKILL.md
Normal file
207
.agents/skills/backend/SKILL.md
Normal 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
536
AGENTS.md
Normal 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
711
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
190
src/automats/artikelAutomat.js
Normal file
190
src/automats/artikelAutomat.js
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
48
src/automats/userSettingsAutomat.js
Normal file
48
src/automats/userSettingsAutomat.js
Normal 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;
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
296
src/components/manifest/PdfViewerPanel.jsx
Normal file
296
src/components/manifest/PdfViewerPanel.jsx
Normal 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;
|
||||
@@ -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;
|
||||
381
src/components/manifest/TextEditorPanel.jsx
Normal file
381
src/components/manifest/TextEditorPanel.jsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,91 +784,108 @@ 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 }}
|
||||
<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
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<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>
|
||||
<BugReportIcon fontSize="small" color="primary" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
|
||||
Automat-Inspector
|
||||
</Typography>
|
||||
<Chip size="small" label={`${names.length} registriert`} />
|
||||
<Tooltip title="Registry neu einlesen">
|
||||
<IconButton size="small" onClick={this.handleRefresh}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
|
||||
<IconButton size="small" onClick={this.handleToggleAll}>
|
||||
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Schließen">
|
||||
<IconButton size="small" onClick={this.handleClose}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Drawer
|
||||
anchor="right"
|
||||
open={open}
|
||||
onClose={this.handleClose}
|
||||
slotProps={{
|
||||
transition: { timeout: 0 },
|
||||
paper: {
|
||||
sx: {
|
||||
width: { xs: '100%', sm: 560 },
|
||||
maxWidth: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{ px: 2, py: 1.25, borderBottom: '1px solid', borderColor: 'divider' }}
|
||||
>
|
||||
<BugReportIcon fontSize="small" color="primary" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, flexGrow: 1 }}>
|
||||
Automat-Inspector
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Name filtern …"
|
||||
value={query}
|
||||
onChange={this.handleQueryChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
|
||||
{entries.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||
{names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'}
|
||||
</Typography>
|
||||
<Chip size="small" label={`${names.length} registriert`} />
|
||||
<Tooltip title="Registry neu einlesen">
|
||||
<IconButton size="small" onClick={this.handleRefresh}>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={allExpanded ? 'Alle einklappen' : 'Alle ausklappen'}>
|
||||
<IconButton size="small" onClick={this.handleToggleAll}>
|
||||
{allExpanded ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Schließen">
|
||||
<IconButton size="small" onClick={this.handleClose}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack spacing={1}>
|
||||
{entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, py: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder="Name filtern …"
|
||||
value={query}
|
||||
onChange={this.handleQueryChange}
|
||||
/>
|
||||
</Box>
|
||||
{this.renderLog()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
<Box sx={{ flexGrow: 1, minHeight: 0, overflowY: 'auto', px: 1.5, pb: 2 }}>
|
||||
{entries.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ p: 2 }}>
|
||||
{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>
|
||||
)}
|
||||
</Box>
|
||||
</Drawer>
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
19
src/main.jsx
19
src/main.jsx
@@ -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>
|
||||
<App />
|
||||
{AutomatInspector ? (
|
||||
<Suspense fallback={null}>
|
||||
<AutomatInspector />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{/* 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>
|
||||
|
||||
@@ -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,43 +27,66 @@ 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() {
|
||||
await Promise.all([
|
||||
this.context.loadArticle(this.props.sku),
|
||||
this.context.categoryTreePromise,
|
||||
this.context.companyPromise,
|
||||
this.context.authMePromise,
|
||||
]);
|
||||
this.setState({ ready: true });
|
||||
const sku = this.props.sku;
|
||||
if (!sku) return;
|
||||
try {
|
||||
await Promise.all([
|
||||
artikelAutomat.actions.loadArticle(sku),
|
||||
this.context.categoryTreePromise,
|
||||
this.context.companyPromise,
|
||||
this.context.authMePromise,
|
||||
]);
|
||||
if (this._mounted) this.setState({ ready: true });
|
||||
} catch (error) {
|
||||
if (this._mounted) this.setState({ error });
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ error: null });
|
||||
void this.ensureArticle();
|
||||
};
|
||||
|
||||
installEscape() {
|
||||
this._onKeyDown = (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,30 +872,21 @@ 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',
|
||||
);
|
||||
void this.ensureAttachmentFile(attachment);
|
||||
return;
|
||||
}
|
||||
this.setState({
|
||||
pdfViewerOpen: true,
|
||||
pdfViewerAttachment: attachment,
|
||||
textEditorOpen: false,
|
||||
textEditorAttachment: null,
|
||||
});
|
||||
void this.ensureAttachmentFile(attachment);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment.file instanceof Blob || attachment.file instanceof File) {
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user