Compare commits

..

103 Commits

Author SHA1 Message Date
seb
90936a449d u 2026-11-04 21:42:37 +01:00
seb
283ce3dc5f u 2026-11-03 10:18:12 +01:00
seb
863422df8e u 2026-11-03 06:46:36 +01:00
seb
9841002398 u 2026-11-03 06:20:29 +01:00
seb
bc11a2c3ab u 2026-11-03 05:34:29 +01:00
seb
ec9f2d9816 u 2026-11-02 00:53:48 +01:00
seb
bc284c6d42 u 2026-11-02 00:19:03 +01:00
seb
0181e08657 u 2026-11-01 23:56:34 +01:00
seb
6071e59d30 u 2026-11-01 23:42:25 +01:00
seb
e7103ac340 u 2026-11-01 20:26:53 +01:00
seb
9c6d5d79e4 u 2026-11-01 06:35:08 +01:00
seb
7c33c097d4 u 2026-11-01 06:27:57 +01:00
seb
76e911ebaf u 2026-11-01 05:59:09 +01:00
seb
d2139fee45 u 2026-11-01 05:57:38 +01:00
seb
8a416ce384 u 2026-11-01 04:33:15 +01:00
seb
7fe9f90f2c u 2026-11-01 00:52:51 +01:00
seb
456389be1b u 2026-11-01 00:47:50 +01:00
seb
e788c960d5 u 2026-11-01 00:47:29 +01:00
seb
b7046d3bc6 u 2026-11-01 00:30:39 +01:00
seb
fcaf3d5821 u 2026-10-31 23:45:47 +01:00
seb
ca28f56f3f u 2026-10-31 23:39:12 +01:00
seb
545eb98191 u 2026-10-31 22:15:03 +01:00
seb
ee80646c1c u 2026-10-31 21:57:47 +01:00
seb
df015da8fc u 2026-10-31 21:53:21 +01:00
seb
2d1a7364f2 u 2026-10-31 21:45:02 +01:00
seb
f6eb31b70d u 2026-10-31 21:19:13 +01:00
seb
6719cf9d51 u 2026-10-31 20:37:21 +01:00
seb
cdb031921b u 2026-10-31 20:28:32 +01:00
seb
9cb82745e3 u 2026-10-30 17:40:15 +01:00
seb
56d376c9bd u 2026-10-30 17:30:10 +01:00
seb
72f168f188 u 2026-10-30 16:50:15 +01:00
seb
eed979bf11 u 2026-10-30 16:26:37 +01:00
seb
28d6fece46 u 2026-10-30 16:22:03 +01:00
seb
1b60965cea u 2026-10-30 15:46:23 +01:00
seb
d3e16fd2b7 u 2026-10-30 15:45:59 +01:00
seb
3bb861907e u 2026-10-30 06:45:09 +01:00
seb
6ca7597c59 u 2026-10-30 05:02:47 +01:00
seb
05f1311892 u 2026-10-30 04:48:35 +01:00
seb
dba9ae5cf0 u 2026-10-30 04:16:56 +01:00
seb
8b680b5a1a u 2026-10-29 17:52:14 +01:00
seb
f555a1c9dd u 2026-10-29 17:32:39 +01:00
seb
867d550814 u 2026-10-29 03:37:04 +01:00
seb
afb33cde38 u 2026-10-29 03:31:06 +01:00
seb
5fcc5670d9 u 2026-10-28 23:25:54 +01:00
seb
6851e7c16f fix(lieferung): lazy load neu component in deliveryId route 2026-10-28 22:27:34 +01:00
seb
6e9d706bd6 u 2026-10-28 22:26:54 +01:00
seb
d21def186e u 2026-10-28 22:26:21 +01:00
seb
6d299c1f43 u 2026-10-28 22:25:58 +01:00
seb
87ef79bea3 u 2026-10-28 21:55:24 +01:00
seb
2e69f49e4d u 2026-10-28 21:44:52 +01:00
seb
003b198b86 u 2026-10-28 20:12:13 +01:00
seb
b13d994058 u 2026-10-28 19:37:28 +01:00
seb
550586f46e u 2026-10-28 19:32:19 +01:00
seb
3c51de06c2 u 2026-10-28 18:33:35 +01:00
seb
e45f35e97b u 2026-10-28 00:46:23 +01:00
seb
0453680c59 u 2026-10-28 00:41:31 +01:00
seb
23e4a0951c u 2026-10-28 00:27:12 +01:00
seb
96d0ae6742 u 2026-10-28 00:08:45 +01:00
seb
fab291f68e u 2026-10-27 08:39:27 +01:00
seb
481ea16a6a u 2026-10-27 03:59:56 +01:00
seb
75691e6afc u 2026-10-27 00:28:06 +01:00
seb
f52ab1be0c u 2026-10-26 19:22:53 +01:00
seb
13e1821355 u 2026-10-25 17:56:12 +01:00
seb
eeb4b92a79 u 2026-10-24 06:43:56 +02:00
seb
3801ac4e52 u 2026-10-24 06:20:51 +02:00
seb
31fdc30c5a u 2026-10-24 01:47:14 +02:00
seb
bf39f4d017 u 2026-10-24 01:39:31 +02:00
seb
2f946442c8 u 2026-10-24 01:24:11 +02:00
seb
35144c7187 u 2026-10-23 23:11:05 +02:00
seb
5dd45916b8 u 2026-10-23 22:40:28 +02:00
seb
b9bd0380cb u 2026-10-23 20:31:34 +02:00
seb
57347bdef9 u 2026-10-23 05:32:37 +02:00
seb
778840ec5e u 2026-10-22 14:40:36 +02:00
seb
82f00ad593 u 2026-10-21 11:56:06 +02:00
seb
985f4a3da2 u 2026-10-21 11:54:46 +02:00
seb
a32ea19b5e u 2026-10-21 04:31:39 +02:00
seb
a2103b9d0b u 2026-10-21 04:29:21 +02:00
seb
ae245af6d7 u 2026-10-21 03:32:36 +02:00
seb
fbf107843f u 2026-10-21 03:22:35 +02:00
seb
b09100b13e u 2026-10-21 02:18:37 +02:00
seb
b9abe8ec55 u 2026-10-21 02:09:00 +02:00
seb
3d571a988a u 2026-10-21 01:37:33 +02:00
seb
18fa62514a u 2026-10-21 00:33:36 +02:00
seb
07206674c1 u 2026-10-20 23:25:47 +02:00
seb
70c7d5eff2 u 2026-10-20 23:22:13 +02:00
seb
4f367ee943 u 2026-10-20 22:20:30 +02:00
seb
c77297af72 u 2026-10-20 22:17:27 +02:00
seb
9bc6d55e85 u 2026-10-20 21:28:36 +02:00
seb
65c908f44e u 2026-10-20 05:33:48 +02:00
seb
42241ea541 u 2026-10-20 03:02:08 +02:00
seb
b727e00928 u 2026-10-20 02:56:31 +02:00
seb
067207c58e u 2026-10-20 02:31:49 +02:00
seb
90ee11fefb u 2026-10-20 02:23:48 +02:00
seb
c86c6a84f5 u 2026-10-20 01:54:51 +02:00
seb
e953bf4c8c u 2026-10-20 01:01:53 +02:00
seb
935e01cb30 u 2026-10-20 00:47:10 +02:00
seb
883c856bdb u 2026-10-19 13:24:39 +02:00
seb
3cec22e4e5 u 2026-10-19 04:57:38 +02:00
seb
c84a504a5c u 2026-10-19 02:39:03 +02:00
seb
06d0dd3bb2 u 2026-10-19 02:37:16 +02:00
seb
7286cd8bf2 u 2026-10-19 02:28:48 +02:00
seb
7499fbc36e u 2026-10-19 01:57:00 +02:00
seb
8961d662e5 u 2026-10-19 01:54:07 +02:00
228 changed files with 20951 additions and 9225 deletions

File diff suppressed because it is too large Load Diff

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: 199 KiB

View File

@@ -2,6 +2,67 @@ import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
const banMuiTooltipRule = {
meta: {
type: 'problem',
docs: {
description: 'Ban MUI Tooltip component',
},
messages: {
badDesign: '<Tooltip> is bad design',
},
},
create(context) {
const muiTooltipIdentifiers = new Set();
return {
ImportDeclaration(node) {
if (node.source.value === '@mui/material/Tooltip') {
context.report({
node,
messageId: 'badDesign',
});
for (const specifier of node.specifiers) {
if (specifier.local?.name) {
muiTooltipIdentifiers.add(specifier.local.name);
}
}
} else if (node.source.value === '@mui/material') {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported?.name === 'Tooltip'
) {
context.report({
node: specifier,
messageId: 'badDesign',
});
if (specifier.local?.name) {
muiTooltipIdentifiers.add(specifier.local.name);
}
}
}
}
},
JSXOpeningElement(node) {
let tagName = '';
if (node.name.type === 'JSXIdentifier') {
tagName = node.name.name;
} else if (node.name.type === 'JSXMemberExpression') {
tagName = node.name.property.name;
}
if (muiTooltipIdentifiers.has(tagName) || tagName === 'Tooltip') {
context.report({
node,
messageId: 'badDesign',
});
}
},
};
},
};
export default [
{
ignores: [
@@ -32,10 +93,16 @@ export default [
},
plugins: {
'react-hooks': reactHooks,
react: {
rules: {
'no-mui-tooltip': banMuiTooltipRule,
},
},
},
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react/no-mui-tooltip': 'error',
'no-unused-vars': [
'warn',
{

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,18 @@ 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: 'schemaVersion: number|null', desc: 'Backend DB schema version', port: 'p_auth_schemaVersion' },
{ 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: 'SELECT', name: 'selectSchemaVersion()', desc: 'Active DB schema version' },
{ type: 'API', name: 'GET /api/auth/session', desc: 'Cookie session check' },
],
},
@@ -166,7 +153,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 +237,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 +266,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 +299,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 +325,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 +333,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 +366,7 @@ const AUTOMAT_SPECS = [
],
},
// 11. Audit & Virtual Grids
// 8. Audit & Virtual Grids
{
id: 'articleHistoryGridAutomat',
title: 'articleHistoryGridAutomat',
@@ -532,7 +401,7 @@ const AUTOMAT_SPECS = [
],
},
// 12. UI, Settings & System
// 9. UI, Navigation & System
{
id: 'navigationAutomat',
title: 'navigationAutomat',
@@ -547,18 +416,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 +452,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 +471,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 +486,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 +503,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

1652
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"dev:pwa:https": "VITE_HTTPS=1 node scripts/dev.mjs",
"cert": "./scripts/generate-cert.sh",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"preview:https": "VITE_HTTPS=1 vite preview",
"rig:collect": "node test/collect.mjs",
@@ -33,8 +34,6 @@
"@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1",
"@uiw/react-codemirror": "^4.25.12",
"ag-grid-community": "^36.2.0",
"ag-grid-react": "^36.2.0",
"dashgui": "file:../dashgui",
"flexsearch": "^0.8.212",
"jsbarcode": "^3.12.3",
@@ -42,12 +41,11 @@
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.1",
"react-automat": "file:../automat",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor#v20.0.0",
"react-dom": "^19.3.1",
"react-router-dom": "^7.18.4",
"react-virtual-aggridautomat": "file:../react-virtual-aggridautomat",
"react-window": "^2.3.4",
"skelehash-wasm": "^0.1.0",
"skelehash-wasm": "^0.1.1",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
@@ -64,6 +62,7 @@
},
"allowScripts": {
"canvas@3.2.3": true,
"react-canvas-label-editor@20.0.0": true,
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
}
}

View File

@@ -1,11 +1,16 @@
import { PureComponent, Suspense } from 'react';
import { PureComponent, lazy, Suspense } from 'react';
import Alert from '@mui/material/Alert';
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';
import AppRouter from './appRouter';
const CompanySetupDialog = lazy(() => import('./components/CompanySetupDialog'));
const OAuthAuthorizePage = lazy(() => import('./routes/oauth/OAuthAuthorizePage'));
const SseMonitor = import.meta.env.DEV
? lazy(() => import('./devtools/SseMonitor'))
: null;
function PageFallback() {
return (
@@ -30,7 +35,13 @@ class App extends PureComponent {
}
componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user']);
this.unsubscribe = authAutomat.subscribe(this, [
'status',
'user',
'hasAuth',
'hasCompany',
'retryNotification',
]);
}
componentWillUnmount() {
@@ -38,25 +49,44 @@ class App extends PureComponent {
}
render() {
const { status, user } = this.state;
const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
if (status === 'loading') {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
gap: 2.5,
p: 2,
}}
>
{retryNotification ? (
<Alert
severity="warning"
variant="outlined"
sx={{
maxWidth: 480,
alignItems: 'center',
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
{retryNotification}
</Alert>
) : null}
<CircularProgress />
</Box>
);
}
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 +94,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}
</>
);
}
}

69
src/api/agentprinter.js Normal file
View File

@@ -0,0 +1,69 @@
import { http } from './http.js';
export const agentprinter = {
/**
* GET /api/agentprinter
* Query: ?printeragentId=<id>
* → { agentprinters: Agentprinter[] }
*/
list(printeragentId) {
const url =
printeragentId != null
? `/api/agentprinter?printeragentId=${encodeURIComponent(printeragentId)}`
: '/api/agentprinter';
return http(url);
},
/**
* GET /api/agentprinter/:id
* → { agentprinter: Agentprinter }
*/
get(id) {
return http(`/api/agentprinter/${id}`);
},
/**
* PATCH /api/agentprinter/:id
* Body: { enabled?: boolean, sort?: number }
* → { agentprinter: Agentprinter }
*/
update(id, patch = {}) {
const body = {};
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
body.sort = Number(patch.sort);
}
return http(`/api/agentprinter/${id}`, {
method: 'PATCH',
body,
});
},
/**
* PUT /api/agentprinter/order
* Body: { printeragentId?: number, agentprinterIds: number[] }
* → { agentprinterIds: number[] }
*/
order({ printeragentId, agentprinterIds }) {
const body = {
agentprinterIds: Array.isArray(agentprinterIds) ? agentprinterIds.map(Number) : [],
};
if (printeragentId != null) {
body.printeragentId = Number(printeragentId);
}
return http('/api/agentprinter/order', {
method: 'PUT',
body,
});
},
/**
* DELETE /api/agentprinter/:id → 204
*/
delete(id) {
return http(`/api/agentprinter/${id}`, {
method: 'DELETE',
});
},
};

View File

