From 536e7b1e2a47a4a54a86a39298cb95b9efe42a85 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 21 Sep 2026 20:21:57 +0200 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 207 +++++ AGENTS.md | 536 +++++++++++++ package-lock.json | 711 +++++++++++++++++- package.json | 1 + src/api/StorageManager.js | 58 +- src/automats/artikelAutomat.js | 190 +++++ src/automats/index.js | 9 + src/automats/userSettingsAutomat.js | 48 ++ src/components/article/ArticleView.jsx | 38 +- .../article/tabs/ArticleSettingsTab.jsx | 2 + src/components/manifest/ManifestLines.jsx | 3 +- src/components/manifest/PdfViewerPanel.jsx | 296 ++++++++ src/components/manifest/TextEditorDialog.jsx | 340 --------- src/components/manifest/TextEditorPanel.jsx | 381 ++++++++++ src/components/manifest/index.js | 3 +- src/devtools/AutomatInspector.jsx | 531 ++++++++++--- src/main.jsx | 19 +- src/pages/artikel/sku.jsx | 81 +- src/pages/lieferung/neu.jsx | 141 +++- src/store/articleAutomat.js | 124 --- 20 files changed, 3047 insertions(+), 672 deletions(-) create mode 100644 .agents/skills/backend/SKILL.md create mode 100644 AGENTS.md create mode 100644 src/automats/artikelAutomat.js create mode 100644 src/automats/userSettingsAutomat.js create mode 100644 src/components/manifest/PdfViewerPanel.jsx delete mode 100644 src/components/manifest/TextEditorDialog.jsx create mode 100644 src/components/manifest/TextEditorPanel.jsx delete mode 100644 src/store/articleAutomat.js diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md new file mode 100644 index 0000000..0e11150 --- /dev/null +++ b/.agents/skills/backend/SKILL.md @@ -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` + - `delete(id: number): Promise` + - `getByEmail(email: string): Promise>` +- [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` + - `getById(id: number, tx?: object): Promise` + - `getByName(name: string, tx?: object): Promise` + - `list(googleAuthId: number): Promise>` + - `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` + - `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` + - `getCategoryArticles(googleAuthId: number, categoryId: number): Promise>` + - `getTreeForCompany(googleAuthId: number): Promise<{languageId: number, tree: Array, articles: Array}>` + - `move(input: {googleAuthId: number, id: number, parentId: number}): Promise<{id: number, parentId: number, sort: number}>` + - `order(input: {googleAuthId: number, parentId: number, categoryIds: Array}): Promise<{categoryIds: Array}>` + - `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` + - `deleteImage(googleAuthId: number, sku: string, fullHash: string): Promise` + - `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>` + - `importRows(input: {googleAuthId: number, rows: Array}): Promise<{imported: Array, skipped: Array, errors: Array}>` + - `listExport(googleAuthId: number, categoryId?: number|null): Promise>` + - `listSearchIndex(googleAuthId: number): Promise, categoryIds: Array}>>` + - `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array}): Promise<{fullHashes: Array}>` + - `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null): Promise` + - `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array): Promise` + - `setEansBySku(googleAuthId: number, sku: string, eans: Array): Promise` + - `setFatherBySku(googleAuthId: number, sku: string, fatherSku: string|null): Promise` + - `setPricesBySku(googleAuthId: number, sku: string, prices: Array<{price: number, tax: number, minmultiplier?: number}>): Promise` + - `setWeightBySku(googleAuthId: number, sku: string, weight: number): Promise` + - `updateBySku(googleAuthId: number, sku: string, patch: {name?: string, unitSi?: string, unitamount?: number}): Promise` +- [lang](API.lang.md) + - `create(iso639: string, tx?: object): Promise` + - `delete(id: number): Promise` + - `ensure(iso639: string, tx?: object): Promise` + - `getByIso639(iso639: string, tx?: object): Promise<{id: number, iso639: string}>` + - `list(): Promise>` +- [tax](API.tax.md) + - `create(rate: number, tx?: object): Promise` + - `ensure(rate: number, tx?: object): Promise` + - `getByRate(rate: number, tx?: object): Promise<{id: number, rate: number}>` + - `list(): Promise>` +- [unit](API.unit.md) + - `delete(googleAuthId: number, si: string): Promise` + - `list(googleAuthId: number): Promise>` + - `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>` + - `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` + - `list(googleAuthId: number): Promise>` + - `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` + - `list(googleAuthId: number): Promise>` + - `order(googleAuthId: number, input: {customergroupIds: Array}): Promise<{customergroupIds: Array}>` + - `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` + - `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` + - `list(googleAuthId: number, options?: {customergroupId?: number, role?: string}): Promise>` + - `peekNextNumber(googleAuthId: number): Promise` + - `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` + - `list(googleAuthId: number, entityId: number): Promise>` + - `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` + - `list(googleAuthId: number, entityId: number): Promise>` + - `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` + - `list(googleAuthId: number): Promise>` + - `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` + - `listByArticleSku(googleAuthId: number, sku: string): Promise>` + - `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string): Promise, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>` + - `order(googleAuthId: number, sku: string, input: {ids: Array}): Promise<{ids: Array}>` + - `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` + - `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>` + - `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` + - `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>` + - `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` + - `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>` + - `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}): Promise<{ids: Array}>` + - `remove(googleAuthId: number, manifestId: number, id: number): Promise` +- [posterminal](API.posterminal.md) + - `delete(googleAuthId: number, id: number): Promise` + - `list(googleAuthId: number): Promise>` + - `setOff(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array}>` + - `setOffAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array}>` + - `setOn(googleAuthId: number, posterminalId: number, categoryId: number): Promise<{id: number, categoryIds: Array}>` + - `setOnAll(googleAuthId: number, posterminalId: number): Promise<{id: number, categoryIds: Array}>` + - `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` + - `getByEmail(email: string): Promise` + - `getById(googleAuthId: number): Promise` + - `listAllowedEmails(): Promise>` + - `removeAllowedEmail(email: string): Promise` + - `setProfile(googleAuthId: number, input: {picture?: string, pictureBinary?: Buffer, sub?: string}): Promise` + - `setSelectedCompany(googleAuthId: number, companyId: number): Promise` +- [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>` + - `update(googleAuthId: number, id: number, input: {prefix?: string, padding?: number, next?: number}): Promise<{id: number, type: string, prefix: string, padding: number, next: number}>` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..def4dfa --- /dev/null +++ b/AGENTS.md @@ -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..()) + 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()` / `` 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
Lädt...
; + return
{article.name}
; + } + } + ``` + +### 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>` 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

