This commit is contained in:
seb
2026-10-28 00:08:45 +01:00
parent fab291f68e
commit 96d0ae6742
88 changed files with 3871 additions and 5334 deletions

View File

@@ -609,6 +609,7 @@ _none_
- Returns: `200 SSE`
- Events:
- `company`: `{ companyId }`
- `category`: `{}`
- `articles`: `{}`
- `article-<sku>`: `{}`

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

@@ -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-<sku>\' 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

View File

@@ -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 <LoginPage />;
}
const isOAuthAuthorize = typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
if (user?.selectedCompanyId == null && !isOAuthAuthorize) {
return <CompanySetupDialog />;
}
const isOAuthAuthorize =
typeof window !== 'undefined' && window.location.pathname === '/oauth/authorize';
return (
<Suspense fallback={<PageFallback />}>
<RouterProvider router={router} />
</Suspense>
<>
<Suspense fallback={<PageFallback />}>
{!isCompanySelected ? (
isOAuthAuthorize ? (
<OAuthAuthorizePage />
) : (
<CompanySetupDialog />
)
) : (
<AppRouter />
)}
</Suspense>
{SseMonitor ? (
<Suspense fallback={null}>
<SseMonitor />
</Suspense>
) : null}
</>
);
}
}

View File

@@ -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: <AuftragVersandartenPage /> },
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
],
},
{
@@ -144,3 +143,7 @@ export const router =
if (import.meta.hot) {
import.meta.hot.data.router = router;
}
export default function AppRouter() {
return <RouterProvider router={router} />;
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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

View File

@@ -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<AttributeState>}
*/
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;

View File

@@ -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<AuthState>}
*/
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<AuthUser>} 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;

View File

@@ -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<object>} brands All brands (full list, decoupled from page UI).
* @property {Array<object>} 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<BrandState>}
*/
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;

View File

@@ -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<Carrier>} carriers Company-scoped carriers.
* @property {Array<Carrier>} 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<Array<Carrier>>} 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<Array<Carrier>>} 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<Array<Carrier>>} Loaded carriers.
*/
load() {
return loadCarriers();
},
/**
* Silently re-fetches the carrier list while keeping the current state.
*
* @returns {Promise<Array<Carrier>>} 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<Carrier>} 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<Carrier>} 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<void>}
*/
async remove(id) {
await carrierApi.delete(id);
await carrierAutomat.actions.reload();
},
/**
* Persists a new sort order for all carriers.
*
* @param {Array<string|number>} carrierIds IDs in the desired order.
* @returns {Promise<void>}
*/
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<Carrier>} 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<CarrierState>}
*/
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;

View File

@@ -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) => {

File diff suppressed because it is too large Load Diff

View File

@@ -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<object>} 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<Array<T>>} options.fetch Loads the full record list.
* @param {(items: Array<T>) => Array<T>} [options.sort] Optional stable ordering.
* @returns {Automat<ListAutomatState>} 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<Array<T>>} 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<Array<T>>} 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<Array<T>>} 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<T>} items Records to cache.
* @returns {Array<T>} 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<Array<T>>} 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<ListAutomatState>} automat List automat.
* @returns {Array<object>} Current records.
*/
export function selectListItems(automat) {
return automat.state.items;
}
/**
* Selects the load status of a list automat.
*
* @param {Automat<ListAutomatState>} automat List automat.
* @returns {'idle'|'loading'|'ready'|'failed'} Current status.
*/
export function selectListStatus(automat) {
return automat.state.status;
}

View File

@@ -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<CustomerGroup>} items Loaded records.
* @property {Array<CustomerGroup>} 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<CustomerGroupState>}
*/
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;

View File

@@ -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;

View File

@@ -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).
* ============================================================================
*/

View File

@@ -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<import('./createListAutomat').ListAutomatState>}
*/
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;

View File

@@ -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<object>} terminals
* @property {number|string|null} companyId
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {PosterminalState} */
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
let posterminalAutomat;
const actions = {
/** Load all posterminals for the selected company. */
async load() {
const companyId = 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<PosterminalState>}
*/
posterminalAutomat = new Automat(initialState, actions, { name: 'posterminal' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void posterminalAutomat.actions.load();
} else if (auth.status === 'anon') {
posterminalAutomat.actions.reset();
}
});
// Selectors
export const selectPosterminals = () => posterminalAutomat.state.terminals;
export const selectPosterminalStatus = () => posterminalAutomat.state.status;
export const selectHasPosterminals = () => posterminalAutomat.state.terminals.length > 0;
export default posterminalAutomat;

View File

@@ -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<PropertyState>}
*/
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;

View File