@@ -50,16 +50,24 @@ export const article = {
// #endregion
/**
* GET /api/article/:sku/history
* GET /api/article/:sku/history?offset=0&limit=50&search=...&seekMonths=-1
* @param {string} sku
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number, offset: number, limit: number }
*/
getBySkuHistory(sku, { limit } = {}) {
getBySkuHistory(sku, { offset = 0, limit = 50, search, seekMonths } = {}) {
const params = new URLSearchParams();
if (limit != null) params.set('limit', String(limit));
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
return http(`/api/article/${encodeURIComponent(sku)}/history${qs ? `?${qs}` : ''}`);
},
/** POST /api/article → 201 { article } */
/** POST /api/article → 201 { sku } */
create({ categoryId, name, track_stock }) {
return http('/api/article', {
method: 'POST',
@@ -75,7 +83,7 @@ export const article = {
* PATCH /api/article/:sku
* Body: at least one of { name, unitamount, unitSi, track_stock, desc, seo }
* unitSi must exist for selected company → 404 Unit not found
* → 200 { article } (same detail shape as GET: includes desc, seo)
* → 204 (no body; refetch via getBySku)
*/
update(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
@@ -87,7 +95,7 @@ export const article = {
/**
* PUT /api/article/:sku/track_stock
* Body: { track_stock: boolean }
* → 200 { article }
* → 204 (no body)
*/
setTrackStock(sku, body) {
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
@@ -147,7 +155,7 @@ export const article = {
* PUT /api/article/:sku/prices
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
* Replace-all POS customer group prices. Empty prices clears all POS prices.
* → 200 { article } (same shape as GET by SKU)
* → 204 (no body)
*/
setPrices(sku, prices) {
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
@@ -158,7 +166,7 @@ export const article = {
/**
* PUT /api/article/:sku/weight
* Body: { weight } → 200 { article }
* Body: { weight } → 204 (no body)
*/
setWeight(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
@@ -171,7 +179,7 @@ export const article = {
* PUT /api/article/:sku/brand
* Body: { brandId: number | null } — null clears the brand.
* brandId must belong to the article's company → 404 Article or brand not found.
* → 200 { article } (same shape as GET by SKU)
* → 204 (no body)
*/
setBrandIdBySku(sku, brandId) {
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
@@ -184,7 +192,7 @@ export const article = {
* PUT /api/article/:sku/eans
* Body: { eans: string[] } — replace-all. Empty array clears all.
* EANs must be non-empty unique strings → 400 Invalid eans.
* → 200 { article } (same shape as GET by SKU)
* → 204 (no body)
*/
setEansBySku(sku, eans) {
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
@@ -197,7 +205,7 @@ export const article = {
* PUT /api/article/:sku/categories
* Body: { categoryIds: number[] } — replace-all. Empty array clears all.
* Categories must belong to the article's company; ids must be unique.
* → 200 { article } (same shape as GET by SKU)
* → 204 (no body)
*/
setCategories(sku, categoryIds) {
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
@@ -342,7 +350,7 @@ export const article = {
/**
* POST /api/article/:sku/parent
* Body: { isParent: boolean, attributeNameId?: number }
* Body: { isParent: boolean, attributeNameId?: number } → 204 (no body)
*/
toggleParent(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
@@ -355,6 +363,7 @@ export const article = {
/**
* POST /api/article/:sku/children
* Body: { attributeValueId?: number, value?: string, name?: string }
* → 201 { sku }
*/
addChild(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/children`, {

View File

@@ -8,12 +8,18 @@ export const auth = {
return {
user: data.user ?? null,
googleClientId: data.googleClientId ?? null,
schemaVersion: data.schemaVersion ?? null,
vhost: Boolean(data.vhost),
vhostSettings: data.vhostSettings ?? null,
};
} catch (err) {
if (err.status === 401 || err.status === 403) {
return {
user: null,
googleClientId: err.data?.googleClientId ?? null,
schemaVersion: err.data?.schemaVersion ?? null,
vhost: Boolean(err.data?.vhost),
vhostSettings: err.data?.vhostSettings ?? null,
forbidden: err.status === 403,
message: err.message,
};

24
src/api/autoinvite.js Normal file
View File

@@ -0,0 +1,24 @@
import { http } from './http.js';
/**
* Pending invites for emails that are not registered yet (admin only).
*/
export const autoinvite = {
// #region GET /api/autoinvite — list pending invites
list() {
return http('/api/autoinvite');
},
// #endregion
// #region POST /api/autoinvite — create an invite
create(body) {
return http('/api/autoinvite', { method: 'POST', body });
},
// #endregion
// #region DELETE /api/autoinvite/:id — delete an invite
delete(id) {
return http(`/api/autoinvite/${id}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -125,6 +125,21 @@ export const category = {
},
// #endregion
/**
* POST /api/category/:id/image/generate
* Body (optional): { size?: 2 | 3 | 4 }
* → 200 { category: { id, fullHash, thumbW, thumbH, thumbHash, size, grid, articleCount } }
*/
// #region POST /api/category/:id/image/generate — generateImage
generateImage(categoryId, { size } = {}) {
return http(`/api/category/${categoryId}/image/generate`, {
method: 'POST',
body: size != null ? { size } : undefined,
});
},
// #endregion
/**
* GET /api/category/export.zip → application/zip (binary download)
* `kategorien.zip` = kategorien.csv (semicolon-separated, Windows-1252,

30
src/api/companyuser.js Normal file
View File

@@ -0,0 +1,30 @@
import { http } from './http.js';
/**
* Company members (admin only).
*/
export const companyuser = {
// #region GET /api/companyuser — list members
list() {
return http('/api/companyuser');
},
// #endregion
// #region POST /api/companyuser — add an existing user by email
create(body) {
return http('/api/companyuser', { method: 'POST', body });
},
// #endregion
// #region PATCH /api/companyuser/:id — change role
setRole(id, role) {
return http(`/api/companyuser/${id}`, { method: 'PATCH', body: { role } });
},
// #endregion
// #region DELETE /api/companyuser/:id — remove a member
delete(id) {
return http(`/api/companyuser/${id}`, { method: 'DELETE' });
},
// #endregion
};

19
src/api/health.js Normal file
View File

@@ -0,0 +1,19 @@
import { http } from './http.js';
/**
* Health API for backend latency samples.
*/
export const health = {
/**
* GET /api/health/latency?range=3h|all
* Returns: 200 { raw: Point[] } for '3h', { raw, avg, max } for 'all'.
* Point = { timestamp: epoch ms, value: latency ms }, sorted oldest first.
* @param {'3h'|'all'} [range='3h']
*/
// #region GET /api/health/latency — getLatency
// MARK: calls GET /api/health/latency from ./api/index.js
getLatency(range = '3h') {
return http(`/api/health/latency?range=${encodeURIComponent(range)}`);
},
// #endregion
};

View File

@@ -15,8 +15,12 @@ export { number } from './number.js';
export { order } from './order.js';
export { posterminal } from './posterminal.js';
export { printer } from './printer.js';
export { printeragent } from './printeragent.js';
export { agentprinter } from './agentprinter.js';
export { printerjob } from './printerjob.js';
export { propertyname } from './propertyname.js';
export { quote } from './quote.js';
export { retoure } from './retoure.js';
export { setting } from './setting.js';
export { serper } from './serper.js';
export { supplier } from './supplier.js';
@@ -30,6 +34,9 @@ export { shipment } from './shipment.js';
export { shipping } from './shipping.js';
export { payment } from './payment.js';
export { account } from './account.js';
export { companyuser } from './companyuser.js';
export { autoinvite } from './autoinvite.js';
export { mcp } from './mcp.js';
export { backup } from './backup.js';
export { carrier } from './carrier.js';
export { health } from './health.js';

View File

@@ -5,10 +5,11 @@ import { http } from './http.js';
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
*/
export const labeltemplate = {
/** GET /api/labeltemplate → { labeltemplates: LabelTemplate[] } */
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
// #region GET /api/labeltemplate — list
list() {
return http('/api/labeltemplate');
list(params) {
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
return http(`/api/labeltemplate${search}`);
},
// #endregion
@@ -48,5 +49,23 @@ export const labeltemplate = {
},
// #endregion
/**
* POST /api/labeltemplate/testprint (or /api/labeltemplate/:id/testprint)
* @param {number|object} idOrBody
* @param {object} [maybeBody]
* → 200 { success: true, printerjob?: PrinterJob }
*/
// #region POST /api/labeltemplate/testprint — testprint
testprint(idOrBody, maybeBody) {
if (typeof idOrBody === 'object' && idOrBody !== null) {
return http('/api/labeltemplate/testprint', { method: 'POST', body: idOrBody });
}
return http(`/api/labeltemplate/${encodeURIComponent(idOrBody)}/testprint`, {
method: 'POST',
body: maybeBody || {},
});
},
// #endregion
// thumb is returned inline as a base64 string in list/get responses
};

View File

@@ -12,13 +12,14 @@ export const order = {
* → { orders: Order[], total: number, offset: number, limit: number }
*/
// #region GET /api/order — list
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths } = {}) {
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true');
if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true');
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
@@ -92,7 +93,7 @@ export const order = {
* POST /api/order/:id/shipment
* Creates shipment from order.
* @param {number|string} id Order ID
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku: string, amount?: number }> }
* @param {object} [body] Optional payload: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }> }
* → 201 { shipment: Shipment }
*/
// #region POST /api/order/:id/shipment — createShipment

View File

@@ -11,8 +11,17 @@ export const payment = {
// #endregion
// #region POST /api/order/:id/payment — create
create(orderId, body) {
return http(`/api/order/${orderId}/payment`, { method: 'POST', body });
create(orderIdOrBody, maybeBody) {
if (typeof orderIdOrBody === 'object' && orderIdOrBody !== null) {
return http('/api/payment', { method: 'POST', body: orderIdOrBody });
}
return http(`/api/order/${orderIdOrBody}/payment`, { method: 'POST', body: maybeBody });
},
// #endregion
// #region POST /api/payment — register
register(body) {
return http('/api/payment', { method: 'POST', body });
},
// #endregion

View File

@@ -2,8 +2,8 @@ import { http } from './http.js';
export const posterminal = {
/**
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
* List all terminals for the selected company.
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin, sort, enabled, clientversion, ... }] }
* List all terminals for the selected company, sorted by sort ASC, id ASC.
*/
// #region GET /api/posterminal — list
list() {
@@ -13,24 +13,29 @@ export const posterminal = {
/**
* PUT /api/posterminal
* Body: { name: string }
* Body: { name: string, sort?: number, enabled?: boolean, rootCategoryId?: number|null, syncRootArticles?: boolean }
* Creates a new terminal (auto-generates 6-digit pin).
* → 200 { posterminal: { id, name, token, pairingpin } }
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
*/
// #region PUT /api/posterminal — create
create({ name }) {
create({ name, sort, enabled, rootCategoryId, syncRootArticles } = {}) {
const body = { name };
if (sort !== undefined) body.sort = Number(sort);
if (enabled !== undefined) body.enabled = Boolean(enabled);
if (rootCategoryId !== undefined) body.rootCategoryId = rootCategoryId != null ? Number(rootCategoryId) : null;
if (syncRootArticles !== undefined) body.syncRootArticles = Boolean(syncRootArticles);
return http('/api/posterminal', {
method: 'PUT',
body: { name },
body,
});
},
// #endregion
/**
* PATCH /api/posterminal/:id
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string }
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
* Update posterminal configuration.
* → 200 { posterminal }
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
*/
// #region PATCH /api/posterminal/:id — update
update(terminalId, patch) {

View File

@@ -51,4 +51,29 @@ export const printer = {
return http('/api/printer/order', { method: 'PUT', body: { printerIds } });
},
// #endregion
/**
* GET /api/printer/state
* Query: ?serial=, ?agentId=
* → { printers: LivePrinter[] }
*/
// #region GET /api/printer/state — live state
state({ serial, agentId } = {}) {
const params = new URLSearchParams();
if (serial) params.set('serial', serial);
if (agentId != null) params.set('agentId', agentId);
const query = params.toString();
return http(`/api/printer/state${query ? `?${query}` : ''}`);
},
// #endregion
/**
* GET /api/printer/:id/state
* → { printer: LivePrinter }
*/
// #region GET /api/printer/:id/state — single printer state
printerState(id) {
return http(`/api/printer/${id}/state`);
},
// #endregion
};

58
src/api/printeragent.js Normal file
View File

@@ -0,0 +1,58 @@
import { http } from './http.js';
export const printeragent = {
/**
* GET /api/printeragent → { printeragents: Printeragent[] }
* List all printer agents for the selected company, sorted by sort ASC, id ASC.
*/
list() {
return http('/api/printeragent');
},
/**
* PUT /api/printeragent
* Body: { name: string, sort?: number }
* Creates a new printer agent (auto-generates 6-digit pairingpin).
* → 200 { printeragent: Printeragent }
*/
create({ name, sort } = {}) {
const body = { name };
if (sort !== undefined && sort !== null && sort !== '') {
body.sort = Number(sort);
}
return http('/api/printeragent', {
method: 'PUT',
body,
});
},
/**
* PATCH /api/printeragent/:id
* Body: { name?: string, sort?: number, enabled?: boolean }
* Updates name, sort, enabled, or any combination.
* → 200 { printeragent: Printeragent }
*/
update(id, patch = {}) {
const body = {};
if (patch.name !== undefined) body.name = patch.name;
if (patch.sort !== undefined && patch.sort !== null && patch.sort !== '') {
body.sort = Number(patch.sort);
}
if (patch.enabled !== undefined) body.enabled = Boolean(patch.enabled);
return http(`/api/printeragent/${id}`, {
method: 'PATCH',
body,
});
},
/**
* DELETE /api/printeragent/:id → 204 No Content
* Permanently deletes agent.
*/
delete(id) {
return http(`/api/printeragent/${id}`, {
method: 'DELETE',
});
},
};

142
src/api/printerjob.js Normal file
View File

@@ -0,0 +1,142 @@
import { http } from './http.js';
export const printerjob = {
/**
* POST /api/zpljob
* @param {{ agentprinterId: number, data: string, copiescount?: number }} payload
* → { printerjob: PrinterJob }
*/
createZplJob({ agentprinterId, data, copiescount = 1 }) {
return http('/api/zpljob', {
method: 'POST',
body: {
agentprinterId: Number(agentprinterId),
data: String(data),
copiescount: Number(copiescount) || 1,
},
});
},
/**
* Sends a minimal ZPL test label to a specific agent printer.
* @param {number} agentprinterId
* @param {{ model?: string }} [options]
* → { printerjob: PrinterJob }
*/
sendTestZpl(agentprinterId, { model = 'Zebra' } = {}) {
const timeStr = new Date().toLocaleTimeString('de-DE');
const safeModel = String(model || 'Drucker').replace(/[\^~]/g, '');
const zpl = [
'^XA',
'^FO40,40^ADN,36,20^FDTestdruck OK^FS',
`^FO40,85^ADN,18,10^FD${safeModel}^FS`,
`^FO40,115^ADN,18,10^FDID: ${agentprinterId} - ${timeStr}^FS`,
'^XZ',
].join('\n');
return this.createZplJob({
agentprinterId,
data: zpl,
copiescount: 1,
});
},
/**
* GET /api/printerjob
* @param {{ agentprinterId?: number, state?: string }} [query]
* → { printerjobs: PrinterJob[] }
*/
list(query = {}) {
const params = new URLSearchParams();
if (query.agentprinterId != null) params.set('agentprinterId', String(query.agentprinterId));
if (query.state != null) params.set('state', String(query.state));
const qs = params.toString();
return http(qs ? `/api/printerjob?${qs}` : '/api/printerjob');
},
/**
* GET /api/printerjob/:id
* → { printerjob: PrinterJob }
*/
get(id) {
return http(`/api/printerjob/${id}`);
},
/**
* POST /api/printerjob
* @param {{ agentprinterId: number, type?: 'zpl'|'pdf'|'epos', data: string }} payload
* → { printerjob: PrinterJob }
*/
create({ agentprinterId, type = 'zpl', data }) {
return http('/api/printerjob', {
method: 'POST',
body: {
agentprinterId: Number(agentprinterId),
type,
data,
},
});
},
/**
* POST /api/printerjob/:id/cancel
* → { success: true, printerjob: PrinterJob }
*/
cancel(id) {
return http(`/api/printerjob/${id}/cancel`, {
method: 'POST',
});
},
/**
* POST /api/printerjob/cancel-all
* Sends ~JA to clear printer buffer and cancels all queued jobs.
* @param {{ agentprinterId?: number }} [payload]
* → { success: true, cancelledCount: number, jobs: PrinterJob[] }
*/
async cancelAll({ agentprinterId } = {}) {
if (agentprinterId != null) {
try {
await this.createZplJob({
agentprinterId: Number(agentprinterId),
data: '~JA',
copiescount: 1,
});
} catch (e) {
console.warn('Failed to send ~JA to printer:', e);
}
}
try {
return await http('/api/printerjob/cancel-all', {
method: 'POST',
body: agentprinterId != null ? { agentprinterId: Number(agentprinterId) } : {},
});
} catch (err) {
if (err?.status === 404) {
const { printerjobs = [] } = await this.list({ agentprinterId });
const cancellable = printerjobs.filter(
(j) => j.state === 'pending' || j.state === 'processing',
);
if (cancellable.length === 0) return { success: true, cancelledCount: 0, jobs: [] };
const results = await Promise.all(cancellable.map((j) => this.cancel(j.id)));
return {
success: true,
cancelledCount: results.length,
jobs: results.map((r) => r.printerjob),
};
}
throw err;
}
},
/**
* DELETE /api/printerjob/:id
* → { success: true, deletedJob: PrinterJob }
*/
delete(id) {
return http(`/api/printerjob/${id}`, {
method: 'DELETE',
});
},
};

View File

@@ -13,11 +13,12 @@ export const quote = {
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
*/
// #region GET /api/quote — list
list({ offset = 0, limit = 50, search, seekMonths } = {}) {
list({ offset = 0, limit = 50, search, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}

187
src/api/retoure.js Normal file
View File

@@ -0,0 +1,187 @@
import { http } from './http.js';
/**
* Normalizes a retouresummary record from either snake_case (raw view) or camelCase.
* Fields: manifest_id, retoure_number, retoure_date, entity_id, customer_name,
* positions, gross_total, paid_total, ordered_quantity, shipped_quantity, is_paid, is_shipped, created_at
*/
export function normalizeRetoureSummary(row) {
if (!row) return null;
const id = row.manifest_id ?? row.id;
const num = row.retoure_number ?? row.number ?? (id ? `RET-${id}` : '');
const dt = row.retoure_date ?? row.date ?? row.created_at ?? row.createdAt;
const entityId = row.entity_id ?? row.entityId ?? null;
const customerName = row.customer_name ?? row.customerName ?? '';
const positions = Number(row.positions) || 0;
const grossTotal = row.gross_total != null ? Number(row.gross_total) : (row.grossTotal != null ? Number(row.grossTotal) : 0);
const paidTotal = row.paid_total != null ? Number(row.paid_total) : (row.paidTotal != null ? Number(row.paidTotal) : 0);
const orderedQuantity = row.ordered_quantity != null ? Number(row.ordered_quantity) : (row.orderedQuantity != null ? Number(row.orderedQuantity) : 0);
const shippedQuantity = row.shipped_quantity != null ? Number(row.shipped_quantity) : (row.shippedQuantity != null ? Number(row.shippedQuantity) : 0);
const isPaid = Boolean(row.is_paid ?? row.isPaid);
const isShipped = Boolean(row.is_shipped ?? row.isShipped);
const createdAt = row.created_at ?? row.createdAt ?? null;
return {
id,
manifest_id: id,
number: num,
retoure_number: num,
date: dt,
retoure_date: dt,
entityId,
entity_id: entityId,
customerName,
customer_name: customerName,
positions,
grossTotal,
gross_total: grossTotal,
paidTotal,
paid_total: paidTotal,
orderedQuantity,
ordered_quantity: orderedQuantity,
shippedQuantity,
shipped_quantity: shippedQuantity,
isPaid,
is_paid: isPaid,
isShipped,
is_shipped: isShipped,
createdAt,
created_at: createdAt,
lines: row.lines ?? [],
files: row.files ?? [],
shipments: row.shipments ?? [],
payments: row.payments ?? [],
};
}
/**
* Customer Returns / Retouren (manifest type = retoure).
*/
export const retoure = {
/**
* GET /api/retoure
* Query params: q, from, to, isPaid, isShipped, page, pageSize, offset, limit, entityId
* → { retoures: RetoureSummary[], total: number, unfilteredTotal: number }
*/
// #region GET /api/retoure — list
async list({
q,
search,
from,
to,
isPaid,
isShipped,
onlyUnpaid,
onlyUndelivered,
page,
pageSize,
offset,
limit,
entityId,
seekMonths,
} = {}) {
const params = new URLSearchParams();
const query = q || search;
if (query && String(query).trim()) {
params.set('q', String(query).trim());
params.set('search', String(query).trim());
}
if (from) params.set('from', String(from));
if (to) params.set('to', String(to));
if (isPaid !== undefined && isPaid !== null) {
params.set('isPaid', String(isPaid));
} else if (onlyUnpaid === true || onlyUnpaid === 'true') {
params.set('isPaid', 'false');
}
if (isShipped !== undefined && isShipped !== null) {
params.set('isShipped', String(isShipped));
} else if (onlyUndelivered === true || onlyUndelivered === 'true') {
params.set('isShipped', 'false');
}
if (page != null) params.set('page', String(page));
if (pageSize != null) params.set('pageSize', String(pageSize));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
const res = await http(`/api/retoure${qs ? `?${qs}` : ''}`);
const rawList = res?.retoures ?? res?.summaries ?? res?.items ?? (Array.isArray(res) ? res : []);
const items = rawList.map(normalizeRetoureSummary);
const total = Number(res?.total ?? res?.count ?? items.length);
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
return {
retoures: items,
summaries: items,
items,
total,
unfilteredTotal,
raw: res,
};
},
// #endregion
/** GET /api/retoure/:id → { retoure: Retoure } */
// #region GET /api/retoure/:id — get
async get(id) {
const res = await http(`/api/retoure/${id}`);
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/**
* POST /api/retoure
* Body: { number?, date, entityId?, externalNumber?, lines }
* → 201 { retoure: Retoure }
*/
// #region POST /api/retoure — create
async create(body) {
const res = await http('/api/retoure', { method: 'POST', body });
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/**
* PUT /api/retoure/:id
* Body: { number?, date?, entityId?, externalNumber?, lines? }
* → 200 { retoure: Retoure }
*/
// #region PUT /api/retoure/:id — update
async update(id, body) {
const res = await http(`/api/retoure/${id}`, { method: 'PUT', body });
if (res && !res.retoure && (res.id != null || res.manifest_id != null)) {
return { retoure: res };
}
return res;
},
// #endregion
/** DELETE /api/retoure/:id → 204 */
// #region DELETE /api/retoure/:id — delete
delete(id) {
return http(`/api/retoure/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/retoure/:id/shipment
* Creates return shipment.
* Body: { date?, externalNumber?, lines?: Array<{ sku?: string, orderLineId?: number, amount?: number }>, shipping?: { carrierId, type, state, warehouseId } }
* → 201 { shipment: Shipment }
*/
// #region POST /api/retoure/:id/shipment — createShipment
createShipment(id, body = {}) {
return http(`/api/retoure/${id}/shipment`, { method: 'POST', body });
},
// #endregion
};

View File

@@ -13,6 +13,7 @@ export const shipment = {
if (params.search) q.set('search', String(params.search));
if (params.state) q.set('state', String(params.state));
if (params.orderId != null) q.set('orderId', String(params.orderId));
if (params.entityId != null) q.set('entityId', String(params.entityId));
if (params.seekMonths != null) q.set('seekMonths', String(params.seekMonths));
const qs = q.toString();
return http(`/api/shipment${qs ? `?${qs}` : ''}`);

View File

@@ -12,4 +12,10 @@ export const thumb = {
return `/api/thumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
// #region GET /api/smallthumb/:fullHash — smallUrl
smallUrl(fullHash) {
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
};

View File

@@ -1,40 +1,46 @@
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 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'));
const LedgerHistoryPage = lazy(() => import('./routes/infos/LedgerHistoryPage'));
const CompanyAutomatDebugPage = import.meta.env.DEV
? lazy(() => import('./routes/debug/CompanyAutomatPage'))
: null;
function LoginRedirect() {
const params = new URLSearchParams(window.location.search);
@@ -109,11 +115,13 @@ const routes = [
{ path: 'bestellung/neu', element: <AuftragBestellungNeuPage /> },
{ path: 'bestellung/:orderId', element: <AuftragBestellungEditPage /> },
{ path: 'ausliefern', element: <AuftragAusliefernPage /> },
{ path: 'retoure', element: <AuftragRetourePage /> },
{ path: 'retoure/neu', element: <AuftragRetoureNeuPage /> },
{ path: 'retoure/:retoureId', element: <AuftragRetoureEditPage /> },
{ path: 'zahlungen', element: <AuftragZahlungenPage /> },
{ path: 'versandarten', element: <AuftragVersandartenPage /> },
{ path: 'lieferarten', element: <Navigate to="/auftrag/versandarten" replace /> },
{ path: ':quoteId', element: <AuftragAngebotEditPage /> },
],
},
{
@@ -126,6 +134,19 @@ const routes = [
{ path: ':entityId', element: <KundeEditPage /> },
],
},
...(import.meta.env.DEV
? [
{
path: 'debug',
element: <SectionLayout sectionPath="/debug" />,
children: [
{ index: true, element: <CompanyAutomatDebugPage /> },
{ path: 'company', element: <CompanyAutomatDebugPage /> },
{ path: 'companyautomat', element: <CompanyAutomatDebugPage /> },
],
},
]
: []),
{ path: '*', element: <Navigate to="/artikel" replace /> },
],
},
@@ -138,3 +159,7 @@ export const router =
if (import.meta.hot) {
import.meta.hot.data.router = router;
}
export default function AppRouter() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,15 @@
import zd41x from './zd41x.png';
// Library of printer images. Entries are matched in order against the
// printer model (as reported by the agent), case-insensitively.
const PRINTER_IMAGES = [
{ pattern: /^ZD41/i, image: zd41x }, // ZD410, ZD411, ZD410-200dpi, ...
];
export function getPrinterImage(model) {
if (!model) return null;
const entry = PRINTER_IMAGES.find(({ pattern }) => pattern.test(String(model).trim()));
return entry?.image ?? null;
}
export default PRINTER_IMAGES;

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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

@@ -11,7 +11,7 @@
* history or supplier details of a specific article.
*/
import { KeyedAutomat } from 'react-automat';
import { Automat, KeyedAutomat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireArray, requireObject } from '../utils/apiShape';
@@ -34,26 +34,73 @@ import { requireArray, requireObject } from '../utils/apiShape';
* @property {number|null} taxRate Tax rate in percent.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import authAutomat from './authAutomat';
/**
* Per-SKU article change history (`GET /api/article/{sku}/history`).
*
* The backend returns the full list; pagination happens client-side in
* `ArticleHistoryTab`. Keyed by SKU so several tabs can watch the same article.
*
* @type {KeyedAutomat<Array<ArticleHistoryEntry>>}
* Registry of SKU-specific chunk-list automats for article history.
* @type {Map<string, import('react-automat').Automat>}
*/
export const articleHistoryAutomat = new KeyedAutomat(
null,
{},
{
name: 'articleHistory',
loader: async (sku) => {
const res = await articleApi.getBySkuHistory(String(sku));
requireObject(res, 'GET /api/article/:sku/history');
return requireArray(res.history, 'history');
},
const skuHistoryAutomats = new Map();
/**
* Gets or creates the chunk-list automat for a specific article's history.
*
* @param {string|number} sku Article SKU.
* @returns {import('react-automat').Automat} Configured chunk-list automat for the SKU.
*/
export function getArticleHistoryAutomat(sku) {
const rawKey = String(sku);
const key = rawKey.trim().toLowerCase();
let inst = skuHistoryAutomats.get(key);
if (!inst) {
inst = createChunkListAutomat({
name: `articleHistory:${rawKey}`,
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => articleApi.getBySkuHistory(rawKey, params),
});
skuHistoryAutomats.set(key, inst);
}
return inst;
}
/**
* Per-SKU article change history manager (`GET /api/article/{sku}/history`).
*
* Backed by chunk-list automats (`createChunkListAutomat`) keyed by SKU with
* multi-filter memory caching, LRU eviction, and infinite-scrolling / FillSpace support.
*/
export const articleHistoryAutomat = {
get: getArticleHistoryAutomat,
key: getArticleHistoryAutomat,
has(sku) {
return skuHistoryAutomats.has(String(sku).trim().toLowerCase());
},
);
async load(sku) {
const inst = getArticleHistoryAutomat(sku);
return inst.getItems();
},
async reload(sku) {
const inst = skuHistoryAutomats.get(String(sku).trim().toLowerCase());
if (inst) {
await inst.actions.reload();
}
},
reset() {
for (const inst of skuHistoryAutomats.values()) {
inst.actions.reset();
}
skuHistoryAutomats.clear();
},
};
// Reset on logout or company change
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
articleHistoryAutomat.reset();
}
});
/**
* Suppliers assigned to an article (`GET /api/article/{sku}/supplier`).
@@ -68,7 +115,12 @@ export const articleSupplierAutomat = new KeyedAutomat(
loader: async (sku) => {
const res = await articleApi.getSuppliers(String(sku));
requireObject(res, 'GET /api/article/:sku/supplier');
return requireArray(res.suppliers, 'suppliers');
const list = requireArray(res.suppliers, 'suppliers');
const comp = Automat.get('company');
if (comp?.actions?.syncArticleSuppliers) {
comp.actions.syncArticleSuppliers(sku, list);
}
return list;
},
},
);

View File

@@ -29,12 +29,17 @@
*/
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import authAutomat from './authAutomat';
import categoryAutomat, { whenCategoryTreeReady } from './categoryAutomat';
import sseAutomat from './sseAutomat';
import {
categoryViews,
collectLoadedCategoryViewData,
loadAllCategoryViews,
invalidateAllCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku,
} from './categoryViewAutomat';
/**
@@ -87,10 +92,6 @@ function scheduleRebuild() {
/**
* Normalizes a category-view article row into an index record.
*
* NOTE: `eans` is not part of the `GET /api/category/:id/articles` payload, so the
* field stays empty until that endpoint exposes EANs. EAN matching in
* `articleSearchAutomat` is therefore not functional yet.
*
* @param {object} article Raw category-view article row.
* @param {number|string|null} categoryId Category the article was loaded from.
* @returns {ArticleIndexRecord} Index record.
@@ -98,7 +99,7 @@ function scheduleRebuild() {
function toIndexRecord(article, categoryId) {
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
const childCount = Number(article.childCount) || 0;
const price = article.price ?? article.net ?? article.vknetto ?? null;
const price = article.price ?? article.net ?? article.vknetto ?? (Array.isArray(article.prices) ? (article.prices.find((p) => p.minmultiplier === 1)?.net ?? article.prices[0]?.net) : null);
const warehouses = Array.isArray(article.warehouses) ? article.warehouses : [];
const trackStock = article.track_stock ?? article.trackStock;
const stock = trackStock === false
@@ -111,12 +112,25 @@ function toIndexRecord(article, categoryId) {
const available = article.available != null && !Number.isNaN(Number(article.available))
? Number(article.available)
: stock;
const categories = categoryId != null
? String(categoryId)
: (Array.isArray(article.categoryIds) ? article.categoryIds.join(',') : (article.categories ?? null));
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? null;
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? null;
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? null;
const photos = article.photos ?? (fullHash ? [{ fullHash, thumbHash }] : []);
const descLength = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
return {
...article,
id: article.id,
sku: article.sku,
name: article.name,
descLength,
seo: article.seo ?? null,
price,
net: article.net ?? price,
stock,
@@ -126,9 +140,15 @@ function toIndexRecord(article, categoryId) {
? article.articlewarehousesummary
: [],
eans: Array.isArray(eans) ? eans.filter(Boolean) : eans ? [eans] : [],
categories: categoryId != null ? String(categoryId) : null,
categories,
isParent: Boolean(article.isParent ?? article.is_parent ?? (childCount > 0)),
childCount,
parentArticleId: article.parentArticleId ?? article.parentarticle_id ?? null,
parentSku: article.parentSku ?? article.parent_sku ?? null,
fullHash,
thumbHash,
photos,
smallThumbBinary,
};
}
@@ -182,6 +202,24 @@ export function buildArticleIndex(views) {
existing.articlewarehousesummary = rec.articlewarehousesummary;
}
}
const incomingDescLen = Number(article.descLength ?? article.desclength ?? (article.desc ? String(article.desc).trim().length : 0)) || 0;
if ((existing.descLength == null || existing.descLength === 0) && incomingDescLen > 0) {
existing.descLength = incomingDescLen;
}
if ((!existing.seo || existing.seo.trim() === '') && article.seo) {
existing.seo = article.seo;
}
if (Array.isArray(article.eans) && article.eans.length > 0) {
const mergedEans = new Set(existing.eans ?? []);
for (const e of article.eans) if (e) mergedEans.add(e);
existing.eans = Array.from(mergedEans);
}
if (!existing.parentArticleId && (article.parentArticleId || article.parentarticle_id)) {
existing.parentArticleId = article.parentArticleId ?? article.parentarticle_id;
}
if (!existing.parentSku && (article.parentSku || article.parent_sku)) {
existing.parentSku = article.parentSku ?? article.parent_sku;
}
}
}
@@ -258,6 +296,32 @@ const actions = {
return items;
},
/**
* Refetches and updates a single article in the index and category views by SKU.
*
* @param {string|number} sku Article SKU.
* @returns {Promise<object|null>} Fresh article or null.
*/
async updateArticleBySku(sku) {
const key = String(sku);
try {
const res = await articleApi.getBySku(key);
const article = res?.article;
if (article) {
updateArticleIndexRecord(article);
return article;
}
return null;
} catch (err) {
if (err?.status === 404 || err?.statusCode === 404) {
removeArticleBySku(key);
const nextItems = articleIndexAutomat.state.items.filter((r) => String(r.sku) !== key);
articleIndexAutomat.setState({ items: nextItems });
}
return null;
}
},
/** Reset to idle state (logout / company switch). */
reset() {
inflightEnsure = null;
@@ -286,11 +350,90 @@ categoryAutomat.subscribe((state) => {
scheduleRebuild();
}
});
if (categoryAutomat.state.treeStatus === 'succeeded' && categoryAutomat.state.viewsLoading === false) {
scheduleRebuild();
}
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') actions.reset();
});
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.onArticle) {
sseAutomat.actions.onArticle(async (sku) => {
if (articleIndexAutomat.state.status === 'ready') {
await actions.updateArticleBySku(sku);
}
});
}
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('articles', () => {
invalidateAllCategoryViews();
void actions.reload().catch(() => {});
});
sseAutomat.actions.on('warehouse', () => {
invalidateAllCategoryViews();
void actions.reload().catch(() => {});
});
}
/**
* Updates a single article record in the derived index and loaded category views.
*
* @param {object} article Article detail or row.
* @returns {Array<ArticleIndexRecord>|undefined} Updated items array.
*/
export function updateArticleIndexRecord(article) {
if (!article || article.sku == null) return;
const key = String(article.sku);
updateArticleInCategoryViews(article);
const items = articleIndexAutomat.state.items;
if (!items || !items.length) return;
const existingIndex = items.findIndex((r) => String(r.sku) === key);
const categoryIds = Array.isArray(article.categoryIds)
? article.categoryIds.map(String)
: [];
const primaryCatId = categoryIds[0] ?? null;
const record = toIndexRecord(article, primaryCatId);
if (categoryIds.length > 0) {
record.categories = categoryIds.join(',');
}
let nextItems;
if (existingIndex >= 0) {
const existing = items[existingIndex];
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
const hasPhotosProp = 'photos' in article;
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
nextItems = [...items];
nextItems[existingIndex] = {
...existing,
...record,
categories: record.categories ?? existing.categories,
brand: article.brand ?? existing.brand,
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
fullHash,
thumbHash,
photos,
smallThumbBinary,
};
} else {
nextItems = [...items, record];
}
articleIndexAutomat.setState({ items: nextItems });
return nextItems;
}
/**
* Patches a single record's name in the derived index (optimistic update).
*

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
@@ -68,6 +67,8 @@ const initialState = {
filter: '',
brand: '',
supplier: '',
empty: '',
hierarchy: '',
offset: 0,
pageSize: null,
loading: false,
@@ -344,6 +345,8 @@ export function searchArticlesIndex(query, options = {}) {
extraArticles = [],
allowParent = false,
categories = [],
hierarchy = '',
empty = '',
offset = 0,
pageSize = 10,
limit,
@@ -368,7 +371,7 @@ export function searchArticlesIndex(query, options = {}) {
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
let pool = rawMatches;
if (!allowParent) {
if (!allowParent && !hierarchy) {
pool = pool.filter((a) => !a.isParent && !a.is_parent);
}
if (excludedSet.size > 0) {
@@ -379,6 +382,58 @@ export function searchArticlesIndex(query, options = {}) {
pool = pool.filter((a) => a.sku && includedSet.has(String(a.sku).toLowerCase()));
}
if (hierarchy === 'father') {
pool = pool.filter((a) => !a.parentArticleId && !a.parentSku);
} else if (hierarchy === 'child') {
pool = pool.filter((a) => !a.isParent && !a.is_parent && Number(a.childCount || 0) === 0);
}
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
pool = pool.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
let matchesList = pool;
if (categories && categories.length > 0) {
@@ -585,7 +640,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 +688,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) {
@@ -670,6 +725,20 @@ function buildSupplierFacets(matchedArticles) {
return facets;
}
function getSkusWithSupplier() {
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const set = new Set();
for (const list of Object.values(articlesBySupplier)) {
if (Array.isArray(list)) {
for (const item of list) {
if (item.article?.sku) set.add(String(item.article.sku).toLowerCase());
if (item.sku) set.add(String(item.sku).toLowerCase());
}
}
}
return set;
}
// #endregion
// #endregion
@@ -690,6 +759,8 @@ const actions = {
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const brand = params.brand !== undefined ? String(params.brand ?? '') : (currentState.brand ?? '');
const supplier = params.supplier !== undefined ? String(params.supplier ?? '') : (currentState.supplier ?? '');
const empty = params.empty !== undefined ? String(params.empty ?? '') : (currentState.empty ?? '');
const hierarchy = params.hierarchy !== undefined ? String(params.hierarchy ?? '') : (currentState.hierarchy ?? '');
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
@@ -738,7 +809,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)] ?? [];
@@ -753,6 +824,64 @@ const actions = {
});
}
// Apply hierarchy filter:
// - "father": "father or normal article" -> brand editable, exclude child variants
// - "child": "child or normal article" -> price editable, exclude parents
if (hierarchy === 'father') {
filteredArticles = filteredArticles.filter((row) => !row.parentArticleId && !row.parentSku);
} else if (hierarchy === 'child') {
filteredArticles = filteredArticles.filter(
(row) => !row.isParent && !row.is_parent && Number(row.childCount || 0) === 0,
);
}
// Apply empty filters: articles missing ean, photo, price, brand, supplier, seo, desc
const emptyFilters = parseFilter(empty);
if (emptyFilters.length > 0) {
const skusWithSupplier = emptyFilters.includes('supplier') ? getSkusWithSupplier() : null;
filteredArticles = filteredArticles.filter((row) => {
for (const f of emptyFilters) {
if (f === 'ean') {
const hasEan = (Array.isArray(row.eans) && row.eans.some((e) => e && String(e).trim())) ||
(row.ean && String(row.ean).trim());
if (hasEan) return false;
} else if (f === 'photo') {
const firstPhoto = Array.isArray(row.photos) && row.photos.length > 0 ? row.photos[0] : null;
const hasPhoto = Boolean(
row.fullHash ||
row.thumbHash ||
row.smallThumbBinary ||
firstPhoto?.fullHash ||
firstPhoto?.thumbHash ||
firstPhoto?.smallThumbBinary
);
if (hasPhoto) return false;
} else if (f === 'price') {
const p = row.price ?? row.net;
const hasPrice = p != null && Number(p) > 0;
if (hasPrice) return false;
} else if (f === 'brand') {
const hasBrand = Boolean(row.brand?.id || row.brand?.name || row.brandId || row.brandName);
if (hasBrand) return false;
} else if (f === 'supplier') {
const hasSupplier = Boolean(
(row.suppliers && row.suppliers.length > 0) ||
row.supplierId != null ||
(skusWithSupplier && skusWithSupplier.has(String(row.sku ?? '').toLowerCase()))
);
if (hasSupplier) return false;
} else if (f === 'seo') {
const hasSeo = Boolean(row.seo && String(row.seo).trim());
if (hasSeo) return false;
} else if (f === 'desc') {
const hasDesc = (Number(row.descLength) > 0) || Boolean((row.desc && String(row.desc).trim()) || (row.description && String(row.description).trim()));
if (hasDesc) return false;
}
}
return true;
});
}
const sortedArticles = sortArticlesByRelevance(filteredArticles, query);
const totalCount = sortedArticles.length;
@@ -782,6 +911,8 @@ const actions = {
filter,
brand,
supplier,
empty,
hierarchy,
offset,
pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading',
@@ -898,15 +1029,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

@@ -20,7 +20,12 @@ import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
import articleIndexAutomat, { setArticleIndexName, setArticleIndexStock } from './articleIndexAutomat';
import articleIndexAutomat, {
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
} from './articleIndexAutomat';
import sseAutomat from './sseAutomat';
/**
* @typedef {Object} ArtikelState
@@ -113,12 +118,7 @@ const actions = {
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
if (article.warehouses || article.stock != null) {
setArticleIndexStock(article.sku, {
stock: article.stock,
warehouses: article.warehouses,
});
}
updateArticleIndexRecord(article);
return article;
},
@@ -169,9 +169,8 @@ const actions = {
actions.setArticleName(key, body.name);
}
try {
const res = await articleApi.update(key, body);
actions.setArticle(res.article);
return res;
await articleApi.update(key, body);
return { article: await actions.reloadArticle(key) };
} catch (err) {
if (nameChange) {
// The optimistic patch was wrong: drop the index entry and refetch.
@@ -192,9 +191,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setEans(sku, eans) {
const res = await articleApi.setEansBySku(String(sku), eans);
actions.setArticle(res.article);
return res;
await articleApi.setEansBySku(String(sku), eans);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -205,9 +203,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setBrand(sku, body) {
const res = await articleApi.setBrandIdBySku(String(sku), body?.brandId);
actions.setArticle(res.article);
return res;
await articleApi.setBrandIdBySku(String(sku), body?.brandId);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -218,9 +215,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setCategories(sku, categoryIds) {
const res = await articleApi.setCategories(String(sku), categoryIds);
actions.setArticle(res.article);
return res;
await articleApi.setCategories(String(sku), categoryIds);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -231,9 +227,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setPrices(sku, prices) {
const res = await articleApi.setPrices(String(sku), prices);
actions.setArticle(res.article);
return res;
await articleApi.setPrices(String(sku), prices);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -244,9 +239,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setWeight(sku, body) {
const res = await articleApi.setWeight(String(sku), body);
actions.setArticle(res.article);
return res;
await articleApi.setWeight(String(sku), body);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -257,9 +251,8 @@ const actions = {
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setTrackStock(sku, body) {
const res = await articleApi.setTrackStock(String(sku), body);
if (res?.article) actions.setArticle(res.article);
return res;
await articleApi.setTrackStock(String(sku), body);
return { article: await actions.reloadArticle(sku) };
},
/**
@@ -332,4 +325,14 @@ authAutomat.subscribe((auth) => {
}
});
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.onArticle) {
sseAutomat.actions.onArticle((sku) => {
const key = String(sku);
if (key in artikelAutomat.state.bySku) {
void actions.reloadArticle(key).catch(() => {});
}
});
}
export default artikelAutomat;

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
@@ -36,7 +30,13 @@ import { auth, company } from '../api';
* @typedef {Object} AuthState
* @property {AuthUser|null} user Authenticated user, or `null` for anonymous sessions.
* @property {string|null} googleClientId Google Identity Services client ID.
* @property {number|null} schemaVersion Database schema version returned by the backend.
* @property {boolean} vhost Whether vhost mode is enabled.
* @property {string|null} vhostSettings Vhost configuration string (e.g. posport:41420;posip:147.135.129.77).
* @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.
* @property {string|null} retryNotification Message indicating a pending retry (e.g. 502 Bad Gateway).
*/
/** Emitted after an authenticated session has been established. */
@@ -50,11 +50,30 @@ export const AUTH_LOGGED_OUT = 'auth/loggedOut';
const initialState = {
user: null,
googleClientId: null,
schemaVersion: null,
vhost: false,
vhostSettings: null,
status: 'loading',
hasAuth: false,
hasCompany: false,
retryNotification: null,
};
let emit = () => { };
// #region helpers
/**
* Detects whether an error represents an HTTP 502 Bad Gateway response.
*
* @param {any} err
* @returns {boolean}
*/
export function is502BadGateway(err) {
if (!err) return false;
if (Number(err.status) === 502) return true;
const msg = String(err.message || '');
return /502|bad\s*gateway/i.test(msg);
}
/**
* Registers the application-level dispatcher used for authentication events.
*
@@ -65,7 +84,7 @@ export function setAuthEventDispatcher(dispatch) {
emit = dispatch;
}
function normalizePicture(picture) {
export function normalizePicture(picture) {
if (!picture || typeof picture !== 'string') return null;
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
return picture;
@@ -93,43 +112,132 @@ 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,
vhost: authAutomat?.state?.vhost ?? false,
vhostSettings: authAutomat?.state?.vhostSettings ?? null,
retryNotification: null,
...extra,
};
}
function syncSse(state) {
const sseAutomat = Automat.get('sse');
if (sseAutomat?.actions?.syncWithAuth) {
sseAutomat.actions.syncWithAuth(state);
}
}
function applySession(nextSession) {
const googleClientId =
nextSession.googleClientId ?? authAutomat.state.googleClientId;
const schemaVersion =
nextSession.schemaVersion !== undefined
? nextSession.schemaVersion
: (authAutomat.state.schemaVersion ?? null);
const vhost =
nextSession.vhost !== undefined
? Boolean(nextSession.vhost)
: (authAutomat.state.vhost ?? false);
const vhostSettings =
nextSession.vhostSettings !== undefined
? nextSession.vhostSettings
: (authAutomat.state.vhostSettings ?? null);
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
} else {
authAutomat.setState({ googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
const nextState = authAutomat.setState(
buildAuthState(user, 'authed', { googleClientId, schemaVersion, vhost, vhostSettings, retryNotification: null })
);
syncSse(nextState);
emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession;
}
// #endregion
let authAutomat;
let inFlightBootstrap = null;
const actions = {
// #region bootstrapSession
// MARK: calls auth.me from ../api
/**
* Restores an existing session or requests it from the authentication API.
* Retries automatically every 5 seconds if a 502 Bad Gateway is received.
*
* @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
* Optional preloaded session.
* @returns {Promise<Object>} Resolved session response.
*/
async bootstrapSession(session) {
try {
const nextSession = session ?? await auth.me();
const googleClientId =
nextSession.googleClientId ?? authAutomat.state.googleClientId;
if (!nextSession.user) {
if (authAutomat.state.status !== 'authed') {
authAutomat.setState({ user: null, googleClientId, status: 'anon' });
} else {
authAutomat.setState({ googleClientId });
}
return nextSession;
}
const user = mergeUser(authAutomat.state.user, nextSession.user);
authAutomat.setState({ user, googleClientId, status: 'authed' });
emit({ type: AUTH_SESSION_ESTABLISHED });
return nextSession;
} catch (error) {
if (authAutomat.state.status === 'loading') {
authAutomat.setState({ user: null, status: 'anon' });
}
throw error;
if (session) {
return applySession(session);
}
},
if (inFlightBootstrap) {
return inFlightBootstrap;
}
inFlightBootstrap = (async () => {
let attempt = 0;
while (true) {
try {
const nextSession = await auth.me();
return applySession(nextSession);
} catch (error) {
if (is502BadGateway(error)) {
attempt++;
authAutomat.setState({
retryNotification: `Server nicht erreichbar (502 Bad Gateway) – erneuter Versuch in 5s…${attempt > 1 ? ` (Versuch ${attempt})` : ''}`,
});
await new Promise((resolve) => setTimeout(resolve, 5000));
continue;
}
if (authAutomat.state.status === 'loading') {
const nextState = authAutomat.setState(
buildAuthState(null, 'anon', { retryNotification: null })
);
syncSse(nextState);
}
throw error;
}
}
})().finally(() => {
inFlightBootstrap = null;
});
return inFlightBootstrap;
},
// #endregion
// #region loginWithGoogle
// MARK: calls auth.loginWithGoogle from ../api
/**
* Exchanges a Google credential for an authenticated application session.
*
@@ -143,19 +251,25 @@ 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,
schemaVersion: session.schemaVersion ?? authAutomat.state.schemaVersion ?? null,
})
);
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 +282,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 +298,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 +309,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 +345,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,16 +359,27 @@ 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;
export const selectRetryNotification = () => authAutomat.state.retryNotification;
export const selectSchemaVersion = () => authAutomat.state.schemaVersion;
export const selectVhost = () => Boolean(authAutomat.state.vhost);
export const selectVhostSettings = () => authAutomat.state.vhostSettings;
/**
* Re-reads `GET /api/auth/me` from the server.
*
* Called after login, logout, and company switches so the automat reflects
* the current server session rather than an optimistic guess.
*
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
* @returns {Promise<{user: AuthUser|null, googleClientId: string|null, schemaVersion?: number|null}>} Session.
*/
export function bootstrapAuthMe() {
return auth.me();
return authAutomat.actions.bootstrapSession();
}
// #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

@@ -30,6 +30,7 @@ import { Automat } from 'react-automat';
import { category as categoryApi, article as articleApi } from '../api';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
import sseAutomat from './sseAutomat';
import {
attachCategoryTree,
categoryViews,
@@ -37,6 +38,7 @@ import {
disposeAllCategoryViewAutomats,
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku,
waitForCategoryTree,
} from './categoryViewAutomat';
@@ -213,8 +215,8 @@ const actions = {
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ categoryId: parentId, name });
const created = res.article;
const { sku } = await articleApi.create({ categoryId: parentId, name });
const { article: created } = await articleApi.getBySku(sku);
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
},
@@ -228,8 +230,9 @@ const actions = {
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ categoryId: parentId, name });
if (res.article) created.push(res.article);
const { sku } = await articleApi.create({ categoryId: parentId, name });
const { article: created1 } = await articleApi.getBySku(sku);
if (created1) created.push(created1);
}
appendArticlesToCategoryViews({ categoryId: parentId, articles: created });
return created;
@@ -400,6 +403,28 @@ const actions = {
return res.category;
},
/**
* Generate a mosaic image for a category from its articles and subcategories.
* @param {{ categoryId: number|string, size?: number }} param0
*/
async generateCategoryImage({ categoryId, size } = {}) {
const res = await categoryApi.generateImage(categoryId, { size });
const { id, fullHash, thumbW, thumbH, thumbHash } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
const nextCategories = {
...categories,
[key]: { ...categories[key], fullHash, thumbW, thumbH, thumbHash },
};
categoryAutomat.setState({
categories: nextCategories,
});
refreshCategoryViewChildren(nextCategories);
return res.category;
},
// ─── Local state patches ───────────────────────────────────────────────────
/** Remove an article from all live category view lists (used after article deletion). */
@@ -429,6 +454,11 @@ const actions = {
refreshCategoryViewChildren(nextCategories);
},
/** Invalidate all loaded category views. */
invalidateAllCategoryViews() {
invalidateAllCategoryViews();
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
@@ -456,9 +486,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;
@@ -471,7 +501,10 @@ companyAutomat.subscribe((company) => {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
}
companyAutomat.subscribe(syncCategoryWithCompany);
syncCategoryWithCompany(companyAutomat.state);
// Reset tree on logout
authAutomat.subscribe((auth) => {
@@ -481,6 +514,14 @@ authAutomat.subscribe((auth) => {
}
});
// Upstream Trigger: sseAutomat
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('category', () => {
void categoryAutomat.actions.loadTree().catch(() => {});
invalidateAllCategoryViews();
});
}
// Re-export pure helpers for consumers that previously imported from categorySlice
export {
ROOT_CATEGORY_NAME,
@@ -501,6 +542,7 @@ export {
disposeAllCategoryViewAutomats,
loadAllCategoryViews,
collectLoadedCategoryViewData,
updateArticleInCategoryViews,
} from './categoryViewAutomat';
export default categoryAutomat;

View File

@@ -323,6 +323,75 @@ export function appendArticlesToCategoryViews({ categoryId, articles }) {
}
}
/**
* Updates an article in all loaded category views.
*
* @param {object} article Article detail or row with at least `sku`.
* @returns {void}
*/
export function updateArticleInCategoryViews(article) {
if (!article || article.sku == null) return;
const sku = String(article.sku);
const targetCategoryIds = Array.isArray(article.categoryIds)
? new Set(article.categoryIds.map(String))
: null;
for (const [slug, item] of categoryViews._items.entries()) {
if (!item?.data) continue;
const viewCatId = String(item.data.categoryId);
const existingIndex = (item.data.articles || []).findIndex((r) => String(r.sku) === sku);
if (existingIndex >= 0) {
if (targetCategoryIds && !targetCategoryIds.has(viewCatId)) {
// Article moved out of this category
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => String(r.sku) !== sku),
});
} else {
// Update in-place
const existing = item.data.articles[existingIndex];
const updatedArticles = [...item.data.articles];
const firstPhoto = Array.isArray(article.photos) && article.photos.length > 0 ? article.photos[0] : null;
const hasPhotosProp = 'photos' in article;
const fullHash = article.fullHash ?? firstPhoto?.fullHash ?? (hasPhotosProp ? null : existing.fullHash);
const thumbHash = article.thumbHash ?? firstPhoto?.thumbHash ?? (hasPhotosProp ? null : existing.thumbHash);
const photos = article.photos ?? (hasPhotosProp ? [] : (existing.photos ?? []));
const smallThumbBinary = article.smallThumbBinary ?? firstPhoto?.smallThumbBinary ?? (hasPhotosProp ? null : existing.smallThumbBinary);
updatedArticles[existingIndex] = {
...existing,
...article,
name: article.name ?? existing.name,
price: article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? article.net ?? existing.price,
net: article.net ?? article.price ?? (Array.isArray(article.prices) ? article.prices[0]?.net : null) ?? existing.net,
stock: article.stock ?? existing.stock,
available: article.available ?? existing.available,
warehouses: article.warehouses ?? existing.warehouses,
brand: article.brand ?? existing.brand,
brandName: article.brand?.name ?? article.brandName ?? existing.brandName,
childCount: article.childCount ?? existing.childCount,
isParent: Boolean(article.childCount > 0 || article.isParent || existing.isParent),
fullHash,
thumbHash,
photos,
smallThumbBinary,
};
categoryViews.key(slug).set({
...item.data,
articles: updatedArticles,
});
}
} else if (targetCategoryIds && targetCategoryIds.has(viewCatId)) {
// Article added to this category
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), article],
});
}
}
}
/**
* Removes a deleted article from every loaded view that contains it.
*

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

@@ -746,6 +746,42 @@ export function createDeliveryEditorAutomat(options = {}) {
})
);
// Preserve already attached files in the editor so loading a draft manifest does not wipe them out.
const currentAttachments =
automat.state.attachments?.length > 0
? automat.state.attachments
: (Array.isArray(automat.state.files) && automat.state.files.length > 0 ? automat.state.files : []);
const rawManifestAttachments =
data.attachments ??
data.files ??
basedata.attachments ??
basedata.files ??
null;
const manifestAttachments = Array.isArray(rawManifestAttachments) ? rawManifestAttachments : [];
let attachments = currentAttachments;
if (currentAttachments.length === 0) {
attachments = manifestAttachments;
} else if (manifestAttachments.length > 0) {
const existingKeys = new Set(
currentAttachments.flatMap((a) => [
a.id != null ? String(a.id) : null,
a.name ? String(a.name).trim().toLowerCase() : null,
]).filter(Boolean)
);
const newAttachments = manifestAttachments.filter((a) => {
const idKey = a.id != null ? String(a.id) : null;
const nameKey = a.name ? String(a.name).trim().toLowerCase() : null;
if (idKey && existingKeys.has(idKey)) return false;
if (nameKey && existingKeys.has(nameKey)) return false;
return true;
});
if (newAttachments.length > 0) {
attachments = [...currentAttachments, ...newAttachments];
}
}
automat.actions.setDelivery({
id: null,
deliveryId: null,
@@ -758,6 +794,8 @@ export function createDeliveryEditorAutomat(options = {}) {
type,
lines,
lineitems: lines,
attachments,
files: attachments,
});
},

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

@@ -0,0 +1,100 @@
/**
* @file healthAutomat.js
* @description Backend latency samples for the settings "Gesundheit" tab
* (`GET /api/health/latency`). Not company-scoped; only polls while a view
* has called `actions.startPolling()`.
*/
import { Automat } from 'react-automat';
import { health as healthApi } from '../api';
const POLL_MS = 60_000;
/**
* @typedef {{ timestamp: number, value: number }} LatencyPoint
* @typedef {Object} HealthState
* @property {'idle'|'loading'|'ready'|'failed'} status
* @property {string|null} error
* @property {'3h'|'all'} range
* @property {LatencyPoint[]} raw Per-minute samples (last 3h)
* @property {LatencyPoint[]} avg 30-min rolling 4h average (range=all only)
* @property {LatencyPoint[]} max 30-min rolling 4h max (range=all only)
*/
/** @type {HealthState} */
const initialState = {
status: 'idle',
error: null,
range: '3h',
raw: [],
avg: [],
max: [],
};
let healthAutomat;
let inflight = null;
let timer = null;
let pollers = 0;
const actions = {
// #region load
// MARK: calls GET /api/health/latency from ./api/index.js
/**
* Loads latency data for the current range. Deduplicates concurrent calls.
* @param {boolean} [silent=false] Keep status 'ready' while refreshing
*/
load(silent = false) {
if (inflight) return inflight;
const { range, status } = healthAutomat.state;
if (!silent && status !== 'ready') healthAutomat.setState({ status: 'loading', error: null });
inflight = (async () => {
try {
const data = await healthApi.getLatency(range);
// Range may have changed while the request was in flight.
if (healthAutomat.state.range !== range) return;
healthAutomat.setState({
status: 'ready',
error: null,
raw: data?.raw ?? [],
avg: data?.avg ?? [],
max: data?.max ?? [],
});
} catch (err) {
healthAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
} finally {
inflight = null;
}
})();
return inflight;
},
// #endregion
/** Switches between the live 3h view and the long-term view. */
setRange(range) {
if (range === healthAutomat.state.range) return;
healthAutomat.setState({ range, status: 'loading', error: null });
void healthAutomat.actions.load();
},
// #region polling
/** Starts the once-a-minute refresh (ref-counted). */
startPolling() {
pollers += 1;
if (pollers > 1) return;
void actions.load(healthAutomat.state.status === 'ready');
timer = setInterval(() => void actions.load(true), POLL_MS);
},
stopPolling() {
pollers = Math.max(0, pollers - 1);
if (pollers === 0 && timer) {
clearInterval(timer);
timer = null;
}
},
// #endregion
};
healthAutomat = new Automat(initialState, actions, { name: 'health' });
export default healthAutomat;

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,
@@ -63,6 +56,7 @@ export {
default as articleIndexAutomat,
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex,
selectArticleIndexItems,
} from './articleIndexAutomat';
@@ -98,9 +92,20 @@ export {
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';
export { default as numberAutomat } from './numberAutomat';
export { default as accountAutomat } from './accountAutomat';
export {
default as retoureAutomat,
selectRetoures,
selectRetoureTotal,
selectRetoureUnfilteredTotal,
isRetourePaid,
isRetoureShipped,
isRetoureDelivered,
getRetoureDate,
getItems as getRetoureItems,
requireChunk as requireRetoureChunk,
getChunkStats as getRetoureChunkStats,
subscribeChunkStats as subscribeRetoureChunkStats,
} from './retoureAutomat';
export { default as backupAutomat } from './backupAutomat';
export {
default as shipmentAutomat,
@@ -113,11 +118,9 @@ 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, articleSupplierAutomat } from './articleDetailAutomat';
export { articleHistoryAutomat, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
/** Single shared GET /api/events connection. */
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
@@ -132,6 +135,7 @@ export {
export { default as companyAutomat } from './companyAutomat';
export {
selectCompanies,
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
@@ -142,19 +146,45 @@ 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';
selectPrinterAgents,
selectPrinterAgentStatus,
selectHasPrinterAgents,
selectAgentPrinters,
selectAgentPrinterStatus,
selectLivePrinters,
selectSuppliers,
selectSuppliersStatus,
selectUnits,
selectUnitsStatus,
setViewerPosition,
setChatDrawerWidth,
selectChatDrawerWidth,
selectWarehouses,
selectWarehousesStatus,
selectProperties,
selectPropertyStatus,
selectCustomergroups,
selectCustomergroupsStatus,
selectCarriers,
selectCarriersStatus,
selectBrands,
selectBrandsStatus,
selectAttributes,
selectAttributeStatus,
selectAccounts,
selectAccountsStatus,
selectCompanyRole,
selectIsCompanyAdmin,
selectMembers,
selectMembersStatus,
selectInvites,
selectInvitesStatus,
} from './companyAutomat';
export { default as categoryAutomat } from './categoryAutomat';
export {
@@ -218,84 +248,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

@@ -224,14 +224,21 @@ export const orderDraftAutomat = createManifestDraftAutomat({
name: 'order-draft',
});
/** Shared singleton automat for retoure draft. */
export const retoureDraftAutomat = createManifestDraftAutomat({
kind: 'retoure',
name: 'retoure-draft',
});
/**
* Returns the draft automat for a given manifest kind.
* @param {'quote'|'order'} kind
* @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null}
*/
export function getManifestDraftAutomat(kind) {
if (kind === 'quote') return quoteDraftAutomat;
if (kind === 'order') return orderDraftAutomat;
if (kind === 'retoure') return retoureDraftAutomat;
return null;
}
@@ -343,6 +350,9 @@ export function getManifestDirtyDetails(current, baseline) {
if (String(cl.unit ?? '').trim() !== String(bl.unit ?? '').trim()) {
lineFieldDiffs.unit = true;
}
if (Boolean(cl.shippable ?? true) !== Boolean(bl.shippable ?? true)) {
lineFieldDiffs.shippable = true;
}
if (Object.keys(lineFieldDiffs).length > 0) {
dirtyLineKeys.push(key);
@@ -420,6 +430,7 @@ export function createManifestEditAutomat({ kind, name }) {
const actions = {
loadDoc(id, doc) {
const date = dateInputValue(doc.date);
const createdAt = doc.createdAt ?? doc.created_at ?? null;
const partyId = doc.partyId ?? null;
const number = doc.number ?? '';
const externalNumber = doc.externalNumber != null ? String(doc.externalNumber) : '';
@@ -428,6 +439,7 @@ export function createManifestEditAutomat({ kind, name }) {
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
@@ -440,6 +452,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id,
date,
createdAt,
partyId,
number,
externalNumber,
@@ -448,10 +461,10 @@ export function createManifestEditAutomat({ kind, name }) {
shipments: doc.shipments || [],
payments: doc.payments || [],
shippings: doc.shippings || [],
orderTotal: doc.orderTotal ?? null,
paidTotal: doc.paidTotal ?? null,
paid: Boolean(doc.paid),
shipped: Boolean(doc.shipped),
orderTotal: doc.orderTotal ?? doc.grossTotal ?? doc.gross_total ?? null,
paidTotal: doc.paidTotal ?? doc.paid_total ?? null,
paid: Boolean(doc.paid ?? doc.isPaid ?? doc.is_paid),
shipped: Boolean(doc.shipped ?? doc.isShipped ?? doc.is_shipped),
delivered: Boolean(doc.delivered),
baseline,
isDirty: false,
@@ -505,6 +518,7 @@ export function createManifestEditAutomat({ kind, name }) {
commitSaved(savedDoc) {
const date = dateInputValue(savedDoc.date);
const createdAt = savedDoc.createdAt ?? savedDoc.created_at ?? automat.state.createdAt ?? null;
const partyId = savedDoc.partyId ?? null;
const number = savedDoc.number ?? automat.state.number;
const externalNumber = savedDoc.externalNumber != null ? String(savedDoc.externalNumber) : '';
@@ -513,6 +527,7 @@ export function createManifestEditAutomat({ kind, name }) {
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
@@ -522,6 +537,7 @@ export function createManifestEditAutomat({ kind, name }) {
automat.setState({
date,
createdAt,
partyId,
number,
externalNumber,
@@ -542,6 +558,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
@@ -563,6 +580,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
@@ -594,13 +612,20 @@ export const orderEditAutomat = createManifestEditAutomat({
name: 'order-edit',
});
/** Shared singleton automat for the single last edited retoure. */
export const retoureEditAutomat = createManifestEditAutomat({
kind: 'retoure',
name: 'retoure-edit',
});
/**
* Returns the edit automat for a given manifest kind.
* @param {'quote'|'order'} kind
* @param {'quote'|'order'|'retoure'} kind
* @returns {Automat|null}
*/
export function getManifestEditAutomat(kind) {
if (kind === 'quote') return quoteEditAutomat;
if (kind === 'order') return orderEditAutomat;
if (kind === 'retoure') return retoureEditAutomat;
return null;
}

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

@@ -69,6 +69,7 @@ const initialState = {
filter: '',
onlyUnpaid: false,
onlyUndelivered: false,
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -117,25 +118,27 @@ export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const onlyUnpaid = Boolean(state.onlyUnpaid);
const onlyUndelivered = Boolean(state.onlyUndelivered);
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths };
return { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, onlyUnpaid, onlyUndelivered, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, s: negativeSeekMonths });
const { filter, onlyUnpaid, onlyUndelivered, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, u: onlyUnpaid, d: onlyUndelivered, e: entityId, s: negativeSeekMonths });
}
export function formatFilterLabel(filterKeyOrState) {
let f, u, d, s;
let f, u, d, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
u = parsed.u;
d = parsed.d;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -145,18 +148,21 @@ export function formatFilterLabel(filterKeyOrState) {
f = filterKeyOrState.f;
u = filterKeyOrState.u;
d = filterKeyOrState.d;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
u = params.onlyUnpaid;
d = params.onlyUndelivered;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (u) parts.push('Nur unbezahlt');
if (d) parts.push('Nur ungeliefert');
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
@@ -447,7 +453,7 @@ export function isOrderPaid(order) {
export function isOrderDelivered(order) {
if (!order) return false;
return Boolean(order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
return Boolean(order.isShipped ?? order.shipped ?? order.delivered ?? order.isDelivered ?? order.is_delivered ?? order.deliveredAt);
}
export function getOrderDate(order) {
@@ -498,6 +504,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
if (trimmed) params.search = trimmed;
if (filterParams.onlyUnpaid) params.onlyUnpaid = true;
if (filterParams.onlyUndelivered) params.onlyUndelivered = true;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -859,6 +866,12 @@ const actions = {
onFilterChange(true);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;

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

@@ -70,6 +70,7 @@ const initialState = {
total: 0,
unfilteredTotal: 0,
filter: '',
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -112,23 +113,25 @@ export function parseLruKey(key) {
// MARK: pure filter helpers
export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, negativeSeekMonths };
return { filter, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, s: negativeSeekMonths });
const { filter, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, e: entityId, s: negativeSeekMonths });
}
export function formatFilterLabel(filterKeyOrState) {
let f, s;
let f, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -136,16 +139,19 @@ export function formatFilterLabel(filterKeyOrState) {
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
if ('f' in filterKeyOrState) {
f = filterKeyOrState.f;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
if (parts.length === 0) return 'Standard (Alle Angebote)';
return parts.join(' · ');
@@ -439,6 +445,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const params = { offset, limit };
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -777,6 +784,12 @@ const actions = {
onFilterChange(false);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ const initialState = {
unfilteredTotal: 0,
filter: '',
stateFilter: '',
entityId: null,
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
@@ -107,15 +108,16 @@ export function parseLruKey(key) {
export function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const stateFilter = String(state.stateFilter ?? '').trim();
const entityId = state.entityId != null && state.entityId !== '' ? Number(state.entityId) : null;
const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0)
? Number(state.negativeSeekMonths)
: 0;
return { filter, stateFilter, negativeSeekMonths };
return { filter, stateFilter, entityId, negativeSeekMonths };
}
export function getFilterKey(state = automat?.state || initialState) {
const { filter, stateFilter, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, st: stateFilter, s: negativeSeekMonths });
const { filter, stateFilter, entityId, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, st: stateFilter, e: entityId, s: negativeSeekMonths });
}
export function formatNegativeSeek(months) {
@@ -134,12 +136,13 @@ export function formatNegativeSeek(months) {
}
export function formatFilterLabel(filterKeyOrState) {
let f, st, s;
let f, st, e, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
st = parsed.st;
e = parsed.e;
s = parsed.s;
} catch {
return filterKeyOrState;
@@ -148,17 +151,20 @@ export function formatFilterLabel(filterKeyOrState) {
if ('f' in filterKeyOrState) {
f = filterKeyOrState.f;
st = filterKeyOrState.st;
e = filterKeyOrState.e;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
st = params.stateFilter;
e = params.entityId;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (e != null) parts.push(`Kunde: #${e}`);
if (st) parts.push(`Status: ${st}`);
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
if (parts.length === 0) return 'Standard (Alle Lieferscheine)';
@@ -436,6 +442,7 @@ function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) {
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (filterParams.stateFilter) params.state = filterParams.stateFilter;
if (filterParams.entityId != null) params.entityId = filterParams.entityId;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
@@ -784,6 +791,12 @@ const actions = {
onFilterChange(true);
},
setEntityId(entityId) {
const parsed = entityId != null && entityId !== '' ? Number(entityId) : null;
automat.setState({ entityId: parsed });
onFilterChange(true);
},
setSeekMonths(months) {
actions.addNegativeSeek(-months);
},

View File

@@ -1,43 +1,27 @@
/**
* @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 shipmentAutomat from './shipmentAutomat';
import carrierAutomat from './carrierAutomat';
/**
* @typedef {Object} SseState
@@ -45,10 +29,10 @@ import carrierAutomat from './carrierAutomat';
* @property {string|null} companyId Company the stream is bound to.
* @property {string|null} clientId Tab-scoped random session id sent as `?cid=` to `/api/events`.
* @property {Array<{id: number, type: string, data: string, lastEventId: string, time: number}>} events Recent stream events.
* @property {{id: number, type: string, data: string, lastEventId: string, time: number}|null} lastEvent Most recent event.
*/
const EVENT_HISTORY_LIMIT = 200;
const SSE_CLIENT_ID_KEY = 'sse.cid';
function createClientId() {
@@ -62,8 +46,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.
*/
@@ -80,7 +62,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} */
@@ -89,6 +70,7 @@ const initialState = {
companyId: null,
clientId: sseClientId,
events: [],
lastEvent: null,
};
/** Event name prefix for per-article signals. */
@@ -97,18 +79,20 @@ const ARTICLE_EVENT_PREFIX = 'article-';
let sseAutomat;
/** @type {EventSource | null} */
let eventSource = null;
/** @type {AbortController | null} */
let streamAbortController = null;
let reconnectTimer = null;
/**
* Handlers for statically named events (`category`, `articles`, …).
* @type {Map<string, Set<{handler: (event: MessageEvent) => void, detach: (() => void)|null}>>}
* Handlers for event subscriptions (`category`, `articles`, `article-*`, …).
* @type {Map<string, Set<{handler: Function, detach: (() => void)|null}>>}
*/
const handlers = new Map();
let nextEventId = 0;
const seenEvents = new WeakSet();
/**
* Handlers per watched SKU, so one `article-<sku>` listener is attached while
* at least one view is open on that SKU.
* Handlers per watched SKU.
* @type {Map<string, Set<(sku: string) => void>>}
*/
const articleWatchers = new Map();
@@ -120,25 +104,41 @@ function recordEvent(event) {
const entry = {
id: ++nextEventId,
type: event.type,
data: typeof event.data === 'string' ? event.data : '',
data: typeof event.data === 'string' ? event.data : (event.data ? JSON.stringify(event.data) : ''),
lastEventId: event.lastEventId ?? '',
time: Date.now(),
};
sseAutomat.setState((state) => ({
events: [...state.events.slice(-(EVENT_HISTORY_LIMIT - 1)), entry],
lastEvent: entry,
}));
}
function addTrackedListener(es, type, handler) {
const listener = (event) => {
recordEvent(event);
handler(event);
};
es.addEventListener(type, listener);
return () => es.removeEventListener(type, listener);
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
clearReconnectTimer();
const auth = authAutomat.state;
const hasAuth = auth.hasAuth ?? (auth.status === 'authed' && Boolean(auth.user));
if (!hasAuth) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 1500);
}
function disconnect() {
clearReconnectTimer();
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
if (eventSource) {
console.log('[sse] disconnect');
try {
@@ -146,21 +146,155 @@ function disconnect() {
} catch {}
eventSource = null;
}
if (sseAutomat.state.status !== 'disconnected') {
if (sseAutomat && sseAutomat.state.status !== 'disconnected') {
sseAutomat.setState({ status: 'disconnected' });
}
}
/**
* Attaches every registered handler to a freshly opened stream.
* Dispatches an incoming event through registered handlers and article watchers.
*
* @param {EventSource} es Open event stream.
* @returns {void}
* @param {{ type: string, data: any, lastEventId?: string }} event
*/
function dispatchSseEvent(event) {
recordEvent(event);
const eventType = String(event.type);
// 1. Built-in system handling (e.g. user / company switch sync)
if (eventType === 'user') {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data || '{}') : (event.data || {});
const nextCompanyId = payload.selectedCompanyId ?? null;
if (authAutomat.state.user?.selectedCompanyId !== nextCompanyId) {
authAutomat.setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, selectedCompanyId: nextCompanyId } : prev.user,
hasCompany: Boolean((prev.hasAuth ?? (prev.status === 'authed' && prev.user)) && nextCompanyId != null),
}));
}
} catch (err) {
console.error('[sse] failed to handle user event', err);
}
}
// 2. Dispatch to exact handlers registered via on(eventType, handler)
const exactSet = handlers.get(eventType);
if (exactSet && exactSet.size > 0) {
for (const entry of exactSet) {
try {
entry.handler(event);
} catch (err) {
console.error(`[sse] error in handler for ${eventType}:`, err);
}
}
} else {
// Fallback direct dispatch if store didn't subscribe yet
if (eventType === 'articles') {
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'category') {
void Automat.get('category')?.actions?.loadTree?.().catch(() => {});
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
} else if (eventType === 'company') {
void Automat.get('company')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'warehouse') {
void Automat.get('company')?.actions?.reloadWarehouses?.().catch(() => {});
Automat.get('category')?.actions?.invalidateAllCategoryViews?.();
void Automat.get('articleIndex')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'supplier') {
void Automat.get('company')?.actions?.reloadSuppliers?.().catch(() => {});
} else if (eventType === 'brand') {
void Automat.get('company')?.actions?.loadBrands?.(true).catch(() => {});
} else if (eventType === 'unit') {
void Automat.get('company')?.actions?.loadUnits?.().catch(() => {});
} else if (eventType === 'carrier') {
void Automat.get('company')?.actions?.reloadCarriers?.().catch(() => {});
} else if (eventType === 'posterminal') {
void Automat.get('company')?.actions?.loadTerminals?.().catch(() => {});
} else if (eventType === 'customergroup') {
void Automat.get('company')?.actions?.reloadCustomergroups?.().catch(() => {});
} else if (eventType === 'propertyname') {
void Automat.get('company')?.actions?.loadProperties?.().catch(() => {});
} else if (eventType === 'attributename') {
void Automat.get('company')?.actions?.loadAttributes?.().catch(() => {});
} else if (eventType === 'account') {
void Automat.get('company')?.actions?.reloadAccounts?.().catch(() => {});
} else if (eventType === 'order') {
void Automat.get('order')?.actions?.reload?.().catch(() => {});
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'delivery') {
void Automat.get('delivery')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'retoure') {
void Automat.get('retoure')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'quote') {
void Automat.get('quote')?.actions?.reload?.().catch(() => {});
} else if (eventType === 'shipment') {
void Automat.get('shipment')?.actions?.reload?.().catch(() => {});
}
}
// 3. Dispatch to article-specific handlers if it starts with article-
if (eventType.startsWith(ARTICLE_EVENT_PREFIX)) {
const sku = eventType.slice(ARTICLE_EVENT_PREFIX.length);
const lowerSku = sku.trim().toLowerCase();
// Watchers registered via watchArticle(sku, handler)
const watchers = articleWatchers.get(lowerSku) || articleWatchers.get(sku);
if (watchers) {
for (const handler of watchers) {
try {
handler(sku);
} catch (err) {
console.error(`[sse] error in article watcher for ${sku}:`, err);
}
}
}
// Article wildcard handlers (registered via on('article-*', ...) or onArticle(...))
const articleWildcard = handlers.get('article-*');
if (articleWildcard) {
for (const entry of articleWildcard) {
try {
entry.handler(sku, event);
} catch (err) {
console.error('[sse] error in article-* handler:', err);
}
}
}
// Single article detail & history/supplier caches invalidation
invalidateArticle(sku);
}
// 4. Wildcard handlers registered via on('*', handler)
const allWildcard = handlers.get('*');
if (allWildcard) {
for (const entry of allWildcard) {
try {
entry.handler(event);
} catch (err) {
console.error('[sse] error in * handler:', err);
}
}
}
}
function addTrackedListener(es, type, handler) {
const listener = (event) => {
dispatchSseEvent(event);
handler(event);
};
es.addEventListener(type, listener);
return () => es.removeEventListener(type, listener);
}
function attachHandlers(es) {
for (const [event, set] of handlers) {
if (event === '*' || event === 'article-*') continue;
for (const entry of set) {
entry.detach = addTrackedListener(es, event, entry.handler);
entry.detach = addTrackedListener(es, event, (ev) => dispatchSseEvent(ev));
}
}
for (const [sku] of articleWatchers) {
@@ -168,144 +302,178 @@ 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}
*/
function attachArticleListener(sku) {
if (!eventSource) return;
addTrackedListener(eventSource, `${ARTICLE_EVENT_PREFIX}${sku}`, (event) => {
const set = articleWatchers.get(sku);
if (!set || set.size === 0) return;
const changed = String(event.type).slice(ARTICLE_EVENT_PREFIX.length);
for (const handler of set) handler(changed);
const set = articleWatchers.get(sku);
if (set) {
for (const handler of set) handler(changed);
}
});
}
function connect() {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (companyId == null) {
disconnect();
return;
async function startFetchStream(url, companyId) {
if (streamAbortController) {
streamAbortController.abort();
streamAbortController = null;
}
const controller = new AbortController();
streamAbortController = controller;
const signal = controller.signal;
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) });
try {
const res = await fetch(url.href, {
credentials: 'include',
headers: { Accept: 'text/event-stream' },
signal,
});
if (signal.aborted) return;
if (!res.ok) {
if (res.status === 401) {
sseAutomat.setState({ status: 'disconnected' });
return;
}
throw new Error(`SSE HTTP ${res.status}`);
}
if (!res.body) {
throw new Error('Response body is null');
}
sseAutomat.setState({ status: 'connected', companyId });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = 'message';
let eventData = '';
let lastEventId = '';
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r\n|\r|\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
if (line === '') {
if (eventData || eventType !== 'message') {
dispatchSseEvent({
type: eventType,
data: eventData,
lastEventId,
});
}
eventType = 'message';
eventData = '';
} else if (line.startsWith(':')) {
continue;
} else if (line.startsWith('event:')) {
eventType = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const chunk = line.slice(5).trim();
eventData = eventData ? `${eventData}\n${chunk}` : chunk;
} else if (line.startsWith('id:')) {
lastEventId = line.slice(3).trim();
}
}
}
} catch (err) {
if (signal.aborted) return;
console.error('[sse] stream error', err);
recordEvent({ type: 'error', data: err?.message || '', lastEventId: '' });
sseAutomat.setState({ status: 'error' });
} finally {
if (!signal.aborted) {
scheduleReconnect();
}
}
}
function startEventSource(url, 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();
});
// 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);
});
});
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);
});
});
addTrackedListener(es, 'carrier', () => {
void carrierAutomat.actions.reload().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);
});
});
addTrackedListener(es, 'propertyname', () => {
void propertyAutomat.actions.load().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);
});
});
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);
});
});
addTrackedListener(es, 'quote', () => {
void quoteAutomat.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);
});
});
addTrackedListener(es, 'delivery', () => {
void deliveryAutomat.actions.reload().catch((err) => {
console.error('[sse] delivery refresh failed', err);
});
});
eventSource = es;
addTrackedListener(es, 'open', () => {
sseAutomat.setState({ status: 'connected' });
sseAutomat.setState({ status: 'connected', companyId });
});
addTrackedListener(es, 'message', () => {});
addTrackedListener(es, 'user', (event) => {
dispatchSseEvent(event);
});
addTrackedListener(es, 'message', (event) => {
dispatchSseEvent(event);
});
// 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: '' });
sseAutomat.setState({ status: 'error' });
};
eventSource = es;
attachHandlers(es);
}
// #region connect
// MARK: opens GET /api/events stream when hasAuth is true
function connect() {
clearReconnectTimer();
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 });
if (typeof fetch === 'function' && typeof ReadableStream !== 'undefined') {
void startFetchStream(url, companyId);
} else {
startEventSource(url, companyId);
}
}
// #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 && (streamAbortController || 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}
*/
@@ -314,18 +482,25 @@ 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: [] });
sseAutomat.setState({ events: [], lastEvent: null });
},
/**
* Registers a handler for a statically named backend event.
* Registers a handler for a named backend event or pattern.
* Supports exact event names (e.g. `category`, `articles`), `article-*` wildcard, or `*`.
*
* 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.
* @param {string} event Event name or pattern.
* @param {Function} handler Signal handler.
* @returns {() => void} Unsubscribe callback.
*/
on(event, handler) {
@@ -338,7 +513,9 @@ const actions = {
if (!entry) {
entry = { handler, detach: null };
set.add(entry);
if (eventSource) entry.detach = addTrackedListener(eventSource, event, handler);
if (eventSource && event !== '*' && event !== 'article-*') {
entry.detach = addTrackedListener(eventSource, event, handler);
}
}
return () => {
entry.detach?.();
@@ -348,40 +525,61 @@ const actions = {
},
/**
* Subscribes to `article-<sku>` signals for one SKU.
* Subscribes to any article mutation event (`article-<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 {(sku: string, event: object) => void} handler Called with changed SKU.
* @returns {() => void} Unsubscribe callback.
*/
onArticle(handler) {
return actions.on('article-*', (sku, event) => handler(sku, event));
},
/**
* Subscribes to `article-<sku>` signals for one specific SKU.
*
* @param {string} sku Article SKU to watch.
* @param {(sku: string) => void} handler Called with the changed SKU.
* @returns {() => void} Unsubscribe callback.
*/
watchArticle(sku, handler) {
const key = String(sku);
let set = articleWatchers.get(key);
const rawKey = String(sku).trim();
const lowerKey = rawKey.toLowerCase();
let set = articleWatchers.get(lowerKey);
if (!set) {
set = new Set();
articleWatchers.set(key, set);
attachArticleListener(key);
articleWatchers.set(lowerKey, set);
attachArticleListener(rawKey);
}
set.add(handler);
return () => {
const current = articleWatchers.get(key);
const current = articleWatchers.get(lowerKey);
if (!current) return;
current.delete(handler);
if (current.size === 0) articleWatchers.delete(key);
if (current.size === 0) articleWatchers.delete(lowerKey);
};
},
/**
* Programmatically dispatches an event through sseAutomat.
*
* @param {string} type Event type (e.g. 'article-ART-000001', 'articles').
* @param {any} [data] Event data payload.
* @param {string} [lastEventId] Optional event ID.
* @returns {void}
*/
dispatch(type, data = {}, lastEventId = '') {
dispatchSseEvent({
type,
data: typeof data === 'string' ? data : JSON.stringify(data),
lastEventId,
});
},
};
/**
* 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' });
@@ -389,48 +587,23 @@ 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(() => {});
const art = Automat.get('artikel');
if (art?.actions?.reloadArticle) {
void art.actions.reloadArticle(key).catch(() => {});
}
if (articleHistoryAutomat.has(key)) {
void articleHistoryAutomat.reload(key).catch(() => {});
}
if (articleSupplierAutomat.has(key)) {
void articleSupplierAutomat.reload(key).catch(() => {});
}
// Reload assigned suppliers and sync with companyAutomat
void articleSupplierAutomat.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

@@ -8,11 +8,15 @@ import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import OnlinePrinterIcons from './OnlinePrinterIcons';
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 +26,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;
@@ -188,8 +199,20 @@ class AppSidebar extends PureComponent {
})}
</List>
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
<Box
sx={{
ml: { xs: 'auto', lg: 0 },
flexShrink: 0,
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'flex-start' },
width: { xs: 'auto', lg: '100%' },
gap: { xs: 0.5, lg: 0 },
}}
>
<OnlinePrinterIcons />
<UserMenu />
<ChatFab />
</Box>
</Box>
);
@@ -403,13 +426,20 @@ 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

@@ -1,7 +1,7 @@
import { Component } from 'react';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import authAutomat, { is502BadGateway } from '../automats/authAutomat';
let sessionBootstrapped = false;
@@ -10,15 +10,16 @@ class AuthBootstrap extends Component {
if (sessionBootstrapped) return;
sessionBootstrapped = true;
bootstrapAuthMe()
authAutomat.actions.bootstrapSession()
.then((session) => {
if (session.forbidden) {
if (session?.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' });
}
return authAutomat.actions.bootstrapSession(session);
})
.catch((err) => {
notifyError(err);
if (!is502BadGateway(err)) {
notifyError(err);
}
});
}

View File

@@ -1,4 +1,4 @@
import { Component } from 'react';
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -34,11 +34,10 @@ import TuneIcon from '@mui/icons-material/Tune';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import AssignmentReturnIcon from '@mui/icons-material/AssignmentReturn';
import BackendError from './BackendError';
import { company, number as numberApi } from '../api';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat';
import authAutomat from '../automats/authAutomat';
import companyAutomat from '../automats/companyAutomat';
import numberAutomat from '../automats/numberAutomat';
import {
defaultIso639,
ISO639_OPTIONS,
@@ -53,13 +52,15 @@ const NUMBER_SCHEMES = [
{ type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon },
{ type: 'quote', label: 'Angebot', icon: RequestQuoteIcon },
{ type: 'order', label: 'Bestellung', icon: ShoppingCartIcon },
{ type: 'return', label: 'Rücksendung (Lieferant)', icon: AssignmentReturnIcon },
{ type: 'retoure', label: 'Retoure (Kunde)', icon: AssignmentReturnIcon },
];
const PRESETS = [
{
id: 'standard',
label: 'Standard',
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-)',
hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-, ANG-, AUF-, RTN-, RET-)',
values: {
sku: { prefix: 'ART-', padding: 6, next: 1 },
entity: { prefix: 'KD-', padding: 6, next: 1001 },
@@ -67,6 +68,8 @@ const PRESETS = [
adjustment: { prefix: 'KORR-', padding: 5, next: 1 },
quote: { prefix: 'ANG-', padding: 6, next: 1 },
order: { prefix: 'AUF-', padding: 6, next: 1 },
return: { prefix: 'RTN-', padding: 6, next: 1 },
retoure: { prefix: 'RET-', padding: 6, next: 1 },
},
},
{
@@ -80,12 +83,14 @@ const PRESETS = [
adjustment: { prefix: '', padding: 5, next: 90001 },
quote: { prefix: '', padding: 5, next: 40001 },
order: { prefix: '', padding: 5, next: 50001 },
return: { prefix: 'RTN', padding: 5, next: 1 },
retoure: { prefix: 'RET', padding: 5, next: 1 },
},
},
{
id: 'compact',
label: 'Kurzkürzel',
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-)',
hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-, AG-, AU-, RTN-, RET-)',
values: {
sku: { prefix: 'A-', padding: 5, next: 1 },
entity: { prefix: 'K-', padding: 5, next: 1001 },
@@ -93,12 +98,14 @@ const PRESETS = [
adjustment: { prefix: 'B-', padding: 5, next: 1 },
quote: { prefix: 'AG-', padding: 5, next: 1 },
order: { prefix: 'AU-', padding: 5, next: 1 },
return: { prefix: 'RTN-', padding: 5, next: 1 },
retoure: { prefix: 'RET-', padding: 5, next: 1 },
},
},
{
id: 'international',
label: 'International',
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-)',
hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-, Q-, ORD-, RTN-, RET-)',
values: {
sku: { prefix: 'SKU-', padding: 5, next: 1 },
entity: { prefix: 'C-', padding: 5, next: 1001 },
@@ -106,6 +113,8 @@ const PRESETS = [
adjustment: { prefix: 'ADJ-', padding: 4, next: 1 },
quote: { prefix: 'Q-', padding: 5, next: 1 },
order: { prefix: 'ORD-', padding: 5, next: 1 },
return: { prefix: 'RTN-', padding: 5, next: 1 },
retoure: { prefix: 'RET-', padding: 5, next: 1 },
},
},
];
@@ -117,12 +126,18 @@ function formatNumberPreview(prefix, padding, next, offset = 0) {
return `${prefix ?? ''}${numStr}`;
}
class CompanySetupDialog extends Component {
function mapCompanyState(state) {
return {
companies: Array.isArray(state?.companies) ? state.companies : [],
loading: state?.status === 'idle' || state?.status === 'loading',
};
}
class CompanySetupDialog extends PureComponent {
constructor(props) {
super(props);
this.state = {
companies: [],
loading: true,
...mapCompanyState(companyAutomat.state),
busy: false,
error: null,
name: '',
@@ -141,26 +156,25 @@ class CompanySetupDialog extends Component {
}
componentDidMount() {
void this.loadCompanies();
this.unsubscribe = companyAutomat.subscribe(this, mapCompanyState);
this.unsubscribeAuth = authAutomat.subscribe(this, (state) => ({
selectedCompanyId: state.user?.selectedCompanyId ?? null,
}));
this.setState(mapCompanyState(companyAutomat.state));
if (companyAutomat.state.status !== 'succeeded') {
void companyAutomat.actions.load().catch(() => {});
}
}
async loadCompanies() {
this.setState({ loading: true });
try {
const data = await company.list();
this.setState({ companies: data.companies ?? [] });
} catch {
this.setState({ companies: [] });
} finally {
this.setState({ loading: false });
}
componentWillUnmount() {
this.unsubscribe?.();
this.unsubscribeAuth?.();
}
async handleSelect(companyId) {
this.setState({ busy: true, error: null });
try {
await authAutomat.actions.selectCompany(companyId);
await bootstrapAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
} finally {
@@ -213,30 +227,8 @@ 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 {
@@ -247,7 +239,6 @@ class CompanySetupDialog extends Component {
async handleLogout() {
try {
await authAutomat.actions.logout();
await bootstrapAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
}
@@ -258,6 +249,10 @@ class CompanySetupDialog extends Component {
}
render() {
if (authAutomat.state.user?.selectedCompanyId != null) {
return null;
}
const {
companies,
loading,
@@ -286,22 +281,7 @@ class CompanySetupDialog extends Component {
maxWidth={hasCompanies ? 'sm' : 'md'}
fullWidth
>
<DialogTitle sx={{ pb: 1 }}>
{loading
? 'Unternehmen'
: hasCompanies
? 'Unternehmen auswählen'
: 'Willkommen bei Dash'}
</DialogTitle>
<DialogContent>
{!loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{hasCompanies
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.'
: 'Legen Sie Ihr erstes Unternehmen an und wählen Sie das Format für Ihre Nummernkreise.'}
</Typography>
) : null}
{error ? (
isBackendFailure(error) ? (
<BackendError error={error} sx={{ mb: 2 }} />
@@ -410,13 +390,6 @@ class CompanySetupDialog extends Component {
{/* Nummernkreise Section (when no company was created yet or creating new) */}
<Box sx={{ pt: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Nummernkreise & Belegnummern
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
</Typography>
<Stack direction="row" spacing={1} useFlexGap sx={{ flexWrap: 'wrap', mb: 1.5 }}>
{PRESETS.map((p) => {
const isSelected = selectedPreset === p.id;

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

@@ -0,0 +1,318 @@
import { PureComponent } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import PrintOutlinedIcon from '@mui/icons-material/PrintOutlined';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import { enqueueSnackbar } from 'notistack';
import companyAutomat from '../automats/companyAutomat';
import { notifyError } from '../utils/notify';
import { getPrinterImage } from '../assets/printers';
export default class OnlinePrinterIcons extends PureComponent {
constructor(props) {
super(props);
this.state = {
agentprinters: Array.isArray(companyAutomat.state.agentprinters)
? companyAutomat.state.agentprinters
: [],
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
menuAnchor: null,
};
this.handleOpenMenu = this.handleOpenMenu.bind(this);
this.handleCloseMenu = this.handleCloseMenu.bind(this);
this.handleTestPrint = this.handleTestPrint.bind(this);
this.handleCancelAllJobs = this.handleCancelAllJobs.bind(this);
this.handleOpenPrinterSettings = this.handleOpenPrinterSettings.bind(this);
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
agentprinters: Array.isArray(state.agentprinters) ? state.agentprinters : [],
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
}));
}
componentWillUnmount() {
this.unsub?.();
}
getOnlinePrinters() {
const { agentprinters = [], livePrinters = [] } = this.state;
const liveBySerial = new Map();
for (const lp of livePrinters) {
if (lp.serial) liveBySerial.set(String(lp.serial).toLowerCase(), lp);
}
if (agentprinters.length > 0) {
return agentprinters
.filter((p) => {
if (p.online === true) return true;
const live =
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
livePrinters.find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === p.id) ||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
);
if (!live) return false;
return live.status !== 'offline' && !live.unreachable && !live.errors?.unreachable;
})
.map((p) => {
const live =
(p.serial ? liveBySerial.get(String(p.serial).toLowerCase()) : null) ??
livePrinters.find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === p.id) ||
(lp.agentId === p.printeragentId && lp.serial === p.serial),
);
return { ...p, live };
});
}
return livePrinters
.filter((lp) => lp.status !== 'offline' && !lp.unreachable && !lp.errors?.unreachable)
.map((lp) => ({
id: lp.agentprinterId || lp.id || lp.serial,
model: lp.model,
serial: lp.serial,
type: lp.type,
online: true,
open: Boolean(lp.errors?.headOpen || lp.headOpen),
pause: Boolean(lp.errors?.pause || lp.pause),
error: Boolean(lp.errors?.other || lp.error),
labelLength: lp.labelLength,
live: lp,
}));
}
handleOpenMenu(event, printer) {
this.setState({
menuAnchor: {
el: event.currentTarget,
printer,
},
});
}
handleCloseMenu() {
this.setState({ menuAnchor: null });
}
handleTestPrint(printer) {
if (!printer?.id) return;
this.handleCloseMenu();
companyAutomat.actions
.sendTestPrint(printer.id, {
model: printer.model || printer.type || 'Zebra',
})
.then(() => {
enqueueSnackbar(
`Testdruck für ${printer.model || 'Drucker'} (#${printer.id}) an Warteschlange gesendet.`,
{ variant: 'success' },
);
})
.catch(notifyError);
}
handleCancelAllJobs(printer) {
if (!printer?.id) return;
this.handleCloseMenu();
companyAutomat.actions
.cancelAllPrinterJobs(printer.id)
.then(() => {
enqueueSnackbar(`Druckaufträge für ${printer.model || 'Drucker'} abgebrochen.`, {
variant: 'info',
});
})
.catch(notifyError);
}
handleOpenPrinterSettings() {
this.handleCloseMenu();
window.dispatchEvent(
new CustomEvent('open-settings', { detail: { tab: 'printeragent' } }),
);
}
render() {
const { menuAnchor } = this.state;
const onlinePrinters = this.getOnlinePrinters();
if (onlinePrinters.length === 0) {
return null;
}
const activePrinter = menuAnchor?.printer;
const activeHasError = Boolean(
activePrinter?.error ||
activePrinter?.open ||
activePrinter?.live?.errors?.other ||
activePrinter?.live?.errors?.headOpen,
);
const activeIsPaused = Boolean(activePrinter?.pause || activePrinter?.live?.errors?.pause);
const activeStatusText = activeHasError
? activePrinter?.open || activePrinter?.live?.errors?.headOpen
? 'Druckkopf offen'
: 'Fehler'
: activeIsPaused
? 'Pausiert'
: 'Bereit';
const anchorEl = menuAnchor?.el;
const isTop = Boolean(
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
);
return (
<>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'flex-start' },
alignSelf: { xs: 'center', lg: 'flex-start' },
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 0.5 },
gap: { xs: 0.5, lg: 1 },
}}
>
{onlinePrinters.map((p, index) => {
const hasError = Boolean(
p.error || p.open || p.live?.errors?.other || p.live?.errors?.headOpen,
);
const isPaused = Boolean(p.pause || p.live?.errors?.pause);
const label = p.model || 'Drucker';
const statusText = hasError
? p.open || p.live?.errors?.headOpen
? 'Druckkopf offen'
: 'Fehler'
: isPaused
? 'Pausiert'
: 'Bereit';
const tooltipTitle = `${label}${p.serial ? ` (${p.serial})` : ''} • ${statusText}`;
return (
<IconButton
key={p.id || p.serial || index}
onClick={(e) => this.handleOpenMenu(e, p)}
title={tooltipTitle}
sx={(theme) => ({
p: 0,
width: 32,
height: 32,
borderRadius: '50%',
color: theme.nav?.item || 'inherit',
'&:hover': {
color: theme.nav?.itemHoverText || 'inherit',
bgcolor: theme.nav?.itemHover || 'action.hover',
},
})}
aria-label={`Drucker ${label}`}
>
<Badge
variant="dot"
color={hasError ? 'error' : isPaused ? 'warning' : 'success'}
overlap="circular"
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<PrintOutlinedIcon sx={{ fontSize: 20 }} />
</Badge>
</IconButton>
);
})}
</Box>
<Menu
anchorEl={anchorEl}
open={Boolean(menuAnchor)}
onClose={this.handleCloseMenu}
anchorOrigin={{
vertical: isTop ? 'bottom' : 'top',
horizontal: isTop ? 'right' : 'left',
}}
transformOrigin={{
vertical: isTop ? 'top' : 'bottom',
horizontal: isTop ? 'right' : 'left',
}}
>
{activePrinter && (
<Box sx={{ px: 2, py: 1, minWidth: 210 }}>
{getPrinterImage(activePrinter.model) ? (
<Box
component="img"
src={getPrinterImage(activePrinter.model)}
alt={activePrinter.model}
sx={{ display: 'block', mx: 'auto', mb: 1, maxWidth: 80, height: 'auto' }}
/>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="subtitle2" fontWeight={600} noWrap>
{activePrinter.model || 'Drucker'}
</Typography>
<Chip
label={activeStatusText}
size="small"
color={activeHasError ? 'error' : activeIsPaused ? 'warning' : 'success'}
variant="outlined"
sx={{ fontSize: '0.65rem', height: 18, fontWeight: 600 }}
/>
</Box>
{activePrinter.serial ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', display: 'block' }}
>
S/N: {activePrinter.serial}
</Typography>
) : null}
{activePrinter.labelLength ? (
<Typography variant="caption" color="text.secondary" display="block">
Etikett: {activePrinter.labelLength} px
</Typography>
) : null}
</Box>
)}
<Divider />
<MenuItem onClick={() => this.handleTestPrint(activePrinter)}>
<ListItemIcon>
<PrintOutlinedIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Testdruck"
/>
</MenuItem>
<MenuItem onClick={() => this.handleCancelAllJobs(activePrinter)}>
<ListItemIcon>
<CancelScheduleSendOutlinedIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText
primary="Aufträge abbrechen"
/>
</MenuItem>
<MenuItem onClick={this.handleOpenPrinterSettings}>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText primary="Einstellungen" />
</MenuItem>
</Menu>
</>
);
}
}

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

@@ -78,7 +78,7 @@ export default class PwaUpdateDialog extends PureComponent {
<DialogContent>
<DialogContentText sx={{ color: 'text.primary', mb: 1, fontWeight: 500 }}>
Eine neue Version von <strong>Dashfront</strong> ist bereit.
Eine neue Version ist bereit.
</DialogContentText>
<DialogContentText variant="body2" color="text.secondary">
Möchten Sie die Anwendung jetzt neu laden, um die Änderungen zu übernehmen?

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, Suspense } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Popover from '@mui/material/Popover';
@@ -13,7 +13,9 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { enqueueSnackbar } from 'notistack';
import PageLayout from './PageLayout';
import { NAV_ITEMS, SUB_NAV, resolveActiveSub } from '../nav';
import { isKategorienPath } from './category/categoryRoute';
import { isKategorienPath, buildCategoryPath, KATEGORIEN_BASE } from './category/categoryRoute';
import CategoryBreadcrumbs from './category/CategoryBreadcrumbs';
import { getCategoryBreadcrumbs, resolveCategoryIdFromSlugPath } from '../utils/categoryUtils';
import categoryAutomat from '../automats/categoryAutomat';
import { ActionElevatedPill, ToggleElevatedPill } from 'dashgui/ElevatedPill';
import { article, category } from '../api';
@@ -41,6 +43,7 @@ function selectCatSlice(s) {
export default function SectionLayout({ sectionPath }) {
const { pathname } = useLocation();
const navigate = useNavigate();
const importInputRef = useRef(null);
const [extrasAnchor, setExtrasAnchor] = useState(null);
const [importing, setImporting] = useState(false);
@@ -141,18 +144,39 @@ export default function SectionLayout({ sectionPath }) {
importInputRef.current?.click();
};
let crumbs = [];
if (showsRenameSwitch && categories && rootCategoryId != null) {
const slugPath = pathname
.slice(KATEGORIEN_BASE.length)
.split('/')
.filter(Boolean)
.map((seg) => {
try {
return decodeURIComponent(seg);
} catch {
return seg;
}
})
.join('/');
const catId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slugPath);
if (catId != null) crumbs = getCategoryBreadcrumbs(categories, catId, rootCategoryId);
}
const titleAction = showsRenameSwitch ? (
<Box sx={{ display: 'inline-flex', gap: 1 }}>
<ToggleElevatedPill
label="Edit"
checked={renameMode}
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
/>
<ActionElevatedPill
label="Extras"
icon={<SettingsIcon sx={{ fontSize: 18 }} />}
onClick={(event) => setExtrasAnchor(event.currentTarget)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
<CategoryBreadcrumbs
crumbs={crumbs}
variant="h6"
sx={{ m: 0, minWidth: 0 }}
onNavigate={(id) => navigate(buildCategoryPath(categories, rootCategoryId, id))}
/>
<Box sx={{ ml: 'auto', display: 'inline-flex' }}>
<ToggleElevatedPill
label="Edit"
checked={renameMode}
onToggle={(checked) => categoryAutomat.actions.setRenameMode(checked)}
/>
</Box>
<input
ref={importInputRef}
type="file"
@@ -195,8 +219,9 @@ export default function SectionLayout({ sectionPath }) {
return (
<PageLayout
title={pageTitle}
title={showsRenameSwitch ? '' : pageTitle}
showTitle={!isArticleDetail}
titleWide={showsRenameSwitch ? 99 : undefined}
titleAction={titleAction}
>
<Suspense

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';
@@ -24,6 +25,7 @@ class UserMenu extends PureComponent {
this.state = {
anchorEl: null,
settingsOpen: false,
settingsInitialTab: null,
user: authAutomat.state.user,
helpEnabled: helpAutomat.state?.enabled ?? false,
pwaUpdateAvailable: pwaAutomat.state.needRefresh,
@@ -36,6 +38,7 @@ class UserMenu extends PureComponent {
this.handleToggleHelp = this.handleToggleHelp.bind(this);
this.handleOpenPwaUpdate = this.handleOpenPwaUpdate.bind(this);
this.handleLogout = this.handleLogout.bind(this);
this.handleOpenSettingsEvent = this.handleOpenSettingsEvent.bind(this);
}
componentDidMount() {
@@ -46,12 +49,23 @@ class UserMenu extends PureComponent {
this.unsubscribePwa = pwaAutomat.subscribe(this, (state) => ({
pwaUpdateAvailable: Boolean(state?.needRefresh),
}));
if (typeof window !== 'undefined') {
window.addEventListener('open-settings', this.handleOpenSettingsEvent);
}
}
componentWillUnmount() {
this.unsubscribeAuth?.();
this.unsubscribeHelp?.();
this.unsubscribePwa?.();
if (typeof window !== 'undefined') {
window.removeEventListener('open-settings', this.handleOpenSettingsEvent);
}
}
handleOpenSettingsEvent(e) {
const tab = e?.detail?.tab;
this.setState({ settingsOpen: true, settingsInitialTab: tab || null });
}
handleOpenPwaUpdate() {
@@ -73,11 +87,11 @@ class UserMenu extends PureComponent {
}
handleOpenSettings() {
this.setState({ anchorEl: null, settingsOpen: true });
this.setState({ anchorEl: null, settingsOpen: true, settingsInitialTab: null });
}
handleCloseSettings() {
this.setState({ settingsOpen: false });
this.setState({ settingsOpen: false, settingsInitialTab: null });
}
handleToggleHelp(e) {
@@ -97,7 +111,7 @@ class UserMenu extends PureComponent {
}
render() {
const { user, anchorEl, settingsOpen, helpEnabled, pwaUpdateAvailable } = this.state;
const { user, anchorEl, settingsOpen, settingsInitialTab, helpEnabled, pwaUpdateAvailable } = this.state;
const open = Boolean(anchorEl);
const pictureSrc = (() => {
@@ -200,7 +214,7 @@ class UserMenu extends PureComponent {
<ListItemText>Sign out</ListItemText>
</MenuItem>
</Menu>
<SettingsDialog open={settingsOpen} onClose={this.handleCloseSettings} />
<SettingsDialog open={settingsOpen} initialTab={settingsInitialTab} onClose={this.handleCloseSettings} />
</>
);
}

View File

@@ -31,7 +31,7 @@ class ArticleNameDialog extends Component {
super(props);
this.state = {
mode: SINGLE_MODE,
name: '',
name: props.initialName ?? '',
bulkText: '',
busy: false,
error: null,
@@ -46,7 +46,7 @@ class ArticleNameDialog extends Component {
if (this.props.open && !prevProps.open) {
this.setState({
mode: SINGLE_MODE,
name: '',
name: this.props.initialName ?? '',
bulkText: '',
busy: false,
error: null,

View File

@@ -9,14 +9,17 @@ import {
class ArticlePhoto extends Component {
constructor(props) {
super(props);
this.state = { thumbLoaded: false };
this.state = { thumbLoaded: false, smallThumbError: false };
this.handleThumbLoad = this.handleThumbLoad.bind(this);
this.handleThumbError = this.handleThumbError.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.photo?.fullHash !== this.props.photo?.fullHash) {
this.setState({ thumbLoaded: false });
if (
prevProps.photo?.fullHash !== this.props.photo?.fullHash ||
prevProps.useSmallThumb !== this.props.useSmallThumb
) {
this.setState({ thumbLoaded: false, smallThumbError: false });
}
}
@@ -25,7 +28,11 @@ class ArticlePhoto extends Component {
}
handleThumbError() {
this.setState({ thumbLoaded: false });
if (this.props.useSmallThumb && !this.state.smallThumbError) {
this.setState({ smallThumbError: true, thumbLoaded: false });
} else {
this.setState({ thumbLoaded: false });
}
}
render() {
@@ -34,9 +41,11 @@ class ArticlePhoto extends Component {
size = 200,
fill = false,
objectFit = fill ? 'cover' : 'contain',
useSmallThumb = false,
sx,
...rest
} = this.props;
const { thumbLoaded } = this.state;
const { thumbLoaded, smallThumbError } = this.state;
const fullHash = photo?.fullHash;
const hasPreview = photo?.thumbHash != null;
const aspectRatio =
@@ -44,8 +53,15 @@ class ArticlePhoto extends Component {
? `${photo.thumbW} / ${photo.thumbH}`
: '1 / 1';
const imgSrc = fullHash
? useSmallThumb && !smallThumbError
? thumb.smallUrl(fullHash)
: thumb.url(fullHash)
: null;
return (
<Box
{...rest}
sx={{
position: 'relative',
...(fill
@@ -78,10 +94,10 @@ class ArticlePhoto extends Component {
<ThumbHashImage thumbHash={photo.thumbHash} />
</Box>
) : null}
{fullHash ? (
{imgSrc ? (
<Box
component="img"
src={thumb.url(fullHash)}
src={imgSrc}
alt=""
onLoad={this.handleThumbLoad}
onError={this.handleThumbError}

View File

@@ -1,16 +1,27 @@
import { PureComponent } from 'react';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import Box from '@mui/material/Box';
import { PanelTile } from 'dashgui/panels';
import { primaryPhoto } from '../../utils/articleDetail';
import { sortedPhotos, primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog';
class ArticlePhotoTile extends PureComponent {
constructor(props) {
super(props);
this.state = { editOpen: false };
this.state = {
editOpen: false,
selectedHash: null,
};
this.handleEditOpen = this.handleEditOpen.bind(this);
this.handleEditClose = this.handleEditClose.bind(this);
this.handleSelectPhoto = this.handleSelectPhoto.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.article?.sku !== this.props.article?.sku) {
this.setState({ selectedHash: null });
}
}
handleEditOpen() {
@@ -21,6 +32,10 @@ class ArticlePhotoTile extends PureComponent {
this.setState({ editOpen: false });
}
handleSelectPhoto(fullHash) {
this.setState({ selectedHash: fullHash });
}
render() {
const {
article: articleData,
@@ -30,8 +45,14 @@ class ArticlePhotoTile extends PureComponent {
sx,
color,
} = this.props;
const { editOpen } = this.state;
const photo = primaryPhoto(articleData);
const { editOpen, selectedHash } = this.state;
const photos = sortedPhotos(articleData);
const primary = primaryPhoto(articleData);
const activePhoto =
(selectedHash && photos.find((p) => p.fullHash === selectedHash)) ||
primary;
const otherPhotos = photos.filter((p) => p.fullHash !== activePhoto?.fullHash);
return (
<>
@@ -47,7 +68,86 @@ class ArticlePhotoTile extends PureComponent {
busy: false,
}}
>
<ArticlePhoto photo={photo} />
{otherPhotos.length === 0 ? (
<ArticlePhoto photo={activePhoto} />
) : (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'flex-start',
gap: 1.5,
}}
>
<ArticlePhoto photo={activePhoto} />
{/* Smallthumbs of all other images beside the main image */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.75,
maxHeight: 200,
overflowY: 'auto',
overflowX: 'hidden',
mt: '32px',
py: 0.25,
px: 0.25,
flexShrink: 0,
scrollbarWidth: 'thin',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-thumb': {
bgcolor: 'action.hover',
borderRadius: 2,
},
}}
>
{otherPhotos.map((p) => (
<Box
key={p.fullHash}
component="button"
type="button"
onClick={(e) => {
e.stopPropagation();
this.handleSelectPhoto(p.fullHash);
}}
sx={{
p: 0,
m: 0,
width: 40,
height: 40,
flexShrink: 0,
cursor: 'pointer',
borderRadius: 1,
overflow: 'hidden',
border: '2px solid transparent',
bgcolor: 'transparent',
outline: 'none',
display: 'block',
transition: 'all 0.15s ease',
'&:hover': {
transform: 'scale(1.08)',
borderColor: 'primary.main',
boxShadow: 2,
},
'&:focus-visible': {
borderColor: 'primary.main',
},
}}
>
<ArticlePhoto
photo={p}
size={40}
useSmallThumb
objectFit="cover"
sx={{ width: '100%', height: '100%', borderRadius: 1 }}
/>
</Box>
))}
</Box>
</Box>
)}
</PanelTile>
<ArticlePhotosDialog
open={editOpen}

View File

@@ -228,7 +228,9 @@ class SearchResultRow extends PureComponent {
{' · '}
</>
)}
{article.sku}
<Box component="span" sx={{ fontFamily: 'monospace' }}>
{article.sku}
</Box>
{article.stock != null && (
<>
{' · '}
@@ -781,16 +783,17 @@ export default class ArticleSearch extends PureComponent {
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
size="small"
label={article.sku}
sx={{
height: 18,
fontSize: '0.68rem',
fontWeight: 600,
bgcolor: 'action.hover',
}}
/>
<Chip
size="small"
label={article.sku}
sx={{
height: 18,
fontSize: '0.68rem',
fontWeight: 600,
fontFamily: 'monospace',
bgcolor: 'action.hover',
}}
/>
<Typography variant="body2" fontWeight={600} noWrap sx={{ flex: 1 }}>
{article.name || '–'}
</Typography>

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,
@@ -42,14 +42,6 @@ import {
SURFACE_RAISED,
} from '../../colors';
const REASON_PRESETS = [
'Inventurdifferenz',
'Korrektur',
'Bruch / Beschädigung',
'Schwund',
'Eigenbedarf / Test',
];
export default class ArticleStockAdjustDialog extends PureComponent {
constructor(props) {
super(props);
@@ -64,7 +56,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 +71,13 @@ 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']);
}
componentWillUnmount() {
@@ -95,7 +86,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
void warehouseAutomat.actions.load().catch(() => {});
const initialWhId = this.resolveInitialWarehouseId(this.props);
const isTrackStock =
this.props.article?.track_stock !== false && this.props.article?.track_stock !== 0;
@@ -107,7 +97,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 +111,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 : '';
}
@@ -257,7 +247,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
}
// Reload article in automat cache
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => {});
void artikelAutomat.actions.reloadArticle(article.sku).catch(() => { });
if (updatedArticle) {
onArticleUpdated?.(updatedArticle);
@@ -358,7 +348,7 @@ export default class ArticleStockAdjustDialog extends PureComponent {
}}
>
<Typography variant="caption" color="text.secondary" display="block">
Artikel ({article?.sku})
Artikel (<Box component="span" sx={{ fontFamily: 'monospace' }}>{article?.sku}</Box>)
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{article?.name || '–'}
@@ -381,11 +371,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Bestand verfolgen
</Typography>
<Typography variant="caption" color="text.secondary">
{trackStock
? 'Lagerbestand wird für diesen Artikel geführt und bei Buchungen aktualisiert.'
: 'Keine Bestandsführung. Artikel hat unbegrenzte Verfügbarkeit.'}
</Typography>
</Box>
<Switch
checked={Boolean(trackStock)}
@@ -475,11 +460,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
>
<ToggleButton value="diff" sx={{ py: 0.75, fontWeight: 500 }}>
<TrendingUpIcon sx={{ fontSize: 18, mr: 0.75 }} />
Menge zu-/abbuchen (+ / -)
Zu-/abbuchen (+ / -)
</ToggleButton>
<ToggleButton value="target" sx={{ py: 0.75, fontWeight: 500 }}>
<TuneIcon sx={{ fontSize: 18, mr: 0.75 }} />
Neuen Zählbestand setzen
Bestand setzen
</ToggleButton>
</ToggleButtonGroup>
</Box>
@@ -493,7 +478,6 @@ export default class ArticleStockAdjustDialog extends PureComponent {
value={diffValue}
onChange={this.handleDiffChange}
disabled={submitting || !trackStock}
placeholder="z.B. +5 oder -2"
slotProps={{
input: {
startAdornment: (
@@ -507,28 +491,15 @@ export default class ArticleStockAdjustDialog extends PureComponent {
),
},
}}
helperText={
valid
? `Neuer Bestand: ${formatStock(newStock)}${unit ? ` ${unit}` : ''}`
: 'Geben Sie einen positiven oder negativen Wert ein'
}
/>
) : (
<TextInput
fullWidth
label="Tatsächlicher Zählbestand (Ist-Bestand)"
label="Bestand"
type="number"
value={targetValue}
onChange={this.handleTargetChange}
disabled={submitting || !trackStock}
placeholder="z.B. 10"
helperText={
isZeroChange
? 'Der eingegebene Zählbestand entspricht dem aktuellen Bestand (keine Änderung).'
: valid
? `Berechnete Korrektur: ${amount > 0 ? `+${formatStock(amount)}` : formatStock(amount)}${unit ? ` ${unit}` : ''}`
: 'Geben Sie den neu gezählten Bestand ein'
}
/>
)}
@@ -566,26 +537,11 @@ export default class ArticleStockAdjustDialog extends PureComponent {
<Box>
<TextInput
fullWidth
label="Grund / Buchungstext"
label="Buchungstext"
value={reason}
onChange={this.handleReasonChange}
disabled={submitting || !trackStock}
placeholder="z.B. Inventur, Bruch, Schwund"
helperText="Wird im Journal / Ledger als Transaktionstext gespeichert"
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mt: 1 }}>
{REASON_PRESETS.map((preset) => (
<Chip
key={preset}
label={preset}
size="small"
clickable
disabled={submitting || !trackStock}
onClick={() => this.setState({ reason: preset })}
sx={{ fontSize: '0.75rem' }}
/>
))}
</Box>
</Box>
</Box>
)}

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';
@@ -68,28 +68,27 @@ export default class ArticleUnitEditorDialog extends Component {
super(props);
this.state = {
busy: false,
units: [],
unitsLoading: false,
units: companyAutomat.state.units,
unitsLoading: companyAutomat.state.unitsStatus === 'loading',
form: emptyForm(),
selectedSi: null,
};
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
units: state.units,
unitsLoading: state.unitsStatus === 'loading',
}));
}
componentWillUnmount() {
this.unsub?.();
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
this.setState({ form: emptyForm(), selectedSi: null });
void this.loadUnits();
}
}
async loadUnits() {
this.setState({ unitsLoading: true });
try {
const units = await unitAutomat.actions.load();
this.setState({ units, unitsLoading: false });
} catch (err) {
this.setState({ unitsLoading: false });
notifyError(err);
}
}
@@ -116,23 +115,22 @@ export default class ArticleUnitEditorDialog extends Component {
if (!formValid(form)) return;
this.setState({ busy: true });
try {
const data = await unitApi.upsert({
const payload = {
si: String(form.si ?? '').trim(),
basesi: String(form.basesi ?? '').trim(),
basesifactor: parseFactor(form.basesifactor),
symbol: String(form.symbol ?? '').trim(),
name: String(form.name ?? '').trim(),
});
requireObject(data, 'PUT /api/unit');
requireObject(data.unit, 'unit');
const units = await unitAutomat.actions.load();
};
const units = await companyAutomat.actions.upsertUnit(payload);
const saved = units.find((u) => u.si === payload.si) ?? payload;
this.setState({
busy: false,
units,
form: formFromUnit(data.unit),
selectedSi: data.unit.si,
form: formFromUnit(saved),
selectedSi: saved.si,
});
this.props.onUnitUpserted?.(data.unit);
this.props.onUnitUpserted?.(saved);
} catch (err) {
this.setState({ busy: false });
notifyError(err);
@@ -144,9 +142,13 @@ export default class ArticleUnitEditorDialog extends Component {
if (!selectedSi || selectedSi === DEFAULT_UNIT_SI) return;
this.setState({ busy: true });
try {
await unitApi.delete(selectedSi);
const units = await unitAutomat.actions.load();
this.setState({ busy: false, units, form: emptyForm(), selectedSi: null });
await companyAutomat.actions.deleteUnit(selectedSi);
this.setState({
busy: false,
units: companyAutomat.state.units,
form: emptyForm(),
selectedSi: null,
});
this.props.onUnitDeleted?.(selectedSi);
} catch (err) {
this.setState({ busy: false });

View File

@@ -101,7 +101,7 @@ class ArticleView extends PureComponent {
article={article}
/>
<ArticleViewStock article={article} />
<ArticleViewStock article={article} navigate={this.props.navigate} />
<ArticleViewBrand article={article} />

View File

@@ -43,14 +43,12 @@ export default class CategoryPickerDialog extends PureComponent {
}
componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => {
this.setState(() => ({
categories: this.props.categories ?? s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
}));
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
});
}
});
}
componentDidUpdate(prevProps) {
@@ -64,10 +62,12 @@ export default class CategoryPickerDialog extends PureComponent {
} else if (this.state.rootCategoryId != null) {
nextSelectedId = this.state.rootCategoryId;
}
this.setState({ selectedId: nextSelectedId });
}
if (
this.setState({
selectedId: nextSelectedId,
categories: categoryAutomat.state.categories ?? this.props.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId ?? this.props.rootCategoryId,
});
} else if (
this.props.categories !== prevProps.categories ||
this.props.rootCategoryId !== prevProps.rootCategoryId
) {

View File

@@ -6,6 +6,8 @@ export function queryFromParams(searchParams) {
filter: searchParams.get('filter') ?? '',
brand: searchParams.get('brand') ?? '',
supplier: searchParams.get('supplier') ?? '',
empty: searchParams.get('empty') ?? '',
hierarchy: searchParams.get('hierarchy') ?? '',
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
};
}
@@ -13,12 +15,16 @@ export function queryFromParams(searchParams) {
export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
let brand = '';
let supplier = '';
let empty = '';
let hierarchy = '';
if (typeof options === 'string') {
brand = options;
supplier = arguments[4] || '';
} else if (options && typeof options === 'object') {
brand = options.brand || '';
supplier = options.supplier || '';
empty = options.empty || '';
hierarchy = options.hierarchy || '';
}
const params = new URLSearchParams();
@@ -26,6 +32,8 @@ export function buildSearchUrl(target, filter = '', offset = 0, options = {}) {
if (filter) params.set('filter', filter);
if (brand) params.set('brand', brand);
if (supplier) params.set('supplier', supplier);
if (empty) params.set('empty', empty);
if (hierarchy) params.set('hierarchy', hierarchy);
if (offset > 0) params.set('offset', String(offset));
const qs = params.toString();
return qs ? `/artikel?${qs}` : '/artikel';
@@ -46,6 +54,8 @@ export function writeArtikelQuery({
offset = 0,
brand = '',
supplier = '',
empty = '',
hierarchy = '',
}) {
try {
if (!target || target.trim().length < 3) {
@@ -54,7 +64,7 @@ export function writeArtikelQuery({
}
localStorage.setItem(
ARTIKEL_QUERY_KEY,
JSON.stringify({ target, filter, offset, brand, supplier }),
JSON.stringify({ target, filter, offset, brand, supplier, empty, hierarchy }),
);
} catch {
// ignore quota / private mode
@@ -76,5 +86,7 @@ export function artikelQueryPath() {
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0, {
brand: query.brand ?? '',
supplier: query.supplier ?? '',
empty: query.empty ?? '',
hierarchy: query.hierarchy ?? '',
});
}

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

@@ -152,7 +152,7 @@ export default class ArticleViewEANs extends PureComponent {
return (
<EditablePanelTile
title={articleData.sku}
title={<Box component="span" sx={{ fontFamily: 'monospace' }}>{articleData.sku}</Box>}
wide={1}
minHeight={SECOND_ROW_MIN_HEIGHT}
color={BORDER}

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';
@@ -46,7 +45,7 @@ import {
alpha,
} from '../../../colors';
function getVariantDetails(child, allArticles, defaultTaxRate) {
function getVariantDetails(child, allArticles, defaultTaxRate, parentArticle) {
const cached = child?.sku ? getArticle(child.sku) : null;
const fromIndex = !cached && child?.sku
? (allArticles || []).find((a) => a.sku === child.sku)
@@ -57,14 +56,30 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
const gross = getArticleGross(merged, defaultTaxRate);
const priceDisplay = gross != null && Number.isFinite(gross) ? formatPrice(gross) : null;
// 2. Stock & Unit
// 2. Stock tracking
const rawTrack =
merged.track_stock ??
merged.trackStock ??
child?.trackStock ??
child?.track_stock ??
parentArticle?.track_stock ??
parentArticle?.trackStock;
const trackStock =
rawTrack !== undefined
? rawTrack !== false && rawTrack !== 0 && rawTrack !== '0'
: true;
// 3. Stock & Unit
let stockVal = null;
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
stockVal = Number(merged.stock);
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
stockVal = Number(child.stock);
if (trackStock) {
if (Array.isArray(merged.warehouses) && merged.warehouses.length > 0) {
stockVal = merged.warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0);
} else if (merged.stock != null && !Number.isNaN(Number(merged.stock))) {
stockVal = Number(merged.stock);
} else if (child?.stock != null && !Number.isNaN(Number(child.stock))) {
stockVal = Number(child.stock);
}
}
const rawUnit =
@@ -77,9 +92,9 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
rawUnit && !['stk', 'stück', 'pc', 'stk.'].includes(rawUnit.trim().toLowerCase())
? ` ${rawUnit}`
: '';
const stockDisplay = stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
const stockDisplay = trackStock && stockVal != null ? `${formatStock(stockVal)}${unit}` : null;
// 3. EAN(s)
// 4. EAN(s)
const eanCandidates = [];
const rawEans = merged.eans ?? child?.eans;
if (Array.isArray(rawEans)) {
@@ -104,6 +119,7 @@ function getVariantDetails(child, allArticles, defaultTaxRate) {
stockDisplay,
primaryEan,
allEans: eanCandidates,
trackStock,
};
}
@@ -124,6 +140,7 @@ export default class ArticleViewLists extends PureComponent {
this.state = {
attributes: initialAttributes,
attributesStatus: companyAutomat.state.attributesStatus || 'idle',
assignedAttributes: [],
allArticles: [],
busy: false,
@@ -159,10 +176,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 +186,21 @@ export default class ArticleViewLists extends PureComponent {
nextSelectedId = selectable[0].id;
}
return {
company: state.current,
attributes: state.attributes,
attributesStatus: state.attributesStatus,
selectedAttributeId: nextSelectedId,
};
});
});
}, ['current', 'attributes', 'attributesStatus']);
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) {
@@ -200,7 +216,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
}).catch(() => { });
}
void this.loadAllArticles();
@@ -221,7 +237,7 @@ export default class ArticleViewLists extends PureComponent {
if (!this._cancelled && parent?.children) {
this.ensureChildren(parent.children);
}
}).catch(() => {});
}).catch(() => { });
} else {
const parent = getArticle(this.props.article.parentSku);
if (parent?.children) {
@@ -262,7 +278,6 @@ export default class ArticleViewLists extends PureComponent {
componentWillUnmount() {
this._cancelled = true;
this.unsubCompany?.();
this.unsubAttr?.();
this.unsubArtikel?.();
}
@@ -270,7 +285,7 @@ export default class ArticleViewLists extends PureComponent {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
}
}
}
@@ -395,30 +410,41 @@ export default class ArticleViewLists extends PureComponent {
const { selectedChildValueId, newChildText, isCustomChildValue, busy } = this.state;
if (busy || !article?.sku) return;
const val = newChildText.trim();
const payload = {};
if (isCustomChildValue) {
const val = newChildText.trim();
if (!val) return;
if (val && (isCustomChildValue || !selectedChildValueId)) {
payload.value = val;
} else if (selectedChildValueId) {
payload.attributeValueId = Number(selectedChildValueId);
} else if (val) {
payload.value = val;
} else {
if (!selectedChildValueId) return;
payload.attributeValueId = Number(selectedChildValueId);
return;
}
this.setState({ busy: true });
try {
const api = this.getApi();
await api.addChild(article.sku, payload);
const res = await api.addChild(article.sku, payload);
if (this._cancelled) return;
if (res?.sku) {
void artikelAutomat.actions.loadArticle(res.sku).catch(() => { });
}
await artikelAutomat.actions.reloadArticle(article.sku);
if (this._cancelled) return;
void articleIndexAutomat.actions.reload().catch(() => { });
void companyAutomat.actions.loadAttributes?.().catch?.(() => { });
enqueueSnackbar('Variante erfolgreich angelegt.', { variant: 'success' });
this.setState({
busy: false,
selectedChildValueId: '',
newChildText: '',
isCustomChildValue: false,
});
} catch (err) {
if (this._cancelled) return;
@@ -493,17 +519,17 @@ export default class ArticleViewLists extends PureComponent {
const nextItems =
existingIndex >= 0
? currentItems.map((item, idx) =>
idx === existingIndex ? { ...item, count: item.count + count } : item
)
idx === existingIndex ? { ...item, count: item.count + count } : item
)
: [
...currentItems,
{
articleId: stuecklisteTargetArticle.id,
sku: stuecklisteTargetArticle.sku,
count,
sort: currentItems.length,
},
];
...currentItems,
{
articleId: stuecklisteTargetArticle.id,
sku: stuecklisteTargetArticle.sku,
count,
sort: currentItems.length,
},
];
this.setState({ busy: true });
try {
@@ -617,20 +643,20 @@ export default class ArticleViewLists extends PureComponent {
const nextOptions =
existingIndex >= 0
? currentOptions.map((opt, idx) =>
idx === existingIndex
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
: opt
)
idx === existingIndex
? { ...opt, count: opt.count + count, mandated: Boolean(optionMandated) }
: opt
)
: [
...currentOptions,
{
articleId: optionTargetArticle.id,
sku: optionTargetArticle.sku,
count,
sort: currentOptions.length,
mandated: Boolean(optionMandated),
},
];
...currentOptions,
{
articleId: optionTargetArticle.id,
sku: optionTargetArticle.sku,
count,
sort: currentOptions.length,
mandated: Boolean(optionMandated),
},
];
this.setState({ busy: true });
try {
@@ -836,69 +862,70 @@ export default class ArticleViewLists extends PureComponent {
}}
>
{stuecklisteItems.map((item) => (
<Box
key={item.id || item.sku}
<Box
key={item.id || item.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${item.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${item.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.name || item.sku}
</Typography>
<Link
component={RouterLink}
to={artikelDetailPath(item.sku)}
sx={{
fontSize: '0.72rem',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.sku}
</Link>
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveStuecklisteItem(item.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
}
</Box>
</Box>
@@ -1045,80 +1072,81 @@ export default class ArticleViewLists extends PureComponent {
}}
>
{optionsItems.map((opt) => (
<Box
key={opt.id || opt.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${opt.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
<Box
key={opt.id || opt.sku}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 1,
py: 0.4,
borderRadius: 1,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Chip
label={`${opt.count}x`}
size="small"
sx={{ height: 20, fontSize: '0.72rem', fontWeight: 700, px: 0.25 }}
/>
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography
variant="body2"
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{opt.name || opt.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Link
component={RouterLink}
to={artikelDetailPath(opt.sku)}
sx={{
fontWeight: 600,
fontSize: '0.82rem',
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: '0.72rem',
fontFamily: 'monospace',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{opt.name || opt.sku}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Link
component={RouterLink}
to={artikelDetailPath(opt.sku)}
sx={{
fontSize: '0.72rem',
color: 'text.secondary',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{opt.sku}
</Link>
{opt.mandated ? (
<Chip
label="Fest"
size="small"
color="warning"
variant="outlined"
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
/>
) : null}
</Box>
{opt.sku}
</Link>
{opt.mandated ? (
<Chip
label="Fest"
size="small"
color="warning"
variant="outlined"
sx={{ height: 16, fontSize: '0.65rem', fontWeight: 600 }}
/>
) : null}
</Box>
</Box>
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
<IconButton
size="small"
disabled={busy}
onClick={() => this.handleRemoveOption(opt.sku)}
sx={{
p: 0.25,
color: 'text.secondary',
'&:hover': { color: 'error.main' },
}}
>
<DeleteOutlinedIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))
}
</Box>
</Box>
@@ -1211,14 +1239,14 @@ export default class ArticleViewLists extends PureComponent {
const siblings = hasSelf
? childrenList
: [
...childrenList,
{
id: articleData.id,
sku: articleData.sku,
name: articleData.name,
attributeValue: variationValue,
},
];
...childrenList,
{
id: articleData.id,
sku: articleData.sku,
name: articleData.name,
attributeValue: variationValue,
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, flex: 1, minHeight: 0 }}>
@@ -1348,7 +1376,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay,
primaryEan,
allEans,
} = getVariantDetails(sibling, allArticles, defaultTaxRate);
trackStock,
} = getVariantDetails(sibling, allArticles, defaultTaxRate, parentArticle);
return (
<Box
@@ -1465,7 +1494,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -1584,7 +1613,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -1604,7 +1633,7 @@ export default class ArticleViewLists extends PureComponent {
}
renderStandardView(selectableAttributes, assignedAttributeIds) {
const { busy, activeCreatorMode, selectedAttributeId } = this.state;
const { busy, activeCreatorMode, selectedAttributeId, attributes, attributesStatus } = this.state;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{activeCreatorMode === 'parent' ? (
@@ -1626,7 +1655,36 @@ export default class ArticleViewLists extends PureComponent {
</Button>
</Box>
{selectableAttributes.length === 0 ? (
{attributesStatus === 'loading' ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<CircularProgress size={14} />
<Typography variant="caption" color="text.secondary">
Merkmale werden geladen…
</Typography>
</Box>
) : !attributes || attributes.length === 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
) : selectableAttributes.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Alle vorhandenen Merkmale sind diesem Artikel bereits zugewiesen.
</Typography>
@@ -1879,11 +1937,7 @@ export default class ArticleViewLists extends PureComponent {
<Typography variant="caption" sx={{ fontWeight: 600, color: 'text.secondary' }}>
Varianten:
</Typography>
{children.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: '0.8rem' }}>
Noch keine Varianten angelegt.
</Typography>
) : (
{children.length === 0 ? null : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{children.map((child) => {
const defaultTaxRate = this.state.company?.taxrate ?? companyAutomat.state.current?.taxrate ?? 19;
@@ -1893,7 +1947,8 @@ export default class ArticleViewLists extends PureComponent {
stockDisplay,
primaryEan,
allEans,
} = getVariantDetails(child, allArticles, defaultTaxRate);
trackStock,
} = getVariantDetails(child, allArticles, defaultTaxRate, articleData);
return (
<Box
@@ -2035,7 +2090,7 @@ export default class ArticleViewLists extends PureComponent {
sx={{
fontSize: '0.74rem',
fontWeight: 600,
color: stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
color: !trackStock || stockVal == null ? 'text.disabled' : stockVal > 0 ? 'success.dark' : 'error.main',
whiteSpace: 'nowrap',
}}
>
@@ -2048,7 +2103,6 @@ export default class ArticleViewLists extends PureComponent {
size="small"
disabled={busy}
onClick={() => this.handleOpenRemoveDialog(child)}
title="Variante entfernen"
sx={{
p: 0.35,
color: 'text.secondary',
@@ -2069,7 +2123,6 @@ export default class ArticleViewLists extends PureComponent {
{isCustomChildValue || availableParentValues.length === 0 ? (
<TextInput
label="Neuer Wert"
placeholder="Neuer Wert..."
value={newChildText}
disabled={busy}
onChange={(e) => this.setState({ newChildText: e.target.value })}
@@ -2114,34 +2167,31 @@ export default class ArticleViewLists extends PureComponent {
</TextInput>
)}
<Tooltip title="Variante hinzufügen">
<span>
<IconButton
size="small"
color="primary"
disabled={
busy ||
(isCustomChildValue || availableParentValues.length === 0
? !newChildText.trim()
: !selectedChildValueId)
}
onClick={this.handleAddChild}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'action.disabled',
},
p: 0.6,
flexShrink: 0,
}}
>
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
</IconButton>
</span>
</Tooltip>
<IconButton
size="small"
color="primary"
aria-label="Variante hinzufügen"
disabled={
busy ||
(isCustomChildValue || availableParentValues.length === 0
? !newChildText.trim()
: !selectedChildValueId)
}
onClick={this.handleAddChild}
sx={{
bgcolor: 'primary.main',
color: 'primary.contrastText',
'&:hover': { bgcolor: 'primary.dark' },
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'action.disabled',
},
p: 0.6,
flexShrink: 0,
}}
>
{busy ? <CircularProgress size={16} color="inherit" /> : <AddIcon fontSize="small" />}
</IconButton>
</Box>
{/* Toggle custom value / predefined value if values exist */}
@@ -2176,16 +2226,16 @@ export default class ArticleViewLists extends PureComponent {
)}
<Box sx={{ mt: 0.5, pt: 1, borderTop: '1px solid', borderColor: 'divider' }}>
{isStueckliste ||
hasOptions ||
activeCreatorMode === 'stueckliste' ||
activeCreatorMode === 'options'
hasOptions ||
activeCreatorMode === 'stueckliste' ||
activeCreatorMode === 'options'
? this.renderCompoundManager(
isStueckliste,
hasOptions,
stuecklisteItems,
optionsItems,
selectableOtherArticles
)
isStueckliste,
hasOptions,
stuecklisteItems,
optionsItems,
selectableOtherArticles
)
: this.renderStandardView(selectableAttributes, assignedAttributeIds)}
</Box>
</Box>
@@ -2264,11 +2314,11 @@ export default class ArticleViewLists extends PureComponent {
<DialogContentText sx={{ fontSize: '0.88rem' }}>
{confirmClearType === 'stueckliste'
? (hasOptions
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
? 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen? Die Optionen bleiben dabei erhalten.'
: 'Möchten Sie alle Komponenten aus dieser Stückliste entfernen und den Artikel wieder als Standard-Artikel führen?')
: (isStueckliste
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
? 'Möchten Sie alle Optionen von diesem Artikel entfernen? Die Stückliste bleibt dabei erhalten.'
: 'Möchten Sie alle Optionen von diesem Artikel entfernen und ihn wieder als Standard-Artikel führen?')}
</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 2, pb: 2 }}>

View File

@@ -1,7 +1,7 @@
import { Component, PureComponent } from 'react';
import { EditablePanelTile } from 'dashgui/panels';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { getCategoryState } from '../../../automats/categoryAutomat';
import categoryAutomat, { getCategoryState } from '../../../automats/categoryAutomat';
import artikelAutomat from '../../../automats/artikelAutomat';
import { getCategoryBreadcrumbs, categoryDisplayName } from '../../../utils/categoryUtils';
import { Box, Chip, IconButton, Typography } from '@mui/material';
@@ -92,10 +92,29 @@ function categoryLabels(categoryIds, categories, rootCategoryId) {
.filter((c) => c.label);
}
class CategoryPills extends Component {
class CategoryPills extends PureComponent {
constructor(props) {
super(props);
this.state = { pickerOpen: false, targetPillId: null };
const catState = getCategoryState();
this.state = {
pickerOpen: false,
targetPillId: null,
categories: catState?.categories ?? categoryAutomat.state.categories,
rootCategoryId: catState?.rootCategoryId ?? categoryAutomat.state.rootCategoryId,
};
}
componentDidMount() {
this._unsubCat = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId,
});
});
}
componentWillUnmount() {
this._unsubCat?.();
}
handleRemoveCategory = (removeId) => {
@@ -135,8 +154,7 @@ class CategoryPills extends Component {
render() {
const { categoryIds, editMode } = this.props;
const { pickerOpen, targetPillId } = this.state;
const { categories, rootCategoryId } = getCategoryState() ?? {};
const { pickerOpen, targetPillId, categories, rootCategoryId } = this.state;
const labels = categoryLabels(categoryIds, categories, rootCategoryId);
const targetNode = categories && targetPillId != null ? categories[String(targetPillId)] : null;

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

@@ -3,11 +3,14 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
import ShoppingCartOutlinedIcon from '@mui/icons-material/ShoppingCartOutlined';
import Tooltip from '@mui/material/Tooltip';
import { PanelTile } from 'dashgui/panels';
import { formatStock } from '../searchFormat';
import ArticleStockAdjustDialog from '../ArticleStockAdjustDialog';
import artikelAutomat, { getArticle } from '../../../automats/artikelAutomat';
import { router } from '../../../appRouter';
import {
GREEN_PANEL,
PETROL_PANEL,
@@ -18,6 +21,135 @@ import {
const SECOND_ROW_MIN_HEIGHT = 220;
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('de-DE');
}
function extractIncoming(articleData, isParent) {
const items = [];
const collect = (article, variantInfo = null) => {
if (!article) return;
if (Array.isArray(article.incoming)) {
for (const inc of article.incoming) {
const amount = Number(inc?.amount) || 0;
if (amount > 0) {
items.push({
date: inc.date || null,
amount,
supplierName: inc.supplierName || null,
supplierSku: inc.sku || null,
pu: inc.pu || null,
variantSku: variantInfo?.sku || null,
variantName: variantInfo?.name || null,
});
}
}
}
if (Array.isArray(article.suppliers)) {
for (const sup of article.suppliers) {
if (Array.isArray(sup?.incoming)) {
for (const inc of sup.incoming) {
const amount = Number(inc?.amount) || 0;
if (amount > 0) {
items.push({
date: inc.date || null,
amount,
supplierName: sup.supplierName || null,
supplierSku: sup.sku || null,
pu: sup.pu || null,
variantSku: variantInfo?.sku || null,
variantName: variantInfo?.name || null,
});
}
}
}
}
}
};
collect(articleData);
if (isParent) {
const children = Array.isArray(articleData?.children) ? articleData.children : [];
for (const child of children) {
const fullChild = getArticle(child.sku) || child;
collect(fullChild, {
sku: fullChild.sku || child.sku,
name: fullChild.name || child.name,
});
}
}
items.sort((a, b) => {
if (a.date && b.date) {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}
if (a.date) return -1;
if (b.date) return 1;
return 0;
});
return items;
}
function extractUndeliveredOrders(articleData, isParent) {
const items = [];
const collect = (article, variantInfo = null) => {
if (!article) return;
if (Array.isArray(article.undeliveredOrders)) {
for (const ord of article.undeliveredOrders) {
const amount = Number(ord?.amount) || 0;
if (amount > 0) {
items.push({
orderId: ord.orderId,
orderNumber: ord.orderNumber || (ord.orderId ? `A-${ord.orderId}` : null),
date: ord.date || null,
customerName: ord.customerName || null,
amount,
variantSku: variantInfo?.sku || null,
variantName: variantInfo?.name || null,
});
}
}
}
};
collect(articleData);
if (isParent) {
const children = Array.isArray(articleData?.children) ? articleData.children : [];
for (const child of children) {
const fullChild = getArticle(child.sku) || child;
collect(fullChild, {
sku: fullChild.sku || child.sku,
name: fullChild.name || child.name,
});
}
}
items.sort((a, b) => {
if (a.date && b.date) {
const diff = new Date(a.date).getTime() - new Date(b.date).getTime();
if (diff !== 0) return diff;
} else if (a.date) {
return -1;
} else if (b.date) {
return 1;
}
return (Number(a.orderId) || 0) - (Number(b.orderId) || 0);
});
return items;
}
export default class ArticleViewStock extends PureComponent {
constructor(props) {
super(props);
@@ -26,6 +158,17 @@ export default class ArticleViewStock extends PureComponent {
};
this.handleOpenAdjust = this.handleOpenAdjust.bind(this);
this.handleCloseAdjust = this.handleCloseAdjust.bind(this);
this.handleOpenOrder = this.handleOpenOrder.bind(this);
}
handleOpenOrder(orderId, event) {
if (!orderId) return;
if (event?.metaKey || event?.ctrlKey) {
window.open(`/auftrag/bestellung/${orderId}`, '_blank');
return;
}
const nav = this.props.navigate || router.navigate;
void nav(`/auftrag/bestellung/${orderId}`);
}
componentDidMount() {
@@ -53,7 +196,7 @@ export default class ArticleViewStock extends PureComponent {
if (!Array.isArray(children)) return;
for (const child of children) {
if (child?.sku && !getArticle(child.sku)) {
void artikelAutomat.actions.loadArticle(child.sku).catch(() => {});
void artikelAutomat.actions.loadArticle(child.sku).catch(() => { });
}
}
}
@@ -123,6 +266,14 @@ export default class ArticleViewStock extends PureComponent {
? rawUnit
: '';
const incomingItems = extractIncoming(articleData, isParent);
const totalIncoming = incomingItems.reduce((sum, inc) => sum + inc.amount, 0);
const hasIncoming = totalIncoming > 0;
const undeliveredOrders = extractUndeliveredOrders(articleData, isParent);
const totalUndelivered = undeliveredOrders.reduce((sum, ord) => sum + ord.amount, 0);
const hasUndelivered = totalUndelivered > 0;
return (
<>
<PanelTile
@@ -133,10 +284,10 @@ export default class ArticleViewStock extends PureComponent {
isParent || isStueckliste
? null
: {
label: 'Bestand anpassen',
icon: EditOutlinedIcon,
onClick: this.handleOpenAdjust,
}
label: 'Bestand anpassen',
icon: EditOutlinedIcon,
onClick: this.handleOpenAdjust,
}
}
>
<Box
@@ -219,8 +370,7 @@ export default class ArticleViewStock extends PureComponent {
onClick={this.handleOpenAdjust}
sx={{
display: 'inline-flex',
alignItems: 'baseline',
gap: 0.75,
flexDirection: 'column',
my: 0.5,
cursor: 'pointer',
borderRadius: 1,
@@ -232,27 +382,29 @@ export default class ArticleViewStock extends PureComponent {
},
}}
>
<Typography
component="div"
sx={{
fontSize: '2.4rem',
fontWeight: 700,
lineHeight: 1.1,
letterSpacing: '-0.02em',
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
}}
>
{formatStock(totalStock)}
</Typography>
{unit ? (
<Box sx={{ display: 'inline-flex', alignItems: 'baseline', gap: 0.75 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontWeight: 500 }}
component="div"
sx={{
fontSize: '2.4rem',
fontWeight: 700,
lineHeight: 1.1,
letterSpacing: '-0.02em',
color: totalStock > 0 ? 'text.primary' : 'text.secondary',
}}
>
{unit}
{formatStock(totalStock)}
</Typography>
) : null}
{unit ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ fontWeight: 500 }}
>
{unit}
</Typography>
) : null}
</Box>
</Box>
</Tooltip>
) : (
@@ -292,8 +444,8 @@ export default class ArticleViewStock extends PureComponent {
)}
</Box>
{/* Bottom section: Per-warehouse breakdown */}
{isTrackStock && hasWarehouses ? (
{/* Bottom section: Per-warehouse breakdown, incoming shipments & open orders */}
{hasWarehouses || hasIncoming || hasUndelivered ? (
<Box
sx={{
mt: 1,
@@ -309,65 +461,226 @@ export default class ArticleViewStock extends PureComponent {
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{warehouses.map((wh) => (
<Box
key={wh.id}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 0.35,
px: 0.75,
borderRadius: 1,
backgroundColor: VEIL_PANEL,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
mr: 1,
}}
>
<WarehouseIcon
{/* Warehouse stocks */}
{hasWarehouses ? (
<>
{warehouses.map((wh) => (
<Box
key={wh.id}
sx={{
fontSize: 15,
color: 'text.secondary',
mr: 0.5,
flexShrink: 0,
}}
/>
<Typography
variant="body2"
noWrap
title={wh.name}
sx={{
fontWeight: 500,
fontSize: '0.82rem',
color: 'text.primary',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 0.35,
px: 0.75,
borderRadius: 1,
backgroundColor: VEIL_PANEL,
}}
>
{wh.name}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
mr: 1,
}}
>
<WarehouseIcon
sx={{
fontSize: 15,
color: 'text.secondary',
mr: 0.5,
flexShrink: 0,
}}
/>
<Typography
variant="body2"
noWrap
title={wh.name}
sx={{
fontWeight: 500,
fontSize: '0.82rem',
color: 'text.primary',
}}
>
{wh.name}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.82rem',
flexShrink: 0,
color:
Number(wh.stock) > 0
? 'text.primary'
: 'text.secondary',
}}
>
{formatStock(wh.stock)}
</Typography>
</Box>
))}
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.82rem',
flexShrink: 0,
color:
Number(wh.stock) > 0
? 'text.primary'
: 'text.secondary',
}}
>
{formatStock(wh.stock)}
</Typography>
</Box>
))}
</>
) : null}
{/* Incoming shipments */}
{hasIncoming ? (
<>
{incomingItems.map((inc, idx) => {
const dateLabel = inc.date ? formatDate(inc.date) : null;
const labelParts = [
inc.variantName,
inc.supplierName || 'Lieferant',
dateLabel,
].filter(Boolean);
const label = labelParts.join(' – ');
return (
<Box
key={idx}
title={label}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 0.35,
px: 0.75,
borderRadius: 1,
backgroundColor: VEIL_PANEL,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
mr: 1,
}}
>
<LocalShippingOutlinedIcon
sx={{
fontSize: 15,
color: 'text.secondary',
mr: 0.6,
flexShrink: 0,
transform: 'scaleX(-1)',
}}
/>
<Typography
variant="body2"
noWrap
sx={{
fontWeight: 500,
fontSize: '0.82rem',
color: 'text.primary',
}}
>
{label}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.82rem',
flexShrink: 0,
color: 'text.primary',
}}
>
+{formatStock(inc.amount)}
</Typography>
</Box>
);
})}
</>
) : null}
{/* Undelivered / Open Customer Orders */}
{hasUndelivered ? (
<>
{undeliveredOrders.map((ord, idx) => {
const dateLabel = ord.date ? formatDate(ord.date) : null;
const orderLabel = ord.orderNumber || (ord.orderId ? `Auftrag #${ord.orderId}` : 'Auftrag');
const labelParts = [
ord.variantName,
orderLabel,
ord.customerName,
dateLabel,
].filter(Boolean);
const label = labelParts.join(' – ');
const tooltipText = ord.orderId
? `Auftrag ${orderLabel}${ord.customerName ? ` (${ord.customerName})` : ''} öffnen`
: label;
return (
<Box
key={ord.orderId ? `order-${ord.orderId}` : `ord-${idx}`}
onClick={(e) => this.handleOpenOrder(ord.orderId, e)}
title={tooltipText}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 0.35,
px: 0.75,
borderRadius: 1,
backgroundColor: VEIL_PANEL,
cursor: ord.orderId ? 'pointer' : 'default',
transition: 'background-color 0.15s ease',
'&:hover': ord.orderId ? {
backgroundColor: TINT_ROW_HOVER,
} : undefined,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
minWidth: 0,
mr: 1,
}}
>
<ShoppingCartOutlinedIcon
sx={{
fontSize: 15,
color: 'text.secondary',
mr: 0.6,
flexShrink: 0,
}}
/>
<Typography
variant="body2"
noWrap
sx={{
fontWeight: 500,
fontSize: '0.82rem',
color: 'text.primary',
}}
>
{label}
</Typography>
</Box>
<Typography
variant="body2"
sx={{
fontWeight: 700,
fontSize: '0.82rem',
flexShrink: 0,
color: 'text.primary',
}}
>
-{formatStock(ord.amount)}
</Typography>
</Box>
);
})}
</>
) : null}
</Box>
</Box>
) : null}

View File

@@ -1,4 +1,6 @@
import { PureComponent } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import Link from '@mui/material/Link';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton';
@@ -10,8 +12,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 +82,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 +110,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 +141,7 @@ export default class ArticleAttributesTab extends PureComponent {
componentWillUnmount() {
this._cancelled = true;
this.unsubAttr?.();
this.unsubProp?.();
this.unsubCompany?.();
this.unsubArtikel?.();
}
@@ -577,6 +564,27 @@ export default class ArticleAttributesTab extends PureComponent {
</Tooltip>
</Box>
{merkmalOptions.length === 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Im System sind noch keine Merkmale angelegt.
</Typography>
<Link
component={RouterLink}
to="/artikel/merkmale"
variant="caption"
sx={{
color: 'primary.main',
fontWeight: 600,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Zu den Merkmalen →
</Link>
</Box>
)}
{/* Unified Assigned Merkmale List */}
{assignedGroups.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>

File diff suppressed because it is too large Load Diff

View File

@@ -9,20 +9,70 @@ import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper';
import PrintIcon from '@mui/icons-material/Print';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelScheduleSendOutlinedIcon from '@mui/icons-material/CancelScheduleSendOutlined';
import { enqueueSnackbar } from 'notistack';
import { labeltemplate, printer, article } from '../../../api';
import { labeltemplate, article } from '../../../api';
import { companyAutomat } from '../../../automats';
import { notifyError } from '../../../utils/notify';
import BackendError from '../../BackendError';
function filterZplPrinters(agentprinters) {
return (Array.isArray(agentprinters) ? agentprinters : []).filter(
(p) => p.enabled !== false && (!p.type || p.type.toLowerCase() === 'zpl'),
);
}
function getPrinterLabel(p) {
let label;
if (p.model && p.serial) {
label = `${p.model} (${p.serial})`;
} else if (p.model) {
label = p.model;
} else if (p.serial) {
label = `Drucker (${p.serial})`;
} else if (p.desc && p.address) {
label = `${p.desc} (${p.address})`;
} else if (p.address) {
label = p.address;
} else if (p.desc) {
label = p.desc;
} else {
label = `Drucker #${p.id}`;
}
if (p.online === false) {
return `${label} (offline)`;
}
return label;
}
function getPrinterLabelLength(printer, livePrinters = []) {
if (!printer) return null;
const live = (livePrinters || []).find(
(lp) =>
(lp.agentprinterId && lp.agentprinterId === printer.id) ||
(lp.id && lp.id === printer.id) ||
(printer.serial && lp.serial && String(printer.serial).toLowerCase() === String(lp.serial).toLowerCase()),
);
const raw = printer.labelLength ?? live?.labelLength;
if (raw == null) return null;
const num = Number(raw);
return Number.isFinite(num) && num > 0 ? num : null;
}
export default class ArticleLabelTab extends PureComponent {
constructor(props) {
super(props);
const initialPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.state = {
templates: null,
printers: null,
printers: initialPrinters,
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: [],
selectedTemplateId: '',
selectedPrinterId: '',
selectedPrinterId: initialPrinters.length > 0 ? initialPrinters[0].id : '',
quantity: 1,
loading: true,
error: null,
@@ -30,27 +80,61 @@ export default class ArticleLabelTab extends PureComponent {
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => {
const zplPrinters = filterZplPrinters(state.agentprinters);
this.setState((prev) => ({
printers: zplPrinters,
livePrinters: Array.isArray(state.livePrinters) ? state.livePrinters : [],
selectedPrinterId:
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
? prev.selectedPrinterId
: (zplPrinters[0]?.id ?? ''),
}));
});
this.loadData();
}
componentDidUpdate(prevProps) {
if (
prevProps.article?.sku !== this.props.article?.sku ||
prevProps.article?.name !== this.props.article?.name ||
prevProps.article?.prices !== this.props.article?.prices ||
prevProps.article?.eans !== this.props.article?.eans
) {
this.loadData();
}
}
componentWillUnmount() {
this.unsub?.();
}
async loadData() {
this.setState({ loading: true, error: null });
const sku = this.props.article?.sku;
try {
const [resTemplates, resPrinters] = await Promise.all([
labeltemplate.list(),
printer.list(),
]);
const resTemplates = await labeltemplate.list(sku ? { sku } : undefined);
const templates = resTemplates.labeltemplates || [];
const zplPrinters = (resPrinters.printers || []).filter((p) => p.type === 'zpl');
const zplPrinters = filterZplPrinters(companyAutomat.state.agentprinters);
this.setState({
this.setState((prev) => ({
templates,
printers: zplPrinters,
selectedTemplateId: templates.length > 0 ? templates[0].id : '',
selectedPrinterId: zplPrinters.length > 0 ? zplPrinters[0].id : '',
livePrinters: Array.isArray(companyAutomat.state.livePrinters)
? companyAutomat.state.livePrinters
: prev.livePrinters,
selectedTemplateId:
templates.length > 0
? prev.selectedTemplateId && templates.some((t) => t.id === prev.selectedTemplateId)
? prev.selectedTemplateId
: templates[0].id
: '',
selectedPrinterId:
prev.selectedPrinterId && zplPrinters.some((p) => p.id === prev.selectedPrinterId)
? prev.selectedPrinterId
: (zplPrinters[0]?.id ?? ''),
loading: false,
});
}));
} catch (error) {
this.setState({ error, loading: false });
}
@@ -68,7 +152,7 @@ export default class ArticleLabelTab extends PureComponent {
try {
await article.printLabel(articleData.sku, {
templateId: selectedTemplateId,
printerId: selectedPrinterId,
agentprinterId: selectedPrinterId,
quantity,
});
enqueueSnackbar('Druckauftrag erfolgreich gesendet', { variant: 'success' });
@@ -77,10 +161,24 @@ export default class ArticleLabelTab extends PureComponent {
}
};
handleCancelAllJobs = async () => {
const { selectedPrinterId, printers } = this.state;
const p = (printers || []).find((item) => item.id === selectedPrinterId);
const printerName = p ? getPrinterLabel(p) : 'Drucker';
try {
await companyAutomat.actions.cancelAllPrinterJobs(selectedPrinterId || undefined);
enqueueSnackbar(`Druckaufträge für ${printerName} abgebrochen.`, { variant: 'info' });
} catch (err) {
notifyError(err);
}
};
render() {
const {
templates,
printers,
livePrinters,
selectedTemplateId,
selectedPrinterId,
quantity,
@@ -88,6 +186,9 @@ export default class ArticleLabelTab extends PureComponent {
error,
} = this.state;
const currentPrinter = (printers || []).find((p) => p.id === selectedPrinterId);
const printerLabelLength = getPrinterLabelLength(currentPrinter, livePrinters);
if (loading) {
return (
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
@@ -130,8 +231,18 @@ export default class ArticleLabelTab extends PureComponent {
<Grid container spacing={2}>
{templates.map((t) => {
const isSelected = t.id === selectedTemplateId;
const dpi = t.dpi === 300 ? 300 : 203;
const tmplHeight = t.height || 200;
const tmplWidth = t.width || 448;
const hasPrinterLength = printerLabelLength != null && printerLabelLength > 0;
const totalHeight = hasPrinterLength ? Math.max(tmplHeight, printerLabelLength) : tmplHeight;
const isCutoff = hasPrinterLength && printerLabelLength < tmplHeight;
const outlineColor = isCutoff ? '#d32f2f' : '#1976d2';
const tmplHeightPercent = (tmplHeight / totalHeight) * 100;
const printerHeightPercent = hasPrinterLength ? (printerLabelLength / totalHeight) * 100 : 100;
return (
<Grid item xs={12} sm={6} md={4} key={t.id}>
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={t.id}>
<Paper
variant="outlined"
onClick={() => this.setState({ selectedTemplateId: t.id })}
@@ -175,18 +286,81 @@ export default class ArticleLabelTab extends PureComponent {
bgcolor: 'background.default',
borderRadius: 1,
overflow: 'hidden',
position: 'relative',
}}
>
{t.thumb ? (
<img
src={`data:image/png;base64,${t.thumb}`}
alt={t.name}
style={{
<Box
sx={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
aspectRatio: `${tmplWidth} / ${totalHeight}`,
boxSizing: 'border-box',
bgcolor: '#fff',
borderRadius: 0.5,
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
}}
/>
>
{/* Template preview image */}
<Box
sx={{
width: '100%',
height: `${tmplHeightPercent}%`,
position: 'relative',
display: 'flex',
overflow: 'hidden',
borderRadius: 0.5,
}}
>
<img
src={`data:image/png;base64,${t.thumb}`}
alt={t.name}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
{/* If template exceeds printer label length, shade the cutoff overflow */}
{isCutoff && (
<Box
sx={{
position: 'absolute',
left: 0,
right: 0,
top: `${(printerLabelLength / tmplHeight) * 100}%`,
bottom: 0,
bgcolor: 'rgba(211, 47, 47, 0.22)',
zIndex: 1,
pointerEvents: 'none',
}}
/>
)}
</Box>
{/* Dotted outline for printer label length */}
{hasPrinterLength && (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: `${printerHeightPercent}%`,
border: `2px dotted ${outlineColor}`,
borderRadius: 0.5,
pointerEvents: 'none',
boxSizing: 'border-box',
zIndex: 2,
}}
/>
)}
</Box>
) : (
<Typography variant="caption" color="text.disabled">
Keine Vorschau
@@ -198,7 +372,7 @@ export default class ArticleLabelTab extends PureComponent {
{t.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{t.width}x{t.height} ({t.dpi} dpi)
{t.width}x{t.height} ({dpi} dpi)
</Typography>
</Box>
</Paper>
@@ -217,11 +391,14 @@ export default class ArticleLabelTab extends PureComponent {
onChange={(e) => this.setState({ selectedPrinterId: e.target.value })}
sx={{ minWidth: 200 }}
>
{printers.map((p) => (
<MenuItem key={p.id} value={p.id}>
{p.desc ? `${p.desc} (${p.address})` : p.address}
</MenuItem>
))}
{printers.map((p) => {
const len = getPrinterLabelLength(p, livePrinters);
return (
<MenuItem key={p.id} value={p.id}>
{getPrinterLabel(p)}{len != null ? ` (${len} px)` : ''}
</MenuItem>
);
})}
</TextInput>
<TextInput
@@ -246,6 +423,18 @@ export default class ArticleLabelTab extends PureComponent {
>
Etiketten drucken
</Button>
<Button
variant="outlined"
color="error"
size="large"
startIcon={<CancelScheduleSendOutlinedIcon />}
onClick={this.handleCancelAllJobs}
disabled={!selectedPrinterId}
sx={{ height: 40 }}
>
Druckaufträge abbrechen
</Button>
</Box>
</Box>
</Box>

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';
@@ -39,8 +38,8 @@ export default class ArticleSupplierTab extends PureComponent {
this.state = {
loading: true,
assignedSuppliers: [],
companySuppliers: [],
company: null,
companySuppliers: companyAutomat.state.suppliers ?? [],
company: companyAutomat.state.current ?? null,
dialogOpen: false,
editItem: null,
busyId: null,
@@ -48,9 +47,14 @@ export default class ArticleSupplierTab extends PureComponent {
}
componentDidMount() {
this.unsubCompany = companyAutomat.subscribe(this, (state) => ({
company: state.current ?? null,
}));
this.unsubCompany = companyAutomat.subscribe(
this,
(state) => ({
company: state.current ?? null,
companySuppliers: state.suppliers ?? [],
}),
['current', 'suppliers'],
);
this.initData();
}
@@ -90,15 +94,6 @@ export default class ArticleSupplierTab extends PureComponent {
this.unwatchSignal = sseAutomat.actions.watchArticle(String(sku), () => {
void articleSupplierAutomat.reload(String(sku)).catch(() => {});
});
// 2. Fetch available company suppliers
try {
const companySuppliers = await supplierAutomat.actions.load();
this.setState({ companySuppliers: companySuppliers ?? [] });
} catch (err) {
notifyError(err);
this.setState({ loading: false });
}
}
/** Re-fetches the assigned suppliers for the current article. */

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 {
@@ -40,6 +40,7 @@ class CarrierItem extends PureComponent {
};
handleBlur = () => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item;
if (!item) return;
const draft = this.state.draft.trim();
@@ -54,7 +55,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 ?? '' });
@@ -63,6 +64,7 @@ class CarrierItem extends PureComponent {
}
handleToggleHasPackages = (e) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item;
if (!item) return;
const nextVal = e.target.checked;
@@ -70,6 +72,7 @@ class CarrierItem extends PureComponent {
};
handleToggleIsReverse = (e) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item;
if (!item) return;
const nextVal = e.target.checked;
@@ -77,6 +80,7 @@ class CarrierItem extends PureComponent {
};
handleKeyDown = (event) => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item;
if (event.key === 'Enter') {
event.preventDefault();
@@ -90,12 +94,13 @@ class CarrierItem extends PureComponent {
};
handleDelete = () => {
if (this.props.disabled) return;
const item = this.props.carrier ?? this.props.item;
if (!item) return;
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

@@ -8,18 +8,18 @@ class CategoryBreadcrumbs extends Component {
};
render() {
const { crumbs } = this.props;
const { crumbs, sx, variant = 'body2' } = this.props;
const visible = (crumbs ?? []).filter((crumb) => crumb.name);
if (visible.length === 0) return null;
return (
<Breadcrumbs sx={{ mb: 2 }} aria-label="Kategoriepfad">
<Breadcrumbs sx={sx ?? { mb: 2 }} aria-label="Kategoriepfad">
{visible.map((crumb, index) => (
<Link
key={crumb.id}
component="button"
type="button"
variant="body2"
variant={variant}
underline="always"
color="inherit"
aria-current={index === visible.length - 1 ? 'page' : undefined}

View File

@@ -118,6 +118,7 @@ class CategoryNameDialog extends Component {
(mode !== 'edit' || name.trim() !== (this.props.initialName ?? '').trim());
const bulkNames = parseBulkNames(bulkText);
const canSubmitBulk = bulkNames.length > 0;
const canBulk = mode === 'create';
return (
<AppDialog open={open} onClose={this.handleClose} maxWidth="xs" fullWidth>

View File

@@ -416,14 +416,12 @@ export default class CategoryTree extends PureComponent {
}
componentDidMount() {
if (this.props.categories === undefined || this.props.rootCategoryId === undefined) {
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: this.props.categories ?? s.categories,
rootCategoryId: this.props.rootCategoryId ?? s.rootCategoryId,
});
this._unsub = categoryAutomat.subscribe((s) => {
this.setState({
categories: s.categories,
rootCategoryId: s.rootCategoryId ?? this.props.rootCategoryId,
});
}
});
}
componentDidUpdate(prevProps) {
@@ -463,7 +461,14 @@ export default class CategoryTree extends PureComponent {
name,
});
enqueueSnackbar(`Kategorie „${name}“ angelegt.`, { variant: 'success' });
this.setState({ addDialogOpen: false });
const nextCategories = categoryAutomat.state.categories ?? {
...(this.state.categories || {}),
[String(created.id)]: created,
};
this.setState({
addDialogOpen: false,
categories: nextCategories,
});
this.props.onSelect?.(created.id, created);
} catch (err) {
notifyError(err);
@@ -489,12 +494,19 @@ export default class CategoryTree extends PureComponent {
if (!name || !renameNode) return;
this.setState({ isSubmitting: true });
try {
await categoryAutomat.actions.renameCategory({
const updated = await categoryAutomat.actions.renameCategory({
categoryId: renameNode.id,
name,
});
enqueueSnackbar(`Kategorie in „${name}“ umbenannt.`, { variant: 'success' });
this.setState({ renameDialogOpen: false });
const nextCategories = categoryAutomat.state.categories ?? {
...(this.state.categories || {}),
[String(updated.id)]: updated,
};
this.setState({
renameDialogOpen: false,
categories: nextCategories,
});
} catch (err) {
notifyError(err);
} finally {
@@ -524,7 +536,10 @@ export default class CategoryTree extends PureComponent {
if (this.props.selectedId != null && String(this.props.selectedId) === String(deleteNode.id)) {
this.props.onSelect?.(deleteNode.parentId ?? rootCategoryId);
}
this.setState({ deleteDialogOpen: false });
this.setState({
deleteDialogOpen: false,
categories: categoryAutomat.state.categories,
});
} catch (err) {
notifyError(err);
} finally {

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,
@@ -156,13 +156,13 @@ export default class ChatDrawer extends PureComponent {
stagedReferences: [],
stagedExtractions: s.pendingExtraction
? [
{
id: `ext-${Date.now()}`,
text: s.pendingExtraction.text,
label: s.pendingExtraction.label || 'parsed invoice',
instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION,
},
]
{
id: `ext-${Date.now()}`,
text: s.pendingExtraction.text,
label: s.pendingExtraction.label || 'parsed invoice',
instruction: s.pendingExtraction.instruction || EXTRACTION_MODEL_INSTRUCTION,
},
]
: [],
previewExtraction: null,
isProcessingFiles: false,
@@ -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 = () => {
@@ -839,7 +839,7 @@ export default class ChatDrawer extends PureComponent {
try {
inputEl.selectionStart = newCursorPos;
inputEl.selectionEnd = newCursorPos;
} catch {}
} catch { }
}
}
);
@@ -1949,11 +1949,6 @@ export default class ChatDrawer extends PureComponent {
type="submit"
color="primary"
disabled={!canSend}
title={
isWorking
? 'Modell antwortet gerade…'
: 'Senden (Enter)'
}
sx={{
bgcolor: isWorking ? 'action.hover' : 'primary.main',
color: isWorking ? 'warning.main' : 'primary.contrastText',

View File

@@ -1,10 +1,9 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Badge from '@mui/material/Badge';
import Tooltip from '@mui/material/Tooltip';
import CircularProgress from '@mui/material/CircularProgress';
import ChatRoundedIcon from '@mui/icons-material/ChatRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
import HelpOutlineRoundedIcon from '@mui/icons-material/HelpOutlineRounded';
@@ -70,144 +69,161 @@ export default class ChatFab extends PureComponent {
return (
<Box
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
left: { xs: 20, lg: 164 },
zIndex: theme.zIndex.speedDial || 1050,
})}
sx={{
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 1 },
alignSelf: { xs: 'center', lg: 'flex-start' },
display: 'flex',
alignItems: 'center',
}}
>
<Box sx={{ position: 'relative' }}>
{/* Animated pulsing halo when awaiting user answer */}
{hasPendingAsk && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -8,
left: -8,
right: -8,
bottom: -8,
borderRadius: '50%',
bgcolor: CHAT_BUSY,
opacity: 0.5,
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabAskPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
},
}}
/>
)}
{/* Animated pulsating halo when model is busy thinking/working */}
{!hasPendingAsk && isBusy && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -6,
left: -6,
right: -6,
bottom: -6,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={66}
thickness={2.5}
sx={{
position: 'absolute',
top: -6,
left: -6,
color: CHAT_BUSY,
pointerEvents: 'none',
}}
/>
)}
<Badge
badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
variant={hasPendingAsk ? 'standard' : 'dot'}
invisible={!hasPendingAsk && !isBusy}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
'& .MuiBadge-badge': hasPendingAsk
? {
bgcolor: RED,
color: WHITE,
fontWeight: 900,
fontSize: '0.85rem',
minWidth: 22,
height: 22,
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
{/* Animated pulsing halo when awaiting user answer */}
{hasPendingAsk && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
borderRadius: '50%',
boxShadow: `0 2px 10px ${RED_GLOW}`,
animation: 'badgePop 1.5s ease infinite',
'@keyframes badgePop': {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' },
bgcolor: CHAT_BUSY,
opacity: 0.5,
animation: 'fabAskPulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabAskPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.8 },
'50%': { transform: 'scale(1.35)', opacity: 0.12 },
'100%': { transform: 'scale(0.95)', opacity: 0.8 },
},
}
: undefined,
}}
>
<Fab
color="primary"
aria-label={ariaLabel}
}}
/>
)}
{/* Animated pulsating halo when model is busy thinking/working */}
{!hasPendingAsk && isBusy && !isOpen && (
<Box
sx={{
position: 'absolute',
top: -4,
left: -4,
right: -4,
bottom: -4,
borderRadius: '50%',
bgcolor: 'warning.main',
opacity: 0.4,
animation: 'fabPulse 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'@keyframes fabPulse': {
'0%': { transform: 'scale(0.95)', opacity: 0.7 },
'50%': { transform: 'scale(1.25)', opacity: 0.15 },
'100%': { transform: 'scale(0.95)', opacity: 0.7 },
},
}}
/>
)}
{/* Circular spinner ring when model is busy */}
{!hasPendingAsk && isBusy && !isOpen && (
<CircularProgress
size={38}
thickness={3}
sx={{
position: 'absolute',
top: -3,
left: -3,
color: CHAT_BUSY,
pointerEvents: 'none',
}}
/>
)}
<IconButton
onClick={this.handleClick}
sx={(theme) => ({
width: 54,
height: 54,
boxShadow: hasPendingAsk
? CHAT_FAB_SHADOW_PENDING
: isBusy
? CHAT_FAB_SHADOW_BUSY
: CHAT_FAB_SHADOW_IDLE,
background: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
})}
aria-label={ariaLabel}
sx={{ p: 0, width: 32, height: 32, borderRadius: '50%' }}
>
{isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 24 }} />
) : hasPendingAsk ? (
<HelpOutlineRoundedIcon
sx={{
fontSize: 26,
animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' },
'15%': { transform: 'rotate(-12deg)' },
'30%': { transform: 'rotate(12deg)' },
'45%': { transform: 'rotate(-8deg)' },
'60%': { transform: 'rotate(0deg)' },
},
}}
/>
) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 24 }} />
) : (
<ChatRoundedIcon sx={{ fontSize: 24 }} />
)}
</Fab>
</Badge>
<Badge
badgeContent={hasPendingAsk ? '?' : undefined}
color={hasPendingAsk ? 'error' : isBusy ? 'warning' : 'default'}
variant={hasPendingAsk ? 'standard' : 'dot'}
invisible={!hasPendingAsk && !isBusy}
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
'& .MuiBadge-badge': hasPendingAsk
? {
bgcolor: RED,
color: WHITE,
fontWeight: 900,
fontSize: '0.75rem',
minWidth: 18,
height: 18,
borderRadius: '50%',
boxShadow: `0 2px 8px ${RED_GLOW}`,
animation: 'badgePop 1.5s ease infinite',
'@keyframes badgePop': {
'0%, 100%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.2)' },
},
}
: undefined,
}}
>
<Avatar
sx={(theme) => ({
width: 32,
height: 32,
bgcolor: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
background: hasPendingAsk
? CHAT_FAB_PENDING
: isBusy
? CHAT_FAB_BUSY
: isOpen
? theme.palette.primary.main
: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
color: WHITE,
transition: 'all 0.2s ease',
boxShadow: hasPendingAsk
? CHAT_FAB_SHADOW_PENDING
: isBusy
? CHAT_FAB_SHADOW_BUSY
: isOpen
? `0 2px 8px rgba(0,0,0,0.25)`
: 'none',
})}
>
{isOpen ? (
<CloseRoundedIcon sx={{ fontSize: 18 }} />
) : hasPendingAsk ? (
<HelpOutlineRoundedIcon
sx={{
fontSize: 18,
animation: 'fabWiggle 2s ease infinite',
'@keyframes fabWiggle': {
'0%, 100%': { transform: 'rotate(0deg)' },
'15%': { transform: 'rotate(-12deg)' },
'30%': { transform: 'rotate(12deg)' },
'45%': { transform: 'rotate(-8deg)' },
'60%': { transform: 'rotate(0deg)' },
},
}}
/>
) : isBusy ? (
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
) : (
<AutoAwesomeRoundedIcon sx={{ fontSize: 18 }} />
)}
</Avatar>
</Badge>
</IconButton>
</Box>
</Box>
</Box>
);
);
}
}

Some files were not shown because too many files have changed in this diff Show More