diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 53b5f7a..dc8720f 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -609,6 +609,7 @@ _none_ - Returns: `200 SSE` - Events: + - `company`: `{ companyId }` - `category`: `{}` - `articles`: `{}` - `article-`: `{}` diff --git a/automat-graph.dot b/automat-graph.dot index 0e07c3c..ac005ca 100644 --- a/automat-graph.dot +++ b/automat-graph.dot @@ -9,19 +9,23 @@ digraph automats { label="Session & Real-Time Hub"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - authAutomat [label=<
authAutomat
Session & Multi-Tenant Gateway
STATEuser: AuthUserCurrent user & company
STATEstatus'anon' | 'authed' | 'loading'
ACTIONbootstrapSession()Restore or fetch session
ACTIONswitchCompany(id)Multi-tenant switch
ACTIONlogout()Clear session cookie
SELECTselectSelectedCompanyId()Tenant selector
HOOKauth/sessionEstablishedSession ready lifecycle
HOOKauth/companyChangedCompany change signal
APIGET /api/auth/sessionCookie session check
>]; + authAutomat [label=<
authAutomat
Always-On Session & Multi-Tenant Gateway
STATEhasAuth: booleanAuthenticated session bit
STATEhasCompany: booleanhasAuth && selectedCompanyId
STATEuser: AuthUserCurrent user & company
STATEstatus'anon' | 'authed' | 'loading'
ACTIONbootstrapSession()Restore or fetch session
ACTIONswitchCompany(id)Multi-tenant switch
ACTIONlogout()Clear session cookie
SELECTselectHasAuth() / selectHasCompany()Auth & tenant gates
APIGET /api/auth/sessionCookie session check
>]; sseAutomat [label=<
sseAutomat
Single Persistent SSE Connection
STATEstatus'connected' | 'error'
STATEclientId (cid)Tab-scoped UUID in session
ACTIONreconnect()Cycle on company switch
ACTIONon(event, handler)Register signal listener
ACTIONwatchArticle(sku)Attach article-<sku>
SELECTinvalidateArticle(sku)Silent cache refresh
SSEcategory / articlesCatalog mutation events
SSEorder / shipmentFulfillment events
APIGET /api/eventsPersistent stream
>]; } + subgraph cluster_company_basedata { + label="Company & Consolidated Base Data"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + companyAutomat [label=<
companyAutomat
Consolidated Company & Base Data Hub
STATEcurrent: CompanyTax rates, legal & root cat
STATEnumbers: NumberSeq[]Document number sequences
STATEterminals: Posterminal[]POS hardware & categories
STATEsuppliers: Supplier[]Vendors & supplier articles
STATEunits: Unit[]Allowed SI & sales units
STATEviewerPosition / chatDrawerWidthPersisted user UI settings
STATEwarehouses: Warehouse[]Storage locations & stock
STATEproperties: PropertyName[]Custom text properties
STATEcustomergroups: Group[]Pricing discount tiers
STATEcarriers: Carrier[]Shipping methods & carriers
STATEbrands: Brand[]Catalog brands & logos
STATEattributes: AttributeName[]Attributes & discrete values
STATEaccounts: Account[]Bank & payment ledgers
ACTIONloadAll() / reset()Fetch or reset all base data
SELECTselectCompanyRootCategoryId()Root category pointer
API/api/company, /api/brand, …13 consolidated endpoints
>]; + } subgraph cluster_catalog_products { label="Catalog & Products"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - categoryAutomat [label=<
categoryAutomat
Hierarchical Category Tree
STATEcategories: MapID -> Category Node Map
STATErootCategoryIdCompany root node ID
STATEarticleCountTotal parent items
ACTIONloadTree()Fetch tree & warm views
ACTIONcreateCategory()Insert child node
ACTIONmoveCategory()Parent reparenting
ACTIONdeleteCategory()Prune subtree
SELECTgetCategoryBreadcrumbs()Hierarchical path trace
APIGET /api/categoryRecursive tree load
>]; + categoryAutomat [label=<
categoryAutomat
Hierarchical Category Tree
STATEcategories: MapID -> Category Node Map
STATErootCategoryIdCompany root node ID
STATEarticleCountTotal parent items
ACTIONloadTree()Fetch tree & warm views
ACTIONcreateCategory()Insert child node
ACTIONmoveCategory()Parent reparenting
ACTIONdeleteCategory()Prune subtree
SELECTgetCategoryBreadcrumbs()Hierarchical path trace
APIGET /api/categoryRecursive tree load
>]; categoryViewAutomat [label=<
categoryViewAutomat
KeyedAutomat (Slug Path)
STATEviews: Map<slug, Data>Articles & direct children
ACTIONloadAllCategoryViews()Prewarm all tree slugs
ACTIONreloadLoadedCategoryViews()Refresh cached views
ACTIONinvalidateAllCategoryViews()Clear on SSE signal
SELECTcollectLoadedCategoryViewData()Extract union data
SELECTgetOrCreateCategoryAutomat()Per-slug reactive store
APIGET /api/category/:id/articlesCategory article batch
>]; artikelAutomat [label=<
artikelAutomat
In-Memory Article Record Cache
STATEarticles: Map<sku, Object>Deduplicated SKU cache
ACTIONloadArticle(sku)Fetch or cached promise
ACTIONreloadArticle(sku)Silent background sync
ACTIONsetArticleName(sku, name)Optimistic rename
ACTIONclearArticle(sku)Evict SKU from cache
SELECTgetArticle(sku)Sync constructor read
SELECTcreateArticleSelector(sku)Fine-grained selector
APIGET /api/article/:skuFull article document
>]; articleDetailAutomat [label=<
articleDetailAutomat
KeyedAutomat (SKU)
STATEarticleHistory: Map<sku>Change history log
STATEarticleSupplier: Map<sku>Assigned supplier list
ACTIONarticleHistoryAutomat.load()Fetch SKU audit trail
ACTIONarticleSupplierAutomat.load()Fetch supplier catalog
APIGET /api/article/:sku/historyAudit change entries
APIGET /api/article/:sku/supplierVendor links & prices
>]; - brandAutomat [label=<
brandAutomat
Catalog Brands & Makers
STATEbrands: Brand[]Decoupled brand catalog
STATEfilteredBrands: Brand[]Filtered search slice
ACTIONload(silent)Deduplicated list fetch
ACTIONcreate() / rename()Brand mutations
ACTIONaddImage() / deleteImage()Brand logo AVIF image
APIGET /api/brandPaginated brand records
SSEbrandSSE signal with sender tab
>]; - unitAutomat [label=<
unitAutomat
Units of Measure (UoM)
STATEunits: Unit[]Allowed SI & sales units
STATEstatus: 'idle'|'ready'Load lifecycle state
ACTIONload()Fetch localized units
ACTIONupsert(body)Create or update unit
ACTIONdelete(id)Remove unused unit
APIGET /api/unitCompany unit directory
>]; } subgraph cluster_search_indexing { label="Search & Indexing"; labeljust="l"; fontsize=15; @@ -30,49 +34,20 @@ digraph automats { articleIndexAutomat [label=<
articleIndexAutomat
Derived Client Search Index
STATEitems: ArticleIndexRecord[]De-duplicated article union
STATEstatus: 'ready'Index lifecycle flag
ACTIONbuildArticleIndex()Derive from category views
ACTIONsetArticleIndexName()Patch on rename
ACTIONreload()Re-derive from views
SELECTselectArticleIndexItems()Fast record projection
SELECTensureLoaded()Await warm views
>]; articleSearchAutomat [label=<
articleSearchAutomat
In-Memory FlexSearch Engine
STATEtarget: stringActive search query
STATEfilter: stringActive category filter
STATEresult: ArticleSearchResultFacets, recordset, count
ACTIONsearch(target, filter)Synchronous indexed query
SELECTselectSearchResult()Reactive search outcome
>]; } - subgraph cluster_properties_attributes { - label="Properties & Attributes"; labeljust="l"; fontsize=15; - style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; - fontcolor="#e2e8f0"; margin=14; - propertyAutomat [label=<
propertyAutomat
Custom Metadata Properties
STATEproperties: PropertyName[]Sorted company properties
ACTIONload()Fetch property definitions
ACTIONcreateProperty()Add new property definition
ACTIONupdateProperty()Edit title & sort
APIGET /api/propertynameCompany properties
SSEpropertynameReal-time property signal
>]; - attributeAutomat [label=<
attributeAutomat
Product Attributes & Discrete Values
STATEattributes: AttributeName[]Attributes with sorted values
ACTIONload()Fetch attribute definitions
ACTIONcreateAttribute()Create attribute group
ACTIONaddValue() / deleteValue()Discrete value options
APIGET /api/attributenameAttributes & values
SSEattributenameReal-time attribute signal
>]; - } subgraph cluster_purchasing_logistics { - label="Suppliers & Deliveries"; labeljust="l"; fontsize=15; + label="Deliveries & Inbound"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - supplierAutomat [label=<
supplierAutomat
Supplier Directory & Catalogs
STATEsuppliers: Supplier[]Active vendor list
STATEstatus: 'ready'Load lifecycle state
ACTIONload()Deduplicated fetch
ACTIONreload()Silent background sync
APIGET /api/supplierVendor records
SSEsupplierReal-time supplier signal
>]; - deliveryAutomat [label=<
deliveryAutomat
createListAutomat (Deliveries)
STATEitems: Delivery[]Company delivery receipts
STATEstatus: 'ready'List lifecycle state
ACTIONload() / reload()Deduplicated fetch & sync
ACTIONreset()Clear on session switch
APIGET /api/deliveryInbound delivery log
>]; + deliveryAutomat [label=<
deliveryAutomat
Chunk-Paginated Deliveries
STATEitems: Delivery[]Company delivery receipts
STATEstatus: 'ready'List lifecycle state
ACTIONload() / reload()Deduplicated fetch & sync
ACTIONreset()Clear on session switch
APIGET /api/deliveryInbound delivery log
>]; deliveryEditorAutomat [label=<
deliveryEditorAutomat
Inbound Receipt & OCR Machine
STATElines: DeliveryLine[]Uncommitted draft lines
STATEdeliveryEntitySelected supplier ID
STATEattachments: File[]Invoice PDFs & OCR results
STATEdraftCount: numberUncommitted lines for badge
ACTIONaddLine() / updateLine()Interactive item editing
ACTIONcommitDelivery()Persist goods receipt
SELECTbuildSupplierArticlesMap()Auto-match SKU/EAN
APIPOST /api/deliveryCreate goods receipt
>]; } subgraph cluster_sales_crm { label="Sales & CRM"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - quoteAutomat [label=<
quoteAutomat
createListAutomat (Quotes)
STATEitems: Quote[]Sales quotes / Angebote
ACTIONload() / reload()Deduplicated fetch
ACTIONreset()Reset on logout
APIGET /api/quoteCompany quote list
>]; + quoteAutomat [label=<
quoteAutomat
Chunk-Paginated Quotes
STATEitems: Quote[]Sales quotes / Angebote
ACTIONload() / reload()Deduplicated fetch
ACTIONreset()Reset on logout
APIGET /api/quoteCompany quote list
>]; orderAutomat [label=<
orderAutomat
Chunk-Paginated Order Engine
STATEitems: Order[]Sales orders & lines
STATEtotal: numberUnfiltered order count
STATEonlyUnpaid / onlyUndeliveredFulfillment filters
ACTIONloadChunk(index)Chunk size 50 prefetch
ACTIONreload()Silent orders re-fetch
SELECTselectOrders() / selectTotal()Filter-applied slice
APIGET /api/orderPaged order stream
SSEorder / shipmentFulfillment mutations
>]; - entityAutomat [label=<
entityAutomat
createListAutomat (Entities)
STATEitems: Entity[]Customers & partners
ACTIONload() / reload()Customer directory load
ACTIONreset()Reset on session switch
APIGET /api/entityDirectory records
>]; - customergroupAutomat [label=<
customergroupAutomat
createListAutomat (Groups)
STATEitems: Customergroup[]Pricing discount tiers
ACTIONload() / reload()Discount group load
APIGET /api/customergroupPricing tiers
>]; - } - subgraph cluster_inventory_storage { - label="Inventory & Warehousing"; labeljust="l"; fontsize=15; - style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; - fontcolor="#e2e8f0"; margin=14; - warehouseAutomat [label=<
warehouseAutomat
Warehouse Locations & Stock
STATEwarehouses: Warehouse[]Storage areas & bins
STATEfilteredWarehousesFilter search slice
ACTIONload() / reload()Deduplicated sync
ACTIONsetFilter(query)Local filter query
APIGET /api/warehouseWarehouse listings
SSEwarehouseReal-time stock movement
>]; - } - subgraph cluster_finance_config { - label="Company & Finance"; labeljust="l"; fontsize=15; - style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; - fontcolor="#e2e8f0"; margin=14; - companyAutomat [label=<
companyAutomat
Active Company Configuration
STATEcurrent: CompanyTax rates & ISO language
ACTIONload() / reload()Fetch tenant settings
SELECTselectCompanyTaxrate()Standard tax rate
SELECTselectCompanyRootCategoryId()Root category pointer
SELECTselectCompanyName()Company legal name
APIGET /api/company/:idLegal & tax profile
>]; - accountAutomat [label=<
accountAutomat
createListAutomat (Accounts)
STATEitems: Account[]Bank & payment ledgers
ACTIONload() / reload()Payment accounts load
APIGET /api/accountChart of accounts
>]; - numberAutomat [label=<
numberAutomat
createListAutomat (Sequences)
STATEitems: NumberSeq[]Invoice/receipt counters
ACTIONload() / reload()Number series sync
APIGET /api/numberDocument counters
>]; - } - subgraph cluster_hardware_pos { - label="POS & Hardware"; labeljust="l"; fontsize=15; - style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; - fontcolor="#e2e8f0"; margin=14; - posterminalAutomat [label=<
posterminalAutomat
POS Terminals & Gating
STATEterminals: Posterminal[]Hardware terminals
ACTIONload()Fetch POS registers
ACTIONcreate() / delete()Terminal registrations
ACTIONtoggleCategory()Gated category filters
SELECTselectPosterminals()Active registers
APIGET /api/posterminalTerminal device configs
>]; + entityAutomat [label=<
entityAutomat
Customer & Partner Directory
STATEitems: Entity[]Customers & partners
ACTIONload() / reload()Customer directory load
ACTIONreset()Reset on session switch
APIGET /api/entityDirectory records
>]; } subgraph cluster_ai_workspace { label="AI Assistant & Workspace"; labeljust="l"; fontsize=15; @@ -90,75 +65,57 @@ digraph automats { ledgerHistoryGridAutomat [label=<
ledgerHistoryGridAutomat
createGridAutomat (Ledger)
STATErows: [] / totalRowsBlock size 50 virtual rows
ACTIONfetchRows(viewport)Infinite scroll query
ACTIONreload()Reload on company switch
APIGET /api/ledger/historyAppend-only ledger audit
>]; } subgraph cluster_system_ui { - label="UI, Settings & System"; labeljust="l"; fontsize=15; + label="UI, Navigation & System"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; navigationAutomat [label=<
navigationAutomat
Cross-Section Navigation Memory
STATElastDeliveryPathActive receipt subpath
STATElastArticleSkuActive SKU selection
STATElastCategorySlug / PathActive category tree node
STATElastSearchTerm / PathPreserved search query
ACTIONsetLastDeliveryPath()Restore on sidebar click
ACTIONsetLastArticleSku()Restore SKU on section click
ACTIONclearNavigation()Reset on company switch
>]; - userSettingsAutomat [label=<
userSettingsAutomat
IndexedDB User Preferences
STATEviewerPosition: 'top'|'right'Editor docking location
STATEchatDrawerWidth: 460AI drawer pixel width
ACTIONsetViewerPosition()Persist docking
ACTIONsetChatDrawerWidth()Persist drawer width
HOOKpersist: trueSurvives page reloads
>]; helpAutomat [label=<
helpAutomat
IndexedDB Contextual Help
STATEenabled: booleanInteractive help badges
STATEactiveHelpId: string|nullActive popper ID
ACTIONtoggle() / setEnabled()Toggle badge mode
ACTIONopenPopper() / closePopper()Single active popper
HOOKpersist: trueSurvives page reloads
>]; pwaAutomat [label=<
pwaAutomat
Service Worker & Offline Lifecycle
STATEneedRefresh: booleanNew SW release pending
STATEofflineReady: booleanOffline assets cached
STATEdialogOpen / updatingPrompt modal state
ACTIONapplyUpdate()Skip waiting & reload
ACTIONopenDialog() / closeDialog()Modal controls
>]; Legend [label=<
Automat Graph Legend
STATEReactive store state property
ACTIONState mutation action
SELECTReactive PureComponent selector
APIBackend REST/Fetch endpoint
SSEInbound Server-Sent Event trigger
HOOKAuth / persistence lifecycle hook
in#Inbound arrival port on Automat header
▪—▶Reactive dataflow / upstream trigger
>]; } - authAutomat:p_auth_user:e -> sseAutomat:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="authAutomat → sseAutomat (bind session & cid)", label=" bind session & cid ", constraint=false, weight=1]; - authAutomat:p_auth_switchCompany:w -> companyAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="authAutomat → companyAutomat (load company profile)", label=" load company profile ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> categoryAutomat:in5:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="authAutomat → categoryAutomat (sync tree on authed)", label=" sync tree on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> brandAutomat:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="authAutomat → brandAutomat (sync brands on authed)", label=" sync brands on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> unitAutomat:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="authAutomat → unitAutomat (sync units on authed)", label=" sync units on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> attributeAutomat:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="authAutomat → attributeAutomat (sync attributes on authed)", label=" sync attributes on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> propertyAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="authAutomat → propertyAutomat (sync properties on authed)", label=" sync properties on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> supplierAutomat:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="authAutomat → supplierAutomat (sync suppliers on authed)", label=" sync suppliers on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> warehouseAutomat:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="authAutomat → warehouseAutomat (sync warehouses on authed)", label=" sync warehouses on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> posterminalAutomat:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="authAutomat → posterminalAutomat (sync terminals on authed)", label=" sync terminals on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> deliveryAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="authAutomat → deliveryAutomat (sync deliveries on authed)", label=" sync deliveries on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> quoteAutomat:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="authAutomat → quoteAutomat (sync quotes on authed)", label=" sync quotes on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> orderAutomat:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="authAutomat → orderAutomat (sync orders on authed)", label=" sync orders on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> entityAutomat:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="authAutomat → entityAutomat (sync entities on authed)", label=" sync entities on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> customergroupAutomat:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="authAutomat → customergroupAutomat (sync customer groups)", label=" sync customer groups ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> numberAutomat:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="authAutomat → numberAutomat (sync number series)", label=" sync number series ", constraint=false, weight=1]; - authAutomat:p_auth_status:w -> accountAutomat:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="authAutomat → accountAutomat (sync payment accounts)", label=" sync payment accounts ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> chatAutomat:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="authAutomat → chatAutomat (auth chat & reload)", label=" auth chat & reload ", weight=3]; - authAutomat:p_auth_status:e -> vfsAutomat:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="authAutomat → vfsAutomat (load company vfs)", label=" load company vfs ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> articleHistoryGridAutomat:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="authAutomat → articleHistoryGridAutomat (reload history on authed)", label=" reload history on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> globalHistoryGridAutomat:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="authAutomat → globalHistoryGridAutomat (reload audit on authed)", label=" reload audit on authed ", constraint=false, weight=1]; - authAutomat:p_auth_status:e -> ledgerHistoryGridAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="authAutomat → ledgerHistoryGridAutomat (reload ledger on authed)", label=" reload ledger on authed ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> categoryAutomat:in6:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="sseAutomat → categoryAutomat ('category' sse trigger)", label=" 'category' sse trigger ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> categoryViewAutomat:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="sseAutomat → categoryViewAutomat (invalidate views on sse)", label=" invalidate views on sse ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> articleIndexAutomat:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="sseAutomat → articleIndexAutomat ('articles' sse trigger)", label=" 'articles' sse trigger ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> artikelAutomat:in4:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="sseAutomat → artikelAutomat ('article-<sku>' sse reload)", label=" 'article-<sku>' sse reload ", weight=3]; - sseAutomat:p_sse_events:w -> articleDetailAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="sseAutomat → articleDetailAutomat (invalidateArticle(sku))", label=" invalidateArticle(sku) ", constraint=false, weight=1]; - sseAutomat:p_sse_events:e -> warehouseAutomat:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="sseAutomat → warehouseAutomat ('warehouse' sse trigger)", label=" 'warehouse' sse trigger ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> supplierAutomat:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="sseAutomat → supplierAutomat ('supplier' sse trigger)", label=" 'supplier' sse trigger ", constraint=false, weight=1]; - sseAutomat:p_sse_events:w -> brandAutomat:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="sseAutomat → brandAutomat ('brand' sse trigger)", label=" 'brand' sse trigger ", constraint=false, weight=1]; - sseAutomat:p_sse_events:e -> attributeAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="sseAutomat → attributeAutomat ('attributename' sse)", label=" 'attributename' sse ", constraint=false, weight=1]; - sseAutomat:p_sse_events:e -> propertyAutomat:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="sseAutomat → propertyAutomat ('propertyname' sse)", label=" 'propertyname' sse ", constraint=false, weight=1]; - sseAutomat:p_sse_events:e -> orderAutomat:in1:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="sseAutomat → orderAutomat ('order'/'shipment' sse)", label=" 'order'/'shipment' sse ", constraint=false, weight=1]; - sseAutomat:p_sse_events:e -> vfsAutomat:in2:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="sseAutomat → vfsAutomat ('vfs' sse mutation)", label=" 'vfs' sse mutation ", constraint=false, weight=1]; - companyAutomat:p_comp_root:e -> categoryAutomat:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="companyAutomat → categoryAutomat (rootCategoryId)", label=" rootCategoryId ", constraint=false, weight=1]; - categoryAutomat:p_cat_loadTree:e -> categoryViewAutomat:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="categoryAutomat → categoryViewAutomat (attachTree & warm slugs)", label=" attachTree & warm slugs ", weight=3]; - categoryViewAutomat:p_catview_collect:e -> articleIndexAutomat:in1:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="categoryViewAutomat → articleIndexAutomat (derived union index)", label=" derived union index ", weight=3]; - articleIndexAutomat:p_idx_items:e -> articleSearchAutomat:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="articleIndexAutomat → articleSearchAutomat (query index items)", label=" query index items ", weight=3]; - categoryAutomat:p_cat_categories:e -> articleSearchAutomat:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="categoryAutomat → articleSearchAutomat (categoryFacets)", label=" categoryFacets ", constraint=false, weight=1]; - artikelAutomat:p_art_setName:e -> articleIndexAutomat:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="artikelAutomat → articleIndexAutomat (setArticleIndexName (optimistic))", label=" setArticleIndexName (optimistic) ", constraint=false, weight=1]; - artikelAutomat:p_art_set:e -> categoryAutomat:in3:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="artikelAutomat → categoryAutomat (articleCount updates)", label=" articleCount updates ", weight=3]; - articleDetailAutomat:p_artdet_hist:e -> artikelAutomat:in2:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="articleDetailAutomat → artikelAutomat (watch SKU details)", label=" watch SKU details ", weight=3]; - deliveryEditorAutomat:p_deled_supplier:w -> supplierAutomat:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="deliveryEditorAutomat → supplierAutomat (match vendor catalog)", label=" match vendor catalog ", constraint=false, weight=1]; - deliveryEditorAutomat:p_deled_lines:e -> artikelAutomat:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="deliveryEditorAutomat → artikelAutomat (lookup SKU & pack units)", label=" lookup SKU & pack units ", constraint=false, weight=1]; - deliveryEditorAutomat:p_deled_commit:e -> deliveryAutomat:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="deliveryEditorAutomat → deliveryAutomat (commit delivery receipt)", label=" commit delivery receipt ", weight=3]; - quoteAutomat:p_quote_items:w -> orderAutomat:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="quoteAutomat → orderAutomat (convert quote to order)", label=" convert quote to order ", weight=3]; - orderAutomat:p_order_items:e -> entityAutomat:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="orderAutomat → entityAutomat (customer lookup)", label=" customer lookup ", weight=3]; - companyAutomat:p_comp_curr:e -> customergroupAutomat:in0:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="companyAutomat → customergroupAutomat (poscustomergroup)", label=" poscustomergroup ", constraint=false, weight=1]; - posterminalAutomat:p_pos_term:e -> categoryAutomat:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="posterminalAutomat → categoryAutomat (filter enabled roots)", label=" filter enabled roots ", weight=3]; - companyAutomat:p_comp_curr:e -> posterminalAutomat:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="companyAutomat → posterminalAutomat (company context)", label=" company context ", weight=3]; - chatAutomat:p_chat_msg:e -> vfsAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="chatAutomat → vfsAutomat (inspect / edit applets)", label=" inspect / edit applets ", constraint=false, weight=1]; - chatAutomat:p_chat_tools:w -> navigationAutomat:in1:n [color="#a98cff", fontcolor="#a98cff", tooltip="chatAutomat → navigationAutomat (set_user_route tool)", label=" set_user_route tool ", constraint=false, weight=1]; - chatAutomat:p_chat_sse:e -> sseAutomat:in1:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="chatAutomat → sseAutomat (getSseClientId())", label=" getSseClientId() ", weight=3]; - navigationAutomat:p_nav_sku:e -> artikelAutomat:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="navigationAutomat → artikelAutomat (restore last SKU)", label=" restore last SKU ", constraint=false, weight=1]; - navigationAutomat:p_nav_catslug:e -> categoryAutomat:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="navigationAutomat → categoryAutomat (restore last category)", label=" restore last category ", constraint=false, weight=1]; - navigationAutomat:p_nav_delpath:e -> deliveryEditorAutomat:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="navigationAutomat → deliveryEditorAutomat (restore draft path)", label=" restore draft path ", weight=3]; - userSettingsAutomat:p_usr_drawer:e -> chatAutomat:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="userSettingsAutomat → chatAutomat (chatDrawerWidth)", label=" chatDrawerWidth ", weight=3]; - helpAutomat:p_help_active:e -> navigationAutomat:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="helpAutomat → navigationAutomat (route-contextual help)", label=" route-contextual help ", weight=3]; - categoryAutomat:p_cat_categories:ne -> categoryAutomat:in4:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="categoryAutomat → categoryAutomat (nested tree hierarchy)", label=" nested tree hierarchy ", weight=3]; - artikelAutomat:p_art_set:ne -> artikelAutomat:in3:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="artikelAutomat → artikelAutomat (variants & parent SKU)", label=" variants & parent SKU ", weight=3]; - chatAutomat:p_chat_msg:nw -> chatAutomat:in2:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="chatAutomat → chatAutomat (stream delta appends)", label=" stream delta appends ", weight=3]; + authAutomat:p_auth_hasAuth:e -> sseAutomat:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="authAutomat → sseAutomat (hasAuth -> connect SSE)", label=" hasAuth -> connect SSE ", constraint=false, weight=1]; + authAutomat:p_auth_hasCompany:e -> companyAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="authAutomat → companyAutomat (hasCompany -> loadAll())", label=" hasCompany -> loadAll() ", constraint=false, weight=1]; + authAutomat:p_auth_hasCompany:w -> categoryAutomat:in2:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="authAutomat → categoryAutomat (sync tree on hasCompany)", label=" sync tree on hasCompany ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> deliveryAutomat:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="authAutomat → deliveryAutomat (sync deliveries on authed)", label=" sync deliveries on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> quoteAutomat:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="authAutomat → quoteAutomat (sync quotes on authed)", label=" sync quotes on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> orderAutomat:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="authAutomat → orderAutomat (sync orders on authed)", label=" sync orders on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> entityAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="authAutomat → entityAutomat (sync entities on authed)", label=" sync entities on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> chatAutomat:in0:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="authAutomat → chatAutomat (auth chat & reload)", label=" auth chat & reload ", weight=3]; + authAutomat:p_auth_status:e -> vfsAutomat:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="authAutomat → vfsAutomat (load company vfs)", label=" load company vfs ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> articleHistoryGridAutomat:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="authAutomat → articleHistoryGridAutomat (reload history on authed)", label=" reload history on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> globalHistoryGridAutomat:in0:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="authAutomat → globalHistoryGridAutomat (reload audit on authed)", label=" reload audit on authed ", constraint=false, weight=1]; + authAutomat:p_auth_status:e -> ledgerHistoryGridAutomat:in0:n [color="#a98cff", fontcolor="#a98cff", tooltip="authAutomat → ledgerHistoryGridAutomat (reload ledger on authed)", label=" reload ledger on authed ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> categoryAutomat:in3:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="sseAutomat → categoryAutomat ('category' sse trigger)", label=" 'category' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> categoryViewAutomat:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="sseAutomat → categoryViewAutomat (invalidate views on sse)", label=" invalidate views on sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> articleIndexAutomat:in2:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="sseAutomat → articleIndexAutomat ('articles' sse trigger)", label=" 'articles' sse trigger ", constraint=false, weight=1]; + sseAutomat:p_sse_events:w -> artikelAutomat:in2:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="sseAutomat → artikelAutomat ('article-<sku>' sse reload)", label=" 'article-<sku>' sse reload ", weight=3]; + sseAutomat:p_sse_events:w -> articleDetailAutomat:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="sseAutomat → articleDetailAutomat (invalidateArticle(sku))", label=" invalidateArticle(sku) ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> companyAutomat:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="sseAutomat → companyAutomat (warehouse/supplier/brand/attr/prop/carrier sse)", label=" warehouse/supplier/brand/attr/prop/carrier sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> orderAutomat:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="sseAutomat → orderAutomat ('order'/'shipment' sse)", label=" 'order'/'shipment' sse ", constraint=false, weight=1]; + sseAutomat:p_sse_events:e -> vfsAutomat:in2:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="sseAutomat → vfsAutomat ('vfs' sse mutation)", label=" 'vfs' sse mutation ", constraint=false, weight=1]; + companyAutomat:p_comp_root:w -> categoryAutomat:in4:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="companyAutomat → categoryAutomat (rootCategoryId & terminals)", label=" rootCategoryId & terminals ", constraint=false, weight=1]; + categoryAutomat:p_cat_loadTree:e -> categoryViewAutomat:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="categoryAutomat → categoryViewAutomat (attachTree & warm slugs)", label=" attachTree & warm slugs ", weight=3]; + categoryViewAutomat:p_catview_collect:e -> articleIndexAutomat:in1:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="categoryViewAutomat → articleIndexAutomat (derived union index)", label=" derived union index ", weight=3]; + articleIndexAutomat:p_idx_items:e -> articleSearchAutomat:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="articleIndexAutomat → articleSearchAutomat (query index items)", label=" query index items ", weight=3]; + categoryAutomat:p_cat_categories:e -> articleSearchAutomat:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="categoryAutomat → articleSearchAutomat (categoryFacets)", label=" categoryFacets ", constraint=false, weight=1]; + artikelAutomat:p_art_setName:e -> articleIndexAutomat:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="artikelAutomat → articleIndexAutomat (setArticleIndexName (optimistic))", label=" setArticleIndexName (optimistic) ", constraint=false, weight=1]; + artikelAutomat:p_art_set:e -> categoryAutomat:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="artikelAutomat → categoryAutomat (articleCount updates)", label=" articleCount updates ", weight=3]; + articleDetailAutomat:p_artdet_hist:w -> artikelAutomat:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="articleDetailAutomat → artikelAutomat (watch SKU details)", label=" watch SKU details ", weight=3]; + deliveryEditorAutomat:p_deled_supplier:w -> companyAutomat:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="deliveryEditorAutomat → companyAutomat (match vendor catalog)", label=" match vendor catalog ", constraint=false, weight=1]; + deliveryEditorAutomat:p_deled_lines:w -> artikelAutomat:in4:n [color="#58d18a", fontcolor="#58d18a", tooltip="deliveryEditorAutomat → artikelAutomat (lookup SKU & pack units)", label=" lookup SKU & pack units ", constraint=false, weight=1]; + deliveryEditorAutomat:p_deled_commit:e -> deliveryAutomat:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="deliveryEditorAutomat → deliveryAutomat (commit delivery receipt)", label=" commit delivery receipt ", weight=3]; + quoteAutomat:p_quote_items:e -> orderAutomat:in2:n [color="#a98cff", fontcolor="#a98cff", tooltip="quoteAutomat → orderAutomat (convert quote to order)", label=" convert quote to order ", weight=3]; + orderAutomat:p_order_items:e -> entityAutomat:in1:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="orderAutomat → entityAutomat (customer lookup)", label=" customer lookup ", weight=3]; + chatAutomat:p_chat_msg:e -> vfsAutomat:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="chatAutomat → vfsAutomat (inspect / edit applets)", label=" inspect / edit applets ", constraint=false, weight=1]; + chatAutomat:p_chat_tools:e -> navigationAutomat:in0:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="chatAutomat → navigationAutomat (set_user_route tool)", label=" set_user_route tool ", constraint=false, weight=1]; + chatAutomat:p_chat_sse:e -> sseAutomat:in1:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="chatAutomat → sseAutomat (getSseClientId())", label=" getSseClientId() ", weight=3]; + navigationAutomat:p_nav_sku:w -> artikelAutomat:in3:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="navigationAutomat → artikelAutomat (restore last SKU)", label=" restore last SKU ", constraint=false, weight=1]; + navigationAutomat:p_nav_catslug:w -> categoryAutomat:in5:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="navigationAutomat → categoryAutomat (restore last category)", label=" restore last category ", constraint=false, weight=1]; + navigationAutomat:p_nav_delpath:e -> deliveryEditorAutomat:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="navigationAutomat → deliveryEditorAutomat (restore draft path)", label=" restore draft path ", weight=3]; + companyAutomat:p_comp_usr:w -> chatAutomat:in2:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="companyAutomat → chatAutomat (chatDrawerWidth)", label=" chatDrawerWidth ", weight=3]; + helpAutomat:p_help_active:e -> navigationAutomat:in1:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="helpAutomat → navigationAutomat (route-contextual help)", label=" route-contextual help ", weight=3]; + categoryAutomat:p_cat_categories:ne -> categoryAutomat:in1:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="categoryAutomat → categoryAutomat (nested tree hierarchy)", label=" nested tree hierarchy ", weight=3]; + artikelAutomat:p_art_set:ne -> artikelAutomat:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="artikelAutomat → artikelAutomat (variants & parent SKU)", label=" variants & parent SKU ", weight=3]; + chatAutomat:p_chat_msg:nw -> chatAutomat:in1:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="chatAutomat → chatAutomat (stream delta appends)", label=" stream delta appends ", weight=3]; } \ No newline at end of file diff --git a/automat-graph.svg b/automat-graph.svg index 9ed5aaf..1defb7e 100644 --- a/automat-graph.svg +++ b/automat-graph.svg @@ -4,2233 +4,1661 @@ - - + + automats - + cluster_session_realtime - -Session & Real-Time Hub + +Session & Real-Time Hub -cluster_catalog_products - -Catalog & Products +cluster_company_basedata + +Company & Consolidated Base Data -cluster_search_indexing - -Search & Indexing +cluster_catalog_products + +Catalog & Products -cluster_properties_attributes - -Properties & Attributes +cluster_search_indexing + +Search & Indexing cluster_purchasing_logistics - -Suppliers & Deliveries + +Deliveries & Inbound cluster_sales_crm - -Sales & CRM + +Sales & CRM -cluster_inventory_storage - -Inventory & Warehousing +cluster_ai_workspace + +AI Assistant & Workspace -cluster_finance_config - -Company & Finance +cluster_audit_grids + +Audit & Virtual Grids -cluster_hardware_pos - -POS & Hardware - - -cluster_ai_workspace - -AI Assistant & Workspace - - -cluster_audit_grids - -Audit & Virtual Grids - - cluster_system_ui - -UI, Settings & System + +UI, Navigation & System authAutomat - - -authAutomat -Session & Multi-Tenant Gateway - - - -STATE -user: AuthUser -Current user & company - - - -STATE -status -'anon' | 'authed' | 'loading' - - - -ACTION -bootstrapSession() -Restore or fetch session - - - -ACTION -switchCompany(id) -Multi-tenant switch - - - -ACTION -logout() -Clear session cookie - - - -SELECT -selectSelectedCompanyId() -Tenant selector - - - -HOOK -auth/sessionEstablished -Session ready lifecycle - - - -HOOK -auth/companyChanged -Company change signal - - - -API -GET /api/auth/session -Cookie session check + + +authAutomat +Always-On Session & Multi-Tenant Gateway + + + +STATE +hasAuth: boolean +Authenticated session bit + + + +STATE +hasCompany: boolean +hasAuth && selectedCompanyId + + + +STATE +user: AuthUser +Current user & company + + + +STATE +status +'anon' | 'authed' | 'loading' + + + +ACTION +bootstrapSession() +Restore or fetch session + + + +ACTION +switchCompany(id) +Multi-tenant switch + + + +ACTION +logout() +Clear session cookie + + + +SELECT +selectHasAuth() / selectHasCompany() +Auth & tenant gates + + + +API +GET /api/auth/session +Cookie session check sseAutomat - - - - -sseAutomat -Single Persistent SSE Connection - - - -STATE -status -'connected' | 'error' - - - -STATE -clientId (cid) -Tab-scoped UUID in session - - - -ACTION -reconnect() -Cycle on company switch - - - -ACTION -on(event, handler) -Register signal listener - - - -ACTION -watchArticle(sku) -Attach article-<sku> - - - -SELECT -invalidateArticle(sku) -Silent cache refresh - - - -SSE -category / articles -Catalog mutation events - - - -SSE -order / shipment -Fulfillment events - - - -API -GET /api/events -Persistent stream + + + + +sseAutomat +Single Persistent SSE Connection + + + +STATE +status +'connected' | 'error' + + + +STATE +clientId (cid) +Tab-scoped UUID in session + + + +ACTION +reconnect() +Cycle on company switch + + + +ACTION +on(event, handler) +Register signal listener + + + +ACTION +watchArticle(sku) +Attach article-<sku> + + + +SELECT +invalidateArticle(sku) +Silent cache refresh + + + +SSE +category / articles +Catalog mutation events + + + +SSE +order / shipment +Fulfillment events + + + +API +GET /api/events +Persistent stream authAutomat:e->sseAutomat:n - - - + + + -  bind session & cid   +  hasAuth -> connect SSE   + + + +companyAutomat + + + + + +companyAutomat +Consolidated Company & Base Data Hub + + + +STATE +current: Company +Tax rates, legal & root cat + + + +STATE +numbers: NumberSeq[] +Document number sequences + + + +STATE +terminals: Posterminal[] +POS hardware & categories + + + +STATE +suppliers: Supplier[] +Vendors & supplier articles + + + +STATE +units: Unit[] +Allowed SI & sales units + + + +STATE +viewerPosition / chatDrawerWidth +Persisted user UI settings + + + +STATE +warehouses: Warehouse[] +Storage locations & stock + + + +STATE +properties: PropertyName[] +Custom text properties + + + +STATE +customergroups: Group[] +Pricing discount tiers + + + +STATE +carriers: Carrier[] +Shipping methods & carriers + + + +STATE +brands: Brand[] +Catalog brands & logos + + + +STATE +attributes: AttributeName[] +Attributes & discrete values + + + +STATE +accounts: Account[] +Bank & payment ledgers + + + +ACTION +loadAll() / reset() +Fetch or reset all base data + + + +SELECT +selectCompanyRootCategoryId() +Root category pointer + + + +API +/api/company, /api/brand, … +13 consolidated endpoints + + + +authAutomat:e->companyAutomat:n + + + + + +  hasCompany -> loadAll()   - + categoryAutomat - - - - - - - - - -categoryAutomat -Hierarchical Category Tree - - - -STATE -categories: Map -ID -> Category Node Map - - - -STATE -rootCategoryId -Company root node ID - - - -STATE -articleCount -Total parent items - - - -ACTION -loadTree() -Fetch tree & warm views - - - -ACTION -createCategory() -Insert child node - - - -ACTION -moveCategory() -Parent reparenting - - - -ACTION -deleteCategory() -Prune subtree - - - -SELECT -getCategoryBreadcrumbs() -Hierarchical path trace - - - -API -GET /api/category -Recursive tree load + + + + + + + + +categoryAutomat +Hierarchical Category Tree + + + +STATE +categories: Map +ID -> Category Node Map + + + +STATE +rootCategoryId +Company root node ID + + + +STATE +articleCount +Total parent items + + + +ACTION +loadTree() +Fetch tree & warm views + + + +ACTION +createCategory() +Insert child node + + + +ACTION +moveCategory() +Parent reparenting + + + +ACTION +deleteCategory() +Prune subtree + + + +SELECT +getCategoryBreadcrumbs() +Hierarchical path trace + + + +API +GET /api/category +Recursive tree load authAutomat:w->categoryAutomat:n - - - + + + -  sync tree on authed   - - - -brandAutomat - - - - -brandAutomat -Catalog Brands & Makers - - - -STATE -brands: Brand[] -Decoupled brand catalog - - - -STATE -filteredBrands: Brand[] -Filtered search slice - - - -ACTION -load(silent) -Deduplicated list fetch - - - -ACTION -create() / rename() -Brand mutations - - - -ACTION -addImage() / deleteImage() -Brand logo AVIF image - - - -API -GET /api/brand -Paginated brand records - - - -SSE -brand -SSE signal with sender tab - - - -authAutomat:w->brandAutomat:n - - - - - -  sync brands on authed   - - - -unitAutomat - - - -unitAutomat -Units of Measure (UoM) - - - -STATE -units: Unit[] -Allowed SI & sales units - - - -STATE -status: 'idle'|'ready' -Load lifecycle state - - - -ACTION -load() -Fetch localized units - - - -ACTION -upsert(body) -Create or update unit - - - -ACTION -delete(id) -Remove unused unit - - - -API -GET /api/unit -Company unit directory - - - -authAutomat:w->unitAutomat:n - - - - - -  sync units on authed   - - - -propertyAutomat - - - - -propertyAutomat -Custom Metadata Properties - - - -STATE -properties: PropertyName[] -Sorted company properties - - - -ACTION -load() -Fetch property definitions - - - -ACTION -createProperty() -Add new property definition - - - -ACTION -updateProperty() -Edit title & sort - - - -API -GET /api/propertyname -Company properties - - - -SSE -propertyname -Real-time property signal - - - -authAutomat:e->propertyAutomat:n - - - - - -  sync properties on authed   - - - -attributeAutomat - - - - -attributeAutomat -Product Attributes & Discrete Values - - - -STATE -attributes: AttributeName[] -Attributes with sorted values - - - -ACTION -load() -Fetch attribute definitions - - - -ACTION -createAttribute() -Create attribute group - - - -ACTION -addValue() / deleteValue() -Discrete value options - - - -API -GET /api/attributename -Attributes & values - - - -SSE -attributename -Real-time attribute signal - - - -authAutomat:e->attributeAutomat:n - - - - - -  sync attributes on authed   - - - -supplierAutomat - - - - - -supplierAutomat -Supplier Directory & Catalogs - - - -STATE -suppliers: Supplier[] -Active vendor list - - - -STATE -status: 'ready' -Load lifecycle state - - - -ACTION -load() -Deduplicated fetch - - - -ACTION -reload() -Silent background sync - - - -API -GET /api/supplier -Vendor records - - - -SSE -supplier -Real-time supplier signal - - - -authAutomat:w->supplierAutomat:n - - - - - -  sync suppliers on authed   +  sync tree on hasCompany   - + deliveryAutomat - - - - -deliveryAutomat -createListAutomat (Deliveries) - - - -STATE -items: Delivery[] -Company delivery receipts - - - -STATE -status: 'ready' -List lifecycle state - - - -ACTION -load() / reload() -Deduplicated fetch & sync - - - -ACTION -reset() -Clear on session switch - - - -API -GET /api/delivery -Inbound delivery log + + + + +deliveryAutomat +Chunk-Paginated Deliveries + + + +STATE +items: Delivery[] +Company delivery receipts + + + +STATE +status: 'ready' +List lifecycle state + + + +ACTION +load() / reload() +Deduplicated fetch & sync + + + +ACTION +reset() +Clear on session switch + + + +API +GET /api/delivery +Inbound delivery log - -authAutomat:w->deliveryAutomat:n - - - + +authAutomat:e->deliveryAutomat:n + + + -  sync deliveries on authed   +  sync deliveries on authed   - + quoteAutomat - - - -quoteAutomat -createListAutomat (Quotes) - - - -STATE -items: Quote[] -Sales quotes / Angebote - - - -ACTION -load() / reload() -Deduplicated fetch - - - -ACTION -reset() -Reset on logout - - - -API -GET /api/quote -Company quote list + + + +quoteAutomat +Chunk-Paginated Quotes + + + +STATE +items: Quote[] +Sales quotes / Angebote + + + +ACTION +load() / reload() +Deduplicated fetch + + + +ACTION +reset() +Reset on logout + + + +API +GET /api/quote +Company quote list - + authAutomat:e->quoteAutomat:n - - - + + + -  sync quotes on authed   +  sync quotes on authed   - + orderAutomat - - - - - -orderAutomat -Chunk-Paginated Order Engine - - - -STATE -items: Order[] -Sales orders & lines - - - -STATE -total: number -Unfiltered order count - - - -STATE -onlyUnpaid / onlyUndelivered -Fulfillment filters - - - -ACTION -loadChunk(index) -Chunk size 50 prefetch - - - -ACTION -reload() -Silent orders re-fetch - - - -SELECT -selectOrders() / selectTotal() -Filter-applied slice - - - -API -GET /api/order -Paged order stream - - - -SSE -order / shipment -Fulfillment mutations + + + + + +orderAutomat +Chunk-Paginated Order Engine + + + +STATE +items: Order[] +Sales orders & lines + + + +STATE +total: number +Unfiltered order count + + + +STATE +onlyUnpaid / onlyUndelivered +Fulfillment filters + + + +ACTION +loadChunk(index) +Chunk size 50 prefetch + + + +ACTION +reload() +Silent orders re-fetch + + + +SELECT +selectOrders() / selectTotal() +Filter-applied slice + + + +API +GET /api/order +Paged order stream + + + +SSE +order / shipment +Fulfillment mutations - + authAutomat:e->orderAutomat:n - - - + + + -  sync orders on authed   +  sync orders on authed   - + entityAutomat - - - - -entityAutomat -createListAutomat (Entities) - - - -STATE -items: Entity[] -Customers & partners - - - -ACTION -load() / reload() -Customer directory load - - - -ACTION -reset() -Reset on session switch - - - -API -GET /api/entity -Directory records + + + + +entityAutomat +Customer & Partner Directory + + + +STATE +items: Entity[] +Customers & partners + + + +ACTION +load() / reload() +Customer directory load + + + +ACTION +reset() +Reset on session switch + + + +API +GET /api/entity +Directory records - + authAutomat:e->entityAutomat:n - - - + + + -  sync entities on authed   - - - -customergroupAutomat - - - - -customergroupAutomat -createListAutomat (Groups) - - - -STATE -items: Customergroup[] -Pricing discount tiers - - - -ACTION -load() / reload() -Discount group load - - - -API -GET /api/customergroup -Pricing tiers - - - -authAutomat:e->customergroupAutomat:n - - - - - -  sync customer groups   - - - -warehouseAutomat - - - - -warehouseAutomat -Warehouse Locations & Stock - - - -STATE -warehouses: Warehouse[] -Storage areas & bins - - - -STATE -filteredWarehouses -Filter search slice - - - -ACTION -load() / reload() -Deduplicated sync - - - -ACTION -setFilter(query) -Local filter query - - - -API -GET /api/warehouse -Warehouse listings - - - -SSE -warehouse -Real-time stock movement - - - -authAutomat:e->warehouseAutomat:n - - - - - -  sync warehouses on authed   - - - -companyAutomat - - - -companyAutomat -Active Company Configuration - - - -STATE -current: Company -Tax rates & ISO language - - - -ACTION -load() / reload() -Fetch tenant settings - - - -SELECT -selectCompanyTaxrate() -Standard tax rate - - - -SELECT -selectCompanyRootCategoryId() -Root category pointer - - - -SELECT -selectCompanyName() -Company legal name - - - -API -GET /api/company/:id -Legal & tax profile - - - -authAutomat:w->companyAutomat:n - - - - - -  load company profile   - - - -accountAutomat - - - -accountAutomat -createListAutomat (Accounts) - - - -STATE -items: Account[] -Bank & payment ledgers - - - -ACTION -load() / reload() -Payment accounts load - - - -API -GET /api/account -Chart of accounts - - - -authAutomat:w->accountAutomat:n - - - - - -  sync payment accounts   - - - -numberAutomat - - - -numberAutomat -createListAutomat (Sequences) - - - -STATE -items: NumberSeq[] -Invoice/receipt counters - - - -ACTION -load() / reload() -Number series sync - - - -API -GET /api/number -Document counters - - - -authAutomat:w->numberAutomat:n - - - - - -  sync number series   - - - -posterminalAutomat - - - - -posterminalAutomat -POS Terminals & Gating - - - -STATE -terminals: Posterminal[] -Hardware terminals - - - -ACTION -load() -Fetch POS registers - - - -ACTION -create() / delete() -Terminal registrations - - - -ACTION -toggleCategory() -Gated category filters - - - -SELECT -selectPosterminals() -Active registers - - - -API -GET /api/posterminal -Terminal device configs - - - -authAutomat:w->posterminalAutomat:n - - - - - -  sync terminals on authed   +  sync entities on authed   - + chatAutomat - - - - - -chatAutomat -AI Assistant Drawer & SSE - - - -STATE -isOpen: boolean -Docked chat drawer toggle - - - -STATE -conversations: [] -Conversation sessions - - - -STATE -messages: ModelMessage[] -Live message transcript - - - -STATE -sseStatus: 'connected' -Token streaming stream - - - -ACTION -sendMessage(text) -Prompt model with tools - - - -ACTION -answerAskUser() -Interactive tool modal - - - -SELECT -userEnv / routeTools -Client navigation tool - - - -API -POST /api/modelconversation -AI conversation session - - - -SSE -modelconversation/events -Live token stream + + + + + +chatAutomat +AI Assistant Drawer & SSE + + + +STATE +isOpen: boolean +Docked chat drawer toggle + + + +STATE +conversations: [] +Conversation sessions + + + +STATE +messages: ModelMessage[] +Live message transcript + + + +STATE +sseStatus: 'connected' +Token streaming stream + + + +ACTION +sendMessage(text) +Prompt model with tools + + + +ACTION +answerAskUser() +Interactive tool modal + + + +SELECT +userEnv / routeTools +Client navigation tool + + + +API +POST /api/modelconversation +AI conversation session + + + +SSE +modelconversation/events +Live token stream - + authAutomat:e->chatAutomat:n - - - + + + -  auth chat & reload   +  auth chat & reload   - + vfsAutomat - - - - - -vfsAutomat -Virtual Filesystem Explorer - - - -STATE -files: VfsFile[] -Company VFS workspace - - - -STATE -selectedFileName -Current active file - - - -STATE -fileContent / isDirty -Editor buffer & diff - - - -ACTION -selectFile() / saveFile() -Applet editor buffer - - - -ACTION -uploadFiles() / deleteFile() -File manipulations - - - -API -GET /api/vfs -VFS directory index - - - -SSE -vfs -VFS mutation signal + + + + + +vfsAutomat +Virtual Filesystem Explorer + + + +STATE +files: VfsFile[] +Company VFS workspace + + + +STATE +selectedFileName +Current active file + + + +STATE +fileContent / isDirty +Editor buffer & diff + + + +ACTION +selectFile() / saveFile() +Applet editor buffer + + + +ACTION +uploadFiles() / deleteFile() +File manipulations + + + +API +GET /api/vfs +VFS directory index + + + +SSE +vfs +VFS mutation signal - + authAutomat:e->vfsAutomat:n - - - + + + -  load company vfs   +  load company vfs   - + articleHistoryGridAutomat - - - -articleHistoryGridAutomat -createGridAutomat (Article) - - - -STATE -rows: [] / totalRows -Block size 50 virtual rows - - - -ACTION -fetchRows(viewport) -Infinite scroll query - - - -ACTION -reload() -Reload on company switch - - - -API -GET /api/article/history -Infinite audit window + + + +articleHistoryGridAutomat +createGridAutomat (Article) + + + +STATE +rows: [] / totalRows +Block size 50 virtual rows + + + +ACTION +fetchRows(viewport) +Infinite scroll query + + + +ACTION +reload() +Reload on company switch + + + +API +GET /api/article/history +Infinite audit window - + authAutomat:e->articleHistoryGridAutomat:n - - - + + + -  reload history on authed   +  reload history on authed   - + globalHistoryGridAutomat - - - -globalHistoryGridAutomat -createGridAutomat (Global) - - - -STATE -rows: [] / totalRows -Block size 50 virtual rows - - - -ACTION -fetchRows(viewport) -Infinite scroll query - - - -ACTION -reload() -Reload on company switch - - - -API -GET /api/globalhistory -Company global audit + + + +globalHistoryGridAutomat +createGridAutomat (Global) + + + +STATE +rows: [] / totalRows +Block size 50 virtual rows + + + +ACTION +fetchRows(viewport) +Infinite scroll query + + + +ACTION +reload() +Reload on company switch + + + +API +GET /api/globalhistory +Company global audit - + authAutomat:e->globalHistoryGridAutomat:n - - - + + + -  reload audit on authed   +  reload audit on authed   - + ledgerHistoryGridAutomat - - - -ledgerHistoryGridAutomat -createGridAutomat (Ledger) - - - -STATE -rows: [] / totalRows -Block size 50 virtual rows - - - -ACTION -fetchRows(viewport) -Infinite scroll query - - - -ACTION -reload() -Reload on company switch - - - -API -GET /api/ledger/history -Append-only ledger audit + + + +ledgerHistoryGridAutomat +createGridAutomat (Ledger) + + + +STATE +rows: [] / totalRows +Block size 50 virtual rows + + + +ACTION +fetchRows(viewport) +Infinite scroll query + + + +ACTION +reload() +Reload on company switch + + + +API +GET /api/ledger/history +Append-only ledger audit - + authAutomat:e->ledgerHistoryGridAutomat:n - - - + + + -  reload ledger on authed   +  reload ledger on authed   + + + +sseAutomat:e->companyAutomat:n + + + + + +  warehouse/supplier/brand/attr/prop/carrier sse   - + sseAutomat:w->categoryAutomat:n - - - + + + -  'category' sse trigger   +  'category' sse trigger   - + categoryViewAutomat - - - - -categoryViewAutomat -KeyedAutomat (Slug Path) - - - -STATE -views: Map<slug, Data> -Articles & direct children - - - -ACTION -loadAllCategoryViews() -Prewarm all tree slugs - - - -ACTION -reloadLoadedCategoryViews() -Refresh cached views - - - -ACTION -invalidateAllCategoryViews() -Clear on SSE signal - - - -SELECT -collectLoadedCategoryViewData() -Extract union data - - - -SELECT -getOrCreateCategoryAutomat() -Per-slug reactive store - - - -API -GET /api/category/:id/articles -Category article batch + + + + +categoryViewAutomat +KeyedAutomat (Slug Path) + + + +STATE +views: Map<slug, Data> +Articles & direct children + + + +ACTION +loadAllCategoryViews() +Prewarm all tree slugs + + + +ACTION +reloadLoadedCategoryViews() +Refresh cached views + + + +ACTION +invalidateAllCategoryViews() +Clear on SSE signal + + + +SELECT +collectLoadedCategoryViewData() +Extract union data + + + +SELECT +getOrCreateCategoryAutomat() +Per-slug reactive store + + + +API +GET /api/category/:id/articles +Category article batch - + sseAutomat:w->categoryViewAutomat:n - - - + + + -  invalidate views on sse   +  invalidate views on sse   - + artikelAutomat - - - - - - - -artikelAutomat -In-Memory Article Record Cache - - - -STATE -articles: Map<sku, Object> -Deduplicated SKU cache - - - -ACTION -loadArticle(sku) -Fetch or cached promise - - - -ACTION -reloadArticle(sku) -Silent background sync - - - -ACTION -setArticleName(sku, name) -Optimistic rename - - - -ACTION -clearArticle(sku) -Evict SKU from cache - - - -SELECT -getArticle(sku) -Sync constructor read - - - -SELECT -createArticleSelector(sku) -Fine-grained selector - - - -API -GET /api/article/:sku -Full article document + + + + + + + +artikelAutomat +In-Memory Article Record Cache + + + +STATE +articles: Map<sku, Object> +Deduplicated SKU cache + + + +ACTION +loadArticle(sku) +Fetch or cached promise + + + +ACTION +reloadArticle(sku) +Silent background sync + + + +ACTION +setArticleName(sku, name) +Optimistic rename + + + +ACTION +clearArticle(sku) +Evict SKU from cache + + + +SELECT +getArticle(sku) +Sync constructor read + + + +SELECT +createArticleSelector(sku) +Fine-grained selector + + + +API +GET /api/article/:sku +Full article document - + sseAutomat:w->artikelAutomat:n - - - + + + -  'article-<sku>' sse reload   +  'article-<sku>' sse reload   - + articleDetailAutomat - - - -articleDetailAutomat -KeyedAutomat (SKU) - - - -STATE -articleHistory: Map<sku> -Change history log - - - -STATE -articleSupplier: Map<sku> -Assigned supplier list - - - -ACTION -articleHistoryAutomat.load() -Fetch SKU audit trail - - - -ACTION -articleSupplierAutomat.load() -Fetch supplier catalog - - - -API -GET /api/article/:sku/history -Audit change entries - - - -API -GET /api/article/:sku/supplier -Vendor links & prices + + + +articleDetailAutomat +KeyedAutomat (SKU) + + + +STATE +articleHistory: Map<sku> +Change history log + + + +STATE +articleSupplier: Map<sku> +Assigned supplier list + + + +ACTION +articleHistoryAutomat.load() +Fetch SKU audit trail + + + +ACTION +articleSupplierAutomat.load() +Fetch supplier catalog + + + +API +GET /api/article/:sku/history +Audit change entries + + + +API +GET /api/article/:sku/supplier +Vendor links & prices - + sseAutomat:w->articleDetailAutomat:n - - - + + + -  invalidateArticle(sku)   - - - -sseAutomat:w->brandAutomat:n - - - - - -  'brand' sse trigger   +  invalidateArticle(sku)   - + articleIndexAutomat - - - - - -articleIndexAutomat -Derived Client Search Index - - - -STATE -items: ArticleIndexRecord[] -De-duplicated article union - - - -STATE -status: 'ready' -Index lifecycle flag - - - -ACTION -buildArticleIndex() -Derive from category views - - - -ACTION -setArticleIndexName() -Patch on rename - - - -ACTION -reload() -Re-derive from views - - - -SELECT -selectArticleIndexItems() -Fast record projection - - - -SELECT -ensureLoaded() -Await warm views + + + + + +articleIndexAutomat +Derived Client Search Index + + + +STATE +items: ArticleIndexRecord[] +De-duplicated article union + + + +STATE +status: 'ready' +Index lifecycle flag + + + +ACTION +buildArticleIndex() +Derive from category views + + + +ACTION +setArticleIndexName() +Patch on rename + + + +ACTION +reload() +Re-derive from views + + + +SELECT +selectArticleIndexItems() +Fast record projection + + + +SELECT +ensureLoaded() +Await warm views - + sseAutomat:w->articleIndexAutomat:n - - - + + + -  'articles' sse trigger   - - - -sseAutomat:e->propertyAutomat:n - - - - - -  'propertyname' sse   - - - -sseAutomat:e->attributeAutomat:n - - - - - -  'attributename' sse   - - - -sseAutomat:w->supplierAutomat:n - - - - - -  'supplier' sse trigger   +  'articles' sse trigger   - + sseAutomat:e->orderAutomat:n - - - + + + -  'order'/'shipment' sse   - - - -sseAutomat:e->warehouseAutomat:n - - - - - -  'warehouse' sse trigger   +  'order'/'shipment' sse   - + sseAutomat:e->vfsAutomat:n - - - + + + -  'vfs' sse mutation   - - - -categoryAutomat:ne->categoryAutomat:n - - - - - -  nested tree hierarchy   - - - -categoryAutomat:e->categoryViewAutomat:n - - - - - -  attachTree & warm slugs   - - - -articleSearchAutomat - - - - -articleSearchAutomat -In-Memory FlexSearch Engine - - - -STATE -target: string -Active search query - - - -STATE -filter: string -Active category filter - - - -STATE -result: ArticleSearchResult -Facets, recordset, count - - - -ACTION -search(target, filter) -Synchronous indexed query - - - -SELECT -selectSearchResult() -Reactive search outcome - - - -categoryAutomat:e->articleSearchAutomat:n - - - - - -  categoryFacets   - - - -categoryViewAutomat:e->articleIndexAutomat:n - - - - - -  derived union index   - - - -artikelAutomat:e->categoryAutomat:n - - - - - -  articleCount updates   - - - -artikelAutomat:ne->artikelAutomat:n - - - - - -  variants & parent SKU   - - - -artikelAutomat:e->articleIndexAutomat:n - - - - - -  setArticleIndexName (optimistic)   - - - -articleDetailAutomat:e->artikelAutomat:n - - - - - -  watch SKU details   - - - -articleIndexAutomat:e->articleSearchAutomat:n - - - - - -  query index items   - - - -deliveryEditorAutomat - - - -deliveryEditorAutomat -Inbound Receipt & OCR Machine - - - -STATE -lines: DeliveryLine[] -Uncommitted draft lines - - - -STATE -deliveryEntity -Selected supplier ID - - - -STATE -attachments: File[] -Invoice PDFs & OCR results - - - -STATE -draftCount: number -Uncommitted lines for badge - - - -ACTION -addLine() / updateLine() -Interactive item editing - - - -ACTION -commitDelivery() -Persist goods receipt - - - -SELECT -buildSupplierArticlesMap() -Auto-match SKU/EAN - - - -API -POST /api/delivery -Create goods receipt - - - -deliveryEditorAutomat:e->artikelAutomat:n - - - - - -  lookup SKU & pack units   - - - -deliveryEditorAutomat:w->supplierAutomat:n - - - - - -  match vendor catalog   - - - -deliveryEditorAutomat:e->deliveryAutomat:n - - - - - -  commit delivery receipt   - - - -quoteAutomat:w->orderAutomat:n - - - - - -  convert quote to order   - - - -orderAutomat:e->entityAutomat:n - - - - - -  customer lookup   +  'vfs' sse mutation   - -companyAutomat:e->categoryAutomat:n - - - + +companyAutomat:w->categoryAutomat:n + + + -  rootCategoryId   +  rootCategoryId & terminals   - - -companyAutomat:e->customergroupAutomat:n - - - + + +companyAutomat:w->chatAutomat:n + + + -  poscustomergroup   +  chatDrawerWidth   - - -companyAutomat:e->posterminalAutomat:n - - - + + +categoryAutomat:ne->categoryAutomat:n + + + -  company context   +  nested tree hierarchy   - - -posterminalAutomat:e->categoryAutomat:n - - - + + +categoryAutomat:e->categoryViewAutomat:n + + + -  filter enabled roots   +  attachTree & warm slugs   + + + +articleSearchAutomat + + + + +articleSearchAutomat +In-Memory FlexSearch Engine + + + +STATE +target: string +Active search query + + + +STATE +filter: string +Active category filter + + + +STATE +result: ArticleSearchResult +Facets, recordset, count + + + +ACTION +search(target, filter) +Synchronous indexed query + + + +SELECT +selectSearchResult() +Reactive search outcome + + + +categoryAutomat:e->articleSearchAutomat:n + + + + + +  categoryFacets   + + + +categoryViewAutomat:e->articleIndexAutomat:n + + + + + +  derived union index   + + + +artikelAutomat:e->categoryAutomat:n + + + + + +  articleCount updates   + + + +artikelAutomat:ne->artikelAutomat:n + + + + + +  variants & parent SKU   + + + +artikelAutomat:e->articleIndexAutomat:n + + + + + +  setArticleIndexName (optimistic)   + + + +articleDetailAutomat:w->artikelAutomat:n + + + + + +  watch SKU details   + + + +articleIndexAutomat:e->articleSearchAutomat:n + + + + + +  query index items   + + + +deliveryEditorAutomat + + + +deliveryEditorAutomat +Inbound Receipt & OCR Machine + + + +STATE +lines: DeliveryLine[] +Uncommitted draft lines + + + +STATE +deliveryEntity +Selected supplier ID + + + +STATE +attachments: File[] +Invoice PDFs & OCR results + + + +STATE +draftCount: number +Uncommitted lines for badge + + + +ACTION +addLine() / updateLine() +Interactive item editing + + + +ACTION +commitDelivery() +Persist goods receipt + + + +SELECT +buildSupplierArticlesMap() +Auto-match SKU/EAN + + + +API +POST /api/delivery +Create goods receipt + + + +deliveryEditorAutomat:w->companyAutomat:n + + + + + +  match vendor catalog   + + + +deliveryEditorAutomat:w->artikelAutomat:n + + + + + +  lookup SKU & pack units   + + + +deliveryEditorAutomat:e->deliveryAutomat:n + + + + + +  commit delivery receipt   + + + +quoteAutomat:e->orderAutomat:n + + + + + +  convert quote to order   + + + +orderAutomat:e->entityAutomat:n + + + + + +  customer lookup   - + chatAutomat:e->sseAutomat:n - - - + + + -  getSseClientId()   +  getSseClientId()   - + chatAutomat:nw->chatAutomat:n - - - + + + -  stream delta appends   +  stream delta appends   - + chatAutomat:e->vfsAutomat:n - - - + + + -  inspect / edit applets   +  inspect / edit applets   - + navigationAutomat - - - - -navigationAutomat -Cross-Section Navigation Memory - - - -STATE -lastDeliveryPath -Active receipt subpath - - - -STATE -lastArticleSku -Active SKU selection - - - -STATE -lastCategorySlug / Path -Active category tree node - - - -STATE -lastSearchTerm / Path -Preserved search query - - - -ACTION -setLastDeliveryPath() -Restore on sidebar click - - - -ACTION -setLastArticleSku() -Restore SKU on section click - - - -ACTION -clearNavigation() -Reset on company switch + + + + +navigationAutomat +Cross-Section Navigation Memory + + + +STATE +lastDeliveryPath +Active receipt subpath + + + +STATE +lastArticleSku +Active SKU selection + + + +STATE +lastCategorySlug / Path +Active category tree node + + + +STATE +lastSearchTerm / Path +Preserved search query + + + +ACTION +setLastDeliveryPath() +Restore on sidebar click + + + +ACTION +setLastArticleSku() +Restore SKU on section click + + + +ACTION +clearNavigation() +Reset on company switch - -chatAutomat:w->navigationAutomat:n - - - + +chatAutomat:e->navigationAutomat:n + + + -  set_user_route tool   +  set_user_route tool   - -navigationAutomat:e->categoryAutomat:n - - - + +navigationAutomat:w->categoryAutomat:n + + + -  restore last category   +  restore last category   - -navigationAutomat:e->artikelAutomat:n - - - + +navigationAutomat:w->artikelAutomat:n + + + -  restore last SKU   +  restore last SKU   - + navigationAutomat:e->deliveryEditorAutomat:n - - - + + + -  restore draft path   - - - -userSettingsAutomat - - -userSettingsAutomat -IndexedDB User Preferences - - - -STATE -viewerPosition: 'top'|'right' -Editor docking location - - - -STATE -chatDrawerWidth: 460 -AI drawer pixel width - - - -ACTION -setViewerPosition() -Persist docking - - - -ACTION -setChatDrawerWidth() -Persist drawer width - - - -HOOK -persist: true -Survives page reloads - - - -userSettingsAutomat:e->chatAutomat:n - - - - - -  chatDrawerWidth   +  restore draft path   - + helpAutomat - - -helpAutomat -IndexedDB Contextual Help - - - -STATE -enabled: boolean -Interactive help badges - - - -STATE -activeHelpId: string|null -Active popper ID - - - -ACTION -toggle() / setEnabled() -Toggle badge mode - - - -ACTION -openPopper() / closePopper() -Single active popper - - - -HOOK -persist: true -Survives page reloads + + +helpAutomat +IndexedDB Contextual Help + + + +STATE +enabled: boolean +Interactive help badges + + + +STATE +activeHelpId: string|null +Active popper ID + + + +ACTION +toggle() / setEnabled() +Toggle badge mode + + + +ACTION +openPopper() / closePopper() +Single active popper + + + +HOOK +persist: true +Survives page reloads - + helpAutomat:e->navigationAutomat:n - - - + + + -  route-contextual help   +  route-contextual help   - + pwaAutomat - - -pwaAutomat -Service Worker & Offline Lifecycle - - - -STATE -needRefresh: boolean -New SW release pending - - - -STATE -offlineReady: boolean -Offline assets cached - - - -STATE -dialogOpen / updating -Prompt modal state - - - -ACTION -applyUpdate() -Skip waiting & reload - - - -ACTION -openDialog() / closeDialog() -Modal controls + + +pwaAutomat +Service Worker & Offline Lifecycle + + + +STATE +needRefresh: boolean +New SW release pending + + + +STATE +offlineReady: boolean +Offline assets cached + + + +STATE +dialogOpen / updating +Prompt modal state + + + +ACTION +applyUpdate() +Skip waiting & reload + + + +ACTION +openDialog() / closeDialog() +Modal controls - + Legend - - - -Automat Graph Legend - - -STATE - -Reactive store state property - - -ACTION - -State mutation action - - -SELECT - -Reactive PureComponent selector - - -API - -Backend REST/Fetch endpoint - - -SSE - -Inbound Server-Sent Event trigger - - -HOOK - -Auth / persistence lifecycle hook - -in# - -Inbound arrival port on Automat header - -▪—▶ - -Reactive dataflow / upstream trigger + + + +Automat Graph Legend + + +STATE + +Reactive store state property + + +ACTION + +State mutation action + + +SELECT + +Reactive PureComponent selector + + +API + +Backend REST/Fetch endpoint + + +SSE + +Inbound Server-Sent Event trigger + + +HOOK + +Auth / persistence lifecycle hook + +in# + +Inbound arrival port on Automat header + +▪—▶ + +Reactive dataflow / upstream trigger diff --git a/generate-automat-graph.js b/generate-automat-graph.js index ea97163..72081c2 100644 --- a/generate-automat-graph.js +++ b/generate-automat-graph.js @@ -58,45 +58,30 @@ const CLUSTERS = [ label: 'Session & Real-Time Hub', automats: ['authAutomat', 'sseAutomat'], }, + { + id: 'company_basedata', + label: 'Company & Consolidated Base Data', + automats: ['companyAutomat'], + }, { id: 'catalog_products', label: 'Catalog & Products', - automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat', 'brandAutomat', 'unitAutomat'], + automats: ['categoryAutomat', 'categoryViewAutomat', 'artikelAutomat', 'articleDetailAutomat'], }, { id: 'search_indexing', label: 'Search & Indexing', automats: ['articleIndexAutomat', 'articleSearchAutomat'], }, - { - id: 'properties_attributes', - label: 'Properties & Attributes', - automats: ['propertyAutomat', 'attributeAutomat'], - }, { id: 'purchasing_logistics', - label: 'Suppliers & Deliveries', - automats: ['supplierAutomat', 'deliveryAutomat', 'deliveryEditorAutomat'], + label: 'Deliveries & Inbound', + automats: ['deliveryAutomat', 'deliveryEditorAutomat'], }, { id: 'sales_crm', label: 'Sales & CRM', - automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat', 'customergroupAutomat'], - }, - { - id: 'inventory_storage', - label: 'Inventory & Warehousing', - automats: ['warehouseAutomat'], - }, - { - id: 'finance_config', - label: 'Company & Finance', - automats: ['companyAutomat', 'accountAutomat', 'numberAutomat'], - }, - { - id: 'hardware_pos', - label: 'POS & Hardware', - automats: ['posterminalAutomat'], + automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'], }, { id: 'ai_workspace', @@ -110,8 +95,8 @@ const CLUSTERS = [ }, { id: 'system_ui', - label: 'UI, Settings & System', - automats: ['navigationAutomat', 'userSettingsAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'], + label: 'UI, Navigation & System', + automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'], }, ]; @@ -136,16 +121,16 @@ const AUTOMAT_SPECS = [ { id: 'authAutomat', title: 'authAutomat', - subtitle: 'Session & Multi-Tenant Gateway', + subtitle: 'Always-On Session & Multi-Tenant Gateway', entries: [ - { type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company' }, - { type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'' }, + { type: 'STATE', name: 'hasAuth: boolean', desc: 'Authenticated session bit', port: 'p_auth_hasAuth' }, + { type: 'STATE', name: 'hasCompany: boolean', desc: 'hasAuth && selectedCompanyId', port: 'p_auth_hasCompany' }, + { type: 'STATE', name: 'user: AuthUser', desc: 'Current user & company', port: 'p_auth_user' }, + { type: 'STATE', name: 'status', desc: '\'anon\' | \'authed\' | \'loading\'', port: 'p_auth_status' }, { type: 'ACTION', name: 'bootstrapSession()', desc: 'Restore or fetch session' }, { type: 'ACTION', name: 'switchCompany(id)', desc: 'Multi-tenant switch', port: 'p_auth_switchCompany' }, { type: 'ACTION', name: 'logout()', desc: 'Clear session cookie' }, - { type: 'SELECT', name: 'selectSelectedCompanyId()', desc: 'Tenant selector' }, - { type: 'HOOK', name: 'auth/sessionEstablished', desc: 'Session ready lifecycle', port: 'p_auth_status' }, - { type: 'HOOK', name: 'auth/companyChanged', desc: 'Company change signal', port: 'p_auth_user' }, + { type: 'SELECT', name: 'selectHasAuth() / selectHasCompany()', desc: 'Auth & tenant gates' }, { type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' }, ], }, @@ -166,7 +151,32 @@ const AUTOMAT_SPECS = [ ], }, - // 2. Catalog & Products + // 2. Company & Consolidated Base Data + { + id: 'companyAutomat', + title: 'companyAutomat', + subtitle: 'Consolidated Company & Base Data Hub', + entries: [ + { type: 'STATE', name: 'current: Company', desc: 'Tax rates, legal & root cat', port: 'p_comp_curr' }, + { type: 'STATE', name: 'numbers: NumberSeq[]', desc: 'Document number sequences', port: 'p_comp_num' }, + { type: 'STATE', name: 'terminals: Posterminal[]', desc: 'POS hardware & categories', port: 'p_comp_pos' }, + { type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Vendors & supplier articles', port: 'p_comp_sup' }, + { type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units', port: 'p_comp_unit' }, + { type: 'STATE', name: 'viewerPosition / chatDrawerWidth', desc: 'Persisted user UI settings', port: 'p_comp_usr' }, + { type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage locations & stock', port: 'p_comp_wh' }, + { type: 'STATE', name: 'properties: PropertyName[]', desc: 'Custom text properties', port: 'p_comp_prop' }, + { type: 'STATE', name: 'customergroups: Group[]', desc: 'Pricing discount tiers', port: 'p_comp_cgrp' }, + { type: 'STATE', name: 'carriers: Carrier[]', desc: 'Shipping methods & carriers', port: 'p_comp_car' }, + { type: 'STATE', name: 'brands: Brand[]', desc: 'Catalog brands & logos', port: 'p_comp_brand' }, + { type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes & discrete values', port: 'p_comp_attr' }, + { type: 'STATE', name: 'accounts: Account[]', desc: 'Bank & payment ledgers', port: 'p_comp_acc' }, + { type: 'ACTION', name: 'loadAll() / reset()', desc: 'Fetch or reset all base data' }, + { type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' }, + { type: 'API', name: '/api/company, /api/brand, …', desc: '13 consolidated endpoints' }, + ], + }, + + // 3. Catalog & Products { id: 'categoryAutomat', title: 'categoryAutomat', @@ -225,35 +235,8 @@ const AUTOMAT_SPECS = [ { type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' }, ], }, - { - id: 'brandAutomat', - title: 'brandAutomat', - subtitle: 'Catalog Brands & Makers', - entries: [ - { type: 'STATE', name: 'brands: Brand[]', desc: 'Decoupled brand catalog' }, - { type: 'STATE', name: 'filteredBrands: Brand[]', desc: 'Filtered search slice' }, - { type: 'ACTION', name: 'load(silent)', desc: 'Deduplicated list fetch' }, - { type: 'ACTION', name: 'create() / rename()', desc: 'Brand mutations' }, - { type: 'ACTION', name: 'addImage() / deleteImage()', desc: 'Brand logo AVIF image' }, - { type: 'API', name: 'GET /api/brand', desc: 'Paginated brand records' }, - { type: 'SSE', name: 'brand', desc: 'SSE signal with sender tab' }, - ], - }, - { - id: 'unitAutomat', - title: 'unitAutomat', - subtitle: 'Units of Measure (UoM)', - entries: [ - { type: 'STATE', name: 'units: Unit[]', desc: 'Allowed SI & sales units' }, - { type: 'STATE', name: 'status: \'idle\'|\'ready\'', desc: 'Load lifecycle state' }, - { type: 'ACTION', name: 'load()', desc: 'Fetch localized units' }, - { type: 'ACTION', name: 'upsert(body)', desc: 'Create or update unit' }, - { type: 'ACTION', name: 'delete(id)', desc: 'Remove unused unit' }, - { type: 'API', name: 'GET /api/unit', desc: 'Company unit directory' }, - ], - }, - // 3. Search & Discovery + // 4. Search & Discovery { id: 'articleIndexAutomat', title: 'articleIndexAutomat', @@ -281,52 +264,11 @@ const AUTOMAT_SPECS = [ ], }, - // 4. Properties & Attributes - { - id: 'propertyAutomat', - title: 'propertyAutomat', - subtitle: 'Custom Metadata Properties', - entries: [ - { type: 'STATE', name: 'properties: PropertyName[]', desc: 'Sorted company properties' }, - { type: 'ACTION', name: 'load()', desc: 'Fetch property definitions' }, - { type: 'ACTION', name: 'createProperty()', desc: 'Add new property definition' }, - { type: 'ACTION', name: 'updateProperty()', desc: 'Edit title & sort' }, - { type: 'API', name: 'GET /api/propertyname', desc: 'Company properties' }, - { type: 'SSE', name: 'propertyname', desc: 'Real-time property signal' }, - ], - }, - { - id: 'attributeAutomat', - title: 'attributeAutomat', - subtitle: 'Product Attributes & Discrete Values', - entries: [ - { type: 'STATE', name: 'attributes: AttributeName[]', desc: 'Attributes with sorted values' }, - { type: 'ACTION', name: 'load()', desc: 'Fetch attribute definitions' }, - { type: 'ACTION', name: 'createAttribute()', desc: 'Create attribute group' }, - { type: 'ACTION', name: 'addValue() / deleteValue()', desc: 'Discrete value options' }, - { type: 'API', name: 'GET /api/attributename', desc: 'Attributes & values' }, - { type: 'SSE', name: 'attributename', desc: 'Real-time attribute signal' }, - ], - }, - - // 5. Suppliers & Deliveries - { - id: 'supplierAutomat', - title: 'supplierAutomat', - subtitle: 'Supplier Directory & Catalogs', - entries: [ - { type: 'STATE', name: 'suppliers: Supplier[]', desc: 'Active vendor list' }, - { type: 'STATE', name: 'status: \'ready\'', desc: 'Load lifecycle state' }, - { type: 'ACTION', name: 'load()', desc: 'Deduplicated fetch' }, - { type: 'ACTION', name: 'reload()', desc: 'Silent background sync' }, - { type: 'API', name: 'GET /api/supplier', desc: 'Vendor records' }, - { type: 'SSE', name: 'supplier', desc: 'Real-time supplier signal' }, - ], - }, + // 5. Deliveries & Inbound { id: 'deliveryAutomat', title: 'deliveryAutomat', - subtitle: 'createListAutomat (Deliveries)', + subtitle: 'Chunk-Paginated Deliveries', entries: [ { type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' }, { type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' }, @@ -355,7 +297,7 @@ const AUTOMAT_SPECS = [ { id: 'quoteAutomat', title: 'quoteAutomat', - subtitle: 'createListAutomat (Quotes)', + subtitle: 'Chunk-Paginated Quotes', entries: [ { type: 'STATE', name: 'items: Quote[]', desc: 'Sales quotes / Angebote', port: 'p_quote_items' }, { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch' }, @@ -381,7 +323,7 @@ const AUTOMAT_SPECS = [ { id: 'entityAutomat', title: 'entityAutomat', - subtitle: 'createListAutomat (Entities)', + subtitle: 'Customer & Partner Directory', entries: [ { type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' }, { type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' }, @@ -389,83 +331,8 @@ const AUTOMAT_SPECS = [ { type: 'API', name: 'GET /api/entity', desc: 'Directory records' }, ], }, - { - id: 'customergroupAutomat', - title: 'customergroupAutomat', - subtitle: 'createListAutomat (Groups)', - entries: [ - { type: 'STATE', name: 'items: Customergroup[]', desc: 'Pricing discount tiers' }, - { type: 'ACTION', name: 'load() / reload()', desc: 'Discount group load' }, - { type: 'API', name: 'GET /api/customergroup', desc: 'Pricing tiers' }, - ], - }, - // 7. Inventory & Storage - { - id: 'warehouseAutomat', - title: 'warehouseAutomat', - subtitle: 'Warehouse Locations & Stock', - entries: [ - { type: 'STATE', name: 'warehouses: Warehouse[]', desc: 'Storage areas & bins' }, - { type: 'STATE', name: 'filteredWarehouses', desc: 'Filter search slice' }, - { type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated sync' }, - { type: 'ACTION', name: 'setFilter(query)', desc: 'Local filter query' }, - { type: 'API', name: 'GET /api/warehouse', desc: 'Warehouse listings' }, - { type: 'SSE', name: 'warehouse', desc: 'Real-time stock movement' }, - ], - }, - - // 8. Company & Finance - { - id: 'companyAutomat', - title: 'companyAutomat', - subtitle: 'Active Company Configuration', - entries: [ - { type: 'STATE', name: 'current: Company', desc: 'Tax rates & ISO language', port: 'p_comp_curr' }, - { type: 'ACTION', name: 'load() / reload()', desc: 'Fetch tenant settings' }, - { type: 'SELECT', name: 'selectCompanyTaxrate()', desc: 'Standard tax rate' }, - { type: 'SELECT', name: 'selectCompanyRootCategoryId()', desc: 'Root category pointer', port: 'p_comp_root' }, - { type: 'SELECT', name: 'selectCompanyName()', desc: 'Company legal name' }, - { type: 'API', name: 'GET /api/company/:id', desc: 'Legal & tax profile' }, - ], - }, - { - id: 'accountAutomat', - title: 'accountAutomat', - subtitle: 'createListAutomat (Accounts)', - entries: [ - { type: 'STATE', name: 'items: Account[]', desc: 'Bank & payment ledgers' }, - { type: 'ACTION', name: 'load() / reload()', desc: 'Payment accounts load' }, - { type: 'API', name: 'GET /api/account', desc: 'Chart of accounts' }, - ], - }, - { - id: 'numberAutomat', - title: 'numberAutomat', - subtitle: 'createListAutomat (Sequences)', - entries: [ - { type: 'STATE', name: 'items: NumberSeq[]', desc: 'Invoice/receipt counters' }, - { type: 'ACTION', name: 'load() / reload()', desc: 'Number series sync' }, - { type: 'API', name: 'GET /api/number', desc: 'Document counters' }, - ], - }, - - // 9. POS & Hardware - { - id: 'posterminalAutomat', - title: 'posterminalAutomat', - subtitle: 'POS Terminals & Gating', - entries: [ - { type: 'STATE', name: 'terminals: Posterminal[]', desc: 'Hardware terminals', port: 'p_pos_term' }, - { type: 'ACTION', name: 'load()', desc: 'Fetch POS registers' }, - { type: 'ACTION', name: 'create() / delete()', desc: 'Terminal registrations' }, - { type: 'ACTION', name: 'toggleCategory()', desc: 'Gated category filters' }, - { type: 'SELECT', name: 'selectPosterminals()', desc: 'Active registers' }, - { type: 'API', name: 'GET /api/posterminal', desc: 'Terminal device configs' }, - ], - }, - - // 10. AI Assistant & Workspace + // 7. AI Assistant & Workspace { id: 'chatAutomat', title: 'chatAutomat', @@ -497,7 +364,7 @@ const AUTOMAT_SPECS = [ ], }, - // 11. Audit & Virtual Grids + // 8. Audit & Virtual Grids { id: 'articleHistoryGridAutomat', title: 'articleHistoryGridAutomat', @@ -532,7 +399,7 @@ const AUTOMAT_SPECS = [ ], }, - // 12. UI, Settings & System + // 9. UI, Navigation & System { id: 'navigationAutomat', title: 'navigationAutomat', @@ -547,18 +414,6 @@ const AUTOMAT_SPECS = [ { type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' }, ], }, - { - id: 'userSettingsAutomat', - title: 'userSettingsAutomat', - subtitle: 'IndexedDB User Preferences', - entries: [ - { type: 'STATE', name: 'viewerPosition: \'top\'|\'right\'', desc: 'Editor docking location' }, - { type: 'STATE', name: 'chatDrawerWidth: 460', desc: 'AI drawer pixel width', port: 'p_usr_drawer' }, - { type: 'ACTION', name: 'setViewerPosition()', desc: 'Persist docking' }, - { type: 'ACTION', name: 'setChatDrawerWidth()', desc: 'Persist drawer width' }, - { type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' }, - ], - }, { id: 'helpAutomat', title: 'helpAutomat', @@ -595,23 +450,13 @@ const AUTOMAT_SPECS = [ */ const EDGE_DEFS = [ // Session & Auth lifecycle - { from: 'authAutomat', to: 'sseAutomat', label: 'bind session & cid', port: 'p_auth_user' }, - { from: 'authAutomat', to: 'companyAutomat', label: 'load company profile', port: 'p_auth_switchCompany' }, - { from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'brandAutomat', label: 'sync brands on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'unitAutomat', label: 'sync units on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'attributeAutomat', label: 'sync attributes on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'propertyAutomat', label: 'sync properties on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'supplierAutomat', label: 'sync suppliers on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'warehouseAutomat', label: 'sync warehouses on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'posterminalAutomat', label: 'sync terminals on authed', port: 'p_auth_status' }, + { from: 'authAutomat', to: 'sseAutomat', label: 'hasAuth -> connect SSE', port: 'p_auth_hasAuth' }, + { from: 'authAutomat', to: 'companyAutomat', label: 'hasCompany -> loadAll()', port: 'p_auth_hasCompany' }, + { from: 'authAutomat', to: 'categoryAutomat', label: 'sync tree on hasCompany', port: 'p_auth_hasCompany' }, { from: 'authAutomat', to: 'deliveryAutomat', label: 'sync deliveries on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'quoteAutomat', label: 'sync quotes on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'orderAutomat', label: 'sync orders on authed', port: 'p_auth_status' }, { from: 'authAutomat', to: 'entityAutomat', label: 'sync entities on authed', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'customergroupAutomat', label: 'sync customer groups', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'numberAutomat', label: 'sync number series', port: 'p_auth_status' }, - { from: 'authAutomat', to: 'accountAutomat', label: 'sync payment accounts', port: 'p_auth_status' }, { from: 'authAutomat', to: 'chatAutomat', label: 'auth chat & reload', port: 'p_auth_status' }, { from: 'authAutomat', to: 'vfsAutomat', label: 'load company vfs', port: 'p_auth_status' }, { from: 'authAutomat', to: 'articleHistoryGridAutomat', label: 'reload history on authed', port: 'p_auth_status' }, @@ -624,16 +469,12 @@ const EDGE_DEFS = [ { from: 'sseAutomat', to: 'articleIndexAutomat', label: '\'articles\' sse trigger', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'artikelAutomat', label: '\'article-\' sse reload', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'articleDetailAutomat', label: 'invalidateArticle(sku)', port: 'p_sse_events' }, - { from: 'sseAutomat', to: 'warehouseAutomat', label: '\'warehouse\' sse trigger', port: 'p_sse_events' }, - { from: 'sseAutomat', to: 'supplierAutomat', label: '\'supplier\' sse trigger', port: 'p_sse_events' }, - { from: 'sseAutomat', to: 'brandAutomat', label: '\'brand\' sse trigger', port: 'p_sse_events' }, - { from: 'sseAutomat', to: 'attributeAutomat', label: '\'attributename\' sse', port: 'p_sse_events' }, - { from: 'sseAutomat', to: 'propertyAutomat', label: '\'propertyname\' sse', port: 'p_sse_events' }, + { from: 'sseAutomat', to: 'companyAutomat', label: 'warehouse/supplier/brand/attr/prop/carrier sse', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'orderAutomat', label: '\'order\'/\'shipment\' sse', port: 'p_sse_events' }, { from: 'sseAutomat', to: 'vfsAutomat', label: '\'vfs\' sse mutation', port: 'p_sse_events' }, // Catalog, Indexing & Search pipeline - { from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId', port: 'p_comp_root' }, + { from: 'companyAutomat', to: 'categoryAutomat', label: 'rootCategoryId & terminals', port: 'p_comp_root' }, { from: 'categoryAutomat', to: 'categoryViewAutomat', label: 'attachTree & warm slugs', port: 'p_cat_loadTree' }, { from: 'categoryViewAutomat', to: 'articleIndexAutomat', label: 'derived union index', port: 'p_catview_collect' }, { from: 'articleIndexAutomat', to: 'articleSearchAutomat', label: 'query index items', port: 'p_idx_items' }, @@ -643,18 +484,13 @@ const EDGE_DEFS = [ { from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' }, // Purchasing & Deliveries - { from: 'deliveryEditorAutomat', to: 'supplierAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' }, + { from: 'deliveryEditorAutomat', to: 'companyAutomat', label: 'match vendor catalog', port: 'p_deled_supplier' }, { from: 'deliveryEditorAutomat', to: 'artikelAutomat', label: 'lookup SKU & pack units', port: 'p_deled_lines' }, { from: 'deliveryEditorAutomat', to: 'deliveryAutomat', label: 'commit delivery receipt', port: 'p_deled_commit' }, // Sales & CRM { from: 'quoteAutomat', to: 'orderAutomat', label: 'convert quote to order', port: 'p_quote_items' }, { from: 'orderAutomat', to: 'entityAutomat', label: 'customer lookup', port: 'p_order_items' }, - { from: 'companyAutomat', to: 'customergroupAutomat', label: 'poscustomergroup', port: 'p_comp_curr' }, - - // POS & Hardware - { from: 'posterminalAutomat', to: 'categoryAutomat', label: 'filter enabled roots', port: 'p_pos_term' }, - { from: 'companyAutomat', to: 'posterminalAutomat', label: 'company context', port: 'p_comp_curr' }, // AI Assistant & Workspace { from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' }, @@ -665,7 +501,7 @@ const EDGE_DEFS = [ { from: 'navigationAutomat', to: 'artikelAutomat', label: 'restore last SKU', port: 'p_nav_sku' }, { from: 'navigationAutomat', to: 'categoryAutomat', label: 'restore last category', port: 'p_nav_catslug' }, { from: 'navigationAutomat', to: 'deliveryEditorAutomat', label: 'restore draft path', port: 'p_nav_delpath' }, - { from: 'userSettingsAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_usr_drawer' }, + { from: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' }, { from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' }, // Self-loops diff --git a/src/App.jsx b/src/App.jsx index dc98375..2a72ce1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,15 @@ -import { PureComponent, Suspense } from 'react'; +import { PureComponent, lazy, Suspense } from 'react'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; -import { RouterProvider } from 'react-router-dom'; import authAutomat from './automats/authAutomat'; import LoginPage from './components/LoginPage'; -import CompanySetupDialog from './components/CompanySetupDialog'; -import { router } from './appRouter'; + +const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog')); +const AppRouter = lazy(() => import('./appRouter')); +const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage')); +const SseMonitor = import.meta.env.DEV + ? lazy(() => import('./devtools/SseMonitor')) + : null; function PageFallback() { return ( @@ -30,7 +34,7 @@ class App extends PureComponent { } componentDidMount() { - this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']); + this.unsubscribe = authAutomat.subscribe(this, ['status', 'user', 'hasAuth', 'hasCompany']); } componentWillUnmount() { @@ -38,7 +42,9 @@ class App extends PureComponent { } render() { - const { status, user } = this.state; + const { status, user, hasAuth, hasCompany } = this.state; + const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user)); + const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null)); if (status === 'loading') { return ( @@ -56,7 +62,7 @@ class App extends PureComponent { ); } - if (status !== 'authed') { + if (!isAuthed) { if (typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize') { const returnTo = encodeURIComponent(window.location.pathname + window.location.search); window.history.replaceState(null, '', `/login?returnTo=${returnTo}`); @@ -64,16 +70,28 @@ class App extends PureComponent { return ; } - const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize'; - - if (user?.selectedCompanyId == null && !isOAuthAuthorize) { - return ; - } + const isOAuthAuthorize = + typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize'; return ( - }> - - + <> + }> + {!isCompanySelected ? ( + isOAuthAuthorize ? ( + + ) : ( + + ) + ) : ( + + )} + + {SseMonitor ? ( + + + + ) : null} + ); } } diff --git a/src/appRouter.jsx b/src/appRouter.jsx index fd6faf7..318b355 100644 --- a/src/appRouter.jsx +++ b/src/appRouter.jsx @@ -1,39 +1,39 @@ import { lazy } from 'react'; -import { Navigate, createBrowserRouter } from 'react-router-dom'; +import { Navigate, RouterProvider, createBrowserRouter } from 'react-router-dom'; import AppShell from './components/AppShell'; import SectionLayout from './components/SectionLayout'; -import ArtikelUebersichtPage from './routes/artikel'; -import ArtikelKategorienPage from './routes/artikel/kategorien'; -import RedirectArtikelKategorien from './routes/artikel/RedirectArtikelKategorien'; -import ArtikelLagerPage from './routes/artikel/lager'; -import ArtikelMarkenPage from './routes/artikel/marken'; -import ArtikelVorlagenPage from './routes/artikel/vorlagen'; -import ArtikelMerkmalePage from './routes/artikel/merkmale'; -import ArtikelSkuPage from './routes/artikel/_sku'; -import LieferungListePage from './routes/lieferung'; -import LieferungNeuPage from './routes/lieferung/neu'; -import LieferungLieferantenPage from './routes/lieferung/lieferanten'; -import LieferungEditPage from './routes/lieferung/_deliveryId'; -import AuftragAngebotPage from './routes/auftrag'; -import AuftragAngebotNeuPage from './routes/auftrag/neuesangebot'; -import AuftragAngebotEditPage from './routes/auftrag/_quoteId'; -import AuftragBestellungPage from './routes/auftrag/bestellung'; -import AuftragBestellungNeuPage from './routes/auftrag/bestellung/neu'; -import AuftragBestellungEditPage from './routes/auftrag/bestellung/_orderId'; -import AuftragAusliefernPage from './routes/auftrag/ausliefern'; -import AuftragRetourePage from './routes/auftrag/retoure'; -import AuftragRetoureNeuPage from './routes/auftrag/retoure/neu'; -import AuftragRetoureEditPage from './routes/auftrag/retoure/_retoureId'; -import AuftragZahlungenPage from './routes/auftrag/zahlungen'; -import AuftragVersandartenPage from './routes/auftrag/versandarten'; +const ArtikelUebersichtPage = lazy(() => import('./routes/artikel')); +const ArtikelKategorienPage = lazy(() => import('./routes/artikel/kategorien')); +const RedirectArtikelKategorien = lazy(() => import('./routes/artikel/RedirectArtikelKategorien')); +const ArtikelLagerPage = lazy(() => import('./routes/artikel/lager')); +const ArtikelMarkenPage = lazy(() => import('./routes/artikel/marken')); +const ArtikelVorlagenPage = lazy(() => import('./routes/artikel/vorlagen')); +const ArtikelMerkmalePage = lazy(() => import('./routes/artikel/merkmale')); +const ArtikelSkuPage = lazy(() => import('./routes/artikel/_sku')); +const LieferungListePage = lazy(() => import('./routes/lieferung')); +const LieferungNeuPage = lazy(() => import('./routes/lieferung/neu')); +const LieferungLieferantenPage = lazy(() => import('./routes/lieferung/lieferanten')); +const LieferungEditPage = lazy(() => import('./routes/lieferung/_deliveryId')); +const AuftragAngebotPage = lazy(() => import('./routes/auftrag')); +const AuftragAngebotNeuPage = lazy(() => import('./routes/auftrag/neuesangebot')); +const AuftragAngebotEditPage = lazy(() => import('./routes/auftrag/_quoteId')); +const AuftragBestellungPage = lazy(() => import('./routes/auftrag/bestellung')); +const AuftragBestellungNeuPage = lazy(() => import('./routes/auftrag/bestellung/neu')); +const AuftragBestellungEditPage = lazy(() => import('./routes/auftrag/bestellung/_orderId')); +const AuftragAusliefernPage = lazy(() => import('./routes/auftrag/ausliefern')); +const AuftragRetourePage = lazy(() => import('./routes/auftrag/retoure')); +const AuftragRetoureNeuPage = lazy(() => import('./routes/auftrag/retoure/neu')); +const AuftragRetoureEditPage = lazy(() => import('./routes/auftrag/retoure/_retoureId')); +const AuftragZahlungenPage = lazy(() => import('./routes/auftrag/zahlungen')); +const AuftragVersandartenPage = lazy(() => import('./routes/auftrag/versandarten')); -import KundeListePage from './routes/kunde'; -import KundeNeuPage from './routes/kunde/neu'; -import KundeGruppenPage from './routes/kunde/gruppen'; -import KundeEditPage from './routes/kunde/_entityId'; -import StatisticsPage from './routes/infos/StatisticsPage'; -import OAuthAuthorizePage from './routes/oauth/OAuthAuthorizePage'; +const KundeListePage = lazy(() => import('./routes/kunde')); +const KundeNeuPage = lazy(() => import('./routes/kunde/neu')); +const KundeGruppenPage = lazy(() => import('./routes/kunde/gruppen')); +const KundeEditPage = lazy(() => import('./routes/kunde/_entityId')); +const StatisticsPage = lazy(() => import('./routes/infos/StatisticsPage')); +const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage')); const ArticleHistoryPage = lazy(() => import('./routes/infos/ArticleHistoryPage')); const GlobalHistoryPage = lazy(() => import('./routes/infos/GlobalHistoryPage')); @@ -119,7 +119,6 @@ const routes = [ { path: 'versandarten', element: }, { path: 'lieferarten', element: }, { path: ':quoteId', element: }, - ], }, { @@ -144,3 +143,7 @@ export const router = if (import.meta.hot) { import.meta.hot.data.router = router; } + +export default function AppRouter() { + return ; +} diff --git a/src/automats/accountAutomat.js b/src/automats/accountAutomat.js deleted file mode 100644 index 5a96369..0000000 --- a/src/automats/accountAutomat.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file accountAutomat.js - * @description Manages cached payment accounts (`GET /api/account`) for the selected company. - */ - -import { createListAutomat } from './createListAutomat'; -import { account as accountApi } from '../api'; - -export const accountAutomat = createListAutomat({ - name: 'account', - fetch: async () => { - const res = await accountApi.list(); - return Array.isArray(res?.accounts) ? res.accounts : []; - }, - sort: (items) => - [...items].sort((a, b) => - String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de') - ), -}); - -export default accountAutomat; diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js index 8c4ae14..8f0a00b 100644 --- a/src/automats/articleIndexAutomat.js +++ b/src/automats/articleIndexAutomat.js @@ -286,6 +286,9 @@ categoryAutomat.subscribe((state) => { scheduleRebuild(); } }); +if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) { + scheduleRebuild(); +} authAutomat.subscribe((auth) => { if (auth.status === 'anon') actions.reset(); diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index 2e68956..c8131ba 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -14,8 +14,7 @@ import { Automat } from 'react-automat'; import FlexSearch from 'flexsearch'; import articleIndexAutomat from './articleIndexAutomat'; import categoryAutomat from './categoryAutomat'; -import brandAutomat from './brandAutomat'; -import supplierAutomat from './supplierAutomat'; +import companyAutomat from './companyAutomat'; import { parseFilter } from '../components/article/searchFormat'; // #region Types & Initial State @@ -585,7 +584,7 @@ function buildCategoryFacets(matchedArticles) { */ function buildBrandFacets(matchedArticles) { const facets = {}; - const allBrands = brandAutomat.state?.brands ?? []; + const allBrands = companyAutomat.state?.brands ?? []; const brandById = new Map(); const brandByName = new Map(); for (const b of allBrands) { @@ -633,8 +632,8 @@ function buildBrandFacets(matchedArticles) { */ function buildSupplierFacets(matchedArticles) { const facets = {}; - const suppliers = supplierAutomat.state?.suppliers ?? []; - const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {}; + const suppliers = companyAutomat.state?.suppliers ?? []; + const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {}; const supplierArticleSets = new Map(); for (const s of suppliers) { @@ -738,7 +737,7 @@ const actions = { // Apply supplier filter if set const selectedSuppliers = parseFilter(supplier); if (selectedSuppliers.length > 0) { - const articlesBySupplier = supplierAutomat.state?.articlesBySupplier ?? {}; + const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {}; const allowedSkus = new Set(); for (const sId of selectedSuppliers) { const list = articlesBySupplier[String(sId)] ?? []; @@ -898,15 +897,10 @@ categoryAutomat.subscribe((catState) => { } }); -// Update facets when brands are loaded or updated. -brandAutomat.subscribe(() => { +// Update facets when brands, suppliers, or supplier articles are loaded or updated. +companyAutomat.subscribe(() => { actions.runSearch(); -}); - -// Update facets when suppliers or supplier articles are loaded or updated. -supplierAutomat.subscribe(() => { - actions.runSearch(); -}); +}, ['brands', 'suppliers', 'articlesBySupplier']); // #endregion // #region Selectors & Exports diff --git a/src/automats/attributeAutomat.js b/src/automats/attributeAutomat.js deleted file mode 100644 index 994dbc1..0000000 --- a/src/automats/attributeAutomat.js +++ /dev/null @@ -1,225 +0,0 @@ -/** - * @file attributeAutomat.js - * @description Manages catalog product attributes and their discrete values - * (e.g. Color, Size) for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on - * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - `sseAutomat`: Calls `actions.load()` upon receiving an `'attributename'` SSE event. - * - UI / Dialogs: Mutating actions (`createAttribute`, `updateAttribute`, - * `deleteAttribute`, `addValue`, `deleteValue`, `reorderValues`). - */ - -import { Automat } from 'react-automat'; -import { attributename as attributenameApi, attributevalue as attributevalueApi } from '../api'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} AttributeValue - * @property {number} id - * @property {number} attributeId - * @property {string} value - * @property {number} sort - */ - -/** - * @typedef {Object} AttributeName - * @property {number} id - * @property {string} name - * @property {number} sort - * @property {boolean} exclusive - * @property {AttributeValue[]} values - */ - -/** - * @typedef {Object} AttributeState - * @property {AttributeName[]} attributes - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ - -/** @type {AttributeState} */ -const initialState = { - attributes: [], - status: 'idle', - error: null, -}; - -function sortValues(values) { - if (!Array.isArray(values)) return []; - return [...values].sort((a, b) => { - const sDiff = (a.sort ?? 0) - (b.sort ?? 0); - if (sDiff !== 0) return sDiff; - return (a.id ?? 0) - (b.id ?? 0); - }); -} - -function normalizeAttributes(attributes) { - if (!Array.isArray(attributes)) return []; - return [...attributes] - .map((attr) => ({ - ...attr, - exclusive: Boolean(attr.exclusive), - values: sortValues(attr.values), - })) - .sort((a, b) => { - const sDiff = (a.sort ?? 0) - (b.sort ?? 0); - if (sDiff !== 0) return sDiff; - return (a.id ?? 0) - (b.id ?? 0); - }); -} - -let attributeAutomat; - -const actions = { - /** Load all attribute names and their values for the selected company. */ - async load() { - attributeAutomat.setState({ status: 'loading', error: null }); - try { - const res = await attributenameApi.list(); - const attributes = normalizeAttributes(res.attributenames); - attributeAutomat.setState({ attributes, status: 'succeeded' }); - return attributes; - } catch (err) { - attributeAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } - }, - - /** - * Create an attribute name. - * @param {{ name: string, sort?: number, exclusive?: boolean }} body - */ - async createAttribute(body) { - const res = await attributenameApi.create(body); - await actions.load(); - return res.attributename; - }, - - /** - * Update an attribute name. - * @param {number} id - * @param {{ name?: string, sort?: number, exclusive?: boolean }} body - */ - async updateAttribute(id, body) { - const res = await attributenameApi.update(id, body); - await actions.load(); - return res.attributename; - }, - - /** - * Delete an attribute name. - * @param {number} id - */ - async deleteAttribute(id) { - await attributenameApi.delete(id); - await actions.load(); - }, - - /** - * Delete multiple attribute names. - * @param {number[]} ids - */ - async deleteAttributes(ids) { - for (const id of ids) { - await attributenameApi.delete(id); - } - await actions.load(); - }, - - /** - * Reorder attribute names. - * @param {number[]} attributenameIds - */ - async reorderAttributes(attributenameIds) { - await attributenameApi.order(attributenameIds); - await actions.load(); - }, - - /** - * Create a value for an attribute. - * @param {number} attributeId - * @param {{ value: string, sort?: number }} body - */ - async createValue(attributeId, body) { - const res = await attributevalueApi.create(attributeId, body); - await actions.load(); - return res.attributevalue; - }, - - /** - * Update an attribute value. - * @param {number} id - * @param {{ value?: string, sort?: number }} body - */ - async updateValue(id, body) { - const res = await attributevalueApi.update(id, body); - await actions.load(); - return res.attributevalue; - }, - - /** - * Delete an attribute value. - * @param {number} id - */ - async deleteValue(id) { - await attributevalueApi.delete(id); - await actions.load(); - }, - - /** - * Delete multiple attribute values. - * @param {number[]} ids - */ - async deleteValues(ids) { - for (const id of ids) { - await attributevalueApi.delete(id); - } - await actions.load(); - }, - - /** - * Reorder values of an attribute. - * @param {number} attributeId - * @param {number[]} attributevalueIds - */ - async reorderValues(attributeId, attributevalueIds) { - await attributevalueApi.order(attributeId, attributevalueIds); - await actions.load(); - }, - - /** Reset to idle state (called on logout / company switch). */ - reset() { - attributeAutomat.setState(initialState); - }, -}; - -/** - * Global attribute state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. - * - * @type {Automat} - */ -attributeAutomat = new Automat(initialState, actions, { name: 'attribute' }); - -// Selectors -export function selectAttributes() { - return attributeAutomat.state.attributes; -} - -export function selectAttributeStatus() { - return attributeAutomat.state.status; -} - -// Subscribe to auth changes -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void attributeAutomat.actions.load(); - } else if (auth.status === 'anon') { - attributeAutomat.actions.reset(); - } -}); - -export default attributeAutomat; diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js index 48e56d5..bbef708 100644 --- a/src/automats/authAutomat.js +++ b/src/automats/authAutomat.js @@ -1,30 +1,24 @@ /** * @file authAutomat.js * @description Central authentication and multi-tenant session manager. - * Tracks current user, Google OAuth client ID, and active company selection. + * Tracks current user, Google OAuth client ID, active company selection, and + * the two core session bits: `hasAuth` and `hasCompany` (`hasCompany` requires `hasAuth`). * * Upstream Triggers: * - `AuthBootstrap.jsx`: Calls `bootstrapSession(session)` on initial app load. - * - `LoginPage.jsx`: Calls `loginWithGoogle(credential)` or `loginWithCredentials(body)`. - * - `UserMenu.jsx`: Calls `switchCompany(companyId)` or `logout()`. + * - `LoginPage.jsx`: Calls `loginWithGoogle(credential)`. + * - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`, + * `createCompany(payload)`, or `logout()`. * * Downstream Triggers: - * Subscribed to by almost all company-scoped automats, orchestrating their loading - * when authenticated (`authed` + `selectedCompanyId`) and reset on logout (`anon`): - * - `companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat` - * - `attributeAutomat`, `propertyAutomat`, `supplierAutomat`, `warehouseAutomat` - * - `sseAutomat`: Disconnects/reconnects SSE stream to `/api/events` per company - * - `chatAutomat`: Reconnects AI chat SSE stream and reloads conversations - * - `articleIndexAutomat`: Resets the derived index on logout; loads it lazily from - * the category views once the session is established - * - `createListAutomat` instances (`deliveryAutomat`, `quoteAutomat`, - * `orderAutomat`, `entityAutomat`, `customergroupAutomat`, `numberAutomat`) - * - Grid history automats (`articleHistoryGridAutomat`, `globalHistoryGridAutomat`) - * - `artikelAutomat`: Clears cached articles on logout + * - With `hasAuth`: SSE (`sseAutomat`) is connected to `/api/events`. + * - With `hasAuth && !hasCompany`: `CompanySetupDialog` is shown (`companyAutomat` not instantiated yet). + * - With `hasCompany` (requires `hasAuth`): 1st-level menu (`AppShell`) and `companyAutomat` + * are mounted and company APIs are triggered. */ import { Automat } from 'react-automat'; -import { auth, company } from '../api'; +import { auth, company, number as numberApi } from '../api'; /** * @typedef {Object} AuthUser @@ -37,6 +31,8 @@ import { auth, company } from '../api'; * @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions. * @property {string|null} googleClientId Google Identity Services client ID. * @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state. + * @property {boolean} hasAuth True when the session has a valid authenticated user. + * @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected. */ /** Emitted after an authenticated session has been established. */ @@ -51,10 +47,13 @@ const initialState = { user: null, googleClientId: null, status: 'loading', + hasAuth: false, + hasCompany: false, }; let emit = () => { }; +// #region helpers /** * Registers the application-level dispatcher used for authentication events. * @@ -93,9 +92,47 @@ function mergeUser(current, incoming) { return merged; } +/** + * Computes `hasAuth` and `hasCompany` (`hasCompany` strictly requires `hasAuth`). + * + * @param {AuthUser|null} user + * @param {'loading'|'anon'|'authed'} status + * @param {object} [extra] + * @returns {Partial} + */ +function buildAuthState(user, status, extra = {}) { + const hasAuth = status === 'authed' && Boolean(user); + const hasCompany = hasAuth && user?.selectedCompanyId != null; + return { + user: hasAuth ? user : null, + status, + hasAuth, + hasCompany, + ...extra, + }; +} + +function syncSse(state) { + if (state.hasAuth) { + void import('./sseAutomat') + .then(({ default: sseAutomat }) => { + sseAutomat.actions.syncWithAuth?.(state); + }) + .catch(() => { }); + } else { + const sseAutomat = Automat.get('sse'); + if (sseAutomat?.actions?.syncWithAuth) { + sseAutomat.actions.syncWithAuth(state); + } + } +} +// #endregion + let authAutomat; const actions = { + // #region bootstrapSession + // MARK: calls auth.me from ../api /** * Restores an existing session or requests it from the authentication API. * @@ -111,7 +148,10 @@ const actions = { if (!nextSession.user) { if (authAutomat.state.status !== 'authed') { - authAutomat.setState({ user: null, googleClientId, status: 'anon' }); + const nextState = authAutomat.setState( + buildAuthState(null, 'anon', { googleClientId }) + ); + syncSse(nextState); } else { authAutomat.setState({ googleClientId }); } @@ -119,17 +159,24 @@ const actions = { } const user = mergeUser(authAutomat.state.user, nextSession.user); - authAutomat.setState({ user, googleClientId, status: 'authed' }); + const nextState = authAutomat.setState( + buildAuthState(user, 'authed', { googleClientId }) + ); + syncSse(nextState); emit({ type: AUTH_SESSION_ESTABLISHED }); return nextSession; } catch (error) { if (authAutomat.state.status === 'loading') { - authAutomat.setState({ user: null, status: 'anon' }); + const nextState = authAutomat.setState(buildAuthState(null, 'anon')); + syncSse(nextState); } throw error; } }, + // #endregion + // #region loginWithGoogle + // MARK: calls auth.loginWithGoogle from ../api /** * Exchanges a Google credential for an authenticated application session. * @@ -143,19 +190,24 @@ const actions = { throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)'); } const user = mergeUser(authAutomat.state.user, session.user); - authAutomat.setState({ - user, - googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, - status: 'authed', - }); + const nextState = authAutomat.setState( + buildAuthState(user, 'authed', { + googleClientId: session.googleClientId ?? authAutomat.state.googleClientId, + }) + ); + syncSse(nextState); emit({ type: AUTH_SESSION_ESTABLISHED }); return session; } catch (error) { - authAutomat.setState({ user: null, status: 'anon' }); + const nextState = authAutomat.setState(buildAuthState(null, 'anon')); + syncSse(nextState); throw error; } }, + // #endregion + // #region selectCompany + // MARK: calls company.select from ../api /** * Selects the company used by the current session. * @@ -168,11 +220,15 @@ const actions = { authAutomat.state.user, data.user ?? { selectedCompanyId: companyId }, ); - authAutomat.setState({ user, status: 'authed' }); + const nextState = authAutomat.setState(buildAuthState(user, 'authed')); + syncSse(nextState); emit({ type: AUTH_COMPANY_CHANGED }); return user; }, + // #endregion + // #region createCompany + // MARK: calls company.create and company.select from ../api /** * Creates a company and selects it for the current user. * @@ -180,7 +236,8 @@ const actions = { * @returns {Promise} Updated user. */ async createCompany(payload) { - const data = await company.create(payload); + const { numbers, ...companyPayload } = payload || {}; + const data = await company.create(companyPayload); let incoming = data.user; if (!incoming && data.company?.id) { const selected = await company.select(data.company.id); @@ -190,12 +247,34 @@ const actions = { throw new Error('Unternehmen angelegt, aber keine Benutzerdaten erhalten.'); } + if (numbers) { + try { + const listRes = await numberApi.list(); + const serverNumbers = listRes?.numbers ?? []; + for (const serverRow of serverNumbers) { + const userConfig = numbers[serverRow.type]; + if (userConfig) { + const prefix = String(userConfig.prefix ?? ''); + const padding = Math.max(0, Number(userConfig.padding) || 0); + const next = Math.max(1, Number(userConfig.next) || 1); + await numberApi.update(serverRow.id, { prefix, padding, next }); + } + } + } catch (numErr) { + console.warn('Initial number sequences could not be saved:', numErr); + } + } + const user = mergeUser(authAutomat.state.user, incoming); - authAutomat.setState({ user, status: 'authed' }); + const nextState = authAutomat.setState(buildAuthState(user, 'authed')); + syncSse(nextState); emit({ type: AUTH_COMPANY_CHANGED }); return user; }, + // #endregion + // #region logout + // MARK: calls auth.logout from ../api /** * Ends the server session, clears the current user, and returns to `/`. * @@ -204,9 +283,11 @@ const actions = { async logout() { await auth.logout(); window.history.replaceState(null, '', '/'); - authAutomat.setState({ user: null, status: 'anon' }); + const nextState = authAutomat.setState(buildAuthState(null, 'anon')); + syncSse(nextState); emit({ type: AUTH_LOGGED_OUT }); }, + // #endregion }; /** @@ -216,6 +297,12 @@ const actions = { */ authAutomat = new Automat(initialState, actions, { name: 'auth' }); +// #region selectors +export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth); +export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany); +export const selectSelectedCompanyId = () => + authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null; + /** * Re-reads `GET /api/auth/me` from the server. * @@ -227,5 +314,6 @@ authAutomat = new Automat(initialState, actions, { name: 'auth' }); export function bootstrapAuthMe() { return auth.me(); } +// #endregion export default authAutomat; diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js deleted file mode 100644 index eca68c4..0000000 --- a/src/automats/brandAutomat.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * @file brandAutomat.js - * @description Manages catalog brands and manufacturers for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on - * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - `sseAutomat`: Subscribes to the single `'brand'` event carrying - * `{ sender, brandId }` — any create/rename/delete/picture change triggers - * `actions.load(true)`, which silently re-fetches the list. Subscribed views - * (e.g. `ArtikelMarkenPage` via `brandAutomat.subscribe(...)`) then - * re-render from the updated state. Events sent by this tab (matching `sender`) - * are ignored. - * - UI / Settings: Mutating actions (`create`, `rename`, `remove`, `addImage`, `deleteImage`). - */ - -import { Automat } from 'react-automat'; -import { brand as brandApi } from '../api'; -import authAutomat from './authAutomat'; -import sseAutomat, { getSseClientId } from './sseAutomat'; - -/** - * @typedef {Object} BrandState - * @property {Array} brands All brands (full list, decoupled from page UI). - * @property {Array} filteredBrands Filtered brands according to active filter query. - * @property {string} filter Active search/filter string. - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ - -/** @type {BrandState} */ -const initialState = { - brands: [], - filteredBrands: [], - filter: '', - status: 'idle', - error: null, -}; - -const sortBrands = (brands) => - [...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); - -const applyFilter = (brands, filter) => { - const query = String(filter ?? '').trim().toLowerCase(); - return query ? brands.filter((b) => String(b.name ?? '').toLowerCase().includes(query)) : brands; -}; - -let brandAutomat; -let inflightLoad = null; -const FETCH_PAGE_SIZE = 100; -// #region fetch -async function fetchAllBrands() { - let offset = 0, total = Infinity; - const collected = []; - while (offset === 0 || collected.length < total) { - const res = await brandApi.list({ offset, limit: FETCH_PAGE_SIZE }); - if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0); - const page = Array.isArray(res.brands) ? res.brands : []; - collected.push(...page); - offset += page.length; - if (page.length === 0) break; - } - return { brands: sortBrands(collected) }; -} - -const updateStateWithBrands = (brands, extra = {}) => { - const filteredBrands = applyFilter(brands, brandAutomat.state.filter); - brandAutomat.setState({ brands, filteredBrands, ...extra }); - return brands; -}; - -const actions = { - /** Sets the active brand search filter and updates filteredBrands. */ - filter(query) { - const filter = String(query ?? ''); - brandAutomat.setState({ filter, filteredBrands: applyFilter(brandAutomat.state.brands, filter) }); - }, - - /** - * Load all brands for the selected company (pages through the API). - * @param {boolean} [silent=false] If true, re-fetches without setting status to 'loading'. - */ - async load(silent = false) { - if (inflightLoad) return inflightLoad; - if (!silent) { - brandAutomat.setState({ status: 'loading', error: null }); - } - return (inflightLoad = fetchAllBrands() - .then(({ brands }) => updateStateWithBrands(brands, { status: 'succeeded', error: null })) - .catch((err) => { - const error = err?.message ?? String(err); - brandAutomat.setState(silent ? { error } : { status: 'failed', error }); - throw err; - }) - .finally(() => { inflightLoad = null; })); - }, - // #region add - async create(body) { - const res = await brandApi.create(body); - const row = res.brand; - updateStateWithBrands(sortBrands([...brandAutomat.state.brands.filter((b) => b.id !== row.id), row])); - return row; - }, - // #region rename - async rename(id, body) { - const res = await brandApi.rename(id, body); - const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; - updateStateWithBrands(sortBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)))); - return res; - }, - // #region del - async remove(id) { - await brandApi.delete(id); - updateStateWithBrands(brandAutomat.state.brands.filter((b) => b.id !== id)); - }, - async removeBrands(ids) { - const idSet = new Set(ids); - for (const id of ids) { - await brandApi.delete(id); - } - updateStateWithBrands(brandAutomat.state.brands.filter((b) => !idSet.has(b.id))); - await actions.load(true); - }, - // #region addPic - async addImage(id, file) { - const res = await brandApi.addImage(id, file); - await brandAutomat.actions.load(true); - return res.brand; - }, - // #region delPic - async deleteImage(id) { - const res = await brandApi.deleteImage(id); - const updated = res.brand ?? { pics: [] }; - updateStateWithBrands(brandAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated, pics: [] } : b))); - return res.brand; - } -}; - -/** - * Global brand state container. - * @type {Automat} - */ -brandAutomat = new Automat(initialState, actions, { name: 'brand' }); - -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) void brandAutomat.actions.load(); - else if (auth.status === 'anon') brandAutomat.setState(initialState); -}); - -// #region onSse -sseAutomat.actions.on('brand', (event) => { - try { - const payload = JSON.parse(event?.data ?? '{}'); - if (payload?.sender && payload.sender === getSseClientId()) return; - } catch { } - void brandAutomat.actions.load(true).catch(() => { }); -}); - -export default brandAutomat; \ No newline at end of file diff --git a/src/automats/carrierAutomat.js b/src/automats/carrierAutomat.js deleted file mode 100644 index 7bf5bc8..0000000 --- a/src/automats/carrierAutomat.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @file carrierAutomat.js - * @description Manages cached shipping carriers / Versandarten (`GET /api/carrier`) for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers - * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. - * - `sseAutomat`: Calls `actions.reload()` upon receiving a `'carrier'` SSE event. - * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or mutation actions. - */ - -import { Automat } from 'react-automat'; -import { carrier as carrierApi } from '../api'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} Carrier - * @property {number} id Carrier identifier. - * @property {string} name Carrier name. - * @property {number} sort Sort order. - * @property {boolean} hasPackages Whether carrier uses package tracking. - * @property {boolean} isReverse Whether carrier is used for returns. - */ - -/** - * @typedef {Object} CarrierState - * @property {Array} carriers Company-scoped carriers. - * @property {Array} filteredCarriers Filtered carriers according to active filter query. - * @property {string} filter Active search/filter string. - * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. - * @property {Error|null} error Last load error. - */ - -/** @type {CarrierState} */ -const initialState = { - carriers: [], - filteredCarriers: [], - filter: '', - status: 'idle', - error: null, -}; - -let carrierAutomat; - -const applyFilter = (carriers, filter) => { - const query = String(filter ?? '').trim().toLowerCase(); - return query ? carriers.filter((c) => String(c.name ?? '').toLowerCase().includes(query)) : carriers; -}; - -const updateStateWithCarriers = (carriers, extra = {}) => { - const list = Array.isArray(carriers) ? carriers : []; - const filteredCarriers = applyFilter(list, carrierAutomat.state.filter); - carrierAutomat.setState({ carriers: list, filteredCarriers, ...extra }); - return list; -}; - -/** - * Fetches the company-scoped carrier list. - * - * @returns {Promise>} Carrier list. - */ -async function fetchCarriers() { - const data = await carrierApi.list(); - if (Array.isArray(data)) return data; - if (data && Array.isArray(data.carriers)) return data.carriers; - return []; -} - -/** In-flight `load()` promise so concurrent callers share one request. */ -let inflightLoad = null; - -/** - * Loads the carrier list once, deduplicating concurrent callers. - * - * @returns {Promise>} Loaded carriers. - */ -function loadCarriers() { - if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - carrierAutomat.setState({ status: 'loading', error: null }); - try { - const carriers = await fetchCarriers(); - updateStateWithCarriers(carriers, { status: 'ready', error: null }); - return carriers; - } catch (error) { - carrierAutomat.setState({ status: 'failed', error }); - throw error; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; -} - -const actions = { - /** Sets the active carrier search filter and updates filteredCarriers. */ - filter(query) { - const filter = String(query ?? ''); - carrierAutomat.setState({ - filter, - filteredCarriers: applyFilter(carrierAutomat.state.carriers, filter), - }); - }, - - /** - * Loads the carrier list, exposing a loading/failed lifecycle. - * - * @returns {Promise>} Loaded carriers. - */ - load() { - return loadCarriers(); - }, - - /** - * Silently re-fetches the carrier list while keeping the current state. - * - * @returns {Promise>} Fresh carriers. - */ - async reload() { - try { - const carriers = await fetchCarriers(); - updateStateWithCarriers(carriers, { status: 'ready', error: null }); - return carriers; - } catch (error) { - carrierAutomat.setState({ error }); - throw error; - } - }, - - /** - * Creates a carrier and refreshes the cached list. - * - * @param {{name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Creation payload. - * @returns {Promise} Created carrier. - */ - async create(body) { - const data = await carrierApi.create(body); - const created = data?.carrier || data; - await carrierAutomat.actions.reload(); - return created; - }, - - /** - * Updates a carrier and refreshes the cached list. - * - * @param {string|number} id Carrier identifier. - * @param {{name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}} body Update payload. - * @returns {Promise} Updated carrier. - */ - async update(id, body) { - const data = await carrierApi.update(id, body); - const updated = data?.carrier || data; - await carrierAutomat.actions.reload(); - return updated; - }, - - /** - * Deletes a carrier and refreshes the cached list. - * - * @param {string|number} id Carrier identifier. - * @returns {Promise} - */ - async remove(id) { - await carrierApi.delete(id); - await carrierAutomat.actions.reload(); - }, - - /** - * Persists a new sort order for all carriers. - * - * @param {Array} carrierIds IDs in the desired order. - * @returns {Promise} - */ - async reorder(carrierIds) { - const byId = new Map(carrierAutomat.state.carriers.map((c) => [c.id, c])); - const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean); - if (reordered.length === carrierAutomat.state.carriers.length) { - updateStateWithCarriers(reordered); - } - await carrierApi.order({ carrierIds }); - await carrierAutomat.actions.reload(); - }, - - /** - * Replaces the cached list without a round-trip. - * - * @param {Array} carriers Carriers to cache. - * @returns {void} - */ - setCarriers(carriers) { - updateStateWithCarriers(carriers, { - status: 'ready', - error: null, - }); - }, - - /** - * Forgets the cached list and returns to the initial state. - * - * @returns {void} - */ - clear() { - carrierAutomat.setState({ ...initialState }); - }, -}; - -/** - * Company-scoped carrier cache. Loads automatically once an authenticated - * session with a selected company exists and is cleared when auth is gone. - * - * @type {Automat} - */ -carrierAutomat = new Automat(initialState, actions, { name: 'carrier' }); - -/** - * Derives the cache key from the auth state: the selected company while - * authenticated, otherwise `null`. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {string|null} Cache key. - */ -function authKey(authState) { - if (authState?.status !== 'authed' || !authState.user) return null; - const companyId = authState.user.selectedCompanyId; - return companyId == null ? null : String(companyId); -} - -let lastAuthKey; - -/** - * Loads the carrier list when auth/company becomes available and forgets it - * when the session ends. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {void} - */ -function syncWithAuth(authState) { - const key = authKey(authState); - if (key === lastAuthKey) return; - lastAuthKey = key; - if (key == null) { - carrierAutomat.actions.clear(); - } else { - void carrierAutomat.actions.load().catch(() => {}); - } -} - -authAutomat.subscribe(syncWithAuth); -syncWithAuth(authAutomat.state); - -export default carrierAutomat; diff --git a/src/automats/categoryAutomat.js b/src/automats/categoryAutomat.js index 1c9ce68..5bb7dcc 100644 --- a/src/automats/categoryAutomat.js +++ b/src/automats/categoryAutomat.js @@ -452,6 +452,11 @@ const actions = { refreshCategoryViewChildren(nextCategories); }, + /** Invalidate all loaded category views. */ + invalidateAllCategoryViews() { + invalidateAllCategoryViews(); + }, + /** Reset state to idle (logout / company switch). */ reset() { disposeAllCategoryViewAutomats(); @@ -479,9 +484,9 @@ categoryAutomat.views = categoryViews; // Load tree whenever the company data changes (or first becomes available) let _lastCompanyId = null; -companyAutomat.subscribe((company) => { - const id = company.current?.id ?? null; - const rootCategoryId = company.current?.rootCategoryId ?? null; +function syncCategoryWithCompany(company) { + const id = company?.current?.id ?? null; + const rootCategoryId = company?.current?.rootCategoryId ?? null; if (id && rootCategoryId && id !== _lastCompanyId) { const isCompanySwitch = _lastCompanyId !== null; _lastCompanyId = id; @@ -494,7 +499,10 @@ companyAutomat.subscribe((company) => { _lastCompanyId = null; categoryAutomat.actions.reset(); } -}); +} + +companyAutomat.subscribe(syncCategoryWithCompany); +syncCategoryWithCompany(companyAutomat.state); // Reset tree on logout authAutomat.subscribe((auth) => { diff --git a/src/automats/companyAutomat.js b/src/automats/companyAutomat.js index 2436095..9b1c932 100644 --- a/src/automats/companyAutomat.js +++ b/src/automats/companyAutomat.js @@ -1,65 +1,413 @@ /** * @file companyAutomat.js - * @description Manages configuration details of the currently selected company - * (tax rates, currency, language, legal details, and root category ID). + * @description Central company state container managing the active company profile + * and all company-coupled base data: + * - Company profile (`current`, tax rates, ISO language, root category ID) + * - Document number sequences (`numbers`) + * - POS terminals (`terminals`) + * - Suppliers & supplier article catalogs (`suppliers`, `articlesBySupplier`) + * - Units of measure (`units`) + * - User UI preferences (`viewerPosition`, `chatDrawerWidth`) + * - Warehouses (`warehouses`, `filteredWarehouses`, `warehouseFilter`) + * - Product properties (`properties`) + * - Customer groups (`customergroups`, `filteredCustomergroups`, `customergroupFilter`) + * - Shipping carriers (`carriers`, `filteredCarriers`, `carrierFilter`) + * - Brands (`brands`, `filteredBrands`, `brandFilter`) + * - Product attributes & values (`attributes`) + * - Payment accounts (`accounts`) * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — triggers `actions.load()` - * when status is `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - UI / Settings: Calls `update(payload)` or manual `load()`. - * - * Downstream Triggers: - * - `categoryAutomat`: Subscribes to `companyAutomat` — triggers `actions.loadTree()` - * whenever `company.current.id` or `rootCategoryId` changes/becomes available; - * resets when company is unset. - * - `posterminalAutomat`: Reads `companyAutomat.state.current?.id` as a fallback company ID. + * Lives with the 1st-level navigation shell (`AppShell`) and triggers when + * `authAutomat.state.hasCompany` is true (`hasCompany` requires `hasAuth`). */ import { Automat } from 'react-automat'; -import { company as companyApi } from '../api'; -import authAutomat from './authAutomat'; +import { + company as companyApi, + number as numberApi, + posterminal as posterminalApi, + supplier as supplierApi, + unit as unitApi, + warehouse as warehouseApi, + propertyname as propertynameApi, + customergroup as customergroupApi, + carrier as carrierApi, + brand as brandApi, + attributename as attributenameApi, + attributevalue as attributevalueApi, + account as accountApi, +} from '../api'; +import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import { normalizeCompany } from '../utils/companyForm'; +import authAutomat from './authAutomat'; -/** - * @typedef {Object} CompanyState - * @property {object|null} current Normalized company object for the selected company. - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ +// #region userSettings persistence helpers +const USER_SETTINGS_STORAGE_KEY = 'dash.userSettings'; +const IDB_NAME = 'automat_db'; +const IDB_STORE = 'states'; +const IDB_USER_SETTINGS_KEY = 'userSettings'; + +function readInitialUserSettings() { + const defaults = { viewerPosition: 'top', chatDrawerWidth: 460 }; + if (typeof window === 'undefined') return defaults; + try { + const raw = window.localStorage.getItem(USER_SETTINGS_STORAGE_KEY); + if (!raw) return defaults; + const parsed = JSON.parse(raw); + const viewerPosition = + parsed?.viewerPosition === 'top' || + parsed?.viewerPosition === 'right' || + parsed?.viewerPosition === 'below' + ? parsed.viewerPosition + : defaults.viewerPosition; + const width = Number(parsed?.chatDrawerWidth); + const chatDrawerWidth = !Number.isNaN(width) && width >= 280 ? Math.round(width) : defaults.chatDrawerWidth; + return { viewerPosition, chatDrawerWidth }; + } catch { + return defaults; + } +} + +function persistUserSettings(settings) { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(USER_SETTINGS_STORAGE_KEY, JSON.stringify(settings)); + } catch {} + if (typeof indexedDB !== 'undefined') { + try { + const req = indexedDB.open(IDB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = () => { + try { + const db = req.result; + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put(settings, IDB_USER_SETTINGS_KEY); + } catch {} + }; + } catch {} + } +} + +function hydrateUserSettingsFromIdb(automat) { + if (typeof indexedDB === 'undefined') return; + try { + const req = indexedDB.open(IDB_NAME, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(IDB_STORE)) { + db.createObjectStore(IDB_STORE); + } + }; + req.onsuccess = () => { + try { + const db = req.result; + const tx = db.transaction(IDB_STORE, 'readonly'); + const getReq = tx.objectStore(IDB_STORE).get(IDB_USER_SETTINGS_KEY); + getReq.onsuccess = () => { + const saved = getReq.result; + if (!saved || typeof saved !== 'object') return; + const patch = {}; + if ( + saved.viewerPosition === 'top' || + saved.viewerPosition === 'right' || + saved.viewerPosition === 'below' + ) { + patch.viewerPosition = saved.viewerPosition; + } + const num = Number(saved.chatDrawerWidth); + if (!Number.isNaN(num) && num >= 280) { + patch.chatDrawerWidth = Math.round(num); + } + if (Object.keys(patch).length > 0) { + automat.setState(patch); + try { + window.localStorage.setItem( + USER_SETTINGS_STORAGE_KEY, + JSON.stringify({ + viewerPosition: patch.viewerPosition ?? automat.state.viewerPosition, + chatDrawerWidth: patch.chatDrawerWidth ?? automat.state.chatDrawerWidth, + }), + ); + } catch {} + } + }; + } catch {} + }; + } catch {} +} +// #endregion + +// #region sorting & filtering helpers +const sortNumbers = (items) => + [...items].sort((a, b) => String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de')); + +const sortUnits = (units) => + [...units].sort((a, b) => { + const left = a.symbol ?? a.si ?? ''; + const right = b.symbol ?? b.si ?? ''; + return String(left).localeCompare(String(right), 'de'); + }); + +const sortBrands = (brands) => + [...brands].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); + +const sortGroups = (items) => + [...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)); + +const sortAccounts = (items) => + [...items].sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); + +const filterByName = (items, filter) => { + const query = String(filter ?? '').trim().toLowerCase(); + return query + ? items.filter((item) => String(item.name ?? '').toLowerCase().includes(query)) + : items; +}; + +function normalizeProperties(properties) { + if (!Array.isArray(properties)) return []; + return [...properties] + .map((p) => ({ + ...p, + exclusive: Boolean(p.exclusive), + })) + .sort((a, b) => { + const sDiff = (a.sort ?? 0) - (b.sort ?? 0); + if (sDiff !== 0) return sDiff; + return (a.id ?? 0) - (b.id ?? 0); + }); +} + +function sortAttributeValues(values) { + if (!Array.isArray(values)) return []; + return [...values].sort((a, b) => { + const sDiff = (a.sort ?? 0) - (b.sort ?? 0); + if (sDiff !== 0) return sDiff; + return (a.id ?? 0) - (b.id ?? 0); + }); +} + +function normalizeAttributes(attributes) { + if (!Array.isArray(attributes)) return []; + return [...attributes] + .map((attr) => ({ + ...attr, + exclusive: Boolean(attr.exclusive), + values: sortAttributeValues(attr.values), + })) + .sort((a, b) => { + const sDiff = (a.sort ?? 0) - (b.sort ?? 0); + if (sDiff !== 0) return sDiff; + return (a.id ?? 0) - (b.id ?? 0); + }); +} +// #endregion + +const initialUserSettings = readInitialUserSettings(); -/** @type {CompanyState} */ const initialState = { + // 1. Company profile current: null, status: 'idle', error: null, + + // 2. Number sequences + numbers: [], + numbersStatus: 'idle', + numbersError: null, + + // 3. POS terminals + terminals: [], + terminalsCompanyId: null, + terminalsStatus: 'idle', + terminalsError: null, + + // 4. Suppliers + suppliers: [], + articlesBySupplier: {}, + articlesLoading: false, + suppliersStatus: 'idle', + suppliersError: null, + + // 5. Units + units: [], + unitsStatus: 'idle', + unitsError: null, + + // 6. User settings + viewerPosition: initialUserSettings.viewerPosition, + chatDrawerWidth: initialUserSettings.chatDrawerWidth, + + // 7. Warehouses + warehouses: [], + filteredWarehouses: [], + warehouseFilter: '', + warehousesStatus: 'idle', + warehousesError: null, + + // 8. Properties + properties: [], + propertiesStatus: 'idle', + propertiesError: null, + + // 9. Customer groups + customergroups: [], + filteredCustomergroups: [], + customergroupFilter: '', + customergroupsStatus: 'idle', + customergroupsError: null, + + // 10. Carriers + carriers: [], + filteredCarriers: [], + carrierFilter: '', + carriersStatus: 'idle', + carriersError: null, + + // 11. Brands + brands: [], + filteredBrands: [], + brandFilter: '', + brandsStatus: 'idle', + brandsError: null, + + // 12. Attributes & values + attributes: [], + attributesStatus: 'idle', + attributesError: null, + + // 13. Payment accounts + accounts: [], + accountsStatus: 'idle', + accountsError: null, }; let companyAutomat; +// #region in-flight deduplication promises +let inflightCompany = null; +let inflightNumbers = null; +let inflightTerminals = null; +let inflightSuppliers = null; +let inflightUnits = null; +let inflightWarehouses = null; +let inflightProperties = null; +let inflightCustomergroups = null; +let inflightCarriers = null; +let inflightBrands = null; +let inflightAttributes = null; +let inflightAccounts = null; +// #endregion + +// #region internal state updaters & fetchers +const BRAND_FETCH_PAGE_SIZE = 100; + +async function fetchAllBrands() { + let offset = 0; + let total = Infinity; + const collected = []; + while (offset === 0 || collected.length < total) { + const res = await brandApi.list({ offset, limit: BRAND_FETCH_PAGE_SIZE }); + if (offset === 0) total = Number(res.total ?? res.brands?.length ?? 0); + const page = Array.isArray(res.brands) ? res.brands : []; + collected.push(...page); + offset += page.length; + if (page.length === 0) break; + } + return { brands: sortBrands(collected) }; +} + +function updateStateWithBrands(brands, extra = {}) { + const list = Array.isArray(brands) ? brands : []; + const filteredBrands = filterByName(list, companyAutomat.state.brandFilter); + companyAutomat.setState({ brands: list, filteredBrands, ...extra }); + return list; +} + +function updateStateWithWarehouses(warehouses, extra = {}) { + const list = Array.isArray(warehouses) ? warehouses : []; + const filteredWarehouses = filterByName(list, companyAutomat.state.warehouseFilter); + companyAutomat.setState({ warehouses: list, filteredWarehouses, ...extra }); + return list; +} + +function updateStateWithCustomergroups(groups, extra = {}) { + const list = sortGroups(Array.isArray(groups) ? groups : []); + const filteredCustomergroups = filterByName(list, companyAutomat.state.customergroupFilter); + companyAutomat.setState({ customergroups: list, filteredCustomergroups, ...extra }); + return list; +} + +function updateStateWithCarriers(carriers, extra = {}) { + const list = Array.isArray(carriers) ? carriers : []; + const filteredCarriers = filterByName(list, companyAutomat.state.carrierFilter); + companyAutomat.setState({ carriers: list, filteredCarriers, ...extra }); + return list; +} + +function patchTerminalCategories(terminalId, categoryIds) { + companyAutomat.setState({ + terminals: companyAutomat.state.terminals.map((t) => + t.id === terminalId ? { ...t, categoryIds } : t, + ), + }); +} + +async function fetchSuppliersList() { + const data = await supplierApi.list(); + requireObject(data, 'GET /api/supplier'); + return requireArray(data.suppliers, 'suppliers'); +} + +async function fetchWarehousesList() { + const data = await warehouseApi.list(); + requireObject(data, 'GET /api/warehouse'); + return requireArray(data.warehouses, 'warehouses'); +} + +async function fetchCustomergroupsList() { + const data = await customergroupApi.list(); + requireObject(data, 'GET /api/customergroup'); + return requireArray(data.customergroups, 'customergroups'); +} + +async function fetchCarriersList() { + const data = await carrierApi.list(); + if (Array.isArray(data)) return data; + if (data && Array.isArray(data.carriers)) return data.carriers; + return []; +} +// #endregion + const actions = { - /** - * Load the company data for the currently selected company. - * @returns {Promise} - */ + // #region 1. Company profile & orchestration + // MARK: calls company.getById from ../api/index.js async load() { const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) return null; + if (inflightCompany) return inflightCompany; companyAutomat.setState({ status: 'loading', error: null }); - try { - const data = await companyApi.getById(companyId); - const normalized = normalizeCompany(data.company); - companyAutomat.setState({ current: normalized, status: 'succeeded' }); - return normalized; - } catch (err) { - companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } + inflightCompany = (async () => { + try { + const data = await companyApi.getById(companyId); + const normalized = normalizeCompany(data.company); + companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); + return normalized; + } catch (err) { + companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); + throw err; + } finally { + inflightCompany = null; + } + })(); + return inflightCompany; }, - /** - * Update company details and refresh cached data. - * @param {object} payload - */ + // MARK: calls company.update and company.getById from ../api/index.js async update(payload) { const companyId = authAutomat.state.user?.selectedCompanyId; if (!companyId) throw new Error('Kein Unternehmen ausgewählt.'); @@ -68,7 +416,7 @@ const actions = { await companyApi.update(companyId, payload); const data = await companyApi.getById(companyId); const normalized = normalizeCompany(data.company, payload); - companyAutomat.setState({ current: normalized, status: 'succeeded' }); + companyAutomat.setState({ current: normalized, status: 'succeeded', error: null }); return normalized; } catch (err) { companyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); @@ -76,31 +424,996 @@ const actions = { } }, - /** Reset to idle (called on logout / company switch). */ - reset() { - companyAutomat.setState(initialState); + // MARK: loads all company-coupled base data in parallel + async loadAll() { + if (!authAutomat.state.hasCompany) return; + await Promise.allSettled([ + actions.load(), + actions.loadNumbers(), + actions.loadTerminals(), + actions.loadSuppliers(), + actions.loadUnits(), + actions.loadWarehouses(), + actions.loadProperties(), + actions.loadCustomergroups(), + actions.loadCarriers(), + actions.loadBrands(), + actions.loadAttributes(), + actions.loadAccounts(), + ]); }, + + reset() { + inflightCompany = null; + inflightNumbers = null; + inflightTerminals = null; + inflightSuppliers = null; + inflightUnits = null; + inflightWarehouses = null; + inflightProperties = null; + inflightCustomergroups = null; + inflightCarriers = null; + inflightBrands = null; + inflightAttributes = null; + inflightAccounts = null; + companyAutomat.setState({ + ...initialState, + viewerPosition: companyAutomat.state.viewerPosition, + chatDrawerWidth: companyAutomat.state.chatDrawerWidth, + }); + }, + // #endregion + + // #region 2. Number sequences (number) + // MARK: calls number.list from ../api/index.js + loadNumbers() { + if (inflightNumbers) return inflightNumbers; + inflightNumbers = (async () => { + companyAutomat.setState({ numbersStatus: 'loading', numbersError: null }); + try { + const data = await numberApi.list(); + const numbers = sortNumbers(data.numbers ?? []); + companyAutomat.setState({ numbers, numbersStatus: 'ready', numbersError: null }); + return numbers; + } catch (error) { + companyAutomat.setState({ numbersStatus: 'failed', numbersError: error }); + throw error; + } finally { + inflightNumbers = null; + } + })(); + return inflightNumbers; + }, + + async reloadNumbers() { + try { + const data = await numberApi.list(); + const numbers = sortNumbers(data.numbers ?? []); + companyAutomat.setState({ numbers, numbersStatus: 'ready', numbersError: null }); + return numbers; + } catch (error) { + companyAutomat.setState({ numbersError: error }); + throw error; + } + }, + + ensureNumbersLoaded() { + if (companyAutomat.state.numbersStatus === 'ready') { + return Promise.resolve(companyAutomat.state.numbers); + } + return actions.loadNumbers(); + }, + + replaceNumbers(items) { + const numbers = sortNumbers(Array.isArray(items) ? items : []); + companyAutomat.setState({ numbers, numbersStatus: 'ready', numbersError: null }); + return numbers; + }, + // #endregion + + // #region 3. POS terminals (posterminal) + // MARK: calls posterminal.list from ../api/index.js + async loadTerminals() { + const companyId = + authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id; + if (!companyId) return []; + if (inflightTerminals) return inflightTerminals; + companyAutomat.setState({ terminalsStatus: 'loading', terminalsError: null }); + inflightTerminals = (async () => { + try { + const res = await posterminalApi.list(); + const terminals = Array.isArray(res.posterminals) ? res.posterminals : []; + companyAutomat.setState({ + terminals, + terminalsCompanyId: companyId, + terminalsStatus: 'succeeded', + terminalsError: null, + }); + return terminals; + } catch (err) { + companyAutomat.setState({ + terminalsStatus: 'failed', + terminalsError: err?.message ?? String(err), + }); + throw err; + } finally { + inflightTerminals = null; + } + })(); + return inflightTerminals; + }, + + loadPosterminals() { + return actions.loadTerminals(); + }, + + async createTerminal({ name }) { + const res = await posterminalApi.create({ name }); + const terminal = res.posterminal; + companyAutomat.setState({ + terminals: [...companyAutomat.state.terminals, terminal], + }); + return terminal; + }, + + createPosterminal(payload) { + return actions.createTerminal(payload); + }, + + async updateTerminal(terminalId, patch) { + const res = await posterminalApi.update(terminalId, patch); + const updated = res.posterminal; + companyAutomat.setState({ + terminals: companyAutomat.state.terminals.map((t) => + t.id === terminalId ? { ...t, ...updated } : t, + ), + }); + return updated; + }, + + updatePosterminal(terminalId, patch) { + return actions.updateTerminal(terminalId, patch); + }, + + async deleteTerminal(terminalId) { + await posterminalApi.delete(terminalId); + companyAutomat.setState({ + terminals: companyAutomat.state.terminals.filter((t) => t.id !== terminalId), + }); + }, + + deletePosterminal(terminalId) { + return actions.deleteTerminal(terminalId); + }, + + async enableAllTerminalCategories({ terminalId }) { + const res = await posterminalApi.enableAllCategories(terminalId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + enableAll(payload) { + return actions.enableAllTerminalCategories(payload); + }, + + async disableAllTerminalCategories({ terminalId }) { + const res = await posterminalApi.disableAllCategories(terminalId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + disableAll(payload) { + return actions.disableAllTerminalCategories(payload); + }, + + async enableTerminalCategory({ terminalId, categoryId }) { + const res = await posterminalApi.enableCategory(terminalId, categoryId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + enableCategory(payload) { + return actions.enableTerminalCategory(payload); + }, + + async disableTerminalCategory({ terminalId, categoryId }) { + const res = await posterminalApi.disableCategory(terminalId, categoryId); + patchTerminalCategories(terminalId, res.categoryIds); + return res; + }, + + disableCategory(payload) { + return actions.disableTerminalCategory(payload); + }, + // #endregion + + // #region 4. Suppliers (supplier) + // MARK: calls supplier.list from ../api/index.js + loadSuppliers() { + if (inflightSuppliers) return inflightSuppliers; + inflightSuppliers = (async () => { + companyAutomat.setState({ suppliersStatus: 'loading', suppliersError: null }); + try { + const suppliers = await fetchSuppliersList(); + companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null }); + void actions.loadAllSupplierArticles().catch(() => {}); + return suppliers; + } catch (error) { + companyAutomat.setState({ suppliersStatus: 'failed', suppliersError: error }); + throw error; + } finally { + inflightSuppliers = null; + } + })(); + return inflightSuppliers; + }, + + async reloadSuppliers() { + try { + const suppliers = await fetchSuppliersList(); + companyAutomat.setState({ suppliers, suppliersStatus: 'ready', suppliersError: null }); + void actions.loadAllSupplierArticles().catch(() => {}); + return suppliers; + } catch (error) { + companyAutomat.setState({ suppliersError: error }); + throw error; + } + }, + + async loadArticlesForSupplier(supplierId) { + const sId = String(supplierId); + if (companyAutomat.state.articlesBySupplier?.[sId]) { + return companyAutomat.state.articlesBySupplier[sId]; + } + const res = await supplierApi.getArticles(supplierId); + const articles = Array.isArray(res?.articles) ? res.articles : []; + companyAutomat.setState({ + articlesBySupplier: { + ...(companyAutomat.state.articlesBySupplier || {}), + [sId]: articles, + }, + }); + return articles; + }, + + async loadAllSupplierArticles() { + const suppliers = companyAutomat.state.suppliers; + if (!suppliers.length) return {}; + companyAutomat.setState({ articlesLoading: true }); + try { + const results = await Promise.allSettled( + suppliers.map(async (s) => { + const res = await supplierApi.getArticles(s.id); + return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] }; + }), + ); + const nextMap = { ...(companyAutomat.state.articlesBySupplier || {}) }; + for (const r of results) { + if (r.status === 'fulfilled') { + nextMap[r.value.id] = r.value.articles; + } + } + companyAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false }); + return nextMap; + } catch { + companyAutomat.setState({ articlesLoading: false }); + return companyAutomat.state.articlesBySupplier || {}; + } + }, + + async createSupplier(body) { + const data = await supplierApi.create(body); + requireObject(data, 'POST /api/supplier'); + requireObject(data.supplier, 'supplier'); + requireDefined(data.supplier.id, 'supplier.id'); + await actions.reloadSuppliers(); + return data.supplier; + }, + + async updateSupplier(id, body) { + const data = await supplierApi.update(id, body); + requireObject(data, 'PATCH /api/supplier/:id'); + requireObject(data.supplier, 'supplier'); + requireDefined(data.supplier.id, 'supplier.id'); + await actions.reloadSuppliers(); + return data.supplier; + }, + + async removeSupplier(id) { + await supplierApi.delete(id); + await actions.reloadSuppliers(); + }, + + setSuppliers(suppliers) { + companyAutomat.setState({ + suppliers: Array.isArray(suppliers) ? suppliers : [], + suppliersStatus: 'ready', + suppliersError: null, + }); + }, + // #endregion + + // #region 5. Units (unit) + // MARK: calls unit.list from ../api/index.js + loadUnits() { + if (inflightUnits) return inflightUnits; + companyAutomat.setState({ unitsStatus: 'loading', unitsError: null }); + inflightUnits = (async () => { + try { + const res = await unitApi.list(); + const units = sortUnits(Array.isArray(res.units) ? res.units : []); + companyAutomat.setState({ units, unitsStatus: 'succeeded', unitsError: null }); + return units; + } catch (err) { + companyAutomat.setState({ + unitsStatus: 'failed', + unitsError: err?.message ?? String(err), + }); + throw err; + } finally { + inflightUnits = null; + } + })(); + return inflightUnits; + }, + + async upsertUnit(body) { + const res = await unitApi.upsert(body); + const row = res.unit; + const units = sortUnits([ + ...companyAutomat.state.units.filter((u) => u.si !== row.si), + row, + ]); + companyAutomat.setState({ units }); + return units; + }, + + async deleteUnit(si) { + await unitApi.delete(si); + companyAutomat.setState({ + units: companyAutomat.state.units.filter((u) => u.si !== si), + }); + }, + // #endregion + + // #region 6. User settings (userSetting) + setViewerPosition(position) { + if (position !== 'top' && position !== 'right' && position !== 'below') { + return; + } + companyAutomat.setState({ viewerPosition: position }); + persistUserSettings({ + viewerPosition: position, + chatDrawerWidth: companyAutomat.state.chatDrawerWidth, + }); + }, + + setChatDrawerWidth(width) { + const num = Number(width); + if (Number.isNaN(num) || num < 280) { + return; + } + const rounded = Math.round(num); + companyAutomat.setState({ chatDrawerWidth: rounded }); + persistUserSettings({ + viewerPosition: companyAutomat.state.viewerPosition, + chatDrawerWidth: rounded, + }); + }, + // #endregion + + // #region 7. Warehouses (warehouse) + // MARK: calls warehouse.list from ../api/index.js + filterWarehouses(query) { + const warehouseFilter = String(query ?? ''); + companyAutomat.setState({ + warehouseFilter, + filteredWarehouses: filterByName(companyAutomat.state.warehouses, warehouseFilter), + }); + }, + + loadWarehouses() { + if (inflightWarehouses) return inflightWarehouses; + inflightWarehouses = (async () => { + companyAutomat.setState({ warehousesStatus: 'loading', warehousesError: null }); + try { + const warehouses = await fetchWarehousesList(); + updateStateWithWarehouses(warehouses, { + warehousesStatus: 'ready', + warehousesError: null, + }); + return warehouses; + } catch (error) { + companyAutomat.setState({ warehousesStatus: 'failed', warehousesError: error }); + throw error; + } finally { + inflightWarehouses = null; + } + })(); + return inflightWarehouses; + }, + + async reloadWarehouses() { + try { + const warehouses = await fetchWarehousesList(); + updateStateWithWarehouses(warehouses, { + warehousesStatus: 'ready', + warehousesError: null, + }); + return warehouses; + } catch (error) { + companyAutomat.setState({ warehousesError: error }); + throw error; + } + }, + + async createWarehouse(body) { + const data = await warehouseApi.create(body); + requireObject(data, 'POST /api/warehouse'); + requireObject(data.warehouse, 'warehouse'); + requireDefined(data.warehouse.id, 'warehouse.id'); + await actions.reloadWarehouses(); + return data.warehouse; + }, + + async updateWarehouse(id, body) { + const data = await warehouseApi.update(id, body); + requireObject(data, 'PATCH /api/warehouse/:id'); + requireObject(data.warehouse, 'warehouse'); + requireDefined(data.warehouse.id, 'warehouse.id'); + await actions.reloadWarehouses(); + return data.warehouse; + }, + + renameWarehouse(id, body) { + return actions.updateWarehouse(id, body); + }, + + async removeWarehouse(id) { + await warehouseApi.delete(id); + await actions.reloadWarehouses(); + }, + + async reorderWarehouses(warehouseIds) { + const byId = new Map(companyAutomat.state.warehouses.map((w) => [w.id, w])); + const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean); + if (reordered.length === companyAutomat.state.warehouses.length) { + updateStateWithWarehouses(reordered); + } + await warehouseApi.order({ warehouseIds }); + await actions.reloadWarehouses(); + }, + + setWarehouses(warehouses) { + updateStateWithWarehouses(warehouses, { + warehousesStatus: 'ready', + warehousesError: null, + }); + }, + // #endregion + + // #region 8. Properties (property) + // MARK: calls propertyname.list from ../api/index.js + loadProperties() { + if (inflightProperties) return inflightProperties; + companyAutomat.setState({ propertiesStatus: 'loading', propertiesError: null }); + inflightProperties = (async () => { + try { + const res = await propertynameApi.list(); + const properties = normalizeProperties(res.propertynames); + companyAutomat.setState({ + properties, + propertiesStatus: 'succeeded', + propertiesError: null, + }); + return properties; + } catch (err) { + companyAutomat.setState({ + propertiesStatus: 'failed', + propertiesError: err?.message ?? String(err), + }); + throw err; + } finally { + inflightProperties = null; + } + })(); + return inflightProperties; + }, + + async createProperty(body) { + const res = await propertynameApi.create(body); + await actions.loadProperties(); + return res.propertyname; + }, + + async updateProperty(id, body) { + const res = await propertynameApi.update(id, body); + await actions.loadProperties(); + return res.propertyname; + }, + + async deleteProperty(id) { + await propertynameApi.delete(id); + await actions.loadProperties(); + }, + + async deleteProperties(ids) { + for (const id of ids) { + await propertynameApi.delete(id); + } + await actions.loadProperties(); + }, + + async reorderProperties(propertynameIds) { + await propertynameApi.order(propertynameIds); + await actions.loadProperties(); + }, + // #endregion + + // #region 9. Customer groups (customergroup) + // MARK: calls customergroup.list from ../api/index.js + filterCustomergroups(query) { + const customergroupFilter = String(query ?? ''); + companyAutomat.setState({ + customergroupFilter, + filteredCustomergroups: filterByName( + companyAutomat.state.customergroups, + customergroupFilter, + ), + }); + }, + + loadCustomergroups() { + if (inflightCustomergroups) return inflightCustomergroups; + inflightCustomergroups = (async () => { + companyAutomat.setState({ customergroupsStatus: 'loading', customergroupsError: null }); + try { + const groups = await fetchCustomergroupsList(); + updateStateWithCustomergroups(groups, { + customergroupsStatus: 'ready', + customergroupsError: null, + }); + return companyAutomat.state.customergroups; + } catch (error) { + companyAutomat.setState({ + customergroupsStatus: 'failed', + customergroupsError: error, + }); + throw error; + } finally { + inflightCustomergroups = null; + } + })(); + return inflightCustomergroups; + }, + + async reloadCustomergroups() { + try { + const groups = await fetchCustomergroupsList(); + updateStateWithCustomergroups(groups, { + customergroupsStatus: 'ready', + customergroupsError: null, + }); + return companyAutomat.state.customergroups; + } catch (error) { + companyAutomat.setState({ customergroupsError: error }); + throw error; + } + }, + + replaceCustomergroups(items) { + return updateStateWithCustomergroups(items, { + customergroupsStatus: 'ready', + customergroupsError: null, + }); + }, + + ensureCustomergroupsLoaded() { + if (companyAutomat.state.customergroupsStatus === 'ready') { + return Promise.resolve(companyAutomat.state.customergroups); + } + return actions.loadCustomergroups(); + }, + + async createCustomergroup(body) { + const data = await customergroupApi.create(body); + requireObject(data, 'POST /api/customergroup'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + await actions.reloadCustomergroups(); + return data.customergroup; + }, + + async updateCustomergroup(id, body) { + const data = await customergroupApi.update(id, body); + requireObject(data, 'PATCH /api/customergroup/:id'); + requireObject(data.customergroup, 'customergroup'); + requireDefined(data.customergroup.id, 'customergroup.id'); + await actions.reloadCustomergroups(); + return data.customergroup; + }, + + renameCustomergroup(id, body) { + return actions.updateCustomergroup(id, body); + }, + + async removeCustomergroup(id) { + await customergroupApi.delete(id); + await actions.reloadCustomergroups(); + }, + + async reorderCustomergroups(customergroupIds) { + const data = await customergroupApi.order({ customergroupIds }); + requireObject(data, 'PUT /api/customergroup/order'); + await actions.reloadCustomergroups(); + }, + // #endregion + + // #region 10. Carriers (carrier) + // MARK: calls carrier.list from ../api/index.js + filterCarriers(query) { + const carrierFilter = String(query ?? ''); + companyAutomat.setState({ + carrierFilter, + filteredCarriers: filterByName(companyAutomat.state.carriers, carrierFilter), + }); + }, + + loadCarriers() { + if (inflightCarriers) return inflightCarriers; + inflightCarriers = (async () => { + companyAutomat.setState({ carriersStatus: 'loading', carriersError: null }); + try { + const carriers = await fetchCarriersList(); + updateStateWithCarriers(carriers, { + carriersStatus: 'ready', + carriersError: null, + }); + return carriers; + } catch (error) { + companyAutomat.setState({ carriersStatus: 'failed', carriersError: error }); + throw error; + } finally { + inflightCarriers = null; + } + })(); + return inflightCarriers; + }, + + async reloadCarriers() { + try { + const carriers = await fetchCarriersList(); + updateStateWithCarriers(carriers, { + carriersStatus: 'ready', + carriersError: null, + }); + return carriers; + } catch (error) { + companyAutomat.setState({ carriersError: error }); + throw error; + } + }, + + async createCarrier(body) { + const data = await carrierApi.create(body); + const created = data?.carrier || data; + await actions.reloadCarriers(); + return created; + }, + + async updateCarrier(id, body) { + const data = await carrierApi.update(id, body); + const updated = data?.carrier || data; + await actions.reloadCarriers(); + return updated; + }, + + async removeCarrier(id) { + await carrierApi.delete(id); + await actions.reloadCarriers(); + }, + + async reorderCarriers(carrierIds) { + const byId = new Map(companyAutomat.state.carriers.map((c) => [c.id, c])); + const reordered = carrierIds.map((id) => byId.get(id)).filter(Boolean); + if (reordered.length === companyAutomat.state.carriers.length) { + updateStateWithCarriers(reordered); + } + await carrierApi.order({ carrierIds }); + await actions.reloadCarriers(); + }, + + setCarriers(carriers) { + updateStateWithCarriers(carriers, { + carriersStatus: 'ready', + carriersError: null, + }); + }, + // #endregion + + // #region 11. Brands (brand) + // MARK: calls brand.list from ../api/index.js + filterBrands(query) { + const brandFilter = String(query ?? ''); + companyAutomat.setState({ + brandFilter, + filteredBrands: filterByName(companyAutomat.state.brands, brandFilter), + }); + }, + + async loadBrands(silent = false) { + if (inflightBrands) return inflightBrands; + if (!silent) { + companyAutomat.setState({ brandsStatus: 'loading', brandsError: null }); + } + inflightBrands = fetchAllBrands() + .then(({ brands }) => + updateStateWithBrands(brands, { brandsStatus: 'succeeded', brandsError: null }), + ) + .catch((err) => { + const brandsError = err?.message ?? String(err); + companyAutomat.setState( + silent ? { brandsError } : { brandsStatus: 'failed', brandsError }, + ); + throw err; + }) + .finally(() => { + inflightBrands = null; + }); + return inflightBrands; + }, + + async createBrand(body) { + const res = await brandApi.create(body); + const row = res.brand; + updateStateWithBrands( + sortBrands([...companyAutomat.state.brands.filter((b) => b.id !== row.id), row]), + ); + return row; + }, + + async renameBrand(id, body) { + const res = await brandApi.rename(id, body); + const updated = res.brand ?? { id: res.id, name: res.name, gsprpinfo: res.gsprpinfo }; + updateStateWithBrands( + sortBrands( + companyAutomat.state.brands.map((b) => (b.id === id ? { ...b, ...updated } : b)), + ), + ); + return res; + }, + + async removeBrand(id) { + await brandApi.delete(id); + updateStateWithBrands(companyAutomat.state.brands.filter((b) => b.id !== id)); + }, + + async removeBrands(ids) { + const idSet = new Set(ids); + for (const id of ids) { + await brandApi.delete(id); + } + updateStateWithBrands(companyAutomat.state.brands.filter((b) => !idSet.has(b.id))); + await actions.loadBrands(true); + }, + + async addBrandImage(id, file) { + const res = await brandApi.addImage(id, file); + await actions.loadBrands(true); + return res.brand; + }, + + async deleteBrandImage(id) { + const res = await brandApi.deleteImage(id); + const updated = res.brand ?? { pics: [] }; + updateStateWithBrands( + companyAutomat.state.brands.map((b) => + b.id === id ? { ...b, ...updated, pics: [] } : b, + ), + ); + return res.brand; + }, + // #endregion + + // #region 12. Attributes & values (attribute) + // MARK: calls attributename.list from ../api/index.js + loadAttributes() { + if (inflightAttributes) return inflightAttributes; + companyAutomat.setState({ attributesStatus: 'loading', attributesError: null }); + inflightAttributes = (async () => { + try { + const res = await attributenameApi.list(); + const attributes = normalizeAttributes(res.attributenames); + companyAutomat.setState({ + attributes, + attributesStatus: 'succeeded', + attributesError: null, + }); + return attributes; + } catch (err) { + companyAutomat.setState({ + attributesStatus: 'failed', + attributesError: err?.message ?? String(err), + }); + throw err; + } finally { + inflightAttributes = null; + } + })(); + return inflightAttributes; + }, + + async createAttribute(body) { + const res = await attributenameApi.create(body); + await actions.loadAttributes(); + return res.attributename; + }, + + async updateAttribute(id, body) { + const res = await attributenameApi.update(id, body); + await actions.loadAttributes(); + return res.attributename; + }, + + async deleteAttribute(id) { + await attributenameApi.delete(id); + await actions.loadAttributes(); + }, + + async deleteAttributes(ids) { + for (const id of ids) { + await attributenameApi.delete(id); + } + await actions.loadAttributes(); + }, + + async reorderAttributes(attributenameIds) { + await attributenameApi.order(attributenameIds); + await actions.loadAttributes(); + }, + + async createValue(attributeId, body) { + const res = await attributevalueApi.create(attributeId, body); + await actions.loadAttributes(); + return res.attributevalue; + }, + + createAttributeValue(attributeId, body) { + return actions.createValue(attributeId, body); + }, + + async updateValue(id, body) { + const res = await attributevalueApi.update(id, body); + await actions.loadAttributes(); + return res.attributevalue; + }, + + updateAttributeValue(id, body) { + return actions.updateValue(id, body); + }, + + async deleteValue(id) { + await attributevalueApi.delete(id); + await actions.loadAttributes(); + }, + + deleteAttributeValue(id) { + return actions.deleteValue(id); + }, + + async deleteValues(ids) { + for (const id of ids) { + await attributevalueApi.delete(id); + } + await actions.loadAttributes(); + }, + + deleteAttributeValues(ids) { + return actions.deleteValues(ids); + }, + + async reorderValues(attributeId, attributevalueIds) { + await attributevalueApi.order(attributeId, attributevalueIds); + await actions.loadAttributes(); + }, + + reorderAttributeValues(attributeId, attributevalueIds) { + return actions.reorderValues(attributeId, attributevalueIds); + }, + // #endregion + + // #region 13. Payment accounts (account) + // MARK: calls account.list from ../api/index.js + loadAccounts() { + if (inflightAccounts) return inflightAccounts; + inflightAccounts = (async () => { + companyAutomat.setState({ accountsStatus: 'loading', accountsError: null }); + try { + const res = await accountApi.list(); + const accounts = sortAccounts(Array.isArray(res?.accounts) ? res.accounts : []); + companyAutomat.setState({ + accounts, + accountsStatus: 'ready', + accountsError: null, + }); + return accounts; + } catch (error) { + companyAutomat.setState({ accountsStatus: 'failed', accountsError: error }); + throw error; + } finally { + inflightAccounts = null; + } + })(); + return inflightAccounts; + }, + + async reloadAccounts() { + try { + const res = await accountApi.list(); + const accounts = sortAccounts(Array.isArray(res?.accounts) ? res.accounts : []); + companyAutomat.setState({ + accounts, + accountsStatus: 'ready', + accountsError: null, + }); + return accounts; + } catch (error) { + companyAutomat.setState({ accountsError: error }); + throw error; + } + }, + + ensureAccountsLoaded() { + if (companyAutomat.state.accountsStatus === 'ready') { + return Promise.resolve(companyAutomat.state.accounts); + } + return actions.loadAccounts(); + }, + + replaceAccounts(items) { + const accounts = sortAccounts(Array.isArray(items) ? items : []); + companyAutomat.setState({ accounts, accountsStatus: 'ready', accountsError: null }); + return accounts; + }, + // #endregion }; /** * Global company state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. + * Instantiated with the 1st-level navigation shell (`AppShell`) and loads all + * company-coupled base data when `authAutomat.state.hasCompany` is true. * - * @type {Automat} + * @type {Automat} */ companyAutomat = new Automat(initialState, actions, { name: 'company' }); -// Subscribe to auth changes — load when authed, reset when logged out / anon -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void companyAutomat.actions.load(); - } else if (auth.status === 'anon') { - companyAutomat.actions.reset(); - } -}); +// Hydrate user layout settings from IndexedDB if present +hydrateUserSettingsFromIdb(companyAutomat); -// Selectors (plain functions for use outside React) +// #region authAutomat synchronization +let lastCompanyKey; + +function syncWithAuth(auth) { + const hasCompany = Boolean( + auth?.hasCompany ?? (auth?.status === 'authed' && auth?.user?.selectedCompanyId != null), + ); + const companyId = hasCompany ? String(auth.user.selectedCompanyId) : null; + if (companyId === lastCompanyKey) return; + lastCompanyKey = companyId; + if (companyId == null) { + companyAutomat.actions.reset(); + } else { + companyAutomat.actions.reset(); + void companyAutomat.actions.loadAll(); + } +} + +authAutomat.subscribe(syncWithAuth); +syncWithAuth(authAutomat.state); +// #endregion + +// #region selectors & exported action helpers +// Company profile selectors export const selectCurrentCompany = () => companyAutomat.state.current; export const selectCompanyId = () => companyAutomat.state.current?.id ?? null; export const selectCompanyStatus = () => companyAutomat.state.status; @@ -112,4 +1425,55 @@ export const selectCompanyReducedTaxrate = () => companyAutomat.state.current?.r export const selectCompanyTaxfreerate = () => companyAutomat.state.current?.taxfreerate ?? null; export const selectCompanyRootCategoryId = () => companyAutomat.state.current?.rootCategoryId ?? null; +// Numbers selectors +export const selectNumbers = () => companyAutomat.state.numbers; +export const selectNumbersStatus = () => companyAutomat.state.numbersStatus; + +// POS terminals selectors +export const selectPosterminals = () => companyAutomat.state.terminals; +export const selectPosterminalStatus = () => companyAutomat.state.terminalsStatus; +export const selectHasPosterminals = () => companyAutomat.state.terminals.length > 0; + +// Suppliers selectors +export const selectSuppliers = () => companyAutomat.state.suppliers; +export const selectSuppliersStatus = () => companyAutomat.state.suppliersStatus; + +// Units selectors +export const selectUnits = () => companyAutomat.state.units; +export const selectUnitsStatus = () => companyAutomat.state.unitsStatus; + +// User settings helpers & selectors +export const setViewerPosition = (position) => companyAutomat.actions.setViewerPosition(position); +export const setChatDrawerWidth = (width) => companyAutomat.actions.setChatDrawerWidth(width); +export const selectChatDrawerWidth = () => companyAutomat.state.chatDrawerWidth || 460; + +// Warehouses selectors +export const selectWarehouses = () => companyAutomat.state.warehouses; +export const selectWarehousesStatus = () => companyAutomat.state.warehousesStatus; + +// Properties selectors +export const selectProperties = () => companyAutomat.state.properties; +export const selectPropertyStatus = () => companyAutomat.state.propertiesStatus; + +// Customer groups selectors +export const selectCustomergroups = () => companyAutomat.state.customergroups; +export const selectCustomergroupsStatus = () => companyAutomat.state.customergroupsStatus; + +// Carriers selectors +export const selectCarriers = () => companyAutomat.state.carriers; +export const selectCarriersStatus = () => companyAutomat.state.carriersStatus; + +// Brands selectors +export const selectBrands = () => companyAutomat.state.brands; +export const selectBrandsStatus = () => companyAutomat.state.brandsStatus; + +// Attributes selectors +export const selectAttributes = () => companyAutomat.state.attributes; +export const selectAttributeStatus = () => companyAutomat.state.attributesStatus; + +// Accounts selectors +export const selectAccounts = () => companyAutomat.state.accounts; +export const selectAccountsStatus = () => companyAutomat.state.accountsStatus; +// #endregion + export default companyAutomat; diff --git a/src/automats/createListAutomat.js b/src/automats/createListAutomat.js deleted file mode 100644 index 62918d2..0000000 --- a/src/automats/createListAutomat.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * @file createListAutomat.js - * @description Factory function for company-scoped list automats with - * deduplicated `load()`, silent `reload()`, and automatic session synchronization. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes each created list automat to auth state — calls - * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - * Used by: - * - `deliveryAutomat`, `quoteAutomat`, `orderAutomat`, - * `entityAutomat`, `customergroupAutomat`, `numberAutomat`. - * - * NOT used by `articleIndexAutomat`: that store is derived from the loaded - * category views rather than fetched from a whole-company endpoint. - */ - -import { Automat } from 'react-automat'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} ListAutomatState - * @property {Array} items Loaded records (empty until the first success). - * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. - * @property {Error|string|null} error Last load error. - */ - -/** - * Creates a company-scoped list store following the brand/unit/warehouse - * pattern: `load()` deduplicates concurrent callers, `reload()` refreshes - * silently, and `reset()` clears on logout. - * - * This replaces the stale-while-revalidate list caches that previously lived - * in `StorageManager` (articleIndex, delivery, quote, order, entity, - * customergroup, number). - * - * @template T - * @param {object} options Factory options. - * @param {string} options.name Automat registry name (also used for logging). - * @param {() => Promise>} options.fetch Loads the full record list. - * @param {(items: Array) => Array} [options.sort] Optional stable ordering. - * @returns {Automat} Configured list automat instance. - */ -export function createListAutomat({ name, fetch: fetchItems, sort }) { - const initialState = { items: [], status: 'idle', error: null }; - - let automat; - /** In-flight `load()` promise so concurrent consumers share one request. */ - let inflightLoad = null; - - /** - * Fetches and normalizes the record list. - * - * @returns {Promise>} Records from the backend. - */ - const fetchRecords = async () => { - const items = await fetchItems(); - return sort ? sort(items) : items; - }; - - const actions = { - /** - * Loads the list, deduplicating concurrent callers. - * - * @returns {Promise>} Loaded records. - */ - load() { - if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - automat.setState({ status: 'loading', error: null }); - try { - const items = await fetchRecords(); - automat.setState({ items, status: 'ready', error: null }); - return items; - } catch (error) { - automat.setState({ status: 'failed', error }); - throw error; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; - }, - - /** - * Silently re-fetches the list while keeping the current state, so - * subscribers do not flash a loading state. - * - * @returns {Promise>} Fresh records. - */ - async reload() { - try { - const items = await fetchRecords(); - automat.setState({ items, status: 'ready', error: null }); - return items; - } catch (error) { - automat.setState({ error }); - throw error; - } - }, - - /** - * Replaces the cached list without a round-trip. - * - * @param {Array} items Records to cache. - * @returns {Array} Cached records. - */ - replace(items) { - automat.setState({ items, status: 'ready', error: null }); - return items; - }, - - /** - * Re-fetches the list once the selected company is known. - * - * @returns {Promise>} Loaded records. - */ - ensureLoaded() { - if (automat.state.status === 'ready') { - return Promise.resolve(automat.state.items); - } - return actions.load(); - }, - - /** Reset to idle state (called on logout). */ - reset() { - inflightLoad = null; - automat.setState(initialState); - }, - }; - - automat = new Automat(initialState, actions, { name }); - - // Load on session/company establishment, reset on logout. - authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void actions.load().catch(() => {}); - } else if (auth.status === 'anon') { - actions.reset(); - } - }); - - return automat; -} - -/** - * Selects the record list of a list automat. - * - * @param {Automat} automat List automat. - * @returns {Array} Current records. - */ -export function selectListItems(automat) { - return automat.state.items; -} - -/** - * Selects the load status of a list automat. - * - * @param {Automat} automat List automat. - * @returns {'idle'|'loading'|'ready'|'failed'} Current status. - */ -export function selectListStatus(automat) { - return automat.state.status; -} diff --git a/src/automats/customergroupAutomat.js b/src/automats/customergroupAutomat.js deleted file mode 100644 index e87e899..0000000 --- a/src/automats/customergroupAutomat.js +++ /dev/null @@ -1,197 +0,0 @@ -/** - * @file customergroupAutomat.js - * @description Manages cached customer groups (`GET /api/customergroup`) for - * the active company. Follows the brand/warehouse automat pattern with - * filtering, deduplicated loads, silent reloads, and CRUD actions. - * - * Upstream Triggers: - * - `authAutomat`: Triggers `actions.load()` on `'authed'` with a `selectedCompanyId`, - * and `actions.reset()` on `'anon'`. - * - UI / Routes: Mutating actions (`create`, `update`, `remove`, `reorder`, `filter`). - */ - -import { Automat } from 'react-automat'; -import { customergroup as customergroupApi } from '../api'; -import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} CustomerGroup - * @property {string|number} id Customer group id. - * @property {string} name Group name. - * @property {number} sort Sort index. - * @property {boolean} editable False for reserved POS / Lieferanten groups. - */ - -/** - * @typedef {Object} CustomerGroupState - * @property {Array} items Loaded records. - * @property {Array} filteredItems Records matching active search filter. - * @property {string} filter Active search/filter string. - * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. - * @property {Error|string|null} error Last load error. - */ - -/** @type {CustomerGroupState} */ -const initialState = { - items: [], - filteredItems: [], - filter: '', - status: 'idle', - error: null, -}; - -const applyFilter = (groups, filter) => { - const query = String(filter ?? '').trim().toLowerCase(); - return query - ? groups.filter((g) => String(g.name ?? '').toLowerCase().includes(query)) - : groups; -}; - -const sortGroups = (items) => - [...items].sort((a, b) => (Number(a.sort) || 0) - (Number(b.sort) || 0)); - -let customergroupAutomat; - -const updateStateWithGroups = (groups, extra = {}) => { - const list = sortGroups(Array.isArray(groups) ? groups : []); - const filteredItems = applyFilter(list, customergroupAutomat.state.filter); - customergroupAutomat.setState({ items: list, filteredItems, ...extra }); - return list; -}; - -async function fetchGroups() { - const data = await customergroupApi.list(); - requireObject(data, 'GET /api/customergroup'); - return requireArray(data.customergroups, 'customergroups'); -} - -let inflightLoad = null; - -function loadGroups() { - if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - customergroupAutomat.setState({ status: 'loading', error: null }); - try { - const groups = await fetchGroups(); - updateStateWithGroups(groups, { status: 'ready', error: null }); - return groups; - } catch (error) { - customergroupAutomat.setState({ status: 'failed', error }); - throw error; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; -} - -const actions = { - /** Sets the active search filter query and updates filteredItems. */ - filter(query) { - const filter = String(query ?? ''); - customergroupAutomat.setState({ - filter, - filteredItems: applyFilter(customergroupAutomat.state.items, filter), - }); - }, - - /** Loads the list, deduplicating concurrent callers. */ - load() { - return loadGroups(); - }, - - /** Silently refreshes the customer groups without flashing loading state. */ - async reload() { - try { - const groups = await fetchGroups(); - updateStateWithGroups(groups, { status: 'ready', error: null }); - return groups; - } catch (error) { - customergroupAutomat.setState({ error }); - throw error; - } - }, - - /** Replaces the cached list without an API call. */ - replace(items) { - return updateStateWithGroups(items, { status: 'ready', error: null }); - }, - - /** Returns cached items if ready, otherwise loads. */ - ensureLoaded() { - if (customergroupAutomat.state.status === 'ready') { - return Promise.resolve(customergroupAutomat.state.items); - } - return actions.load(); - }, - - /** Resets state to initial idle values. */ - reset() { - inflightLoad = null; - customergroupAutomat.setState(initialState); - }, - - /** Creates a customer group and refreshes the cache. */ - async create(body) { - const data = await customergroupApi.create(body); - requireObject(data, 'POST /api/customergroup'); - requireObject(data.customergroup, 'customergroup'); - requireDefined(data.customergroup.id, 'customergroup.id'); - await customergroupAutomat.actions.reload(); - return data.customergroup; - }, - - /** Updates a customer group name and refreshes the cache. */ - async update(id, body) { - const data = await customergroupApi.update(id, body); - requireObject(data, 'PATCH /api/customergroup/:id'); - requireObject(data.customergroup, 'customergroup'); - requireDefined(data.customergroup.id, 'customergroup.id'); - await customergroupAutomat.actions.reload(); - return data.customergroup; - }, - - rename(id, body) { - return actions.update(id, body); - }, - - /** Deletes a customer group and refreshes the cache. */ - async remove(id) { - await customergroupApi.delete(id); - await customergroupAutomat.actions.reload(); - }, - - /** Reorders customer groups and refreshes the cache. */ - async reorder(customergroupIds) { - const data = await customergroupApi.order({ customergroupIds }); - requireObject(data, 'PUT /api/customergroup/order'); - await customergroupAutomat.actions.reload(); - }, -}; - -/** - * Company customer groups (`GET /api/customergroup`). - * - * Reserved groups (`POS`, `Lieferanten`) are flagged `editable: false` and must - * not be renamed or deleted by the UI. - * - * @type {Automat} - */ -customergroupAutomat = new Automat(initialState, actions, { - name: 'customergroup', -}); - -export { customergroupAutomat }; - -// Load on session/company establishment, reset on logout. -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void actions.load().catch(() => {}); - } else if (auth.status === 'anon') { - actions.reset(); - } -}); - -export default customergroupAutomat; diff --git a/src/automats/entityAutomat.js b/src/automats/entityAutomat.js index e3fbfac..aa7f270 100644 --- a/src/automats/entityAutomat.js +++ b/src/automats/entityAutomat.js @@ -14,7 +14,7 @@ import { Automat } from 'react-automat'; import { entity as entityApi } from '../api'; import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; import authAutomat from './authAutomat'; -import customergroupAutomat from './customergroupAutomat'; +import companyAutomat from './companyAutomat'; /** * @typedef {Object} Entity @@ -48,7 +48,7 @@ const initialState = { const applyFilter = (entities, filter) => { const query = String(filter ?? '').trim().toLowerCase(); if (!query) return entities; - const groups = customergroupAutomat?.state?.items ?? []; + const groups = companyAutomat?.state?.customergroups ?? []; const groupsById = Object.fromEntries(groups.map((g) => [g.id, g])); return entities.filter((e) => { @@ -200,13 +200,15 @@ entityAutomat = new Automat(initialState, actions, { export { entityAutomat }; // Load on session/company establishment, reset on logout. -authAutomat.subscribe((auth) => { +const syncEntityWithAuth = (auth) => { const companyId = auth.user?.selectedCompanyId; if (auth.status === 'authed' && companyId) { void actions.load().catch(() => {}); } else if (auth.status === 'anon') { actions.reset(); } -}); +}; +authAutomat.subscribe(syncEntityWithAuth); +syncEntityWithAuth(authAutomat.state); export default entityAutomat; diff --git a/src/automats/index.js b/src/automats/index.js index 5b67392..b912ab9 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -1,6 +1,6 @@ /** * @file index.js - * @description Central barrel exporting all domain, configuration, and list + * @description Central barrel exporting domain, configuration, and list * automats, along with their action creators and reactive selectors. */ @@ -22,9 +22,6 @@ export { clearNavigation, } from './navigationAutomat'; -export { default as userSettingsAutomat } from './userSettingsAutomat'; -export { setViewerPosition } from './userSettingsAutomat'; - export { default as helpAutomat } from './helpAutomat'; export { toggleHelp, @@ -44,10 +41,6 @@ export { applyPwaUpdate, } from './pwaAutomat'; -export { default as supplierAutomat } from './supplierAutomat'; -export { default as warehouseAutomat } from './warehouseAutomat'; -export { default as carrierAutomat } from './carrierAutomat'; - /** Article search over the derived index (replaces StorageManager.doSearch). */ export { default as articleSearchAutomat, @@ -112,9 +105,6 @@ export { getChunkStats as getRetoureChunkStats, subscribeChunkStats as subscribeRetoureChunkStats, } from './retoureAutomat'; -export { default as customergroupAutomat } from './customergroupAutomat'; -export { default as numberAutomat } from './numberAutomat'; -export { default as accountAutomat } from './accountAutomat'; export { default as backupAutomat } from './backupAutomat'; export { default as shipmentAutomat, @@ -127,8 +117,6 @@ export { getChunkStats as getShipmentChunkStats, subscribeChunkStats as subscribeShipmentChunkStats, } from './shipmentAutomat'; -export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat'; - /** Per-SKU article detail views keyed by SKU. */ export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat'; @@ -156,19 +144,33 @@ export { selectCompanyReducedTaxrate, selectCompanyTaxfreerate, selectCompanyRootCategoryId, -} from './companyAutomat'; - -export { default as brandAutomat } from './brandAutomat'; -export { default as unitAutomat } from './unitAutomat'; -export { default as attributeAutomat, selectAttributes, selectAttributeStatus } from './attributeAutomat'; -export { default as propertyAutomat, selectProperties, selectPropertyStatus } from './propertyAutomat'; - -export { default as posterminalAutomat } from './posterminalAutomat'; -export { + selectNumbers, + selectNumbersStatus, selectPosterminals, selectPosterminalStatus, selectHasPosterminals, -} from './posterminalAutomat'; + selectSuppliers, + selectSuppliersStatus, + selectUnits, + selectUnitsStatus, + setViewerPosition, + setChatDrawerWidth, + selectChatDrawerWidth, + selectWarehouses, + selectWarehousesStatus, + selectProperties, + selectPropertyStatus, + selectCustomergroups, + selectCustomergroupsStatus, + selectCarriers, + selectCarriersStatus, + selectBrands, + selectBrandsStatus, + selectAttributes, + selectAttributeStatus, + selectAccounts, + selectAccountsStatus, +} from './companyAutomat'; export { default as categoryAutomat } from './categoryAutomat'; export { @@ -232,84 +234,3 @@ export { closeExecution, normalizeRoute, } from './pinnedAppletAutomat'; - - -/** - * ============================================================================ - * Overview of Automats in Dashfront - * ============================================================================ - * - * Automats are lightweight, reactive, out-of-tree state containers powered by - * `react-automat`. They manage all client-side state without React context, - * hooks, or HOCs, enabling pure, zero-flash rendering in `React.PureComponent` - * via constructor reads and fine-grained subscriptions. - * - * 1. Multi-Tenant Session & Security - * ---------------------------------------------------------------------------- - * - `authAutomat`: Tracks authenticated session (`user`, `googleClientId`, - * `selectedCompanyId`, and auth status `'loading' | 'anon' | 'authed'`). - * Coordinates session bootstrap, company switching, and acts as the root - * lifecycle trigger that loads or resets all company-scoped automats. - * - * 2. Real-Time SSE Hub - * ---------------------------------------------------------------------------- - * - `sseAutomat`: Manages the single persistent Server-Sent Events connection - * (`GET /api/events`). Automatically reconnects when the active company changes - * and terminates on logout. Dispatches incoming server mutation signals into - * targeted automats (`artikelAutomat`, `articleDetailAutomat`, `categoryAutomat`, - * `articleIndexAutomat`, `warehouseAutomat`, `supplierAutomat`, `attributeAutomat`, - * `propertyAutomat`). - * - * 3. Domain & Entity Caches - * ---------------------------------------------------------------------------- - * - `artikelAutomat`: In-memory cache for full article records (`GET /api/article/:sku`) - * with inflight request deduplication, optimistic renames, and reactive selectors. - * - `articleDetailAutomat`: Keyed automats (`articleHistoryAutomat`, `articleSupplierAutomat`) - * providing per-SKU supplier catalog bindings and change history. - * - `deliveryEditorAutomat`: Interactive draft state machine for incoming goods receipts - * and invoice reconciliation (line items, supplier article auto-matching, PDF/receipt OCR). - * - * 4. Company Configuration & Catalogs - * ---------------------------------------------------------------------------- - * - `companyAutomat`: Active company settings (tax rates, ISO language, root category ID). - * - `categoryAutomat`: Hierarchical category tree (ID -> node map), mutations (create, - * rename, move, reorder, delete), and breadcrumb resolution. - * - `categoryViewAutomat`: Keyed view container (`categoryViews`) holding articles and child - * categories per slug path, auto-preloaded when category tree finishes loading. - * - `brandAutomat`: Catalog manufacturer and brand records. - * - `unitAutomat`: Allowed units of measure (Stück, kg, Liter, etc.). - * - `posterminalAutomat`: Point-of-sale hardware terminals and enabled category filters. - * - `attributeAutomat`: Product attribute definitions and discrete values (e.g. Size, Color). - * - `propertyAutomat`: Custom product property and metadata names. - * - `supplierAutomat`: Directory of vendor entities and supplier article mappings. - * - `warehouseAutomat`: Warehouse locations and stock storage areas. - * - * 5. Company-Scoped List Automats (`createListAutomat`) - * ---------------------------------------------------------------------------- - * Built with the shared list factory with deduplicated `load()`, silent `reload()`, - * and automatic synchronization with `authAutomat`: - * - `deliveryAutomat`: Delivery receipt list (`GET /api/delivery`). - * - `quoteAutomat`: Sales quotes / Angebote (`GET /api/quote`). - * - `orderAutomat`: Sales orders / Bestellungen (`GET /api/order`). - * - `entityAutomat`: Customers and business partners (`GET /api/entity`). - * - `customergroupAutomat`: Customer discount groups (`GET /api/customergroup`). - * - `numberAutomat`: Document numbering sequences (`GET /api/number`). - * - * 6. Search & Infinite-Scroll Grid Automats - * ---------------------------------------------------------------------------- - * - `articleIndexAutomat`: Fast client-side search index, **derived** from the loaded - * category views (`GET /api/category/:id/articles`) — no dedicated index endpoint. - * - `articleSearchAutomat`: Client-side search engine querying `articleIndexAutomat`. - * - `articleHistoryGridAutomat`: Virtualized infinite-scroll grid for article history. - * - `globalHistoryGridAutomat`: Virtualized infinite-scroll grid for global audit logs. - * - * 7. UI State & Utilities - * ---------------------------------------------------------------------------- - * - `chatAutomat`: AI assistant chat drawer state, conversations, and live SSE streaming tokens. - * - `navigationAutomat`: Persists last visited delivery document path and article SKU. - * - `userSettingsAutomat`: User layout preferences (viewer position, drawer widths in IndexedDB). - * - `helpAutomat`: Interactive help badge states and popper positioning (in IndexedDB). - * ============================================================================ - */ - - diff --git a/src/automats/numberAutomat.js b/src/automats/numberAutomat.js deleted file mode 100644 index 6dec96e..0000000 --- a/src/automats/numberAutomat.js +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file numberAutomat.js - * @description Manages cached document numbering sequences (`GET /api/number`) - * (e.g. delivery, entity) for the active company. Built via `createListAutomat`. - * - * Upstream Triggers: - * - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with - * a `selectedCompanyId`, and `reset()` on `'anon'`. - * - UI / Routes: Manual calls to `load()` or `reload()`. - */ - -import { number as numberApi } from '../api'; -import { createListAutomat } from './createListAutomat'; - -/** - * @typedef {Object} NumberSequence - * @property {string|number} id Sequence id. - * @property {string} type Document type key (e.g. `delivery`, `entity`). - * @property {string} prefix Prefix rendered before the counter. - * @property {number} padding Zero-padding width. - * @property {number} next Next counter value. - */ - -/** - * Company document number sequences (`GET /api/number`). - * - * @type {import('react-automat').Automat} - */ -export const numberAutomat = createListAutomat({ - name: 'number', - fetch: async () => { - const data = await numberApi.list(); - return data.numbers ?? []; - }, - sort: (items) => - [...items].sort((a, b) => - String(a.type ?? '').localeCompare(String(b.type ?? ''), 'de'), - ), -}); - -export default numberAutomat; diff --git a/src/automats/posterminalAutomat.js b/src/automats/posterminalAutomat.js deleted file mode 100644 index 6efa6ec..0000000 --- a/src/automats/posterminalAutomat.js +++ /dev/null @@ -1,170 +0,0 @@ -/** - * @file posterminalAutomat.js - * @description Manages registered Point-of-Sale (POS) hardware terminals and - * their enabled category mappings for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on - * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - `companyAutomat`: Reads `companyAutomat.state.current?.id` as fallback when loading. - * - UI / Settings: POS terminal actions (`create`, `delete`, `toggleCategory`, `setCategoryEnabled`). - */ - -import { Automat } from 'react-automat'; -import { posterminal as posterminalApi } from '../api'; -import authAutomat from './authAutomat'; -import companyAutomat from './companyAutomat'; - -/** - * @typedef {Object} PosterminalState - * @property {Array} terminals - * @property {number|string|null} companyId - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ - -/** @type {PosterminalState} */ -const initialState = { - terminals: [], - companyId: null, - status: 'idle', - error: null, -}; - -let posterminalAutomat; - -const actions = { - /** Load all posterminals for the selected company. */ - async load() { - const companyId = authAutomat.state.user?.selectedCompanyId ?? companyAutomat.state.current?.id; - if (!companyId) return []; - posterminalAutomat.setState({ status: 'loading', error: null }); - try { - const res = await posterminalApi.list(); - const terminals = Array.isArray(res.posterminals) ? res.posterminals : []; - posterminalAutomat.setState({ terminals, companyId, status: 'succeeded' }); - return terminals; - } catch (err) { - posterminalAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } - }, - - /** - * Create a new posterminal. - * @param {{ name: string }} param0 - */ - async create({ name }) { - const res = await posterminalApi.create({ name }); - const terminal = res.posterminal; - posterminalAutomat.setState({ - terminals: [...posterminalAutomat.state.terminals, terminal], - }); - return terminal; - }, - - /** - * Update a posterminal. - * @param {number} terminalId - * @param {object} patch - */ - async update(terminalId, patch) { - const res = await posterminalApi.update(terminalId, patch); - const updated = res.posterminal; - posterminalAutomat.setState({ - terminals: posterminalAutomat.state.terminals.map((t) => - t.id === terminalId ? { ...t, ...updated } : t - ), - }); - return updated; - }, - - /** - * Delete a posterminal. - * @param {number} terminalId - */ - async delete(terminalId) { - await posterminalApi.delete(terminalId); - posterminalAutomat.setState({ - terminals: posterminalAutomat.state.terminals.filter((t) => t.id !== terminalId), - }); - }, - - /** - * Enable all categories on a terminal. - * @param {{ terminalId: number }} param0 - */ - async enableAll({ terminalId }) { - const res = await posterminalApi.enableAllCategories(terminalId); - patchTerminalCategories(terminalId, res.categoryIds); - return res; - }, - - /** - * Disable all categories on a terminal. - * @param {{ terminalId: number }} param0 - */ - async disableAll({ terminalId }) { - const res = await posterminalApi.disableAllCategories(terminalId); - patchTerminalCategories(terminalId, res.categoryIds); - return res; - }, - - /** - * Enable a specific category on a terminal. - * @param {{ terminalId: number, categoryId: number }} param0 - */ - async enableCategory({ terminalId, categoryId }) { - const res = await posterminalApi.enableCategory(terminalId, categoryId); - patchTerminalCategories(terminalId, res.categoryIds); - return res; - }, - - /** - * Disable a specific category on a terminal. - * @param {{ terminalId: number, categoryId: number }} param0 - */ - async disableCategory({ terminalId, categoryId }) { - const res = await posterminalApi.disableCategory(terminalId, categoryId); - patchTerminalCategories(terminalId, res.categoryIds); - return res; - }, - - /** Reset to idle state (called on logout / company switch). */ - reset() { - posterminalAutomat.setState(initialState); - }, -}; - -function patchTerminalCategories(terminalId, categoryIds) { - posterminalAutomat.setState({ - terminals: posterminalAutomat.state.terminals.map((t) => - t.id === terminalId ? { ...t, categoryIds } : t, - ), - }); -} - -/** - * Global posterminal state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. - * - * @type {Automat} - */ -posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' }); - -// Subscribe to auth changes -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void posterminalAutomat.actions.load(); - } else if (auth.status === 'anon') { - posterminalAutomat.actions.reset(); - } -}); - -// Selectors -export const selectPosterminals = () => posterminalAutomat.state.terminals; -export const selectPosterminalStatus = () => posterminalAutomat.state.status; -export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0; - -export default posterminalAutomat; diff --git a/src/automats/propertyAutomat.js b/src/automats/propertyAutomat.js deleted file mode 100644 index 570a488..0000000 --- a/src/automats/propertyAutomat.js +++ /dev/null @@ -1,153 +0,0 @@ -/** - * @file propertyAutomat.js - * @description Manages catalog product property names (custom metadata definitions) - * for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on - * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - `sseAutomat`: Calls `actions.load()` upon receiving a `'propertyname'` SSE event. - * - UI / Settings: Mutating actions (`createProperty`, `updateProperty`, - * `deleteProperty`, `reorderProperties`). - */ - -import { Automat } from 'react-automat'; -import { propertyname as propertynameApi } from '../api'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} PropertyName - * @property {number} id - * @property {string} name - * @property {number} sort - */ - -/** - * @typedef {Object} PropertyState - * @property {PropertyName[]} properties - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ - -/** @type {PropertyState} */ -const initialState = { - properties: [], - status: 'idle', - error: null, -}; - -function normalizeProperties(properties) { - if (!Array.isArray(properties)) return []; - return [...properties] - .map((p) => ({ - ...p, - exclusive: Boolean(p.exclusive), - })) - .sort((a, b) => { - const sDiff = (a.sort ?? 0) - (b.sort ?? 0); - if (sDiff !== 0) return sDiff; - return (a.id ?? 0) - (b.id ?? 0); - }); -} - -let propertyAutomat; - -const actions = { - /** Load all property names for the selected company. */ - async load() { - propertyAutomat.setState({ status: 'loading', error: null }); - try { - const res = await propertynameApi.list(); - const properties = normalizeProperties(res.propertynames); - propertyAutomat.setState({ properties, status: 'succeeded' }); - return properties; - } catch (err) { - propertyAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } - }, - - /** - * Create a property name. - * @param {{ name: string, sort?: number, exclusive?: boolean }} body - */ - async createProperty(body) { - const res = await propertynameApi.create(body); - await actions.load(); - return res.propertyname; - }, - - /** - * Update a property name. - * @param {number} id - * @param {{ name?: string, sort?: number, exclusive?: boolean }} body - */ - async updateProperty(id, body) { - const res = await propertynameApi.update(id, body); - await actions.load(); - return res.propertyname; - }, - - /** - * Delete a property name. - * @param {number} id - */ - async deleteProperty(id) { - await propertynameApi.delete(id); - await actions.load(); - }, - - /** - * Delete multiple property names. - * @param {number[]} ids - */ - async deleteProperties(ids) { - for (const id of ids) { - await propertynameApi.delete(id); - } - await actions.load(); - }, - - /** - * Reorder property names. - * @param {number[]} propertynameIds - */ - async reorderProperties(propertynameIds) { - await propertynameApi.order(propertynameIds); - await actions.load(); - }, - - /** Reset to idle state (called on logout / company switch). */ - reset() { - propertyAutomat.setState(initialState); - }, -}; - -/** - * Global property state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. - * - * @type {Automat} - */ -propertyAutomat = new Automat(initialState, actions, { name: 'property' }); - -// Selectors -export function selectProperties() { - return propertyAutomat.state.properties; -} - -export function selectPropertyStatus() { - return propertyAutomat.state.status; -} - -// Subscribe to auth changes -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void propertyAutomat.actions.load(); - } else if (auth.status === 'anon') { - propertyAutomat.actions.reset(); - } -}); - -export default propertyAutomat; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 7127952..3679447 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -1,44 +1,26 @@ /** * @file sseAutomat.js * @description Central Server-Sent Events hub managing the single persistent - * `GET /api/events` connection and fanning mutation signals into domain automats. + * `GET /api/events` connection and fanning mutation signals into active automats. * * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — reconnects stream when - * `selectedCompanyId` changes/becomes available; disconnects on `'anon'` (logout). + * - `authAutomat`: Connects stream when `hasAuth` is true (and reconnects when + * `selectedCompanyId` changes); disconnects when `hasAuth` is false (logout). * * Downstream Triggers: - * Dispatches real-time SSE mutation events into specific automats: - * - `artikelAutomat`: Re-fetches article via `reloadArticle(sku)` on `article-` signals. - * - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`): - * Reloads SKU-keyed views on `article-` signals. - * - `categoryAutomat`: Triggers `actions.loadTree()` and `invalidateAllCategoryViews()` - * on `'category'` SSE events. - * - `articleIndexAutomat`: Triggers `actions.reload()` on `'articles'` SSE events; - * the index is derived from the reloaded category views, so no extra request. - * - `warehouseAutomat`: Triggers `actions.reload()` on `'warehouse'` SSE events. - * - `supplierAutomat`: Triggers `actions.reload()` on `'supplier'` SSE events. - * - `attributeAutomat`: Triggers `actions.load()` on `'attributename'` SSE events. - * - `propertyAutomat`: Triggers `actions.load()` on `'propertyname'` SSE events. + * Dispatches real-time SSE mutation events into active automats without eagerly + * importing or instantiating unmounted stores: + * - `companyAutomat`: Refreshes warehouses, carriers, attributes, properties, + * suppliers, and brands when `companyAutomat` is active. + * - `categoryAutomat`: Refreshes category tree and open category views on `'category'`. + * - `articleIndexAutomat`: Refreshes derived article index on `'articles'` / `'warehouse'`. + * - `artikelAutomat` & `articleDetailAutomat`: Refreshes watched SKU on `article-`. + * - `orderAutomat`, `quoteAutomat`, `shipmentAutomat`, `deliveryAutomat`, `retoureAutomat`: + * Reloads when those automats are active. */ import { Automat } from 'react-automat'; import authAutomat from './authAutomat'; -import artikelAutomat from './artikelAutomat'; -import { articleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat'; -import { articleIndexAutomat } from './articleIndexAutomat'; -import categoryAutomat, { invalidateAllCategoryViews } from './categoryAutomat'; -import warehouseAutomat from './warehouseAutomat'; -import attributeAutomat from './attributeAutomat'; -import propertyAutomat from './propertyAutomat'; -import supplierAutomat from './supplierAutomat'; -import deliveryAutomat from './deliveryAutomat'; -import orderAutomat from './orderAutomat'; -import quoteAutomat from './quoteAutomat'; -import retoureAutomat from './retoureAutomat'; -import shipmentAutomat from './shipmentAutomat'; -import carrierAutomat from './carrierAutomat'; - /** * @typedef {Object} SseState @@ -49,7 +31,6 @@ import carrierAutomat from './carrierAutomat'; */ const EVENT_HISTORY_LIMIT = 200; - const SSE_CLIENT_ID_KEY = 'sse.cid'; function createClientId() { @@ -63,8 +44,6 @@ function createClientId() { /** * Returns the tab-scoped SSE client id, creating and persisting it on first use. - * Created when the automat module is evaluated so every `EventSource` in this - * tab shares the same `cid`. * * @returns {string} Client id. */ @@ -81,7 +60,6 @@ export function getSseClientId() { } } -// Created once per tab at automat creation time and stored to sessionStorage. const sseClientId = typeof window !== 'undefined' ? getSseClientId() : null; /** @type {SseState} */ @@ -172,10 +150,6 @@ function attachHandlers(es) { /** * Binds the single `article-` listener for a watched SKU. * - * The listener resolves the handler set at call time, so a SKU that lost all - * watchers turns into a cheap no-op instead of needing removal. Listeners are - * reclaimed whenever the stream is rebuilt on a company switch. - * * @param {string} sku Article SKU. * @returns {void} */ @@ -189,119 +163,179 @@ function attachArticleListener(sku) { }); } +// #region connect +// MARK: opens GET /api/events EventSource when hasAuth is true function connect() { - const companyId = authAutomat.state.user?.selectedCompanyId; - if (companyId == null) { + const auth = authAutomat.state; + const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user)); + if (!hasAuth) { disconnect(); return; } + const companyId = + auth.user?.selectedCompanyId != null ? String(auth.user.selectedCompanyId) : null; const url = new URL('/api/events', window.location.origin); if (sseClientId) url.searchParams.set('cid', sseClientId); - console.log(`[sse] connecting`); - sseAutomat.setState({ status: 'connecting', companyId: String(companyId) }); + console.log('[sse] connecting'); + sseAutomat.setState({ status: 'connecting', companyId }); const es = new EventSource(url.href, { withCredentials: true }); // Category tree changed: refresh the tree and every open category view. addTrackedListener(es, 'category', () => { - void categoryAutomat.actions.loadTree().catch((err) => { - console.error('[sse] category tree refresh failed', err); - }); - invalidateAllCategoryViews(); + const cat = Automat.get('category'); + if (cat?.actions?.loadTree) { + void cat.actions.loadTree().catch((err) => { + console.error('[sse] category tree refresh failed', err); + }); + } + cat?.actions?.invalidateAllCategoryViews?.(); }); // Article index changed: refresh the index and open category views. addTrackedListener(es, 'articles', () => { - invalidateAllCategoryViews(); - void articleIndexAutomat.actions.reload().catch((err) => { - console.error('[sse] article index refresh failed', err); - }); + Automat.get('category')?.actions?.invalidateAllCategoryViews?.(); + const idx = Automat.get('articleIndex'); + if (idx?.actions?.reload) { + void idx.actions.reload().catch((err) => { + console.error('[sse] article index refresh failed', err); + }); + } }); addTrackedListener(es, 'warehouse', () => { - void warehouseAutomat.actions.reload().catch((err) => { - console.error('[sse] warehouse refresh failed', err); - }); - invalidateAllCategoryViews(); - void articleIndexAutomat.actions.reload().catch((err) => { - console.error('[sse] article index refresh on warehouse event failed', err); - }); + const comp = Automat.get('company'); + if (comp?.actions?.reloadWarehouses) { + void comp.actions.reloadWarehouses().catch((err) => { + console.error('[sse] warehouse refresh failed', err); + }); + } + Automat.get('category')?.actions?.invalidateAllCategoryViews?.(); + const idx = Automat.get('articleIndex'); + if (idx?.actions?.reload) { + void idx.actions.reload().catch((err) => { + console.error('[sse] article index refresh on warehouse event failed', err); + }); + } }); addTrackedListener(es, 'carrier', () => { - void carrierAutomat.actions.reload().catch((err) => { - console.error('[sse] carrier refresh failed', err); - }); + const comp = Automat.get('company'); + if (comp?.actions?.reloadCarriers) { + void comp.actions.reloadCarriers().catch((err) => { + console.error('[sse] carrier refresh failed', err); + }); + } }); addTrackedListener(es, 'attributename', () => { - void attributeAutomat.actions.load().catch((err) => { - console.error('[sse] attributename refresh failed', err); - }); + const comp = Automat.get('company'); + if (comp?.actions?.loadAttributes) { + void comp.actions.loadAttributes().catch((err) => { + console.error('[sse] attributename refresh failed', err); + }); + } }); addTrackedListener(es, 'propertyname', () => { - void propertyAutomat.actions.load().catch((err) => { - console.error('[sse] propertyname refresh failed', err); - }); + const comp = Automat.get('company'); + if (comp?.actions?.loadProperties) { + void comp.actions.loadProperties().catch((err) => { + console.error('[sse] propertyname refresh failed', err); + }); + } }); addTrackedListener(es, 'supplier', () => { - void supplierAutomat.actions.reload().catch((err) => { - console.error('[sse] supplier refresh failed', err); - }); + const comp = Automat.get('company'); + if (comp?.actions?.reloadSuppliers) { + void comp.actions.reloadSuppliers().catch((err) => { + console.error('[sse] supplier refresh failed', err); + }); + } + }); + + addTrackedListener(es, 'brand', (event) => { + try { + const payload = JSON.parse(event?.data ?? '{}'); + if (payload?.sender && payload.sender === getSseClientId()) return; + } catch {} + const comp = Automat.get('company'); + if (comp?.actions?.loadBrands) { + void comp.actions.loadBrands(true).catch(() => {}); + } }); addTrackedListener(es, 'order', () => { - void orderAutomat.actions.reload().catch((err) => { - console.error('[sse] order refresh failed', err); - }); - void shipmentAutomat.actions.reload().catch((err) => { - console.error('[sse] shipment refresh failed', err); - }); + const ord = Automat.get('order'); + if (ord?.actions?.reload) { + void ord.actions.reload().catch((err) => { + console.error('[sse] order refresh failed', err); + }); + } + const shp = Automat.get('shipment'); + if (shp?.actions?.reload) { + void shp.actions.reload().catch((err) => { + console.error('[sse] shipment refresh failed', err); + }); + } }); addTrackedListener(es, 'quote', () => { - void quoteAutomat.actions.reload().catch((err) => { - console.error('[sse] quote refresh failed', err); - }); + const q = Automat.get('quote'); + if (q?.actions?.reload) { + void q.actions.reload().catch((err) => { + console.error('[sse] quote refresh failed', err); + }); + } }); addTrackedListener(es, 'shipment', () => { - void shipmentAutomat.actions.reload().catch((err) => { - console.error('[sse] shipment refresh failed', err); - }); - void orderAutomat.actions.reload().catch((err) => { - console.error('[sse] order refresh failed', err); - }); + const shp = Automat.get('shipment'); + if (shp?.actions?.reload) { + void shp.actions.reload().catch((err) => { + console.error('[sse] shipment refresh failed', err); + }); + } + const ord = Automat.get('order'); + if (ord?.actions?.reload) { + void ord.actions.reload().catch((err) => { + console.error('[sse] order refresh failed', err); + }); + } }); addTrackedListener(es, 'delivery', () => { - void deliveryAutomat.actions.reload().catch((err) => { - console.error('[sse] delivery refresh failed', err); - }); + const del = Automat.get('delivery'); + if (del?.actions?.reload) { + void del.actions.reload().catch((err) => { + console.error('[sse] delivery refresh failed', err); + }); + } }); addTrackedListener(es, 'retoure', () => { - void retoureAutomat.actions.reload().catch((err) => { - console.error('[sse] retoure refresh failed', err); - }); - void shipmentAutomat.actions.reload().catch((err) => { - console.error('[sse] shipment refresh failed', err); - }); + const ret = Automat.get('retoure'); + if (ret?.actions?.reload) { + void ret.actions.reload().catch((err) => { + console.error('[sse] retoure refresh failed', err); + }); + } + const shp = Automat.get('shipment'); + if (shp?.actions?.reload) { + void shp.actions.reload().catch((err) => { + console.error('[sse] shipment refresh failed', err); + }); + } }); - - - addTrackedListener(es, 'open', () => { sseAutomat.setState({ status: 'connected' }); }); addTrackedListener(es, 'message', () => {}); - // EventSource reconnects on its own; only surface the state. es.onerror = (e) => { console.error('[sse] event stream error', e); recordEvent(e || { type: 'error', data: '', lastEventId: '' }); @@ -311,11 +345,37 @@ function connect() { eventSource = es; attachHandlers(es); } +// #endregion + +let wasAuthed = false; + +function handleAuthSync(auth) { + const isAuthed = Boolean(auth?.hasAuth ?? (auth?.status === 'authed' && auth?.user)); + const companyId = + isAuthed && auth?.user?.selectedCompanyId != null + ? String(auth.user.selectedCompanyId) + : null; + const relogin = isAuthed && !wasAuthed; + wasAuthed = isAuthed; + + if (!isAuthed) { + disconnect(); + sseAutomat.setState({ companyId: null }); + return; + } + + if (!relogin && eventSource && companyId === sseAutomat.state.companyId) { + return; + } + + disconnect(); + sseAutomat.setState({ companyId }); + connect(); +} const actions = { /** - * Closes and reopens the event stream. Required after a company switch, - * because the stream is scoped to the selected company at connect time. + * Closes and reopens the event stream. * * @returns {void} */ @@ -324,6 +384,15 @@ const actions = { connect(); }, + /** + * Synchronizes the SSE connection with `authAutomat` state. + * + * @param {import('./authAutomat').AuthState} authState + */ + syncWithAuth(authState) { + handleAuthSync(authState ?? authAutomat.state); + }, + clearEvents() { sseAutomat.setState({ events: [] }); }, @@ -331,9 +400,6 @@ const actions = { /** * Registers a handler for a statically named backend event. * - * Registration order does not matter: handlers added while disconnected are - * attached on the next connect. - * * @param {string} event Event name (e.g. `category`, `articles`). * @param {(event: MessageEvent) => void} handler Signal handler. * @returns {() => void} Unsubscribe callback. @@ -360,9 +426,6 @@ const actions = { /** * Subscribes to `article-` signals for one SKU. * - * The backend emits this event whenever that article changes. Exactly one - * listener is attached per SKU while at least one caller is subscribed. - * * @param {string} sku Article SKU to watch. * @param {(sku: string) => void} handler Called with the changed SKU. * @returns {() => void} Unsubscribe callback. @@ -389,9 +452,6 @@ const actions = { /** * Global `GET /api/events` connection. * - * Owns the single `EventSource` for the tab and dispatches signals into the - * domain automats. Reconnects whenever the selected company changes. - * * @type {Automat} */ sseAutomat = new Automat(initialState, actions, { name: 'sse' }); @@ -399,48 +459,26 @@ sseAutomat = new Automat(initialState, actions, { name: 'sse' }); /** * Refreshes the cached article plus any open history/supplier view. * - * Background refreshes stay silent: an article that is not on screen has no - * visible subscriber to notify, so failures are swallowed. - * * @param {string} sku Article SKU. * @returns {void} */ export function invalidateArticle(sku) { const key = String(sku); - void artikelAutomat.actions.reloadArticle(key).catch(() => {}); - if (articleHistoryAutomat.has(key)) { - void articleHistoryAutomat.reload(key).catch(() => {}); + const art = Automat.get('artikel'); + if (art?.actions?.reloadArticle) { + void art.actions.reloadArticle(key).catch(() => {}); } - if (articleSupplierAutomat.has(key)) { - void articleSupplierAutomat.reload(key).catch(() => {}); + const hist = Automat.get('articleHistory'); + if (hist?.has?.(key)) { + void hist.reload(key).catch(() => {}); + } + const sup = Automat.get('articleSupplier'); + if (sup?.has?.(key)) { + void sup.reload(key).catch(() => {}); } } -// Reconnect when the selected company changes; disconnect on logout. -// On logout the stream must close; on relogin (anon -> authed with a company) -// the stream is re-established even if the company id matches the previous one. -let wasAuthed = false; -authAutomat.subscribe((auth) => { - const companyId = - auth.status === 'authed' && auth.user?.selectedCompanyId != null - ? String(auth.user.selectedCompanyId) - : null; - const isAuthed = companyId != null; - const relogin = isAuthed && !wasAuthed; - wasAuthed = isAuthed; - - if (!relogin && companyId === sseAutomat.state.companyId) return; - - if (companyId == null) { - disconnect(); - sseAutomat.setState({ companyId: null }); - return; - } - - // A different company (or a fresh login) means a different stream scope. - disconnect(); - sseAutomat.setState({ companyId }); - connect(); -}); +authAutomat.subscribe(handleAuthSync); +handleAuthSync(authAutomat.state); export default sseAutomat; diff --git a/src/automats/supplierAutomat.js b/src/automats/supplierAutomat.js deleted file mode 100644 index dbebc20..0000000 --- a/src/automats/supplierAutomat.js +++ /dev/null @@ -1,268 +0,0 @@ -/** - * @file supplierAutomat.js - * @description Manages cached supplier list (`GET /api/supplier`) for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers - * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. - * - `sseAutomat`: Calls `actions.reload()` upon receiving a `'supplier'` SSE event. - * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setSuppliers()`. - */ - -import { Automat } from 'react-automat'; -import { supplier as supplierApi } from '../api'; -import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} Supplier - * @property {string|number} id Supplier identifier. - * @property {string} name Supplier name. - */ - -/** - * @typedef {Object} SupplierState - * @property {Array} suppliers Company-scoped suppliers. - * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. - * @property {Error|null} error Last load error. - */ - -/** @type {SupplierState} */ -const initialState = { - suppliers: [], - articlesBySupplier: {}, - articlesLoading: false, - status: 'idle', - error: null, -}; - -let supplierAutomat; - -/** - * Fetches the company-scoped supplier list. - * - * @returns {Promise>} Supplier list. - */ -async function fetchSuppliers() { - const data = await supplierApi.list(); - requireObject(data, 'GET /api/supplier'); - return requireArray(data.suppliers, 'suppliers'); -} - -/** In-flight `load()` promise so concurrent consumers share one request. */ -let inflightLoad = null; - -/** - * Loads the supplier list once, deduplicating concurrent callers. - * - * @returns {Promise>} Loaded suppliers. - */ -function loadSuppliers() { - if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - supplierAutomat.setState({ status: 'loading', error: null }); - try { - const suppliers = await fetchSuppliers(); - supplierAutomat.setState({ suppliers, status: 'ready', error: null }); - void actions.loadAllSupplierArticles().catch(() => {}); - return suppliers; - } catch (error) { - supplierAutomat.setState({ status: 'failed', error }); - throw error; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; -} - -const actions = { - /** - * Loads the supplier list, exposing a loading/failed lifecycle. - * - * @returns {Promise>} Loaded suppliers. - */ - load() { - return loadSuppliers(); - }, - - /** - * Silently re-fetches the supplier list while keeping the current state. - * - * @returns {Promise>} Fresh suppliers. - */ - async reload() { - try { - const suppliers = await fetchSuppliers(); - supplierAutomat.setState({ suppliers, status: 'ready', error: null }); - void actions.loadAllSupplierArticles().catch(() => {}); - return suppliers; - } catch (error) { - supplierAutomat.setState({ error }); - throw error; - } - }, - - /** - * Loads articles for a single supplier, caching them in state.articlesBySupplier. - * - * @param {string|number} supplierId - * @returns {Promise>} - */ - async loadArticlesForSupplier(supplierId) { - const sId = String(supplierId); - if (supplierAutomat.state.articlesBySupplier?.[sId]) { - return supplierAutomat.state.articlesBySupplier[sId]; - } - const res = await supplierApi.getArticles(supplierId); - const articles = Array.isArray(res?.articles) ? res.articles : []; - supplierAutomat.setState({ - articlesBySupplier: { - ...(supplierAutomat.state.articlesBySupplier || {}), - [sId]: articles, - }, - }); - return articles; - }, - - /** - * Loads articles for all suppliers in the supplier list, caching them in state.articlesBySupplier. - * - * @returns {Promise>>} - */ - async loadAllSupplierArticles() { - const suppliers = supplierAutomat.state.suppliers; - if (!suppliers.length) return {}; - supplierAutomat.setState({ articlesLoading: true }); - try { - const results = await Promise.allSettled( - suppliers.map(async (s) => { - const res = await supplierApi.getArticles(s.id); - return { id: String(s.id), articles: Array.isArray(res?.articles) ? res.articles : [] }; - }), - ); - const nextMap = { ...(supplierAutomat.state.articlesBySupplier || {}) }; - for (const r of results) { - if (r.status === 'fulfilled') { - nextMap[r.value.id] = r.value.articles; - } - } - supplierAutomat.setState({ articlesBySupplier: nextMap, articlesLoading: false }); - return nextMap; - } catch { - supplierAutomat.setState({ articlesLoading: false }); - return supplierAutomat.state.articlesBySupplier || {}; - } - }, - - /** - * Creates a supplier and refreshes the cached list. - * - * @param {{name: string}} body Creation payload. - * @returns {Promise} Created supplier. - */ - async create(body) { - const data = await supplierApi.create(body); - requireObject(data, 'POST /api/supplier'); - requireObject(data.supplier, 'supplier'); - requireDefined(data.supplier.id, 'supplier.id'); - await supplierAutomat.actions.reload(); - return data.supplier; - }, - - /** - * Renames a supplier and refreshes the cached list. - * - * @param {string|number} id Supplier identifier. - * @param {{name: string}} body Update payload. - * @returns {Promise} Updated supplier. - */ - async update(id, body) { - const data = await supplierApi.update(id, body); - requireObject(data, 'PATCH /api/supplier/:id'); - requireObject(data.supplier, 'supplier'); - requireDefined(data.supplier.id, 'supplier.id'); - await supplierAutomat.actions.reload(); - return data.supplier; - }, - - /** - * Deletes a supplier and refreshes the cached list. - * - * @param {string|number} id Supplier identifier. - * @returns {Promise} - */ - async remove(id) { - await supplierApi.delete(id); - await supplierAutomat.actions.reload(); - }, - - /** - * Replaces the cached list without a round-trip. - * - * @param {Array} suppliers Suppliers to cache. - * @returns {void} - */ - setSuppliers(suppliers) { - supplierAutomat.setState({ - suppliers: Array.isArray(suppliers) ? suppliers : [], - status: 'ready', - error: null, - }); - }, - - /** - * Forgets the cached list and returns to the initial state. - * - * @returns {void} - */ - clear() { - supplierAutomat.setState({ ...initialState }); - }, -}; - -/** - * Company-scoped supplier cache. Loads automatically once an authenticated - * session with a selected company exists and is cleared when auth is gone. - * - * @type {Automat} - */ -supplierAutomat = new Automat(initialState, actions, { name: 'supplier' }); - -/** - * Derives the cache key from the auth state: the selected company while - * authenticated, otherwise `null`. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {string|null} Cache key. - */ -function authKey(authState) { - if (authState?.status !== 'authed' || !authState.user) return null; - const companyId = authState.user.selectedCompanyId; - return companyId == null ? null : String(companyId); -} - -let lastAuthKey; - -/** - * Loads the supplier list when auth/company becomes available and forgets it - * when the session ends. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {void} - */ -function syncWithAuth(authState) { - const key = authKey(authState); - if (key === lastAuthKey) return; - lastAuthKey = key; - if (key == null) { - supplierAutomat.actions.clear(); - } else { - void supplierAutomat.actions.load().catch(() => {}); - } -} - -authAutomat.subscribe(syncWithAuth); -syncWithAuth(authAutomat.state); - -export default supplierAutomat; \ No newline at end of file diff --git a/src/automats/unitAutomat.js b/src/automats/unitAutomat.js deleted file mode 100644 index f922be8..0000000 --- a/src/automats/unitAutomat.js +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @file unitAutomat.js - * @description Manages allowable units of measure (Stück, kg, Liter, etc.) - * for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on - * `'authed'` with a `selectedCompanyId`, and `actions.reset()` on `'anon'`. - * - UI / Settings: Mutating actions (`upsert`, `delete`). - */ - -import { Automat } from 'react-automat'; -import { unit as unitApi } from '../api'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} UnitState - * @property {Array} units - * @property {'idle'|'loading'|'succeeded'|'failed'} status - * @property {string|null} error - */ - -/** @type {UnitState} */ -const initialState = { - units: [], - status: 'idle', - error: null, -}; - -function sortUnits(units) { - return [...units].sort((a, b) => { - const left = a.symbol ?? a.si ?? ''; - const right = b.symbol ?? b.si ?? ''; - return String(left).localeCompare(String(right), 'de'); - }); -} - -let unitAutomat; - -const actions = { - /** Load all units for the selected company. */ - async load() { - unitAutomat.setState({ status: 'loading', error: null }); - try { - const res = await unitApi.list(); - const units = sortUnits(Array.isArray(res.units) ? res.units : []); - unitAutomat.setState({ units, status: 'succeeded' }); - return units; - } catch (err) { - unitAutomat.setState({ status: 'failed', error: err?.message ?? String(err) }); - throw err; - } - }, - - /** - * Upsert (create or update) a unit. - * @param {object} body - */ - async upsert(body) { - const res = await unitApi.upsert(body); - const row = res.unit; - const units = sortUnits([ - ...unitAutomat.state.units.filter((u) => u.si !== row.si), - row, - ]); - unitAutomat.setState({ units }); - return units; - }, - - /** - * Delete a unit by SI symbol. - * @param {string} si - */ - async delete(si) { - await unitApi.delete(si); - unitAutomat.setState({ - units: unitAutomat.state.units.filter((u) => u.si !== si), - }); - }, - - /** Reset to idle state (called on logout / company switch). */ - reset() { - unitAutomat.setState(initialState); - }, -}; - -/** - * Global unit state container. - * Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId. - * - * @type {Automat} - */ -unitAutomat = new Automat(initialState, actions, { name: 'unit' }); - -// Subscribe to auth changes -authAutomat.subscribe((auth) => { - const companyId = auth.user?.selectedCompanyId; - if (auth.status === 'authed' && companyId) { - void unitAutomat.actions.load(); - } else if (auth.status === 'anon') { - unitAutomat.actions.reset(); - } -}); - -export default unitAutomat; diff --git a/src/automats/userSettingsAutomat.js b/src/automats/userSettingsAutomat.js deleted file mode 100644 index 17e0f34..0000000 --- a/src/automats/userSettingsAutomat.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @file userSettingsAutomat.js - * @description Manages client-side user UI layout preferences (attachment viewer - * position, chat drawer width). Persisted in IndexedDB. - * - * Upstream Triggers: - * - UI / User layout preferences: Calls to `setViewerPosition(position)` and - * `setChatDrawerWidth(width)`. - */ - -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. - * @property {number} chatDrawerWidth Preferred width of the chat drawer in pixels. - */ - -/** @type {UserSettingsState} */ -const initialState = { - viewerPosition: 'top', - chatDrawerWidth: 460, -}; - -/** - * 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 }); - }, - - /** - * Set the width of the chat drawer in pixels. - * - * @param {number} width - * @returns {void} - */ - setChatDrawerWidth(width) { - const num = Number(width); - if (Number.isNaN(num) || num < 280) { - return; - } - userSettingsAutomat.setState({ chatDrawerWidth: Math.round(num) }); - }, - }, - { - name: 'userSettings', - persist: true, - } -); - -export default userSettingsAutomat; -export const { setViewerPosition, setChatDrawerWidth } = userSettingsAutomat.actions; -export const selectChatDrawerWidth = () => userSettingsAutomat.state.chatDrawerWidth || 460; diff --git a/src/automats/warehouseAutomat.js b/src/automats/warehouseAutomat.js deleted file mode 100644 index 67be642..0000000 --- a/src/automats/warehouseAutomat.js +++ /dev/null @@ -1,256 +0,0 @@ -/** - * @file warehouseAutomat.js - * @description Manages cached warehouse locations (`GET /api/warehouse`) for the active company. - * - * Upstream Triggers: - * - `authAutomat`: Subscribes to auth state changes (`syncWithAuth`) — triggers - * `actions.load()` on `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`. - * - `sseAutomat`: Calls `actions.reload()` upon receiving a `'warehouse'` SSE event. - * - UI / Routes: Calls to `actions.load()`, `actions.reload()`, or `actions.setWarehouses()`. - */ - -import { Automat } from 'react-automat'; -import { warehouse as warehouseApi } from '../api'; -import { requireArray, requireDefined, requireObject } from '../utils/apiShape'; -import authAutomat from './authAutomat'; - -/** - * @typedef {Object} Warehouse - * @property {string|number} id Warehouse identifier. - * @property {string} name Warehouse name. - */ - -/** - * @typedef {Object} WarehouseState - * @property {Array} warehouses Company-scoped warehouses. - * @property {Array} filteredWarehouses Filtered warehouses according to active filter query. - * @property {string} filter Active search/filter string. - * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. - * @property {Error|null} error Last load error. - */ - -/** @type {WarehouseState} */ -const initialState = { - warehouses: [], - filteredWarehouses: [], - filter: '', - status: 'idle', - error: null, -}; - -let warehouseAutomat; - -const applyFilter = (warehouses, filter) => { - const query = String(filter ?? '').trim().toLowerCase(); - return query ? warehouses.filter((w) => String(w.name ?? '').toLowerCase().includes(query)) : warehouses; -}; - -const updateStateWithWarehouses = (warehouses, extra = {}) => { - const list = Array.isArray(warehouses) ? warehouses : []; - const filteredWarehouses = applyFilter(list, warehouseAutomat.state.filter); - warehouseAutomat.setState({ warehouses: list, filteredWarehouses, ...extra }); - return list; -}; - -/** - * Fetches the company-scoped warehouse list. - * - * @returns {Promise>} Warehouse list. - */ -async function fetchWarehouses() { - const data = await warehouseApi.list(); - requireObject(data, 'GET /api/warehouse'); - return requireArray(data.warehouses, 'warehouses'); -} - -/** In-flight `load()` promise so concurrent callers share one request. */ -let inflightLoad = null; - -/** - * Loads the warehouse list once, deduplicating concurrent callers. - * - * @returns {Promise>} Loaded warehouses. - */ -function loadWarehouses() { - if (inflightLoad) return inflightLoad; - inflightLoad = (async () => { - warehouseAutomat.setState({ status: 'loading', error: null }); - try { - const warehouses = await fetchWarehouses(); - updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); - return warehouses; - } catch (error) { - warehouseAutomat.setState({ status: 'failed', error }); - throw error; - } finally { - inflightLoad = null; - } - })(); - return inflightLoad; -} - -const actions = { - /** Sets the active warehouse search filter and updates filteredWarehouses. */ - filter(query) { - const filter = String(query ?? ''); - warehouseAutomat.setState({ - filter, - filteredWarehouses: applyFilter(warehouseAutomat.state.warehouses, filter), - }); - }, - - /** - * Loads the warehouse list, exposing a loading/failed lifecycle. - * - * @returns {Promise>} Loaded warehouses. - */ - load() { - return loadWarehouses(); - }, - - /** - * Silently re-fetches the warehouse list while keeping the current state. - * - * @returns {Promise>} Fresh warehouses. - */ - async reload() { - try { - const warehouses = await fetchWarehouses(); - updateStateWithWarehouses(warehouses, { status: 'ready', error: null }); - return warehouses; - } catch (error) { - warehouseAutomat.setState({ error }); - throw error; - } - }, - - /** - * Creates a warehouse and refreshes the cached list. - * - * @param {{name: string}} body Creation payload. - * @returns {Promise} Created warehouse. - */ - async create(body) { - const data = await warehouseApi.create(body); - requireObject(data, 'POST /api/warehouse'); - requireObject(data.warehouse, 'warehouse'); - requireDefined(data.warehouse.id, 'warehouse.id'); - await warehouseAutomat.actions.reload(); - return data.warehouse; - }, - - /** - * Renames a warehouse and refreshes the cached list. - * - * @param {string|number} id Warehouse identifier. - * @param {{name: string}} body Update payload. - * @returns {Promise} Updated warehouse. - */ - async update(id, body) { - const data = await warehouseApi.update(id, body); - requireObject(data, 'PATCH /api/warehouse/:id'); - requireObject(data.warehouse, 'warehouse'); - requireDefined(data.warehouse.id, 'warehouse.id'); - await warehouseAutomat.actions.reload(); - return data.warehouse; - }, - - rename(id, body) { - return actions.update(id, body); - }, - - /** - * Deletes a warehouse and refreshes the cached list. - * - * @param {string|number} id Warehouse identifier. - * @returns {Promise} - */ - async remove(id) { - await warehouseApi.delete(id); - await warehouseAutomat.actions.reload(); - }, - - /** - * Persists a new sort order for all warehouses. - * - * @param {Array} warehouseIds IDs in the desired order. - * @returns {Promise} - */ - async reorder(warehouseIds) { - const byId = new Map(warehouseAutomat.state.warehouses.map((w) => [w.id, w])); - const reordered = warehouseIds.map((id) => byId.get(id)).filter(Boolean); - if (reordered.length === warehouseAutomat.state.warehouses.length) { - updateStateWithWarehouses(reordered); - } - await warehouseApi.order({ warehouseIds }); - await warehouseAutomat.actions.reload(); - }, - - /** - * Replaces the cached list without a round-trip. - * - * @param {Array} warehouses Warehouses to cache. - * @returns {void} - */ - setWarehouses(warehouses) { - updateStateWithWarehouses(warehouses, { - status: 'ready', - error: null, - }); - }, - - /** - * Forgets the cached list and returns to the initial state. - * - * @returns {void} - */ - clear() { - warehouseAutomat.setState({ ...initialState }); - }, -}; - -/** - * Company-scoped warehouse cache. Loads automatically once an authenticated - * session with a selected company exists and is cleared when auth is gone. - * - * @type {Automat} - */ -warehouseAutomat = new Automat(initialState, actions, { name: 'warehouse' }); - -/** - * Derives the cache key from the auth state: the selected company while - * authenticated, otherwise `null`. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {string|null} Cache key. - */ -function authKey(authState) { - if (authState?.status !== 'authed' || !authState.user) return null; - const companyId = authState.user.selectedCompanyId; - return companyId == null ? null : String(companyId); -} - -let lastAuthKey; - -/** - * Loads the warehouse list when auth/company becomes available and forgets it - * when the session ends. - * - * @param {import('./authAutomat').AuthState} authState Auth automat state. - * @returns {void} - */ -function syncWithAuth(authState) { - const key = authKey(authState); - if (key === lastAuthKey) return; - lastAuthKey = key; - if (key == null) { - warehouseAutomat.actions.clear(); - } else { - void warehouseAutomat.actions.load().catch(() => {}); - } -} - -authAutomat.subscribe(syncWithAuth); -syncWithAuth(authAutomat.state); - -export default warehouseAutomat; diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index f9595b2..c60b3e9 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -12,7 +12,10 @@ import HelpBadge from './HelpBadge'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery'; import { defaultCategoryPath } from './category/categoryRoute'; -import { navigationAutomat } from '../automats'; +import companyAutomat from '../automats/companyAutomat'; +import navigationAutomat from '../automats/navigationAutomat'; +import { initPwa } from '../automats/pwaAutomat'; +import PwaUpdateDialog from './PwaUpdateDialog'; import { NAV_ITEM_ACTIVE_ACCENT, NAV_SUB_ACTIVE, @@ -22,9 +25,16 @@ import { import { ChatFab, ChatDrawer } from './chat'; import { PinnedAppletFabs, PinnedAppletDialog } from './applet'; +// Ensure companyAutomat is retained as part of the 1st-level navigation shell +void companyAutomat; +initPwa(); + const BreakpointOverlay = 0 && import.meta.env.DEV ? lazy(() => import('./BreakpointOverlay')) : null; +const ChunkCacheMonitor = import.meta.env.DEV + ? lazy(() => import('../devtools/ChunkCacheMonitor')) + : null; const SIDEBAR_WIDTH = 144; @@ -403,13 +413,21 @@ export default function AppShell() { minWidth: 0, })} > - + + + + + {ChunkCacheMonitor ? ( + + + + ) : null} {BreakpointOverlay && ( diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index 7c47606..4bf8bef 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -35,10 +35,8 @@ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import RequestQuoteIcon from '@mui/icons-material/RequestQuote'; import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import BackendError from './BackendError'; -import { company, number as numberApi } from '../api'; +import { company } from '../api'; import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; -import companyAutomat from '../automats/companyAutomat'; -import numberAutomat from '../automats/numberAutomat'; import { defaultIso639, ISO639_OPTIONS, @@ -213,30 +211,10 @@ class CompanySetupDialog extends Component { iso639, taxrate: parsedTaxrate, reducedtaxrate: parsedReducedtaxrate, + numbers, }); - // Apply initial number sequences if set - if (numbers) { - try { - const listRes = await numberApi.list(); - const serverNumbers = listRes?.numbers ?? []; - for (const serverRow of serverNumbers) { - const userConfig = numbers[serverRow.type]; - if (userConfig) { - const prefix = String(userConfig.prefix ?? ''); - const padding = Math.max(0, Number(userConfig.padding) || 0); - const next = Math.max(1, Number(userConfig.next) || 1); - await numberApi.update(serverRow.id, { prefix, padding, next }); - } - } - } catch (numErr) { - console.warn('Initial number sequences could not be saved:', numErr); - } - } - await bootstrapAuthMe(); - await companyAutomat.actions.load(); - await numberAutomat.actions.reload().catch(() => { }); } catch (err) { this.setState({ error: backendErrorFrom(err) }); } finally { diff --git a/src/components/HelpBadge.jsx b/src/components/HelpBadge.jsx index 4f85759..3f411cd 100644 --- a/src/components/HelpBadge.jsx +++ b/src/components/HelpBadge.jsx @@ -7,7 +7,7 @@ import Paper from '@mui/material/Paper'; import Popper from '@mui/material/Popper'; import Typography from '@mui/material/Typography'; import CloseIcon from '@mui/icons-material/Close'; -import { helpAutomat } from '../automats'; +import helpAutomat from '../automats/helpAutomat'; import { RED, RED_HOVER, diff --git a/src/components/PicturesDialog.jsx b/src/components/PicturesDialog.jsx index fa9c1e5..c349887 100644 --- a/src/components/PicturesDialog.jsx +++ b/src/components/PicturesDialog.jsx @@ -22,7 +22,7 @@ import { enqueueSnackbar } from 'notistack'; import AppDialog from './AppDialog'; import { thumb, article as articleApi } from '../api'; import artikelAutomat from '../automats/artikelAutomat'; -import brandAutomat from '../automats/brandAutomat'; +import companyAutomat from '../automats/companyAutomat'; import categoryAutomat from '../automats/categoryAutomat'; import chatAutomat from '../automats/chatAutomat'; import { requireArray, requireObject } from '../utils/apiShape'; @@ -238,7 +238,7 @@ export default class PicturesDialog extends PureComponent { if (onUpload) { await onUpload(file); } else if (brand?.id) { - await brandAutomat.actions.addImage(brand.id, file); + await companyAutomat.actions.addBrandImage(brand.id, file); enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' }); } else if (article?.sku) { const uploadStarted = performance.now(); @@ -318,7 +318,7 @@ export default class PicturesDialog extends PureComponent { if (onDelete) { await onDelete(pic, 0); } else if (brand?.id) { - await brandAutomat.actions.deleteImage(brand.id); + await companyAutomat.actions.deleteBrandImage(brand.id); enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' }); } else if (category?.id) { await categoryAutomat.actions.deleteCategoryImage(category.id); diff --git a/src/components/UserMenu.jsx b/src/components/UserMenu.jsx index b0865e6..7e81c9f 100644 --- a/src/components/UserMenu.jsx +++ b/src/components/UserMenu.jsx @@ -14,7 +14,8 @@ import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; import Switch from '@mui/material/Switch'; import Divider from '@mui/material/Divider'; import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; -import { helpAutomat, toggleHelp, pwaAutomat, openPwaDialog } from '../automats'; +import helpAutomat, { toggleHelp } from '../automats/helpAutomat'; +import pwaAutomat, { openPwaDialog } from '../automats/pwaAutomat'; import SettingsDialog from './settings/SettingsDialog'; import { notifyError } from '../utils/notify'; diff --git a/src/components/article/ArticleStockAdjustDialog.jsx b/src/components/article/ArticleStockAdjustDialog.jsx index 2674994..5c00da0 100644 --- a/src/components/article/ArticleStockAdjustDialog.jsx +++ b/src/components/article/ArticleStockAdjustDialog.jsx @@ -31,7 +31,7 @@ import { stock } from '../../api'; import { formatStock } from './searchFormat'; import { notifyError } from '../../utils/notify'; import artikelAutomat from '../../automats/artikelAutomat'; -import warehouseAutomat from '../../automats/warehouseAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { BORDER, GREEN_BG, @@ -64,7 +64,7 @@ export default class ArticleStockAdjustDialog extends PureComponent { reason: '', submitting: false, error: null, - warehouses: warehouseAutomat.state.warehouses || [], + warehouses: companyAutomat.state.warehouses || [], trackStock: isTrackStock, initialTrackStock: isTrackStock, }; @@ -79,14 +79,14 @@ export default class ArticleStockAdjustDialog extends PureComponent { } componentDidMount() { - this.unsubWarehouse = warehouseAutomat.subscribe((state) => { + this.unsubWarehouse = companyAutomat.subscribe((state) => { const warehouses = state.warehouses || []; this.setState((prev) => ({ warehouses, warehouseId: prev.warehouseId || (warehouses.length > 0 ? warehouses[0].id : ''), })); - }); - void warehouseAutomat.actions.load().catch(() => {}); + }, ['warehouses']); + void companyAutomat.actions.loadWarehouses().catch(() => {}); } componentWillUnmount() { @@ -95,7 +95,7 @@ export default class ArticleStockAdjustDialog extends PureComponent { componentDidUpdate(prevProps) { if (this.props.open && !prevProps.open) { - void warehouseAutomat.actions.load().catch(() => {}); + void companyAutomat.actions.loadWarehouses().catch(() => {}); const initialWhId = this.resolveInitialWarehouseId(this.props); const isTrackStock = this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0; @@ -107,7 +107,7 @@ export default class ArticleStockAdjustDialog extends PureComponent { reason: '', submitting: false, error: null, - warehouses: warehouseAutomat.state.warehouses || [], + warehouses: companyAutomat.state.warehouses || [], trackStock: isTrackStock, initialTrackStock: isTrackStock, }); @@ -121,7 +121,7 @@ export default class ArticleStockAdjustDialog extends PureComponent { if (articleWarehouses.length > 0) { return articleWarehouses[0].id; } - const allWarehouses = warehouseAutomat.state.warehouses || []; + const allWarehouses = companyAutomat.state.warehouses || []; return allWarehouses.length > 0 ? allWarehouses[0].id : ''; } diff --git a/src/components/article/ArticleUnifyDialog.jsx b/src/components/article/ArticleUnifyDialog.jsx index db39c81..59c75ae 100644 --- a/src/components/article/ArticleUnifyDialog.jsx +++ b/src/components/article/ArticleUnifyDialog.jsx @@ -25,7 +25,7 @@ import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; import AppDialog from '../AppDialog'; import ArticlePhoto from './ArticlePhoto'; import { article as articleApi } from '../../api'; -import brandAutomat from '../../automats/brandAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import categoryAutomat from '../../automats/categoryAutomat'; import { formatPrice } from '../../utils/price'; @@ -195,7 +195,7 @@ export default class ArticleUnifyDialog extends Component { resolveBrandName(article) { if (!article) return '–'; - const brands = brandAutomat.state.brands || []; + const brands = companyAutomat.state.brands || []; if (article.brandId != null) { const b = brands.find((x) => x.id === article.brandId); if (b?.name) return b.name; diff --git a/src/components/article/ArticleUnitEditorDialog.jsx b/src/components/article/ArticleUnitEditorDialog.jsx index 9215c9a..395158b 100644 --- a/src/components/article/ArticleUnitEditorDialog.jsx +++ b/src/components/article/ArticleUnitEditorDialog.jsx @@ -15,7 +15,7 @@ import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; import TextInput from '../TextInput'; import Typography from '@mui/material/Typography'; -import unitAutomat from '../../automats/unitAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { unit as unitApi } from '../../api'; import { requireObject } from '../../utils/apiShape'; import { notifyError } from '../../utils/notify'; @@ -85,7 +85,7 @@ export default class ArticleUnitEditorDialog extends Component { async loadUnits() { this.setState({ unitsLoading: true }); try { - const units = await unitAutomat.actions.load(); + const units = await companyAutomat.actions.loadUnits(); this.setState({ units, unitsLoading: false }); } catch (err) { this.setState({ unitsLoading: false }); @@ -125,7 +125,7 @@ export default class ArticleUnitEditorDialog extends Component { }); requireObject(data, 'PUT /api/unit'); requireObject(data.unit, 'unit'); - const units = await unitAutomat.actions.load(); + const units = await companyAutomat.actions.loadUnits(); this.setState({ busy: false, units, @@ -145,7 +145,7 @@ export default class ArticleUnitEditorDialog extends Component { this.setState({ busy: true }); try { await unitApi.delete(selectedSi); - const units = await unitAutomat.actions.load(); + const units = await companyAutomat.actions.loadUnits(); this.setState({ busy: false, units, form: emptyForm(), selectedSi: null }); this.props.onUnitDeleted?.(selectedSi); } catch (err) { diff --git a/src/components/article/boxes/ArticleViewBrand.jsx b/src/components/article/boxes/ArticleViewBrand.jsx index 9d9bc3b..0f01d06 100644 --- a/src/components/article/boxes/ArticleViewBrand.jsx +++ b/src/components/article/boxes/ArticleViewBrand.jsx @@ -4,7 +4,7 @@ import TextInput from '../../TextInput'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { EditablePanelTile, PanelTile } from 'dashgui/panels'; -import brandAutomat from '../../../automats/brandAutomat'; +import companyAutomat from '../../../automats/companyAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { requireObject } from '../../../utils/apiShape'; import { notifyError } from '../../../utils/notify'; @@ -119,16 +119,16 @@ export default class ArticleViewBrand extends PureComponent { constructor(props) { super(props); this.state = { - brands: brandAutomat.state.brands ?? [], + brands: companyAutomat.state.brands ?? [], }; this._cancelled = false; } componentDidMount() { this._cancelled = false; - this.unsub = brandAutomat.subscribe(this, (state) => ({ + this.unsub = companyAutomat.subscribe(this, (state) => ({ brands: state.brands ?? [], - })); + }), ['brands']); const parentSku = this.props.article?.parentSku || this.props.article?.parent_sku; if (parentSku && !getArticle(parentSku)) { void artikelAutomat.actions.loadArticle(parentSku).catch(() => {}); @@ -168,8 +168,8 @@ export default class ArticleViewBrand extends PureComponent { if (existing) { brandId = existing.id; } else { - await brandAutomat.actions.create({ name }); - const nextBrands = await brandAutomat.actions.load(); + await companyAutomat.actions.createBrand({ name }); + const nextBrands = await companyAutomat.actions.loadBrands(); this.setState({ brands: nextBrands }); brandId = nextBrands.find( (row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(), diff --git a/src/components/article/boxes/ArticleViewLists.jsx b/src/components/article/boxes/ArticleViewLists.jsx index df6316c..0a2b55e 100644 --- a/src/components/article/boxes/ArticleViewLists.jsx +++ b/src/components/article/boxes/ArticleViewLists.jsx @@ -32,9 +32,8 @@ import { PanelTile } from 'dashgui/panels'; import { enqueueSnackbar } from 'notistack'; import { artikelDetailPath } from '../articleRoute'; import { article as articleApi } from '../../../api'; -import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; -import companyAutomat from '../../../automats/companyAutomat'; +import companyAutomat, { selectAttributes } from '../../../automats/companyAutomat'; import { formatStock } from '../searchFormat'; import { formatPrice, getArticleGross } from '../../../utils/price'; import { notifyError } from '../../../utils/notify'; @@ -159,10 +158,7 @@ export default class ArticleViewLists extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ - company: state.current, - })); - this.unsubAttr = attributeAutomat.subscribe((state) => { + this.unsubCompany = companyAutomat.subscribe((state) => { if (this._cancelled) return; this.setState((prev) => { const assignedIds = new Set(prev.assignedAttributes.map((a) => a.attributeId)); @@ -172,19 +168,20 @@ export default class ArticleViewLists extends PureComponent { nextSelectedId = selectable[0].id; } return { + company: state.current, attributes: state.attributes, selectedAttributeId: nextSelectedId, }; }); - }); + }, ['current', 'attributes']); this.unsubArtikel = artikelAutomat.subscribe(() => { if (this._cancelled) return; this.forceUpdate(); }); - if (attributeAutomat.state.status === 'idle') { - void attributeAutomat.actions.load(); + if (companyAutomat.state.attributesStatus === 'idle') { + void companyAutomat.actions.loadAttributes(); } if (this.props.article?.sku) { @@ -262,7 +259,6 @@ export default class ArticleViewLists extends PureComponent { componentWillUnmount() { this._cancelled = true; this.unsubCompany?.(); - this.unsubAttr?.(); this.unsubArtikel?.(); } diff --git a/src/components/article/boxes/ArticleViewPrice.jsx b/src/components/article/boxes/ArticleViewPrice.jsx index 1faa94e..7774b8a 100644 --- a/src/components/article/boxes/ArticleViewPrice.jsx +++ b/src/components/article/boxes/ArticleViewPrice.jsx @@ -5,7 +5,6 @@ import Typography from '@mui/material/Typography'; import { EditablePanelTile, PanelTile } from 'dashgui/panels'; import ArticlePriceTiersDialog from '../ArticlePriceTiersDialog'; import ArticleUnitEditorDialog from '../ArticleUnitEditorDialog'; -import unitAutomat from '../../../automats/unitAutomat'; import companyAutomat from '../../../automats/companyAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { requireDefined, requireObject } from '../../../utils/apiShape'; @@ -333,7 +332,7 @@ export default class ArticleViewPrice extends PureComponent { constructor(props) { super(props); const company = companyFromStore(); - const units = unitAutomat.state.units; + const units = companyAutomat.state.units; this.state = { tiersDialogOpen: false, unitEditorOpen: false, @@ -345,12 +344,10 @@ export default class ArticleViewPrice extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsubUnits = unitAutomat.subscribe(this, (state) => ({ - units: state.units ?? [], - })); this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ + units: state.units ?? [], company: state.current ?? null, - })); + }), ['units', 'current']); this.unsubArtikel = artikelAutomat.subscribe(this, () => ({})); if (this.props.article?.isParent) { this.ensureChildren(this.props.article.children); @@ -369,8 +366,6 @@ export default class ArticleViewPrice extends PureComponent { componentWillUnmount() { this._cancelled = true; - this.unsubUnits?.(); - this.unsubUnits = null; this.unsubCompany?.(); this.unsubCompany = null; this.unsubArtikel?.(); @@ -462,7 +457,7 @@ export default class ArticleViewPrice extends PureComponent { } refreshUnits = async () => { - const units = await unitAutomat.actions.load(); + const units = await companyAutomat.actions.loadUnits(); this.setState({ units }); }; diff --git a/src/components/article/tabs/ArticleAttributesTab.jsx b/src/components/article/tabs/ArticleAttributesTab.jsx index 1f25f2e..983b186 100644 --- a/src/components/article/tabs/ArticleAttributesTab.jsx +++ b/src/components/article/tabs/ArticleAttributesTab.jsx @@ -10,8 +10,7 @@ import Tooltip from '@mui/material/Tooltip'; import AddIcon from '@mui/icons-material/Add'; import { notifyError } from '../../../utils/notify'; import { enqueueSnackbar } from 'notistack'; -import attributeAutomat, { selectAttributes } from '../../../automats/attributeAutomat'; -import propertyAutomat, { selectProperties } from '../../../automats/propertyAutomat'; +import companyAutomat, { selectAttributes, selectProperties } from '../../../automats/companyAutomat'; import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { article as articleApi } from '../../../api'; @@ -81,26 +80,27 @@ export default class ArticleAttributesTab extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsubAttr = attributeAutomat.subscribe((state) => { + this.unsubCompany = companyAutomat.subscribe((state) => { if (this._cancelled) return; this.setState((prev) => { let nextKey = prev.selectedMerkmalKey; let nextItemId = prev.selectedItemId; const selectableAttrs = state.attributes; + const globalProps = state.properties; const isCurrentKeyValid = nextKey && (selectableAttrs.some((a) => `attr-${a.id}` === nextKey) || - prev.globalProperties.some((p) => `prop-${p.id}` === nextKey)); + globalProps.some((p) => `prop-${p.id}` === nextKey)); if (!isCurrentKeyValid) { const first = selectableAttrs[0]; if (first) { nextKey = `attr-${first.id}`; nextItemId = first.values?.[0]?.id ?? ''; - } else if (prev.globalProperties[0]) { - nextKey = `prop-${prev.globalProperties[0].id}`; + } else if (globalProps[0]) { + nextKey = `prop-${globalProps[0].id}`; nextItemId = ''; } else { nextKey = ''; @@ -108,38 +108,24 @@ export default class ArticleAttributesTab extends PureComponent { } } return { - globalAttributes: state.attributes, + globalAttributes: selectableAttrs, + globalProperties: globalProps, selectedMerkmalKey: nextKey, selectedItemId: nextItemId, }; }); - }); - - this.unsubProp = propertyAutomat.subscribe((state) => { - if (this._cancelled) return; - this.setState((prev) => { - const selectableAttrs = prev.globalAttributes; - let nextKey = prev.selectedMerkmalKey; - if (!nextKey && !selectableAttrs.length && state.properties[0]) { - nextKey = `prop-${state.properties[0].id}`; - } - return { - globalProperties: state.properties, - selectedMerkmalKey: nextKey, - }; - }); - }); + }, ['attributes', 'properties']); this.unsubArtikel = artikelAutomat.subscribe(() => { if (this._cancelled) return; this.forceUpdate(); }); - if (attributeAutomat.state.status === 'idle') { - void attributeAutomat.actions.load(); + if (companyAutomat.state.attributesStatus === 'idle') { + void companyAutomat.actions.loadAttributes(); } - if (propertyAutomat.state.status === 'idle') { - void propertyAutomat.actions.load(); + if (companyAutomat.state.propertiesStatus === 'idle') { + void companyAutomat.actions.loadProperties(); } void this.loadArticleData(this.props.sku); @@ -153,8 +139,7 @@ export default class ArticleAttributesTab extends PureComponent { componentWillUnmount() { this._cancelled = true; - this.unsubAttr?.(); - this.unsubProp?.(); + this.unsubCompany?.(); this.unsubArtikel?.(); } diff --git a/src/components/article/tabs/ArticleHistoryTab.jsx b/src/components/article/tabs/ArticleHistoryTab.jsx index 5abf343..f23dfb2 100644 --- a/src/components/article/tabs/ArticleHistoryTab.jsx +++ b/src/components/article/tabs/ArticleHistoryTab.jsx @@ -16,7 +16,7 @@ import sseAutomat from '../../../automats/sseAutomat'; import shipmentAutomat from '../../../automats/shipmentAutomat'; import orderAutomat from '../../../automats/orderAutomat'; import entityAutomat from '../../../automats/entityAutomat'; -import brandAutomat from '../../../automats/brandAutomat'; +import companyAutomat from '../../../automats/companyAutomat'; import { shipment as shipmentApi, order as orderApi } from '../../../api'; import { ShipmentDetailDialog } from '../../manifest'; import SkeleHashImage from '../../settings/SkeleHashImage'; @@ -709,8 +709,8 @@ class ArticleHistoryTab extends PureComponent { if (entityAutomat.state.status === 'idle') { void entityAutomat.actions.load().catch(() => {}); } - if (brandAutomat.state.status === 'idle') { - void brandAutomat.actions.load().catch(() => {}); + if (companyAutomat.state.brandsStatus === 'idle') { + void companyAutomat.actions.loadBrands().catch(() => {}); } } @@ -868,7 +868,7 @@ class ArticleHistoryTab extends PureComponent { handleOpenBrand(brandName) { if (!brandName) return; - const match = (brandAutomat.state.brands || []).find( + const match = (companyAutomat.state.brands || []).find( (b) => b && (b.name?.toLowerCase() === brandName.toLowerCase() || String(b.id) === String(brandName)), ); const brandParam = match?.id != null ? String(match.id) : brandName; diff --git a/src/components/article/tabs/ArticleSettingsTab.jsx b/src/components/article/tabs/ArticleSettingsTab.jsx index 9d60141..7d8fcff 100644 --- a/src/components/article/tabs/ArticleSettingsTab.jsx +++ b/src/components/article/tabs/ArticleSettingsTab.jsx @@ -9,7 +9,7 @@ import GoogleIcon from '@mui/icons-material/Google'; import { enqueueSnackbar } from 'notistack'; import { article as articleApi } from '../../../api'; -import { artikelAutomat, getArticle } from '../../../automats'; +import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat'; import { notifyError } from '../../../utils/notify'; import { articleEans, diff --git a/src/components/article/tabs/ArticleSupplierTab.jsx b/src/components/article/tabs/ArticleSupplierTab.jsx index 433e490..7cbde5f 100644 --- a/src/components/article/tabs/ArticleSupplierTab.jsx +++ b/src/components/article/tabs/ArticleSupplierTab.jsx @@ -23,7 +23,6 @@ import { enqueueSnackbar } from 'notistack'; import { article as articleApi } from '../../../api'; import { notifyError } from '../../../utils/notify'; import { formatPrice } from '../../../utils/price'; -import supplierAutomat from '../../../automats/supplierAutomat'; import { articleSupplierAutomat } from '../../../automats/articleDetailAutomat'; import sseAutomat from '../../../automats/sseAutomat'; import companyAutomat from '../../../automats/companyAutomat'; @@ -93,7 +92,7 @@ export default class ArticleSupplierTab extends PureComponent { // 2. Fetch available company suppliers try { - const companySuppliers = await supplierAutomat.actions.load(); + const companySuppliers = await companyAutomat.actions.loadSuppliers(); this.setState({ companySuppliers: companySuppliers ?? [] }); } catch (err) { notifyError(err); diff --git a/src/components/brand/ArtikelMarke.jsx b/src/components/brand/ArtikelMarke.jsx index f7abc6e..4713ea8 100644 --- a/src/components/brand/ArtikelMarke.jsx +++ b/src/components/brand/ArtikelMarke.jsx @@ -9,7 +9,7 @@ import ImageSearchIcon from '@mui/icons-material/ImageSearch'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import Typography from '@mui/material/Typography'; import { enqueueSnackbar } from 'notistack'; -import brandAutomat from '../../automats/brandAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; import BrandThumb from './BrandThumb'; import BrandImageSearchDialog from './BrandImageSearchDialog'; @@ -56,7 +56,7 @@ class ArtikelMarke extends PureComponent { async renameBrand(brand, name) { try { - await brandAutomat.actions.rename(brand.id, { name }); + await companyAutomat.actions.renameBrand(brand.id, { name }); enqueueSnackbar('Marke gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: brand?.name ?? '' }); @@ -99,7 +99,7 @@ class ArtikelMarke extends PureComponent { if (!window.confirm(`Marke „${brand.name}" wirklich löschen?`)) return; void (async () => { try { - await brandAutomat.actions.remove(brand.id); + await companyAutomat.actions.removeBrand(brand.id); enqueueSnackbar('Marke gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/brand/ArtikelMarkeNew.jsx b/src/components/brand/ArtikelMarkeNew.jsx index 3c61138..a06c732 100644 --- a/src/components/brand/ArtikelMarkeNew.jsx +++ b/src/components/brand/ArtikelMarkeNew.jsx @@ -3,7 +3,7 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import brandAutomat from '../../automats/brandAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; // Own component so typing a new brand name never rerenders ArtikelMarkenPage @@ -19,7 +19,7 @@ class ArtikelMarkeNew extends PureComponent { const name = this.state.newName.trim(); if (!name) return; try { - const row = await brandAutomat.actions.create({ name }); + const row = await companyAutomat.actions.createBrand({ name }); enqueueSnackbar(`Marke „${row.name || name}" angelegt.`, { variant: 'success' }); this.setState({ newName: '' }); } catch (err) { diff --git a/src/components/brand/BrandImageSearchDialog.jsx b/src/components/brand/BrandImageSearchDialog.jsx index 143c3d7..23d46ec 100644 --- a/src/components/brand/BrandImageSearchDialog.jsx +++ b/src/components/brand/BrandImageSearchDialog.jsx @@ -25,7 +25,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh'; import { enqueueSnackbar } from 'notistack'; import { serper } from '../../api'; -import brandAutomat from '../../automats/brandAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo'; import { CHECKERBOARD, @@ -211,8 +211,8 @@ export default class BrandImageSearchDialog extends PureComponent { this.setState({ processingStep: 'Logo wird gespeichert...' }); - // 2. Upload as brandpic and update brandAutomat - await brandAutomat.actions.addImage(brand.id, avifFile); + // 2. Upload as brandpic and update companyAutomat + await companyAutomat.actions.addBrandImage(brand.id, avifFile); enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, { variant: 'success', diff --git a/src/components/carrier/CarrierItem.jsx b/src/components/carrier/CarrierItem.jsx index 10a1eba..e7af04f 100644 --- a/src/components/carrier/CarrierItem.jsx +++ b/src/components/carrier/CarrierItem.jsx @@ -9,7 +9,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import carrierAutomat from '../../automats/carrierAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; class CarrierItem extends PureComponent { @@ -54,7 +54,7 @@ class CarrierItem extends PureComponent { async updateCarrier(item, patch) { try { - await carrierAutomat.actions.update(item.id, patch); + await companyAutomat.actions.updateCarrier(item.id, patch); enqueueSnackbar('Versandart gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: item.name ?? '' }); @@ -95,7 +95,7 @@ class CarrierItem extends PureComponent { if (!window.confirm(`Versandart „${item.name}“ wirklich löschen?`)) return; void (async () => { try { - await carrierAutomat.actions.remove(item.id); + await companyAutomat.actions.removeCarrier(item.id); enqueueSnackbar('Versandart gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/chat/ChatDrawer.jsx b/src/components/chat/ChatDrawer.jsx index 604b845..21961c9 100644 --- a/src/components/chat/ChatDrawer.jsx +++ b/src/components/chat/ChatDrawer.jsx @@ -40,7 +40,7 @@ import { setting as settingApi } from '../../api'; import chatAutomat, { selectPendingAskUser, } from '../../automats/chatAutomat'; -import userSettingsAutomat from '../../automats/userSettingsAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import vfsAutomat from '../../automats/vfsAutomat'; import { VfsEditorPanel, VfsFileIcon } from '../vfs'; import ChatFlowList from './ChatFlowList'; @@ -130,7 +130,7 @@ export default class ChatDrawer extends PureComponent { constructor(props) { super(props); const s = chatAutomat.state; - const settings = userSettingsAutomat.state; + const settings = companyAutomat.state; const vfs = vfsAutomat.state; this.state = { isOpen: s.isOpen, @@ -293,12 +293,12 @@ export default class ChatDrawer extends PureComponent { return patch; }); - this.unsubSettings = userSettingsAutomat.subscribe(this, (state) => { + this.unsubSettings = companyAutomat.subscribe(this, (state) => { if (!this.state.isResizing) { return { drawerWidth: state.chatDrawerWidth || DEFAULT_DRAWER_WIDTH }; } return null; - }); + }, ['chatDrawerWidth']); this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({ isVfsOpen: state.isOpen, @@ -465,7 +465,7 @@ export default class ChatDrawer extends PureComponent { this.setState({ isResizing: false }); vfsAutomat.actions.setVfsWidth(currentVfsWidth); - userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); + companyAutomat.actions.setChatDrawerWidth(currentChatWidth); }; window.addEventListener('pointermove', onMove, { passive: false }); @@ -531,7 +531,7 @@ export default class ChatDrawer extends PureComponent { if (isVfs) { vfsAutomat.actions.setVfsWidth(currentVfsWidth); } else { - userSettingsAutomat.actions.setChatDrawerWidth(currentChatWidth); + companyAutomat.actions.setChatDrawerWidth(currentChatWidth); } }; @@ -546,7 +546,7 @@ export default class ChatDrawer extends PureComponent { handleResizeDoubleClick = () => { this.setState({ drawerWidth: DEFAULT_DRAWER_WIDTH }); - userSettingsAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH); + companyAutomat.actions.setChatDrawerWidth(DEFAULT_DRAWER_WIDTH); }; cleanupResizeListeners = () => { diff --git a/src/components/chat/ChatFlowList.jsx b/src/components/chat/ChatFlowList.jsx index ceeaf18..8f4dd39 100644 --- a/src/components/chat/ChatFlowList.jsx +++ b/src/components/chat/ChatFlowList.jsx @@ -30,7 +30,8 @@ import EditNoteIcon from '@mui/icons-material/EditNote'; import PaidRoundedIcon from '@mui/icons-material/PaidRounded'; import Button from '@mui/material/Button'; import { enqueueSnackbar } from 'notistack'; -import { deliveryEditorAutomat, setLastDeliveryPath } from '../../automats'; +import deliveryEditorAutomat from '../../automats/deliveryEditorAutomat'; +import { setLastDeliveryPath } from '../../automats/navigationAutomat'; import HealingMarkdown from './HealingMarkdown'; import { VfsAttachmentLink, diff --git a/src/components/kunde/KundeGruppe.jsx b/src/components/kunde/KundeGruppe.jsx index 30f3efa..98b54e7 100644 --- a/src/components/kunde/KundeGruppe.jsx +++ b/src/components/kunde/KundeGruppe.jsx @@ -6,7 +6,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import customergroupAutomat from '../../automats/customergroupAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; class KundeGruppe extends PureComponent { @@ -54,7 +54,7 @@ class KundeGruppe extends PureComponent { async renameGroup(item, name) { try { - await customergroupAutomat.actions.update(item.id, { name }); + await companyAutomat.actions.updateCustomergroup(item.id, { name }); enqueueSnackbar('Kundengruppe gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: item.name ?? '' }); @@ -87,7 +87,7 @@ class KundeGruppe extends PureComponent { if (!window.confirm(`Kundengruppe „${item.name}“ wirklich löschen?`)) return; void (async () => { try { - await customergroupAutomat.actions.remove(item.id); + await companyAutomat.actions.removeCustomergroup(item.id); enqueueSnackbar('Kundengruppe gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/manifest/BookDeliveryDialog.jsx b/src/components/manifest/BookDeliveryDialog.jsx index b886fd4..1edcc88 100644 --- a/src/components/manifest/BookDeliveryDialog.jsx +++ b/src/components/manifest/BookDeliveryDialog.jsx @@ -37,7 +37,7 @@ import { } from '../../utils/deliveryLedger'; import { formatStock } from '../article/searchFormat'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; -import warehouseAutomat from '../../automats/warehouseAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { supplier as supplierApi } from '../../api'; import deliveryEditorAutomat, { buildSupplierArticlesMap } from '../../automats/deliveryEditorAutomat'; import { @@ -65,7 +65,7 @@ export default class BookDeliveryDialog extends PureComponent { } } - const initialWarehouses = warehouseAutomat.state.warehouses || []; + const initialWarehouses = companyAutomat.state.warehouses || []; const initialSupplierMap = props.supplierArticlesMap || (deliveryEditorAutomat?.state?.supplierArticlesMap ?? {}); @@ -107,7 +107,7 @@ export default class BookDeliveryDialog extends PureComponent { } componentDidMount() { - this.unsubWarehouse = warehouseAutomat.subscribe((state) => { + this.unsubWarehouse = companyAutomat.subscribe((state) => { const warehouses = state.warehouses || []; this.setState((prev) => { const selectedId = @@ -118,11 +118,11 @@ export default class BookDeliveryDialog extends PureComponent { selectedWarehouseId: selectedId, }; }); - }); + }, ['warehouses']); this.unsubArticle = artikelAutomat.subscribe(() => { this.forceUpdate(); }); - void warehouseAutomat.actions.load().catch(() => { }); + void companyAutomat.actions.loadWarehouses().catch(() => { }); this.loadSupplierArticlesIfNeeded(); if (this.props.open) { @@ -205,7 +205,7 @@ export default class BookDeliveryDialog extends PureComponent { componentDidUpdate(prevProps) { if (this.props.open && !prevProps.open) { - const currentWarehouses = this.state.warehouses || warehouseAutomat.state.warehouses || []; + const currentWarehouses = this.state.warehouses || companyAutomat.state.warehouses || []; const currentSupplierMap = this.props.supplierArticlesMap || (deliveryEditorAutomat?.state?.supplierArticlesMap ?? this.state.supplierArticlesMap ?? {}); @@ -225,7 +225,7 @@ export default class BookDeliveryDialog extends PureComponent { supplierArticlesMap: currentSupplierMap, }, () => { - void warehouseAutomat.actions.load().catch(() => { }); + void companyAutomat.actions.loadWarehouses().catch(() => { }); this.loadSupplierArticlesIfNeeded(); if (this.props.summary) { this.initAmountsFromSummary(this.props.summary, this.props.prefillLineId); diff --git a/src/components/manifest/DeliveryList.jsx b/src/components/manifest/DeliveryList.jsx index d1452df..bf83fc4 100644 --- a/src/components/manifest/DeliveryList.jsx +++ b/src/components/manifest/DeliveryList.jsx @@ -23,7 +23,7 @@ import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import deliveryAutomat, { CHUNK_SIZE } from '../../automats/deliveryAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { delivery as deliveryApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -693,11 +693,15 @@ export class DeliveryList extends PureComponent { supplierId: deliveryAutomat.state.supplierId ?? null, }); } - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.headerItem = createDummyTestDelivery(); - this.forceUpdate(); - }); - void numberAutomat.actions.ensureLoaded().catch(() => { }); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.headerItem = createDummyTestDelivery(); + this.forceUpdate(); + }, + ['numbers'], + ); + void companyAutomat.actions.ensureNumbersLoaded().catch(() => { }); void this.loadSuppliers(); } diff --git a/src/components/manifest/ManifestForm.jsx b/src/components/manifest/ManifestForm.jsx index c691d97..cd79911 100644 --- a/src/components/manifest/ManifestForm.jsx +++ b/src/components/manifest/ManifestForm.jsx @@ -17,12 +17,9 @@ import OrderDocumentsTiles from './OrderDocumentsTiles'; import OrderShipmentDialog from './OrderShipmentDialog'; import artikelAutomat, { getArticle } from '../../automats/artikelAutomat'; import entityAutomat from '../../automats/entityAutomat'; -import customergroupAutomat from '../../automats/customergroupAutomat'; -import supplierAutomat from '../../automats/supplierAutomat'; import orderAutomat from '../../automats/orderAutomat'; import retoureAutomat from '../../automats/retoureAutomat'; import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; -import warehouseAutomat from '../../automats/warehouseAutomat'; import sseAutomat from '../../automats/sseAutomat'; import OrderLedgerPanel from './OrderLedgerPanel'; import { fetchOrderLedgerEntries } from '../../utils/orderLedger'; @@ -67,15 +64,15 @@ class ManifestForm extends PureComponent { constructor(props) { super(props); this.cfg = manifestConfig(props.kind); - const groups = customergroupAutomat.state.items ?? []; + const groups = companyAutomat.state.customergroups ?? []; const initialParties = props.kind === 'delivery' - ? (supplierAutomat.state.status === 'ready' ? (supplierAutomat.state.suppliers ?? []) : []) + ? (companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : []) : (entityAutomat.state.status === 'ready' ? filterNonSupplierEntities(entityAutomat.state.items ?? [], groups) : []); const partiesReady = props.kind === 'delivery' - ? supplierAutomat.state.status === 'ready' - : (entityAutomat.state.status === 'ready' && customergroupAutomat.state.status === 'ready'); + ? companyAutomat.state.suppliersStatus === 'ready' + : (entityAutomat.state.status === 'ready' && companyAutomat.state.customergroupsStatus === 'ready'); this.draftAutomat = props.mode === 'create' ? getManifestDraftAutomat(props.kind) : null; this.editAutomat = props.mode === 'edit' ? getManifestEditAutomat(props.kind) : null; @@ -184,64 +181,69 @@ class ManifestForm extends PureComponent { componentDidMount() { this._cancelled = false; - if (this.props.kind === 'delivery') { - this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ - parties: state.status === 'ready' ? (state.suppliers ?? []) : [], - partiesLoading: state.status === 'loading', - })); - } else { - const updateParties = () => { + const syncPartiesFromState = () => { + if (this.props.kind === 'delivery') { + this.setState({ + parties: companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : [], + partiesLoading: companyAutomat.state.suppliersStatus === 'loading', + }); + } else { const entities = entityAutomat.state.items ?? []; - const groups = customergroupAutomat.state.items ?? []; + const groups = companyAutomat.state.customergroups ?? []; this.setState({ parties: filterNonSupplierEntities(entities, groups), partiesLoading: entityAutomat.state.status === 'loading' || - customergroupAutomat.state.status === 'loading', + companyAutomat.state.customergroupsStatus === 'loading', }); - }; + } + }; + + if (this.props.kind !== 'delivery') { this.unsubParties = entityAutomat.subscribe(this, () => { - updateParties(); + syncPartiesFromState(); return null; }); - this.unsubGroups = customergroupAutomat.subscribe(this, () => { - updateParties(); - return null; - }); - void customergroupAutomat.actions.ensureLoaded(); + void companyAutomat.actions.ensureCustomergroupsLoaded(); } if (companyAutomat.state.status === 'idle') { void companyAutomat.actions.load(); } - this.unsubCompany = companyAutomat.subscribe(this, (state) => { - const taxrate = state.current?.taxrate; - if (taxrate != null) { - this.setState((prev) => { - if ( - prev.lines.length === 1 && - !prev.lines[0].sku && - !prev.lines[0].name && - !prev.lines[0].net && - prev.lines[0].tax !== String(taxrate) - ) { - return { - lines: [ - { - ...prev.lines[0], - tax: String(taxrate), - taxrate: String(taxrate), - }, - ], - }; - } - return null; - }); - } - }); + this.unsubCompany = companyAutomat.subscribe( + this, + (state) => { + syncPartiesFromState(); + const taxrate = state.current?.taxrate; + if (taxrate != null) { + this.setState((prev) => { + if ( + prev.lines.length === 1 && + !prev.lines[0].sku && + !prev.lines[0].name && + !prev.lines[0].net && + prev.lines[0].tax !== String(taxrate) + ) { + return { + lines: [ + { + ...prev.lines[0], + tax: String(taxrate), + taxrate: String(taxrate), + }, + ], + }; + } + return null; + }); + } + return null; + }, + ['current', 'suppliers', 'suppliersStatus', 'customergroups', 'customergroupsStatus'], + ); - if (warehouseAutomat.state.status === 'idle') { - void warehouseAutomat.actions.load(); + if (companyAutomat.state.warehousesStatus === 'idle') { + void companyAutomat.actions.loadWarehouses(); } if (this.props.mode === 'edit') { @@ -304,32 +306,29 @@ class ManifestForm extends PureComponent { if (prevProps.kind !== this.props.kind) { this.cfg = manifestConfig(this.props.kind); this.unsubParties?.(); - this.unsubGroups?.(); + this.unsubParties = null; if (this.props.kind === 'delivery') { - this.unsubParties = supplierAutomat.subscribe(this, (state) => ({ - parties: state.status === 'ready' ? (state.suppliers ?? []) : [], - partiesLoading: state.status === 'loading', - })); + this.setState({ + parties: companyAutomat.state.suppliersStatus === 'ready' ? (companyAutomat.state.suppliers ?? []) : [], + partiesLoading: companyAutomat.state.suppliersStatus === 'loading', + }); } else { const updateParties = () => { const entities = entityAutomat.state.items ?? []; - const groups = customergroupAutomat.state.items ?? []; + const groups = companyAutomat.state.customergroups ?? []; this.setState({ parties: filterNonSupplierEntities(entities, groups), partiesLoading: entityAutomat.state.status === 'loading' || - customergroupAutomat.state.status === 'loading', + companyAutomat.state.customergroupsStatus === 'loading', }); }; this.unsubParties = entityAutomat.subscribe(this, () => { updateParties(); return null; }); - this.unsubGroups = customergroupAutomat.subscribe(this, () => { - updateParties(); - return null; - }); - void customergroupAutomat.actions.ensureLoaded(); + updateParties(); + void companyAutomat.actions.ensureCustomergroupsLoaded(); } } diff --git a/src/components/manifest/ManifestHeader.jsx b/src/components/manifest/ManifestHeader.jsx index d4eddcd..3c91b70 100644 --- a/src/components/manifest/ManifestHeader.jsx +++ b/src/components/manifest/ManifestHeader.jsx @@ -25,9 +25,7 @@ import { } from './manifestConfig'; import { notifyError, notifyErrorMessage } from '../../utils/notify'; import entityAutomat from '../../automats/entityAutomat'; -import customergroupAutomat from '../../automats/customergroupAutomat'; -import supplierAutomat from '../../automats/supplierAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import quoteAutomat from '../../automats/quoteAutomat'; import orderAutomat from '../../automats/orderAutomat'; import deliveryAutomat from '../../automats/deliveryAutomat'; @@ -42,7 +40,7 @@ class ManifestHeader extends PureComponent { const selected = (props.parties || []).find((row) => row.id === props.partyId) ?? (props.kind === 'delivery' - ? (supplierAutomat.state.suppliers || []).find((row) => row.id === props.partyId) + ? (companyAutomat.state.suppliers || []).find((row) => row.id === props.partyId) : (entityAutomat.state.items || []).find((row) => row.id === props.partyId)) ?? null; this.state = { @@ -68,7 +66,7 @@ class ManifestHeader extends PureComponent { (this.props.parties || []).find((row) => row.id === this.props.partyId) ?? (this.state.createdParty?.id === this.props.partyId ? this.state.createdParty : null) ?? (this.props.kind === 'delivery' - ? (supplierAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId) + ? (companyAutomat.state.suppliers || []).find((row) => row.id === this.props.partyId) : (entityAutomat.state.items || []).find((row) => row.id === this.props.partyId)); if (selected) { this.setState({ partyInput: partyDisplayName(this.props.kind, selected) }); @@ -147,14 +145,14 @@ class ManifestHeader extends PureComponent { try { const { kind, onPartyChange } = this.props; if (kind === 'delivery') { - const row = await supplierAutomat.actions.create({ name: raw }); + const row = await companyAutomat.actions.createSupplier({ name: raw }); const displayName = row?.name || raw; enqueueSnackbar(`Lieferant „${displayName}“ angelegt.`, { variant: 'success' }); this.setState({ creatingParty: false, createdParty: row, partyInput: displayName }); onPartyChange?.(row.id); } else { - await customergroupAutomat.actions.ensureLoaded(); - const groups = customergroupAutomat.state.items ?? []; + await companyAutomat.actions.ensureCustomergroupsLoaded(); + const groups = companyAutomat.state.customergroups ?? []; const targetGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0]; if (!targetGroup) { throw new Error('Keine Kundengruppe vorhanden.'); @@ -257,11 +255,15 @@ class ManifestHeader extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.forceUpdate(); - }); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.forceUpdate(); + }, + ['numbers'], + ); this.subscribeListAutomat(this.props.kind); - void numberAutomat.actions.ensureLoaded().catch(() => {}); + void companyAutomat.actions.ensureNumbersLoaded().catch(() => {}); if (!this.props.number && this.props.docId == null) { void ensureDocumentNumber(this.props.kind) .then(() => { diff --git a/src/components/manifest/OrderList.jsx b/src/components/manifest/OrderList.jsx index 58a65cd..4cd3032 100644 --- a/src/components/manifest/OrderList.jsx +++ b/src/components/manifest/OrderList.jsx @@ -26,7 +26,7 @@ import orderAutomat, { isOrderPaid, isOrderDelivered, } from '../../automats/orderAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { order as orderApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -595,11 +595,15 @@ export class OrderList extends PureComponent { filterKey: orderAutomat.getFilterKey(state), entityId: state.entityId ?? null, })); - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.headerItem = createDummyTestOrder(); - this.forceUpdate(); - }); - void numberAutomat.actions.ensureLoaded().catch(() => {}); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.headerItem = createDummyTestOrder(); + this.forceUpdate(); + }, + ['numbers'], + ); + void companyAutomat.actions.ensureNumbersLoaded().catch(() => {}); void this.loadParties(); } diff --git a/src/components/manifest/OrderPaymentList.jsx b/src/components/manifest/OrderPaymentList.jsx index ab41267..581c3c4 100644 --- a/src/components/manifest/OrderPaymentList.jsx +++ b/src/components/manifest/OrderPaymentList.jsx @@ -17,7 +17,7 @@ import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import orderAutomat from '../../automats/orderAutomat'; -import accountAutomat from '../../automats/accountAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { payment as paymentApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -184,14 +184,18 @@ export class OrderPaymentList extends PureComponent { status: state.status, error: state.error ? backendErrorFrom(state.error) : null, })); - this.unsubAccount = accountAutomat.subscribe(this, (state) => ({ - accountsById: Object.fromEntries((state.items || []).map((a) => [a.id, a.name])), - })); + this.unsubAccount = companyAutomat.subscribe( + this, + (state) => ({ + accountsById: Object.fromEntries((state.accounts || []).map((a) => [a.id, a.name])), + }), + ['accounts'], + ); if (orderAutomat.state.status === 'idle') { void orderAutomat.actions.load().catch(() => {}); } - void accountAutomat.actions.ensureLoaded().catch(() => {}); + void companyAutomat.actions.ensureAccountsLoaded().catch(() => {}); void this.loadPartiesData(); } diff --git a/src/components/manifest/QuoteList.jsx b/src/components/manifest/QuoteList.jsx index e25c7dd..cf26a58 100644 --- a/src/components/manifest/QuoteList.jsx +++ b/src/components/manifest/QuoteList.jsx @@ -18,7 +18,7 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import SkeleHashImage from '../settings/SkeleHashImage'; import quoteAutomat, { CHUNK_SIZE } from '../../automats/quoteAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { quote as quoteApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -593,11 +593,15 @@ export class QuoteList extends PureComponent { filterKey: quoteAutomat.getFilterKey(state), entityId: state.entityId ?? null, })); - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.headerItem = createDummyTestQuote(); - this.forceUpdate(); - }); - void numberAutomat.actions.ensureLoaded().catch(() => { }); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.headerItem = createDummyTestQuote(); + this.forceUpdate(); + }, + ['numbers'], + ); + void companyAutomat.actions.ensureNumbersLoaded().catch(() => { }); void this.loadParties(); } diff --git a/src/components/manifest/RetoureList.jsx b/src/components/manifest/RetoureList.jsx index 4ec0bc2..1b781cd 100644 --- a/src/components/manifest/RetoureList.jsx +++ b/src/components/manifest/RetoureList.jsx @@ -26,7 +26,7 @@ import retoureAutomat, { isRetourePaid, isRetoureShipped, } from '../../automats/retoureAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { retoure as retoureApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -600,10 +600,14 @@ export class RetoureList extends PureComponent { filterKey: retoureAutomat.getFilterKey(state), entityId: state.entityId ?? null, })); - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.headerItem = createDummyTestRetoure(); - this.forceUpdate(); - }); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.headerItem = createDummyTestRetoure(); + this.forceUpdate(); + }, + ['numbers'], + ); void this.loadParties(); } diff --git a/src/components/manifest/ShipmentDetailDialog.jsx b/src/components/manifest/ShipmentDetailDialog.jsx index f784c43..656d073 100644 --- a/src/components/manifest/ShipmentDetailDialog.jsx +++ b/src/components/manifest/ShipmentDetailDialog.jsx @@ -32,14 +32,14 @@ import { notifyError } from '../../utils/notify'; import { formatDate, formatDateTime } from './manifestConfig'; import orderAutomat from '../../automats/orderAutomat'; import shipmentAutomat from '../../automats/shipmentAutomat'; -import carrierAutomat from '../../automats/carrierAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { SURFACE_RAISED, WHITE } from '../../colors'; // MARK: class AddPackageDialog extends PureComponent { constructor(props) { super(props); - const carriers = (carrierAutomat.state.carriers || []).filter((c) => !c.isReverse); + const carriers = (companyAutomat.state.carriers || []).filter((c) => !c.isReverse); this.state = { carriers, carrierId: carriers[0]?.id ?? '', @@ -50,17 +50,21 @@ class AddPackageDialog extends PureComponent { } componentDidMount() { - this.unsub = carrierAutomat.subscribe(this, (state) => { - const carriers = (state.carriers || []).filter((c) => !c.isReverse); - const currentId = this.state.carrierId; - const valid = carriers.some((c) => c.id === currentId); - return { - carriers, - carrierId: valid ? currentId : (carriers[0]?.id ?? ''), - }; - }); - if (carrierAutomat.state.status !== 'ready') { - void carrierAutomat.actions.load().catch(() => { }); + this.unsub = companyAutomat.subscribe( + this, + (state) => { + const carriers = (state.carriers || []).filter((c) => !c.isReverse); + const currentId = this.state.carrierId; + const valid = carriers.some((c) => c.id === currentId); + return { + carriers, + carrierId: valid ? currentId : (carriers[0]?.id ?? ''), + }; + }, + ['carriers'], + ); + if (companyAutomat.state.carriersStatus !== 'ready') { + void companyAutomat.actions.loadCarriers().catch(() => { }); } } @@ -414,7 +418,7 @@ export default class ShipmentDetailDialog extends PureComponent { const shLines = Array.isArray(shipment.lines) ? shipment.lines : []; const shPackages = Array.isArray(shipment.shippings) ? shipment.shippings : []; - const carriers = carrierAutomat.state.carriers || []; + const carriers = companyAutomat.state.carriers || []; const isShipmentShipped = Boolean( shipment.state === 'delivered' || shipment.state === 'shipped' || diff --git a/src/components/manifest/ShipmentList.jsx b/src/components/manifest/ShipmentList.jsx index a2a6bea..54bc629 100644 --- a/src/components/manifest/ShipmentList.jsx +++ b/src/components/manifest/ShipmentList.jsx @@ -24,7 +24,7 @@ import BackendError from '../BackendError'; import AppDialog from '../AppDialog'; import shipmentAutomat, { CHUNK_SIZE } from '../../automats/shipmentAutomat'; import orderAutomat from '../../automats/orderAutomat'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { shipment as shipmentApi } from '../../api'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -949,11 +949,15 @@ export class ShipmentList extends PureComponent { orders: state.items || [], })); - this.unsubNumber = numberAutomat.subscribe(this, () => { - this.headerItem = createDummyTestShipment(); - this.forceUpdate(); - }); - void numberAutomat.actions.ensureLoaded().catch(() => {}); + this.unsubNumber = companyAutomat.subscribe( + this, + () => { + this.headerItem = createDummyTestShipment(); + this.forceUpdate(); + }, + ['numbers'], + ); + void companyAutomat.actions.ensureNumbersLoaded().catch(() => {}); void shipmentAutomat.actions.load().catch(() => {}); void orderAutomat.actions.load().catch(() => {}); diff --git a/src/components/manifest/manifestConfig.js b/src/components/manifest/manifestConfig.js index acf6520..e20b465 100644 --- a/src/components/manifest/manifestConfig.js +++ b/src/components/manifest/manifestConfig.js @@ -4,15 +4,13 @@ import { order as orderApi, retoure as retoureApi, } from '../../api'; -import supplierAutomat from '../../automats/supplierAutomat'; import entityAutomat from '../../automats/entityAutomat'; -import customergroupAutomat from '../../automats/customergroupAutomat'; import deliveryAutomat from '../../automats/deliveryAutomat'; import quoteAutomat from '../../automats/quoteAutomat'; import orderAutomat from '../../automats/orderAutomat'; import retoureAutomat from '../../automats/retoureAutomat'; import { getArticle } from '../../automats/artikelAutomat'; -import { selectCompanyTaxrate } from '../../automats/companyAutomat'; +import companyAutomat, { selectCompanyTaxrate } from '../../automats/companyAutomat'; import { netFromGross } from '../../utils/price'; import { ensureDocumentNumber, getNextDocumentNumber } from '../../utils/documentNumber'; import { @@ -646,18 +644,18 @@ export function apiFor(cfgOrKind) { export async function loadParties(cfgOrKind) { const kind = typeof cfgOrKind === 'string' ? cfgOrKind : cfgOrKind?.kind; if (kind === 'delivery') { - if (supplierAutomat.state.status === 'ready') { - return sortSuppliers(supplierAutomat.state.suppliers); + if (companyAutomat.state.suppliersStatus === 'ready') { + return sortSuppliers(companyAutomat.state.suppliers); } - const list = await supplierAutomat.actions.load(); + const list = await companyAutomat.actions.loadSuppliers(); return sortSuppliers(list); } const [entities, groups] = await Promise.all([ entityAutomat.actions.ensureLoaded(), - customergroupAutomat.actions.ensureLoaded(), + companyAutomat.actions.ensureCustomergroupsLoaded(), ]); const entityList = entities ?? entityAutomat.state.items ?? []; - const groupList = groups ?? customergroupAutomat.state.items ?? []; + const groupList = groups ?? companyAutomat.state.customergroups ?? []; return filterNonSupplierEntities(entityList, groupList); } diff --git a/src/components/merkmale/ArtikelMerkmalAuswahl.jsx b/src/components/merkmale/ArtikelMerkmalAuswahl.jsx index 5799332..87f0bc5 100644 --- a/src/components/merkmale/ArtikelMerkmalAuswahl.jsx +++ b/src/components/merkmale/ArtikelMerkmalAuswahl.jsx @@ -7,7 +7,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import attributeAutomat from '../../automats/attributeAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; import { SELECT_BG, @@ -56,7 +56,7 @@ class ArtikelMerkmalAuswahl extends PureComponent { async renameAttribute(attribute, name) { this.setState({ busy: true }); try { - await attributeAutomat.actions.updateAttribute(attribute.id, { name }); + await companyAutomat.actions.updateAttribute(attribute.id, { name }); enqueueSnackbar('Attribut gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: attribute.name ?? '' }); @@ -87,7 +87,7 @@ class ArtikelMerkmalAuswahl extends PureComponent { const nextExclusive = !attribute.exclusive; this.setState({ busy: true }); try { - await attributeAutomat.actions.updateAttribute(attribute.id, { exclusive: nextExclusive }); + await companyAutomat.actions.updateAttribute(attribute.id, { exclusive: nextExclusive }); enqueueSnackbar( `Attribut „${attribute.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`, { variant: 'info' } @@ -111,7 +111,7 @@ class ArtikelMerkmalAuswahl extends PureComponent { this.setState({ busy: true }); void (async () => { try { - await attributeAutomat.actions.deleteAttribute(attribute.id); + await companyAutomat.actions.deleteAttribute(attribute.id); enqueueSnackbar('Attribut gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/merkmale/ArtikelMerkmalText.jsx b/src/components/merkmale/ArtikelMerkmalText.jsx index a0e9126..422c5a5 100644 --- a/src/components/merkmale/ArtikelMerkmalText.jsx +++ b/src/components/merkmale/ArtikelMerkmalText.jsx @@ -7,7 +7,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import propertyAutomat from '../../automats/propertyAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; class ArtikelMerkmalText extends PureComponent { @@ -54,7 +54,7 @@ class ArtikelMerkmalText extends PureComponent { async renameProperty(property, name) { this.setState({ busy: true }); try { - await propertyAutomat.actions.updateProperty(property.id, { name }); + await companyAutomat.actions.updateProperty(property.id, { name }); enqueueSnackbar('Merkmal gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: property.name ?? '' }); @@ -85,7 +85,7 @@ class ArtikelMerkmalText extends PureComponent { const nextExclusive = !item.exclusive; this.setState({ busy: true }); try { - await propertyAutomat.actions.updateProperty(item.id, { exclusive: nextExclusive }); + await companyAutomat.actions.updateProperty(item.id, { exclusive: nextExclusive }); enqueueSnackbar( `Merkmal „${item.name}“ auf ${nextExclusive ? 'Einzelauswahl' : 'Mehrfachauswahl'} gestellt.`, { variant: 'info' } @@ -108,7 +108,7 @@ class ArtikelMerkmalText extends PureComponent { this.setState({ busy: true }); void (async () => { try { - await propertyAutomat.actions.deleteProperty(item.id); + await companyAutomat.actions.deleteProperty(item.id); enqueueSnackbar('Merkmal gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/merkmale/ArtikelMerkmalWert.jsx b/src/components/merkmale/ArtikelMerkmalWert.jsx index 53edbca..1c85c2d 100644 --- a/src/components/merkmale/ArtikelMerkmalWert.jsx +++ b/src/components/merkmale/ArtikelMerkmalWert.jsx @@ -7,7 +7,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import attributeAutomat from '../../automats/attributeAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; class ArtikelMerkmalWert extends PureComponent { @@ -54,7 +54,7 @@ class ArtikelMerkmalWert extends PureComponent { async renameValue(item, val) { this.setState({ busy: true }); try { - await attributeAutomat.actions.updateValue(item.id, { value: val }); + await companyAutomat.actions.updateValue(item.id, { value: val }); enqueueSnackbar('Wert gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: item.value ?? '' }); @@ -88,7 +88,7 @@ class ArtikelMerkmalWert extends PureComponent { this.setState({ busy: true }); void (async () => { try { - await attributeAutomat.actions.deleteValue(item.id); + await companyAutomat.actions.deleteValue(item.id); enqueueSnackbar('Wert gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/merkmale/AuswahlMerkmaleTab.jsx b/src/components/merkmale/AuswahlMerkmaleTab.jsx index 0878b8a..cadf409 100644 --- a/src/components/merkmale/AuswahlMerkmaleTab.jsx +++ b/src/components/merkmale/AuswahlMerkmaleTab.jsx @@ -14,7 +14,7 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import attributeAutomat, { selectAttributes } from '../../automats/attributeAutomat'; +import companyAutomat, { selectAttributes } from '../../automats/companyAutomat'; import ArtikelMerkmalAuswahl from './ArtikelMerkmalAuswahl'; import ArtikelMerkmalWert from './ArtikelMerkmalWert'; import { BLUE_PANEL, PURPLE_PANEL } from '../../colors'; @@ -30,8 +30,8 @@ class AuswahlMerkmaleTab extends PureComponent { const initialSelectedAttrId = initialAttributes[0]?.id ?? null; this.state = { - attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - error: attributeAutomat.state.error || null, + attrStatus: companyAutomat.state.attributesStatus === 'succeeded' ? 'ready' : 'loading', + error: companyAutomat.state.attributesError || null, attributes: initialAttributes, selectedAttributeId: initialSelectedAttrId, @@ -64,9 +64,9 @@ class AuswahlMerkmaleTab extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsubAttr = attributeAutomat.subscribe((state) => { + this.unsubAttr = companyAutomat.subscribe((state) => { if (this._cancelled) return; - if (state.status === 'succeeded') { + if (state.attributesStatus === 'succeeded') { let selectedId = this.state.selectedAttributeId; if (!selectedId || !state.attributes.some((a) => a.id === selectedId)) { selectedId = state.attributes[0]?.id ?? null; @@ -77,12 +77,12 @@ class AuswahlMerkmaleTab extends PureComponent { attributes: state.attributes, selectedAttributeId: selectedId, }); - } else if (state.status === 'failed') { - this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.error) }); + } else if (state.attributesStatus === 'failed') { + this.setState({ attrStatus: 'failed', error: backendErrorFrom(state.attributesError) }); } - }); + }, ['attributes', 'attributesStatus', 'attributesError']); - if (attributeAutomat.state.status !== 'succeeded') { + if (companyAutomat.state.attributesStatus !== 'succeeded') { void this.load(); } else { this._initAttrSortable(); @@ -216,21 +216,21 @@ class AuswahlMerkmaleTab extends PureComponent { async _persistAttrOrder(ids) { try { - await attributeAutomat.actions.reorderAttributes(ids); + await companyAutomat.actions.reorderAttributes(ids); } catch (err) { if (this._cancelled) return; notifyError(err); - void attributeAutomat.actions.load(); + void companyAutomat.actions.loadAttributes(); } } async _persistValOrder(attributeId, ids) { try { - await attributeAutomat.actions.reorderValues(attributeId, ids); + await companyAutomat.actions.reorderValues(attributeId, ids); } catch (err) { if (this._cancelled) return; notifyError(err); - void attributeAutomat.actions.load(); + void companyAutomat.actions.loadAttributes(); } } // #endregion @@ -238,11 +238,11 @@ class AuswahlMerkmaleTab extends PureComponent { // #region Actions & Handlers async load() { this.setState({ - attrStatus: attributeAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + attrStatus: companyAutomat.state.attributesStatus === 'succeeded' ? 'ready' : 'loading', error: null, }); try { - await attributeAutomat.actions.load(); + await companyAutomat.actions.loadAttributes(); } catch (err) { if (this._cancelled) return; this.setState({ error: backendErrorFrom(err) }); @@ -263,7 +263,7 @@ class AuswahlMerkmaleTab extends PureComponent { this.setState({ attrSubmitting: true }); try { - const row = await attributeAutomat.actions.createAttribute({ + const row = await companyAutomat.actions.createAttribute({ name, exclusive: true, }); @@ -288,7 +288,7 @@ class AuswahlMerkmaleTab extends PureComponent { this.setState({ valSubmitting: true }); try { - await attributeAutomat.actions.createValue(selectedAttributeId, { value }); + await companyAutomat.actions.createValue(selectedAttributeId, { value }); if (this._cancelled) return; this.setState({ valFilter: '', valSubmitting: false }); enqueueSnackbar(`Wert „${value}“ angelegt.`, { variant: 'success' }); @@ -311,7 +311,7 @@ class AuswahlMerkmaleTab extends PureComponent { this.setState({ attrDeletingUnused: true }); try { - await attributeAutomat.actions.deleteAttributes(unused.map((a) => a.id)); + await companyAutomat.actions.deleteAttributes(unused.map((a) => a.id)); enqueueSnackbar(`${unused.length} ungenutzte Auswahl-Merkmale gelöscht.`, { variant: 'success' }); } catch (err) { notifyError(err); @@ -336,7 +336,7 @@ class AuswahlMerkmaleTab extends PureComponent { this.setState({ valDeletingUnused: true }); try { - await attributeAutomat.actions.deleteValues(unused.map((v) => v.id)); + await companyAutomat.actions.deleteValues(unused.map((v) => v.id)); enqueueSnackbar(`${unused.length} ungenutzte Werte gelöscht.`, { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/merkmale/TextMerkmaleTab.jsx b/src/components/merkmale/TextMerkmaleTab.jsx index 66df355..4401ebe 100644 --- a/src/components/merkmale/TextMerkmaleTab.jsx +++ b/src/components/merkmale/TextMerkmaleTab.jsx @@ -13,7 +13,7 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import BackendError from '../BackendError'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import propertyAutomat, { selectProperties } from '../../automats/propertyAutomat'; +import companyAutomat, { selectProperties } from '../../automats/companyAutomat'; import ArtikelMerkmalText from './ArtikelMerkmalText'; import { ORANGE_PANEL } from '../../colors'; @@ -27,8 +27,8 @@ class TextMerkmaleTab extends PureComponent { const initialProperties = selectProperties() || []; this.state = { - propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', - error: propertyAutomat.state.error || null, + propStatus: companyAutomat.state.propertiesStatus === 'succeeded' ? 'ready' : 'loading', + error: companyAutomat.state.propertiesError || null, properties: initialProperties, propFilter: '', @@ -52,20 +52,20 @@ class TextMerkmaleTab extends PureComponent { this._cancelled = false; console.log('TextMerkmaleTab mounted', this.props); - this.unsubProp = propertyAutomat.subscribe((state) => { + this.unsubProp = companyAutomat.subscribe((state) => { if (this._cancelled) return; - if (state.status === 'succeeded') { + if (state.propertiesStatus === 'succeeded') { this.setState({ propStatus: 'ready', error: null, properties: state.properties, }); - } else if (state.status === 'failed') { - this.setState({ propStatus: 'failed', error: backendErrorFrom(state.error) }); + } else if (state.propertiesStatus === 'failed') { + this.setState({ propStatus: 'failed', error: backendErrorFrom(state.propertiesError) }); } - }); + }, ['properties', 'propertiesStatus', 'propertiesError']); - if (propertyAutomat.state.status !== 'succeeded') { + if (companyAutomat.state.propertiesStatus !== 'succeeded') { void this.load(); } else { this._initPropSortable(); @@ -139,11 +139,11 @@ class TextMerkmaleTab extends PureComponent { async _persistPropOrder(ids) { try { - await propertyAutomat.actions.reorderProperties(ids); + await companyAutomat.actions.reorderProperties(ids); } catch (err) { if (this._cancelled) return; notifyError(err); - void propertyAutomat.actions.load(); + void companyAutomat.actions.loadProperties(); } } // #endregion @@ -151,11 +151,11 @@ class TextMerkmaleTab extends PureComponent { // #region Actions & Handlers async load() { this.setState({ - propStatus: propertyAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + propStatus: companyAutomat.state.propertiesStatus === 'succeeded' ? 'ready' : 'loading', error: null, }); try { - await propertyAutomat.actions.load(); + await companyAutomat.actions.loadProperties(); } catch (err) { if (this._cancelled) return; this.setState({ error: backendErrorFrom(err) }); @@ -172,7 +172,7 @@ class TextMerkmaleTab extends PureComponent { this.setState({ propSubmitting: true }); try { - const row = await propertyAutomat.actions.createProperty({ + const row = await companyAutomat.actions.createProperty({ name, exclusive: true, }); @@ -198,7 +198,7 @@ class TextMerkmaleTab extends PureComponent { this.setState({ propDeletingUnused: true }); try { - await propertyAutomat.actions.deleteProperties(unused.map((p) => p.id)); + await companyAutomat.actions.deleteProperties(unused.map((p) => p.id)); enqueueSnackbar(`${unused.length} ungenutzte Text-Merkmale gelöscht.`, { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/components/settings/ImportExportSettingsPanel.jsx b/src/components/settings/ImportExportSettingsPanel.jsx index 0285c6d..a5b3e3a 100644 --- a/src/components/settings/ImportExportSettingsPanel.jsx +++ b/src/components/settings/ImportExportSettingsPanel.jsx @@ -17,7 +17,7 @@ import { enqueueSnackbar } from 'notistack'; import { company } from '../../api'; import categoryAutomat from '../../automats/categoryAutomat'; import articleIndexAutomat from '../../automats/articleIndexAutomat'; -import brandAutomat from '../../automats/brandAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { orderAutomat } from '../../automats/orderAutomat'; import SkeleHashImage from './SkeleHashImage'; @@ -240,7 +240,7 @@ export default class ImportExportSettingsPanel extends Component { enqueueSnackbar('Import erfolgreich abgeschlossen.', { variant: 'success' }); void categoryAutomat.actions.loadTree(); void articleIndexAutomat.actions.reload?.(); - void brandAutomat.actions.load?.(); + void companyAutomat.actions.loadBrands?.(); if (stats?.ordersImported) { void orderAutomat.actions.reload?.(); } diff --git a/src/components/settings/NumberSettingsPanel.jsx b/src/components/settings/NumberSettingsPanel.jsx index 252d21a..2155c22 100644 --- a/src/components/settings/NumberSettingsPanel.jsx +++ b/src/components/settings/NumberSettingsPanel.jsx @@ -16,7 +16,7 @@ import SaveOutlinedIcon from '@mui/icons-material/SaveOutlined'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../BackendError'; import { number as numberApi } from '../../api'; -import numberAutomat from '../../automats/numberAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -53,7 +53,7 @@ export default class NumberSettingsPanel extends Component { this.setState({ status: 'loading', error: null }); try { const numbers = requireArray( - await numberAutomat.actions.reload(), + await companyAutomat.actions.reloadNumbers(), 'numbers', ); const drafts = Object.fromEntries( @@ -146,7 +146,7 @@ export default class NumberSettingsPanel extends Component { const updated = requireDefined(data.number, 'number'); const numbers = this.state.numbers.map((n) => (n.id === id ? { ...n, ...body } : n)); - await this.context.updateNumber(); + await companyAutomat.actions.reloadNumbers(); this.setState({ numbers, drafts: { diff --git a/src/components/settings/PosTerminalSettingsPanel.jsx b/src/components/settings/PosTerminalSettingsPanel.jsx index fc09754..445df6f 100644 --- a/src/components/settings/PosTerminalSettingsPanel.jsx +++ b/src/components/settings/PosTerminalSettingsPanel.jsx @@ -17,7 +17,7 @@ import PointOfSaleIcon from '@mui/icons-material/PointOfSale'; import { enqueueSnackbar } from 'notistack'; import BackendError from '../BackendError'; import CategoryPickerDialog from '../article/CategoryPickerDialog'; -import posterminalAutomat from '../../automats/posterminalAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import categoryAutomat, { whenCategoryTreeReady } from '../../automats/categoryAutomat'; import { categoryDisplayName } from '../../utils/categoryUtils'; import { backendErrorFrom } from '../../utils/backendError'; @@ -43,9 +43,9 @@ function formatDateTime(val) { export default class PosTerminalSettingsPanel extends Component { constructor(props) { super(props); - const terminals = posterminalAutomat.state.terminals ?? []; + const terminals = companyAutomat.state.terminals ?? []; this.state = { - status: posterminalAutomat.state.status === 'succeeded' ? 'ready' : 'loading', + status: companyAutomat.state.terminalsStatus === 'succeeded' ? 'ready' : 'loading', error: null, terminals, categories: categoryAutomat.state.categories, @@ -67,9 +67,9 @@ export default class PosTerminalSettingsPanel extends Component { } componentDidMount() { - this.unsub = posterminalAutomat.subscribe(this, (state) => ({ + this.unsub = companyAutomat.subscribe(this, (state) => ({ terminals: Array.isArray(state.terminals) ? state.terminals : [], - status: state.status === 'succeeded' ? 'ready' : state.status === 'failed' ? 'failed' : this.state.status, + status: state.terminalsStatus === 'succeeded' ? 'ready' : state.terminalsStatus === 'failed' ? 'failed' : this.state.status, })); this.unsubCategory = categoryAutomat.subscribe(this, (state) => ({ categories: state.categories, @@ -89,7 +89,7 @@ export default class PosTerminalSettingsPanel extends Component { async loadTerminals() { this.setState({ error: null }); try { - const terminals = await posterminalAutomat.actions.load(); + const terminals = await companyAutomat.actions.loadTerminals(); this.setState({ status: 'ready', terminals: Array.isArray(terminals) ? terminals : [], @@ -111,14 +111,14 @@ export default class PosTerminalSettingsPanel extends Component { this.setState({ creatingTerminal: true }); try { - const created = await posterminalAutomat.actions.create({ name: trimmed }); + const created = await companyAutomat.actions.createTerminal({ name: trimmed }); if (enableAllForNew && created?.id != null) { - await posterminalAutomat.actions.enableAll({ terminalId: created.id }); + await companyAutomat.actions.enableAllTerminalCategories({ terminalId: created.id }); await whenCategoryTreeReady(); } - await posterminalAutomat.actions.load(); + await companyAutomat.actions.loadTerminals(); this.setState({ newTerminalName: '' }); enqueueSnackbar('Terminal hinzugefügt.', { variant: 'success' }); } catch (err) { @@ -131,8 +131,8 @@ export default class PosTerminalSettingsPanel extends Component { async handleDeleteTerminal(terminal) { this.setState({ deletingId: terminal.id }); try { - await posterminalAutomat.actions.delete(terminal.id); - await posterminalAutomat.actions.load(); + await companyAutomat.actions.deleteTerminal(terminal.id); + await companyAutomat.actions.loadTerminals(); enqueueSnackbar('Terminal gelöscht.', { variant: 'info' }); } catch (err) { notifyError(err); @@ -144,7 +144,7 @@ export default class PosTerminalSettingsPanel extends Component { async handleSetRootCategory(terminal, categoryId) { this.setState({ patchingTerminalId: terminal.id }); try { - await posterminalAutomat.actions.update(terminal.id, { + await companyAutomat.actions.updateTerminal(terminal.id, { rootCategoryId: categoryId != null ? Number(categoryId) : null, }); enqueueSnackbar('Root-Kategorie aktualisiert.', { variant: 'success' }); @@ -158,7 +158,7 @@ export default class PosTerminalSettingsPanel extends Component { async handleToggleSyncRootArticles(terminal, nextValue) { this.setState({ patchingTerminalId: terminal.id }); try { - await posterminalAutomat.actions.update(terminal.id, { + await companyAutomat.actions.updateTerminal(terminal.id, { syncRootArticles: Boolean(nextValue), }); enqueueSnackbar('Synchronisierungseinstellung gespeichert.', { variant: 'success' }); diff --git a/src/components/warehouse/ArtikelLager.jsx b/src/components/warehouse/ArtikelLager.jsx index 1317c03..fe2dbd8 100644 --- a/src/components/warehouse/ArtikelLager.jsx +++ b/src/components/warehouse/ArtikelLager.jsx @@ -6,7 +6,7 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { enqueueSnackbar } from 'notistack'; -import warehouseAutomat from '../../automats/warehouseAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { notifyError } from '../../utils/notify'; class ArtikelLager extends PureComponent { @@ -51,7 +51,7 @@ class ArtikelLager extends PureComponent { async renameWarehouse(item, name) { try { - await warehouseAutomat.actions.update(item.id, { name }); + await companyAutomat.actions.updateWarehouse(item.id, { name }); enqueueSnackbar('Lager gespeichert.', { variant: 'success' }); } catch (err) { this.setState({ draft: item.name ?? '' }); @@ -78,7 +78,7 @@ class ArtikelLager extends PureComponent { if (!window.confirm(`Lager „${item.name}" wirklich löschen?`)) return; void (async () => { try { - await warehouseAutomat.actions.remove(item.id); + await companyAutomat.actions.removeWarehouse(item.id); enqueueSnackbar('Lager gelöscht.', { variant: 'success' }); } catch (err) { notifyError(err); diff --git a/src/main.jsx b/src/main.jsx index 642095e..191af25 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -9,17 +9,11 @@ import App from './App'; import { getActiveTheme } from './theme'; import { installCssVars } from './colors'; import { disableTouchAutofocus } from './utils/touch'; +import './index.css'; // Globally suppress automatic focus on touch devices to avoid virtual keyboard popups disableTouchAutofocus(); -// Side-effect imports: each module registers its automat and subscribes to -// authAutomat, so the stores must be evaluated before the session bootstraps. -import './automats'; -import { initPwa } from './automats'; -import PwaUpdateDialog from './components/PwaUpdateDialog'; -import './index.css'; - // Publish the color tokens as CSS custom properties so index.css (and any // other plain-CSS stylesheet) reads the same palette as the React components. installCssVars(); @@ -29,12 +23,6 @@ installCssVars(); const AutomatInspector = import.meta.env.DEV ? lazy(() => import('./devtools/AutomatInspector')) : null; -const SseMonitor = import.meta.env.DEV - ? lazy(() => import('./devtools/SseMonitor')) - : null; -const ChunkCacheMonitor = import.meta.env.DEV - ? lazy(() => import('./devtools/ChunkCacheMonitor')) - : null; async function clearDevServiceWorker() { if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return; @@ -50,9 +38,6 @@ if (import.meta.env.DEV) { void clearDevServiceWorker(); } -// Initialize PWA service worker registration and update listener in production -initPwa(); - createRoot(document.getElementById('root')).render( @@ -74,17 +59,6 @@ createRoot(document.getElementById('root')).render( ) : null} - {SseMonitor ? ( - - - - ) : null} - {ChunkCacheMonitor ? ( - - - - ) : null} - , diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index 6c9a568..4878d3d 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -40,16 +40,13 @@ import { preserveSearchFocus } from '../components/article/searchFocus'; import { artikelDetailPath } from '../components/article/articleRoute'; import { buildCategoryPath } from '../components/category/categoryRoute'; import withSearchParams from '../components/article/withSearchParams'; -import { +import navigationAutomat, { setLastArticleSku, setLastSearchTerm, setLastCategoryId, - navigationAutomat, - categoryAutomat, - companyAutomat, - brandAutomat, - supplierAutomat, -} from '../automats'; +} from '../automats/navigationAutomat'; +import categoryAutomat from '../automats/categoryAutomat'; +import companyAutomat from '../automats/companyAutomat'; import { BrandLabel, BrandThumb } from '../components/brand'; import { SCRIM_LABEL } from '../colors'; // #endregion @@ -358,12 +355,14 @@ class ArtikelSearchPage extends Component { // Push search results into local component state. this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); - this.unsubBrand = brandAutomat.subscribe(this, () => ({})); - this.unsubSupplier = supplierAutomat.subscribe(this, () => ({})); + this.unsubCompany = companyAutomat.subscribe(this, (state) => ({ + _brands: state.brands, + _suppliers: state.suppliers, + })); - void brandAutomat.actions.load().catch(() => {}); - void supplierAutomat.actions.load().catch(() => {}); - void supplierAutomat.actions.loadAllSupplierArticles().catch(() => {}); + void companyAutomat.actions.loadBrands().catch(() => {}); + void companyAutomat.actions.loadSuppliers().catch(() => {}); + void companyAutomat.actions.loadAllSupplierArticles().catch(() => {}); // Initial search: kicks off index loading if idle and derives current items const { @@ -410,10 +409,8 @@ class ArtikelSearchPage extends Component { document.removeEventListener('mouseup', preserveSearchFocus, true); this.unsubSearch?.(); this.unsubSearch = null; - this.unsubBrand?.(); - this.unsubBrand = null; - this.unsubSupplier?.(); - this.unsubSupplier = null; + this.unsubCompany?.(); + this.unsubCompany = null; this.persistQuery(); const query = queryFromParams(this.props.searchParams); const target = this.state.input; @@ -711,7 +708,7 @@ class ArtikelSearchPage extends Component { {selectedBrands.map((brandId) => { const label = brandFacets?.[brandId]?.name || - brandAutomat.state.brands?.find((b) => String(b.id) === String(brandId) || b.name === brandId)?.name || + companyAutomat.state.brands?.find((b) => String(b.id) === String(brandId) || b.name === brandId)?.name || brandId; return ( { const label = supplierFacets?.[supplierId]?.name || - supplierAutomat.state.suppliers?.find((s) => String(s.id) === String(supplierId))?.name || + companyAutomat.state.suppliers?.find((s) => String(s.id) === String(supplierId))?.name || `Lieferant #${supplierId}`; return ( ({ + terminals: s.terminals, companyId: s.current?.id ?? null, })); this.ensureTreeLoaded(); @@ -138,7 +137,6 @@ class ArtikelKategorienPage extends PureComponent { componentWillUnmount() { setLastCategorySlug(this.props.categorySlugPath); this._unsubCat?.(); - this._unsubTerm?.(); this._unsubCompany?.(); this._unsubHandle?.(); this.sortableModule?.destroy(); @@ -197,7 +195,7 @@ class ArtikelKategorienPage extends PureComponent { ensurePosterminalsLoaded = () => { const { companyId } = this.state; if (!companyId) return; - void posterminalAutomat.actions.load(); + void companyAutomat.actions.loadTerminals(); }; syncCategoryFromUrl = () => { @@ -472,8 +470,8 @@ class ArtikelKategorienPage extends PureComponent { categoryPosterminalIds: item.posterminalIds || [], onTerminalToggle: (terminalId, enabled) => { const action = enabled - ? posterminalAutomat.actions.enableCategory - : posterminalAutomat.actions.disableCategory; + ? companyAutomat.actions.enableCategory + : companyAutomat.actions.disableCategory; void action({ terminalId, categoryId: item.id }) .then(() => { categoryAutomat.actions.patchCategoryPosterminalIds({ diff --git a/src/routes/artikel/lager.jsx b/src/routes/artikel/lager.jsx index f977a44..8ed4e08 100644 --- a/src/routes/artikel/lager.jsx +++ b/src/routes/artikel/lager.jsx @@ -13,8 +13,8 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import { setLastArticleSku } from '../../automats'; -import warehouseAutomat from '../../automats/warehouseAutomat'; +import { setLastArticleSku } from '../../automats/navigationAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { ArtikelLager } from '../../components/warehouse'; import { GREEN_PANEL } from '../../colors'; @@ -37,20 +37,20 @@ function getInitialParams(props) { class ArtikelLagerSearchField extends PureComponent { constructor(props) { super(props); - const initial = warehouseAutomat.state.filter || props.prefill || ''; + const initial = companyAutomat.state.warehouseFilter || props.prefill || ''; this.state = { input: initial, submitting: false, }; - if (props.prefill && !warehouseAutomat.state.filter) { - warehouseAutomat.actions.filter(props.prefill); + if (props.prefill && !companyAutomat.state.warehouseFilter) { + companyAutomat.actions.filterWarehouses(props.prefill); } this.handleSubmit = this.handleSubmit.bind(this); } componentDidMount() { - this.unsub = warehouseAutomat.subscribe(this, (state) => ({ - input: state.filter, + this.unsub = companyAutomat.subscribe(this, (state) => ({ + input: state.warehouseFilter, })); } @@ -60,7 +60,7 @@ class ArtikelLagerSearchField extends PureComponent { onInputChange(input) { this.setState({ input }); - warehouseAutomat.actions.filter(input); + companyAutomat.actions.filterWarehouses(input); } async handleSubmit() { @@ -69,10 +69,10 @@ class ArtikelLagerSearchField extends PureComponent { this.setState({ submitting: true }); try { - const row = await warehouseAutomat.actions.create({ name }); + const row = await companyAutomat.actions.createWarehouse({ name }); enqueueSnackbar(`Lager „${row.name || name}“ angelegt.`, { variant: 'success' }); this.setState({ input: '', submitting: false }); - warehouseAutomat.actions.filter(''); + companyAutomat.actions.filterWarehouses(''); if (this.props.returnTo && this.props.navigate) { this.props.navigate(this.props.returnTo); } @@ -157,12 +157,12 @@ class ArtikelLagerPage extends PureComponent { constructor(props) { super(props); const initialWarehouses = - warehouseAutomat.state.filteredWarehouses ?? warehouseAutomat.state.warehouses ?? []; + companyAutomat.state.filteredWarehouses ?? companyAutomat.state.warehouses ?? []; this.state = { - status: warehouseAutomat.state.status === 'ready' ? 'ready' : 'loading', - error: warehouseAutomat.state.error ? backendErrorFrom(warehouseAutomat.state.error) : null, + status: companyAutomat.state.warehousesStatus === 'ready' ? 'ready' : 'loading', + error: companyAutomat.state.warehousesError ? backendErrorFrom(companyAutomat.state.warehousesError) : null, warehouses: initialWarehouses, - filter: warehouseAutomat.state.filter ?? '', + filter: companyAutomat.state.warehouseFilter ?? '', }; this.searchRef = createRef(); this.listRef = createRef(); @@ -177,15 +177,15 @@ class ArtikelLagerPage extends PureComponent { setLastArticleSku(null); this._cancelled = false; - this.unsub = warehouseAutomat.subscribe(this, (state) => ({ + this.unsub = companyAutomat.subscribe(this, (state) => ({ warehouses: state.filteredWarehouses ?? state.warehouses, - filter: state.filter ?? '', - status: state.status, - error: state.error ? backendErrorFrom(state.error) : null, + filter: state.warehouseFilter ?? '', + status: state.warehousesStatus, + error: state.warehousesError ? backendErrorFrom(state.warehousesError) : null, })); - if (warehouseAutomat.state.status !== 'ready') { - void warehouseAutomat.actions.load().catch((err) => notifyError(err)); + if (companyAutomat.state.warehousesStatus !== 'ready') { + void companyAutomat.actions.loadWarehouses().catch((err) => notifyError(err)); } else { this._initSortable(); } @@ -249,16 +249,16 @@ class ArtikelLagerPage extends PureComponent { async _persistOrder(warehouseIds) { try { - await warehouseAutomat.actions.reorder(warehouseIds); + await companyAutomat.actions.reorderWarehouses(warehouseIds); } catch (err) { if (this._cancelled) return; notifyError(err); - void warehouseAutomat.actions.reload(); + void companyAutomat.actions.reloadWarehouses(); } } handleRetry() { - void warehouseAutomat.actions.load().catch((err) => notifyError(err)); + void companyAutomat.actions.loadWarehouses().catch((err) => notifyError(err)); } render() { diff --git a/src/routes/artikel/marken.jsx b/src/routes/artikel/marken.jsx index 397c6a2..4b7fa1f 100644 --- a/src/routes/artikel/marken.jsx +++ b/src/routes/artikel/marken.jsx @@ -8,8 +8,8 @@ import FillSpace from 'dashgui/FillSpace'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import { setLastArticleSku } from '../../automats'; -import brandAutomat from '../../automats/brandAutomat'; +import { setLastArticleSku } from '../../automats/navigationAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { enqueueSnackbar } from 'notistack'; import { ArtikelMarke, @@ -22,9 +22,9 @@ import { class ArtikelMarkenSearchField extends PureComponent { constructor(props) { super(props); - const brands = brandAutomat.state.brands || []; + const brands = companyAutomat.state.brands || []; this.state = { - input: brandAutomat.state.filter ?? '', + input: companyAutomat.state.brandFilter ?? '', submitting: false, deletingUnused: false, unusedCount: brands.filter((b) => (b.articleCount ?? 0) === 0).length, @@ -34,8 +34,8 @@ class ArtikelMarkenSearchField extends PureComponent { } componentDidMount() { - this.unsub = brandAutomat.subscribe(this, (state) => ({ - input: state.filter, + this.unsub = companyAutomat.subscribe(this, (state) => ({ + input: state.brandFilter, unusedCount: (state.brands || []).filter((b) => (b.articleCount ?? 0) === 0).length, })); } @@ -46,7 +46,7 @@ class ArtikelMarkenSearchField extends PureComponent { onInputChange(input) { this.setState({ input }); - brandAutomat.actions.filter(input); + companyAutomat.actions.filterBrands(input); } async handleSubmit() { @@ -55,10 +55,10 @@ class ArtikelMarkenSearchField extends PureComponent { this.setState({ submitting: true }); try { - const row = await brandAutomat.actions.create({ name }); + const row = await companyAutomat.actions.createBrand({ name }); enqueueSnackbar(`Marke „${row.name || name}“ angelegt.`, { variant: 'success' }); this.setState({ input: '', submitting: false }); - brandAutomat.actions.filter(''); + companyAutomat.actions.filterBrands(''); } catch (err) { this.setState({ submitting: false }); notifyError(err); @@ -67,7 +67,7 @@ class ArtikelMarkenSearchField extends PureComponent { async handleDeleteUnused() { if (this.state.deletingUnused) return; - const allBrands = brandAutomat.state.brands || []; + const allBrands = companyAutomat.state.brands || []; const unused = allBrands.filter((b) => (b.articleCount ?? 0) === 0); if (!unused.length) return; @@ -77,7 +77,7 @@ class ArtikelMarkenSearchField extends PureComponent { this.setState({ deletingUnused: true }); try { - await brandAutomat.actions.removeBrands(unused.map((b) => b.id)); + await companyAutomat.actions.removeBrands(unused.map((b) => b.id)); enqueueSnackbar(`${unused.length} ungenutzte Marken gelöscht.`, { variant: 'success' }); } catch (err) { notifyError(err); @@ -148,10 +148,10 @@ class ArtikelMarkenPage extends PureComponent { constructor(props) { super(props); this.state = { - status: brandAutomat.state.status, - error: brandAutomat.state.error ? backendErrorFrom({ message: brandAutomat.state.error }) : null, - brands: brandAutomat.state.filteredBrands ?? brandAutomat.state.brands, - totalCount: brandAutomat.state.brands?.length ?? 0, + status: companyAutomat.state.brandsStatus, + error: companyAutomat.state.brandsError ? backendErrorFrom({ message: companyAutomat.state.brandsError }) : null, + brands: companyAutomat.state.filteredBrands ?? companyAutomat.state.brands, + totalCount: companyAutomat.state.brands?.length ?? 0, }; this.searchRef = createRef(); this.handleRetry = this.handleRetry.bind(this); @@ -160,11 +160,11 @@ class ArtikelMarkenPage extends PureComponent { componentDidMount() { setLastArticleSku(null); - this.unsub = brandAutomat.subscribe(this, (state) => ({ + this.unsub = companyAutomat.subscribe(this, (state) => ({ brands: state.filteredBrands, totalCount: state.brands?.length ?? 0, - status: state.status, - error: state.error ? backendErrorFrom({ message: state.error }) : null, + status: state.brandsStatus, + error: state.brandsError ? backendErrorFrom({ message: state.brandsError }) : null, })); } @@ -173,7 +173,7 @@ class ArtikelMarkenPage extends PureComponent { } handleRetry() { - void brandAutomat.actions.load().catch((err) => notifyError(err)); + void companyAutomat.actions.loadBrands().catch((err) => notifyError(err)); } render() { diff --git a/src/routes/artikel/merkmale.jsx b/src/routes/artikel/merkmale.jsx index f0c5a78..a317931 100644 --- a/src/routes/artikel/merkmale.jsx +++ b/src/routes/artikel/merkmale.jsx @@ -4,7 +4,7 @@ import TuneIcon from '@mui/icons-material/Tune'; import StyleIcon from '@mui/icons-material/Style'; import { TabbedPanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; -import { setLastArticleSku } from '../../automats'; +import { setLastArticleSku } from '../../automats/navigationAutomat'; import { AuswahlMerkmaleTab, TextMerkmaleTab, diff --git a/src/routes/artikel/vorlagen.jsx b/src/routes/artikel/vorlagen.jsx index 69673f4..ee4aee7 100644 --- a/src/routes/artikel/vorlagen.jsx +++ b/src/routes/artikel/vorlagen.jsx @@ -22,7 +22,7 @@ import BackendError from '../../components/BackendError'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import { setLastArticleSku } from '../../automats'; +import { setLastArticleSku } from '../../automats/navigationAutomat'; import { labeltemplate } from '../../api'; import { enqueueSnackbar } from 'notistack'; import { diff --git a/src/routes/auftrag/versandarten.jsx b/src/routes/auftrag/versandarten.jsx index 6164cd0..b12b89d 100644 --- a/src/routes/auftrag/versandarten.jsx +++ b/src/routes/auftrag/versandarten.jsx @@ -12,14 +12,14 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import carrierAutomat from '../../automats/carrierAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { CarrierItem } from '../../components/carrier'; import { GREEN_PANEL } from '../../colors'; class VersandartenSearchField extends PureComponent { constructor(props) { super(props); - const initial = carrierAutomat.state.filter || ''; + const initial = companyAutomat.state.carrierFilter || ''; this.state = { input: initial, submitting: false, @@ -28,8 +28,8 @@ class VersandartenSearchField extends PureComponent { } componentDidMount() { - this.unsub = carrierAutomat.subscribe(this, (state) => ({ - input: state.filter, + this.unsub = companyAutomat.subscribe(this, (state) => ({ + input: state.carrierFilter, })); } @@ -39,7 +39,7 @@ class VersandartenSearchField extends PureComponent { onInputChange(input) { this.setState({ input }); - carrierAutomat.actions.filter(input); + companyAutomat.actions.filterCarriers(input); } async handleSubmit() { @@ -48,14 +48,14 @@ class VersandartenSearchField extends PureComponent { this.setState({ submitting: true }); try { - const row = await carrierAutomat.actions.create({ + const row = await companyAutomat.actions.createCarrier({ name, hasPackages: true, isReverse: false, }); enqueueSnackbar(`Versandart „${row.name || name}“ angelegt.`, { variant: 'success' }); this.setState({ input: '', submitting: false }); - carrierAutomat.actions.filter(''); + companyAutomat.actions.filterCarriers(''); } catch (err) { this.setState({ submitting: false }); notifyError(err); @@ -118,12 +118,12 @@ class AuftragVersandartenPage extends PureComponent { constructor(props) { super(props); const initialCarriers = - carrierAutomat.state.filteredCarriers ?? carrierAutomat.state.carriers ?? []; + companyAutomat.state.filteredCarriers ?? companyAutomat.state.carriers ?? []; this.state = { - status: carrierAutomat.state.status === 'ready' ? 'ready' : 'loading', - error: carrierAutomat.state.error ? backendErrorFrom(carrierAutomat.state.error) : null, + status: companyAutomat.state.carriersStatus === 'ready' ? 'ready' : 'loading', + error: companyAutomat.state.carriersError ? backendErrorFrom(companyAutomat.state.carriersError) : null, carriers: initialCarriers, - filter: carrierAutomat.state.filter ?? '', + filter: companyAutomat.state.carrierFilter ?? '', }; this.searchRef = createRef(); this.listRef = createRef(); @@ -137,15 +137,15 @@ class AuftragVersandartenPage extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsub = carrierAutomat.subscribe(this, (state) => ({ + this.unsub = companyAutomat.subscribe(this, (state) => ({ carriers: state.filteredCarriers ?? state.carriers, - filter: state.filter ?? '', - status: state.status, - error: state.error ? backendErrorFrom(state.error) : null, + filter: state.carrierFilter ?? '', + status: state.carriersStatus, + error: state.carriersError ? backendErrorFrom(state.carriersError) : null, })); - if (carrierAutomat.state.status !== 'ready') { - void carrierAutomat.actions.load().catch((err) => notifyError(err)); + if (companyAutomat.state.carriersStatus !== 'ready') { + void companyAutomat.actions.loadCarriers().catch((err) => notifyError(err)); } else { this._initSortable(); } @@ -209,16 +209,16 @@ class AuftragVersandartenPage extends PureComponent { async _persistOrder(carrierIds) { try { - await carrierAutomat.actions.reorder(carrierIds); + await companyAutomat.actions.reorderCarriers(carrierIds); } catch (err) { if (this._cancelled) return; notifyError(err); - void carrierAutomat.actions.reload(); + void companyAutomat.actions.reloadCarriers(); } } handleRetry() { - void carrierAutomat.actions.load().catch((err) => notifyError(err)); + void companyAutomat.actions.loadCarriers().catch((err) => notifyError(err)); } render() { diff --git a/src/routes/kunde.jsx b/src/routes/kunde.jsx index e9d2e0d..989d8b5 100644 --- a/src/routes/kunde.jsx +++ b/src/routes/kunde.jsx @@ -13,7 +13,7 @@ import withNavigate from '../components/withNavigate'; import { backendErrorFrom } from '../utils/backendError'; import { notifyError } from '../utils/notify'; import entityAutomat from '../automats/entityAutomat'; -import customergroupAutomat from '../automats/customergroupAutomat'; +import companyAutomat from '../automats/companyAutomat'; import { getLowestSortNonSupplierCustomerGroup } from '../components/manifest/manifestConfig'; import { Kunde } from '../components/kunde'; import { BLUE_PANEL } from '../colors'; @@ -69,8 +69,8 @@ class KundeSearchField extends PureComponent { this.setState({ submitting: true }); try { - await customergroupAutomat.actions.ensureLoaded(); - const groups = customergroupAutomat.state.items ?? []; + await companyAutomat.actions.ensureCustomergroupsLoaded(); + const groups = companyAutomat.state.customergroups ?? []; const defaultGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0]; if (!defaultGroup) { throw new Error('Keine Kundengruppe vorhanden.'); @@ -190,7 +190,7 @@ class KundeListePage extends PureComponent { status: entityAutomat.state.status === 'ready' ? 'ready' : 'loading', error: entityAutomat.state.error ? backendErrorFrom(entityAutomat.state.error) : null, entities: initialEntities, - groups: customergroupAutomat.state.items ?? [], + groups: companyAutomat.state.customergroups ?? [], filter: entityAutomat.state.filter ?? '', }; this.searchRef = createRef(); @@ -206,15 +206,15 @@ class KundeListePage extends PureComponent { error: state.error ? backendErrorFrom(state.error) : null, })); - this.unsubGroups = customergroupAutomat.subscribe(this, (state) => ({ - groups: state.items, + this.unsubGroups = companyAutomat.subscribe(this, (state) => ({ + groups: state.customergroups, })); if (entityAutomat.state.status !== 'ready') { void entityAutomat.actions.load().catch((err) => notifyError(err)); } - if (customergroupAutomat.state.status !== 'ready') { - void customergroupAutomat.actions.load().catch(() => { }); + if (companyAutomat.state.customergroupsStatus !== 'ready') { + void companyAutomat.actions.loadCustomergroups().catch(() => { }); } } diff --git a/src/routes/kunde/_entityId.jsx b/src/routes/kunde/_entityId.jsx index e9bef8e..b5830cf 100644 --- a/src/routes/kunde/_entityId.jsx +++ b/src/routes/kunde/_entityId.jsx @@ -14,7 +14,7 @@ import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { entity as entityApi } from '../../api'; import entityAutomat from '../../automats/entityAutomat'; -import customergroupAutomat from '../../automats/customergroupAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; @@ -137,7 +137,7 @@ class KundeEditPage extends PureComponent { entityApi.get(entityId), entityApi.listAddresses(entityId), entityApi.listContacts(entityId), - customergroupAutomat.actions.ensureLoaded(), + companyAutomat.actions.ensureCustomergroupsLoaded(), ]); if (this._cancelled) return; diff --git a/src/routes/kunde/gruppen.jsx b/src/routes/kunde/gruppen.jsx index b61c434..4b8eb61 100644 --- a/src/routes/kunde/gruppen.jsx +++ b/src/routes/kunde/gruppen.jsx @@ -13,7 +13,7 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import customergroupAutomat from '../../automats/customergroupAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { KundeGruppe } from '../../components/kunde'; import { PURPLE_BG } from '../../colors'; @@ -36,20 +36,20 @@ function getInitialParams(props) { class KundeGruppenSearchField extends PureComponent { constructor(props) { super(props); - const initial = customergroupAutomat.state.filter || props.prefill || ''; + const initial = companyAutomat.state.customergroupFilter || props.prefill || ''; this.state = { input: initial, submitting: false, }; - if (props.prefill && !customergroupAutomat.state.filter) { - customergroupAutomat.actions.filter(props.prefill); + if (props.prefill && !companyAutomat.state.customergroupFilter) { + companyAutomat.actions.filterCustomergroups(props.prefill); } this.handleSubmit = this.handleSubmit.bind(this); } componentDidMount() { - this.unsub = customergroupAutomat.subscribe(this, (state) => ({ - input: state.filter, + this.unsub = companyAutomat.subscribe(this, (state) => ({ + input: state.customergroupFilter, })); } @@ -59,7 +59,7 @@ class KundeGruppenSearchField extends PureComponent { onInputChange(input) { this.setState({ input }); - customergroupAutomat.actions.filter(input); + companyAutomat.actions.filterCustomergroups(input); } async handleSubmit() { @@ -68,10 +68,10 @@ class KundeGruppenSearchField extends PureComponent { this.setState({ submitting: true }); try { - const row = await customergroupAutomat.actions.create({ name }); + const row = await companyAutomat.actions.createCustomergroup({ name }); enqueueSnackbar(`Kundengruppe „${row.name || name}“ angelegt.`, { variant: 'success' }); this.setState({ input: '', submitting: false }); - customergroupAutomat.actions.filter(''); + companyAutomat.actions.filterCustomergroups(''); if (this.props.returnTo && this.props.navigate) { this.props.navigate(this.props.returnTo); } @@ -157,12 +157,12 @@ class KundeGruppenPage extends PureComponent { constructor(props) { super(props); const initialGroups = - customergroupAutomat.state.filteredItems ?? customergroupAutomat.state.items ?? []; + companyAutomat.state.filteredCustomergroups ?? companyAutomat.state.customergroups ?? []; this.state = { - status: customergroupAutomat.state.status === 'ready' ? 'ready' : 'loading', - error: customergroupAutomat.state.error ? backendErrorFrom(customergroupAutomat.state.error) : null, + status: companyAutomat.state.customergroupsStatus === 'ready' ? 'ready' : 'loading', + error: companyAutomat.state.customergroupsError ? backendErrorFrom(companyAutomat.state.customergroupsError) : null, groups: initialGroups, - filter: customergroupAutomat.state.filter ?? '', + filter: companyAutomat.state.customergroupFilter ?? '', }; this.searchRef = createRef(); this.listRef = createRef(); @@ -176,15 +176,15 @@ class KundeGruppenPage extends PureComponent { componentDidMount() { this._cancelled = false; - this.unsub = customergroupAutomat.subscribe(this, (state) => ({ - groups: state.filteredItems ?? state.items, - filter: state.filter ?? '', - status: state.status, - error: state.error ? backendErrorFrom(state.error) : null, + this.unsub = companyAutomat.subscribe(this, (state) => ({ + groups: state.filteredCustomergroups ?? state.customergroups, + filter: state.customergroupFilter ?? '', + status: state.customergroupsStatus, + error: state.customergroupsError ? backendErrorFrom(state.customergroupsError) : null, })); - if (customergroupAutomat.state.status !== 'ready') { - void customergroupAutomat.actions.load().catch((err) => notifyError(err)); + if (companyAutomat.state.customergroupsStatus !== 'ready') { + void companyAutomat.actions.loadCustomergroups().catch((err) => notifyError(err)); } else { this._initSortable(); } @@ -248,16 +248,16 @@ class KundeGruppenPage extends PureComponent { async _persistOrder(customergroupIds) { try { - await customergroupAutomat.actions.reorder(customergroupIds); + await companyAutomat.actions.reorderCustomergroups(customergroupIds); } catch (err) { if (this._cancelled) return; notifyError(err); - void customergroupAutomat.actions.reload(); + void companyAutomat.actions.reloadCustomergroups(); } } handleRetry() { - void customergroupAutomat.actions.load().catch((err) => notifyError(err)); + void companyAutomat.actions.loadCustomergroups().catch((err) => notifyError(err)); } render() { diff --git a/src/routes/kunde/neu.jsx b/src/routes/kunde/neu.jsx index d762016..c33f615 100644 --- a/src/routes/kunde/neu.jsx +++ b/src/routes/kunde/neu.jsx @@ -10,7 +10,7 @@ import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { entity as entityApi } from '../../api'; import entityAutomat from '../../automats/entityAutomat'; -import customergroupAutomat from '../../automats/customergroupAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { getLowestSortNonSupplierCustomerGroup } from '../../components/manifest/manifestConfig'; import { router } from '../../appRouter'; import { requireDefined, requireObject } from '../../utils/apiShape'; @@ -45,7 +45,7 @@ function getInitialPrefill(props) { class KundeNeuPage extends PureComponent { constructor(props) { super(props); - const groups = customergroupAutomat.state.items ?? []; + const groups = companyAutomat.state.customergroups ?? []; const defaultGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups[0]; const prefill = getInitialPrefill(props); this.state = { @@ -62,7 +62,7 @@ class KundeNeuPage extends PureComponent { componentDidMount() { void this.loadNumber(); if (this.state.customergroupId == null) { - void customergroupAutomat.actions.ensureLoaded().then((groups) => { + void companyAutomat.actions.ensureCustomergroupsLoaded().then((groups) => { const defaultGroup = getLowestSortNonSupplierCustomerGroup(groups) ?? groups?.[0]; if (defaultGroup && this.state.customergroupId == null) { this.setState({ customergroupId: defaultGroup.id }); @@ -125,7 +125,7 @@ class KundeNeuPage extends PureComponent { render() { const { customergroupId, number, name, firstName, saving } = this.state; - const groups = customergroupAutomat.state.items ?? []; + const groups = companyAutomat.state.customergroups ?? []; const canSave = customergroupId != null && name.trim() !== '' && !saving; diff --git a/src/routes/lieferung.jsx b/src/routes/lieferung.jsx index 8ff18d9..1926b9a 100644 --- a/src/routes/lieferung.jsx +++ b/src/routes/lieferung.jsx @@ -1,6 +1,6 @@ import { PureComponent } from 'react'; import { DeliveryList } from '../components/manifest'; -import { setLastDeliveryPath } from '../automats'; +import { setLastDeliveryPath } from '../automats/navigationAutomat'; class LieferungListePage extends PureComponent { componentDidMount() { diff --git a/src/routes/lieferung/lieferanten.jsx b/src/routes/lieferung/lieferanten.jsx index b137c6f..a9dca09 100644 --- a/src/routes/lieferung/lieferanten.jsx +++ b/src/routes/lieferung/lieferanten.jsx @@ -18,8 +18,8 @@ import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import withNavigate from '../../components/withNavigate'; import { backendErrorFrom } from '../../utils/backendError'; import { notifyError } from '../../utils/notify'; -import { setLastDeliveryPath } from '../../automats'; -import supplierAutomat from '../../automats/supplierAutomat'; +import { setLastDeliveryPath } from '../../automats/navigationAutomat'; +import companyAutomat from '../../automats/companyAutomat'; import { deliveryDraftAutomat } from '../../automats/deliveryEditorAutomat'; import { GREEN_PANEL, @@ -109,7 +109,7 @@ class LieferungLieferantenPage extends PureComponent { async loadSuppliers() { this.setState({ status: 'loading', error: null }); try { - const list = await supplierAutomat.actions.load(); + const list = await companyAutomat.actions.loadSuppliers(); if (this._cancelled) return; this.applySuppliers(list); } catch (err) { @@ -123,7 +123,7 @@ class LieferungLieferantenPage extends PureComponent { } async refreshFromServer() { - const list = await supplierAutomat.actions.reload(); + const list = await companyAutomat.actions.reloadSuppliers(); if (this._cancelled) return; this.applySuppliers(list); } @@ -136,7 +136,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: 'new' }); try { - const newSupplier = await supplierAutomat.actions.create({ name }); + const newSupplier = await companyAutomat.actions.createSupplier({ name }); // Assign the new supplier to the active delivery draft & clear unmatched name deliveryDraftAutomat.actions.setDeliveryEntity(newSupplier.id); @@ -170,7 +170,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: supplier.id }); try { - await supplierAutomat.actions.update(supplier.id, { name: draft }); + await companyAutomat.actions.updateSupplier(supplier.id, { name: draft }); await this.refreshFromServer(); if (this._cancelled) return; this.setState({ busyKey: null }); @@ -193,7 +193,7 @@ class LieferungLieferantenPage extends PureComponent { this.setState({ busyKey: supplier.id }); try { - await supplierAutomat.actions.remove(supplier.id); + await companyAutomat.actions.removeSupplier(supplier.id); await this.refreshFromServer(); if (this._cancelled) return; this.setState({ busyKey: null }); diff --git a/src/routes/lieferung/neu.jsx b/src/routes/lieferung/neu.jsx index 7d8092b..d9bd0c5 100644 --- a/src/routes/lieferung/neu.jsx +++ b/src/routes/lieferung/neu.jsx @@ -38,8 +38,7 @@ import { BookDeliveryDialog, } from '../../components/manifest'; import { loadParties, apiFor, resolveLineSkus, sortSuppliers } from '../../components/manifest/manifestConfig'; -import supplierAutomat from '../../automats/supplierAutomat'; -import warehouseAutomat from '../../automats/warehouseAutomat'; +import companyAutomat, { setViewerPosition } from '../../automats/companyAutomat'; import { article as articleApi, delivery as deliveryApi } from '../../api'; import { getCategoryState } from '../../automats/categoryAutomat'; import { requireArray, requireDefined, requireObject } from '../../utils/apiShape'; @@ -49,7 +48,7 @@ import { generatePdfThumbnail } from '../../utils/pdfThumbnail'; import { generateTextThumbnail } from '../../utils/textThumbnail'; import { extractPdfText } from '../../utils/pdfText'; import { getDeliveryEditorAutomat } from '../../automats/deliveryEditorAutomat'; -import { setLastDeliveryPath, userSettingsAutomat, setViewerPosition } from '../../automats'; +import { setLastDeliveryPath } from '../../automats/navigationAutomat'; import chatAutomat from '../../automats/chatAutomat'; import { fetchDeliveryLedgerSummary } from '../../utils/deliveryLedger'; @@ -110,10 +109,10 @@ class LieferungNeuPage extends PureComponent { this.cfg = manifestConfig('delivery'); this.automat = getDeliveryEditorAutomat(props.docId); if ( - supplierAutomat.state.status === 'ready' && + companyAutomat.state.suppliersStatus === 'ready' && (!this.automat.state.deliveryEntities || this.automat.state.deliveryEntities.length === 0) ) { - this.automat.actions.setDeliveryEntities(sortSuppliers(supplierAutomat.state.suppliers)); + this.automat.actions.setDeliveryEntities(sortSuppliers(companyAutomat.state.suppliers)); } const initialDate = this.automat.state.date || (props.docId == null ? todayInputValue() : null); if (props.docId == null && !this.automat.state.date) { @@ -129,7 +128,7 @@ class LieferungNeuPage extends PureComponent { pdfUploading: false, textEditorOpen: false, textEditorAttachment: null, - viewerPosition: userSettingsAutomat.state.viewerPosition, + viewerPosition: companyAutomat.state.viewerPosition, pdfViewerOpen: false, pdfViewerAttachment: null, articleSearchDialogOpen: false, @@ -139,7 +138,7 @@ class LieferungNeuPage extends PureComponent { bookDeliveryDialogOpen: false, bookDeliveryPrefillLineId: null, pdfCreating: false, - warehouses: warehouseAutomat.state.warehouses || [], + warehouses: companyAutomat.state.warehouses || [], }; this._cancelled = false; this.handleRetry = this.handleRetry.bind(this); @@ -183,18 +182,16 @@ class LieferungNeuPage extends PureComponent { componentDidMount() { this.unsubscribe = this.automat.subscribe(this); - this._settingsUnsub = userSettingsAutomat.subscribe(this, (state) => ({ - viewerPosition: state.viewerPosition, - })); - this._warehouseUnsub = warehouseAutomat.subscribe(this, (state) => ({ - warehouses: state.warehouses, - })); - this._supplierUnsub = supplierAutomat.subscribe(this, (state) => { - if (state.status === 'ready' && Array.isArray(state.suppliers)) { + this._companyUnsub = companyAutomat.subscribe(this, (state) => { + if (state.suppliersStatus === 'ready' && Array.isArray(state.suppliers)) { this.automat.actions.setDeliveryEntities(sortSuppliers(state.suppliers)); } + return { + viewerPosition: state.viewerPosition, + warehouses: state.warehouses, + }; }); - void warehouseAutomat.actions.load().catch(() => { }); + void companyAutomat.actions.loadWarehouses().catch(() => { }); this._cancelled = false; if (this.props.docId != null) { setLastDeliveryPath(`/lieferung/${this.props.docId}`); @@ -237,9 +234,7 @@ class LieferungNeuPage extends PureComponent { this._skuDebounceTimers = null; } this.unsubscribe?.(); - this._settingsUnsub?.(); - this._warehouseUnsub?.(); - this._supplierUnsub?.(); + this._companyUnsub?.(); } async loadPartiesOnly() { diff --git a/src/utils/articleDetail.js b/src/utils/articleDetail.js index 1a21777..cc58691 100644 --- a/src/utils/articleDetail.js +++ b/src/utils/articleDetail.js @@ -6,7 +6,7 @@ import { netFromGross, parsePriceInput, } from './price'; -import brandAutomat from '../automats/brandAutomat'; +import companyAutomat from '../automats/companyAutomat'; export function sortedPhotos(article) { if (!Array.isArray(article?.photos)) return []; @@ -176,7 +176,7 @@ export function getArticleBrand(article) { return String(article.brandName).trim(); } if (article.brandId != null) { - const brands = brandAutomat?.state?.brands || []; + const brands = companyAutomat?.state?.brands || []; const b = brands.find((x) => x.id === article.brandId); if (b?.name) return String(b.name).trim(); } diff --git a/src/utils/documentNumber.js b/src/utils/documentNumber.js index a005977..aa41b46 100644 --- a/src/utils/documentNumber.js +++ b/src/utils/documentNumber.js @@ -1,4 +1,4 @@ -import numberAutomat from '../automats/numberAutomat'; +import companyAutomat from '../automats/companyAutomat'; import quoteAutomat from '../automats/quoteAutomat'; import orderAutomat from '../automats/orderAutomat'; import deliveryAutomat from '../automats/deliveryAutomat'; @@ -41,7 +41,7 @@ export function formatNumberPattern(prefix, padding, next) { * @returns {string} */ export function getNextDocumentNumber(kind) { - const numbers = numberAutomat.state.items ?? []; + const numbers = companyAutomat.state.numbers ?? []; const seq = numbers.find((r) => r.type === kind); let prefix = ''; @@ -135,7 +135,7 @@ export function getNextDocumentNumber(kind) { export async function ensureDocumentNumber(kind) { const listAutomat = kind === 'quote' ? quoteAutomat : (kind === 'order' ? orderAutomat : (kind === 'delivery' ? deliveryAutomat : (kind === 'retoure' ? retoureAutomat : null))); await Promise.all([ - numberAutomat.actions.ensureLoaded().catch(() => {}), + companyAutomat.actions.ensureNumbersLoaded().catch(() => {}), listAutomat?.actions?.ensureLoaded ? listAutomat.actions.ensureLoaded().catch(() => {}) : Promise.resolve(), ]); @@ -156,7 +156,7 @@ export async function ensureDocumentNumber(kind) { } if (recentDocs.length > 0) { - const numbers = numberAutomat.state.items ?? []; + const numbers = companyAutomat.state.numbers ?? []; const seq = numbers.find((r) => r.type === kind); let prefix = seq?.prefix; if (prefix == null) { @@ -197,7 +197,7 @@ export async function ensureDocumentNumber(kind) { * @returns {string} */ export function getMaxDocumentNumber(kind = 'quote') { - const numbers = numberAutomat.state.items ?? []; + const numbers = companyAutomat.state.numbers ?? []; const seq = numbers.find((r) => r.type === kind); if (seq) { const pad = Math.max(1, Number(seq.padding) || 6); diff --git a/src/utils/orderLedger.js b/src/utils/orderLedger.js index a14a831..4e2eab5 100644 --- a/src/utils/orderLedger.js +++ b/src/utils/orderLedger.js @@ -1,5 +1,5 @@ import { ledger as defaultLedgerApi, shipment as defaultShipmentApi } from '../api'; -import warehouseAutomat from '../automats/warehouseAutomat'; +import companyAutomat from '../automats/companyAutomat'; /** * Fetches ledger entries for an order by inspecting: @@ -100,7 +100,7 @@ export async function fetchOrderLedgerEntries(orderId, { }) ); - const warehouses = warehouseAutomat.state.warehouses || []; + const warehouses = companyAutomat.state.warehouses || []; const allEntries = []; for (const { target, entries } of results) {