{article?.name ?? 'Lädt...'}

; + } +} +``` + +### 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; +} +``` diff --git a/package-lock.json b/package-lock.json index f3bc8ef..83c8428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index e0cda11..ad25583 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/StorageManager.js b/src/api/StorageManager.js index 9198fa5..cfde2fe 100644 --- a/src/api/StorageManager.js +++ b/src/api/StorageManager.js @@ -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 | null} */ #articleIndexCache = null; - /** @type {Map} sku → article detail */ - #articlesBySku = new Map(); /** @type {Map void>>} sku → article listeners */ #articleWatchers = new Map(); /** @type {Map 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-`). * @param {string} sku @@ -334,12 +334,12 @@ 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 }); } diff --git a/src/automats/artikelAutomat.js b/src/automats/artikelAutomat.js new file mode 100644 index 0000000..fb726a6 --- /dev/null +++ b/src/automats/artikelAutomat.js @@ -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} 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>} + */ +const inflight = new Map(); + +/** @type {Automat} */ +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} 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} 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} + */ +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} Scoped slice. + */ +export function selectArticle(sku) { + return artikelAutomat.select(createArticleSelector(sku)); +} + +export default artikelAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index 641e3a9..fe94911 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -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'; diff --git a/src/automats/userSettingsAutomat.js b/src/automats/userSettingsAutomat.js new file mode 100644 index 0000000..badfdea --- /dev/null +++ b/src/automats/userSettingsAutomat.js @@ -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} + */ +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; diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 6b09518..47ca88d 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -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'; @@ -25,18 +25,11 @@ 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), })); } diff --git a/src/components/article/tabs/ArticleSettingsTab.jsx b/src/components/article/tabs/ArticleSettingsTab.jsx index 4af7c5f..300ab30 100644 --- a/src/components/article/tabs/ArticleSettingsTab.jsx +++ b/src/components/article/tabs/ArticleSettingsTab.jsx @@ -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); diff --git a/src/components/manifest/ManifestLines.jsx b/src/components/manifest/ManifestLines.jsx index caebfa8..7b44949 100644 --- a/src/components/manifest/ManifestLines.jsx +++ b/src/components/manifest/ManifestLines.jsx @@ -33,10 +33,11 @@ class ManifestLines extends PureComponent { onSearchArticle, onRemoveSupplierArticle, busySupplierArticleLineKeys = {}, + wide = 3, } = this.props; return ( - + diff --git a/src/components/manifest/PdfViewerPanel.jsx b/src/components/manifest/PdfViewerPanel.jsx new file mode 100644 index 0000000..7bdc172 --- /dev/null +++ b/src/components/manifest/PdfViewerPanel.jsx @@ -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 ( + + + + + + + + {filename} + + + PDF-Vorschau + + + + + + onPositionChange?.('top')} + > + + + + + onPositionChange?.('right')} + > + + + + + onPositionChange?.('below')} + > + + + + + + + + + + + {loading && ( + + + + )} + {error ? ( + + {error} + + ) : ( + url && ( + + ) + )} + + + + + + + + + + + + ); + } +} + +export default PdfViewerPanel; \ No newline at end of file diff --git a/src/components/manifest/TextEditorDialog.jsx b/src/components/manifest/TextEditorDialog.jsx deleted file mode 100644 index f706492..0000000 --- a/src/components/manifest/TextEditorDialog.jsx +++ /dev/null @@ -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 ( - - - - - - - {filename} - - - {linesCount} Zeilen · {charCount} Zeichen - - - {isDirty && ( - - )} - - - - - - - - {loading && ( - - - - )} - - - - - - - - - - - {!readOnly && ( - - )} - - - - ); - } -} - -export default TextEditorDialog; diff --git a/src/components/manifest/TextEditorPanel.jsx b/src/components/manifest/TextEditorPanel.jsx new file mode 100644 index 0000000..9a64972 --- /dev/null +++ b/src/components/manifest/TextEditorPanel.jsx @@ -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 ( + + + + + + + + {filename} + + + {linesCount} Zeilen · {charCount} Zeichen + + + {isDirty && ( + + )} + + + + onPositionChange?.('top')} + > + + + + + onPositionChange?.('right')} + > + + + + + onPositionChange?.('below')} + > + + + + + + + + + + + {loading && ( + + + + )} + + + + + + + + + + + {!readOnly && ( + + )} + + + + + ); + } +} + +export default TextEditorPanel; \ No newline at end of file diff --git a/src/components/manifest/index.js b/src/components/manifest/index.js index cb4c23a..565f86c 100644 --- a/src/components/manifest/index.js +++ b/src/components/manifest/index.js @@ -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 { diff --git a/src/devtools/AutomatInspector.jsx b/src/devtools/AutomatInspector.jsx index 7254d1f..eefe56e 100644 --- a/src/devtools/AutomatInspector.jsx +++ b/src/devtools/AutomatInspector.jsx @@ -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, expanded: Object, 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, expanded: Object, query: string, revision: number, logOpen: boolean, log: Array}>} */ class AutomatInspector extends PureComponent { constructor(props) { @@ -177,11 +341,15 @@ class AutomatInspector extends PureComponent { expanded: {}, query: '', revision: 0, + logOpen: false, + log: [], }; /** @type {Map 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 ( + + + {formatTime(entry.time)}{' '} + + + {entry.automat}.{entry.action} + + + ({entry.args}) + + {entry.status === 'error' ? ( + + {' '} + ✗ {entry.error} + + ) : entry.status === 'ok' ? ( + + {' '} + → {entry.result} + + ) : null}{' '} + + {entry.status === 'running' + ? '…' + : `${entry.status === 'error' ? '✗' : '✓'} ${Math.round(entry.duration)}ms`} + + + ); + } + + /** 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 ( + + + + {logOpen ? : } + + Action-Log + + + {errorCount > 0 ? : null} + + + + + + + + + + + + + + + + + + {logOpen ? ( + + {log.length === 0 ? ( + + Noch keine Action-Aufrufe protokolliert. + + ) : ( + log.map((entry) => this.renderLogLine(entry)) + )} + + ) : null} + + ); + } + + /** 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 ( - <> - theme.zIndex.modal + 1 }} + theme.zIndex.appBar, + }} + > + - - - - + + + Automat-Inspector + + + + + + - + + + {allExpanded ? : } + + + + + + + + - - - - - Automat-Inspector + + + + + + {entries.length === 0 ? ( + + {names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'} - - - - - - - - - {allExpanded ? : } - - - - - - - - + ) : ( + + {entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))} + + )} + - - - + {this.renderLog()} + + ); + } - - {entries.length === 0 ? ( - - {names.length === 0 ? 'Kein Automat registriert.' : 'Kein Automat passt zum Filter.'} - - ) : ( - {entries.map((automat) => this.renderEntry(automat, Boolean(expanded[automat.name])))} - )} - - + render() { + const { open, names } = this.state; + + return ( + <> + {open ? null : ( + theme.zIndex.modal + 1 }} + > + + + + + + + )} + + {open ? this.renderPanel() : null} ); } diff --git a/src/main.jsx b/src/main.jsx index fe1da17..f69306b 100644 --- a/src/main.jsx +++ b/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( > - - {AutomatInspector ? ( - - - - ) : null} + {/* Flex row so the dev inspector docks beside the app instead of + overlaying it; the app content shrinks while the panel is open. */} + + + + + {AutomatInspector ? ( + + + + ) : null} + diff --git a/src/pages/artikel/sku.jsx b/src/pages/artikel/sku.jsx index 93d071d..71f12ea 100644 --- a/src/pages/artikel/sku.jsx +++ b/src/pages/artikel/sku.jsx @@ -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 ( + + + + ); +} 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 ; + } + if (!article || !ready) return ; return ; } } diff --git a/src/pages/lieferung/neu.jsx b/src/pages/lieferung/neu.jsx index 8c725a2..dcc72cc 100644 --- a/src/pages/lieferung/neu.jsx +++ b/src/pages/lieferung/neu.jsx @@ -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( + , + ); + } + + if (pdfViewerOpen && pdfViewerAttachment) { + panels.push( + , + ); + } + + if (panels.length === 0) return null; + + return ( + = 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} + + ); + } + + 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) ? ( {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 && ( @@ -1162,14 +1253,6 @@ class LieferungNeuPage extends PureComponent { onAbort={this.handleAbortParse} /> - ({ - * article: selector(state), - * })); - * } - * - * componentWillUnmount() { - * if (this.unsub) { - * this.unsub(); - * this.unsub = null; - * } - * } - * - * render() { - * const { article } = this.state; - * if (!article) return null; - * return
{article.name}
; - * } - * } - * ``` - */ -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;