@@ -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-<sku>` signals.
* - `articleDetailAutomat` (`articleHistoryAutomat`, `articleSupplierAutomat`):
* Reloads SKU-keyed views on `article-<sku>` 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-<sku>`.
* - `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-<sku>` 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-<sku>` 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<SseState>}
*/
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;

View File

@@ -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<Supplier>} 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<Array<Supplier>>} 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<Array<Supplier>>} 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<Array<Supplier>>} Loaded suppliers.
*/
load() {
return loadSuppliers();
},
/**
* Silently re-fetches the supplier list while keeping the current state.
*
* @returns {Promise<Array<Supplier>>} 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<Array<object>>}
*/
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<Record<string, Array<object>>>}
*/
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<Supplier>} 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<Supplier>} 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<void>}
*/
async remove(id) {
await supplierApi.delete(id);
await supplierAutomat.actions.reload();
},
/**
* Replaces the cached list without a round-trip.
*
* @param {Array<Supplier>} 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<SupplierState>}
*/
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;

View File

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

View File

@@ -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<UserSettingsState>}
*/
const userSettingsAutomat = new Automat(
initialState,
{
/**
* Set the docking position of the attachment viewer.
*
* @param {ViewerPosition} position One of `'top'`, `'right'` or `'below'`.
* @returns {void}
*/
setViewerPosition(position) {
if (position !== 'top' && position !== 'right' && position !== 'below') {
return;
}
userSettingsAutomat.setState({ viewerPosition: position });
},
/**
* 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;

View File

@@ -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<Warehouse>} warehouses Company-scoped warehouses.
* @property {Array<Warehouse>} 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<Array<Warehouse>>} 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<Array<Warehouse>>} 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<Array<Warehouse>>} Loaded warehouses.
*/
load() {
return loadWarehouses();
},
/**
* Silently re-fetches the warehouse list while keeping the current state.
*
* @returns {Promise<Array<Warehouse>>} 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<Warehouse>} 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<Warehouse>} 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<void>}
*/
async remove(id) {
await warehouseApi.delete(id);
await warehouseAutomat.actions.reload();
},
/**
* Persists a new sort order for all warehouses.
*
* @param {Array<string|number>} warehouseIds IDs in the desired order.
* @returns {Promise<void>}
*/
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<Warehouse>} 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<WarehouseState>}
*/
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;

View File

@@ -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,
})}
>
<Outlet />
<Suspense fallback={null}>
<Outlet />
</Suspense>
</Box>
</Box>
<ChatFab />
<ChatDrawer />
<PinnedAppletFabs pathname={pathname} />
<PinnedAppletDialog />
<PwaUpdateDialog />
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
{BreakpointOverlay && (
<Suspense fallback={null}>
<BreakpointOverlay />

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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';

View File

@@ -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 : '';
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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(),

View File

@@ -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?.();
}

View File

@@ -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 });
};

View File

@@ -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?.();
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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',

View File

@@ -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);

View File

@@ -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 = () => {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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(() => {

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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: <AddPackageDialog/>
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' ||

View File

@@ -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(() => {});

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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?.();
}

View File

@@ -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: {

View File

@@ -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' });

View File

@@ -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);

View File

@@ -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(
<ThemeProvider theme={getActiveTheme()}>
<CssBaseline />
@@ -74,17 +59,6 @@ createRoot(document.getElementById('root')).render(
</Suspense>
) : null}
</Box>
{SseMonitor ? (
<Suspense fallback={null}>
<SseMonitor />
</Suspense>
) : null}
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
<PwaUpdateDialog />
</AuthBootstrap>
</SnackbarProvider>
</ThemeProvider>,

View File

@@ -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 (
<Chip
@@ -737,7 +734,7 @@ class ArtikelSearchPage extends Component {
{selectedSuppliers.map((supplierId) => {
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 (
<Chip

View File

@@ -6,12 +6,11 @@ import companyAutomat from '../../automats/companyAutomat';
import { bootstrapAuthMe } from '../../automats/authAutomat';
import BackendError from '../../components/BackendError';
import { ArticleView } from '../../components/article';
import {
artikelAutomat,
import artikelAutomat, {
createArticleSelector,
getArticle,
setLastArticleSku,
} from '../../automats';
} from '../../automats/artikelAutomat';
import { setLastArticleSku } from '../../automats/navigationAutomat';
import withSkuRoute from '../../components/article/withSkuRoute';
/** Full-screen loading state while the shared article cache resolves. */

View File

@@ -26,9 +26,8 @@ import {
ROOT_CATEGORY_NAME,
} from '../../components/category/categoryRoute';
import categoryAutomat from '../../automats/categoryAutomat';
import posterminalAutomat from '../../automats/posterminalAutomat';
import companyAutomat from '../../automats/companyAutomat';
import { setLastArticleSku, setLastCategorySlug, setLastCategoryId } from '../../automats';
import { setLastArticleSku, setLastCategorySlug, setLastCategoryId } from '../../automats/navigationAutomat';
import {
categoryDisplayName,
getCategoryBreadcrumbs,
@@ -86,7 +85,7 @@ class ArtikelKategorienPage extends PureComponent {
loaded: view?.loaded ?? false,
loading: view?.loading ?? false,
routeError: view?.error ?? null,
...selectTerminals(posterminalAutomat.state),
...selectTerminals(companyAutomat.state),
companyId: companyAutomat.state.current?.id ?? null,
moveCategory: null,
};
@@ -100,8 +99,8 @@ class ArtikelKategorienPage extends PureComponent {
setLastArticleSku(null);
setLastCategorySlug(this.props.categorySlugPath);
this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory);
this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals);
this._unsubCompany = companyAutomat.subscribe(this, (s) => ({
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({

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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(() => { });
}
}

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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;

View File

@@ -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() {

View File

@@ -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 });

View File

@@ -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() {

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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) {