Compare commits

..

196 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
seb
c647068067 u 2026-10-19 01:36:35 +02:00
seb
c77f367216 u 2026-10-19 01:32:39 +02:00
seb
578387adca u 2026-10-19 01:19:34 +02:00
seb
037a2b0ae4 u 2026-10-19 01:04:02 +02:00
seb
eb15bcb83b u 2026-10-19 01:02:08 +02:00
seb
9b40d1db87 u 2026-10-18 23:32:53 +02:00
seb
f7ee9182ec u 2026-10-18 22:55:02 +02:00
seb
479cac85a5 u 2026-10-18 22:01:56 +02:00
seb
0a9deac0da u 2026-10-18 21:29:13 +02:00
seb
abd40ff4fe u 2026-10-18 21:05:25 +02:00
seb
5faea634ba u 2026-10-18 21:04:18 +02:00
seb
35cfea91f8 u 2026-10-18 20:55:48 +02:00
seb
9f9310e1ff u 2026-10-18 20:54:08 +02:00
seb
c211040cd3 u 2026-10-18 20:34:17 +02:00
seb
ac9034685f u 2026-10-18 20:21:27 +02:00
seb
328ba164ec u 2026-10-18 19:55:28 +02:00
seb
180f65f4d6 u 2026-10-18 19:51:54 +02:00
seb
94f385e753 u 2026-10-18 19:36:56 +02:00
seb
587170ce9b u 2026-10-18 19:32:31 +02:00
seb
199bf869a6 u 2026-10-18 19:10:43 +02:00
seb
596d0dc6f1 u 2026-10-18 18:40:25 +02:00
seb
c15fa0a53e u 2026-10-18 18:38:38 +02:00
seb
9cb93bddeb u 2026-10-18 17:47:41 +02:00
seb
b42b63a0af u 2026-10-18 17:47:37 +02:00
seb
cf85ec102d u 2026-10-18 16:10:18 +02:00
seb
5644678b7d u 2026-10-18 15:35:58 +02:00
seb
3df8c5e9c7 u 2026-10-18 04:32:38 +02:00
seb
ea52753ef9 u 2026-10-18 04:01:40 +02:00
seb
33684fc860 u 2026-10-18 03:51:53 +02:00
seb
1b4b31b016 u 2026-10-18 03:25:57 +02:00
seb
dc73905a19 u 2026-10-18 03:16:24 +02:00
seb
ab9d5fe413 u 2026-10-18 03:14:15 +02:00
seb
0a59a0987a u 2026-10-18 00:38:45 +02:00
seb
691854fdd4 u 2026-10-18 00:27:27 +02:00
seb
666d84b9f3 u 2026-10-17 23:59:20 +02:00
seb
b8f226bd39 u 2026-10-17 23:32:21 +02:00
seb
0d11dd5014 u 2026-10-17 21:40:53 +02:00
seb
d1ccbf335c u 2026-10-17 20:54:04 +02:00
seb
c332be8916 u 2026-10-17 19:51:27 +02:00
seb
7080382f56 u 2026-10-17 18:38:15 +02:00
seb
45c1067d9f u 2026-10-17 18:36:18 +02:00
seb
582bf04476 u 2026-10-17 18:09:55 +02:00
seb
c0ae79ba6b u 2026-10-17 17:43:03 +02:00
seb
01d17ebb25 u 2026-10-17 17:11:11 +02:00
seb
20d83b00fc u 2026-10-17 05:11:07 +02:00
seb
54066fba7f u 2026-10-17 05:00:17 +02:00
seb
b3051953b0 u 2026-10-17 04:51:09 +02:00
seb
cb2f6ad37f u 2026-10-17 04:39:37 +02:00
seb
bea427f3df u 2026-10-17 04:18:43 +02:00
seb
bd16df64a1 u 2026-10-17 04:11:42 +02:00
seb
6df833b362 u 2026-10-17 03:57:36 +02:00
seb
e63abfe2c0 u 2026-10-17 03:47:09 +02:00
seb
d729423249 u 2026-10-17 03:09:27 +02:00
seb
e031458a42 u 2026-10-17 01:58:30 +02:00
seb
bb22da7aae u 2026-10-17 01:48:41 +02:00
seb
0b0ffb2203 u 2026-10-17 00:53:01 +02:00
seb
b3a81efe46 u 2026-10-16 23:53:09 +02:00
seb
82db75ac0f u 2026-10-16 22:40:15 +02:00
seb
5d8ac3a8eb u 2026-10-16 22:27:34 +02:00
seb
0851c31970 u 2026-10-16 22:08:14 +02:00
seb
d8c1d44652 u 2026-10-16 21:04:00 +02:00
seb
23a198b0c1 u 2026-10-16 20:46:26 +02:00
seb
7ad8a3a301 u 2026-10-16 19:36:08 +02:00
seb
4a7a02a0a4 u 2026-10-16 18:05:20 +02:00
seb
8e098b145e u 2026-10-16 17:10:54 +02:00
seb
7a4d452109 u 2026-10-16 16:36:43 +02:00
seb
99d95ee234 u 2026-10-16 16:03:31 +02:00
seb
f5a5cb51e3 u 2026-10-16 15:39:25 +02:00
seb
52411ac45d u 2026-10-16 15:38:39 +02:00
seb
5c57097ad0 u 2026-10-16 14:32:10 +02:00
seb
a592cc30f6 u 2026-10-16 14:17:22 +02:00
seb
764d6f850a u 2026-10-16 13:51:18 +02:00
seb
d851c6e1e9 u 2026-10-16 13:10:58 +02:00
seb
7bc1da4de6 u 2026-10-16 04:12:18 +02:00
seb
ff165799d4 u 2026-10-16 02:25:27 +02:00
seb
a52366c477 u 2026-10-15 23:50:02 +02:00
seb
2b2b5b945d u 2026-10-15 21:54:00 +02:00
seb
fafeec93b6 u 2026-10-15 21:04:37 +02:00
seb
beed294cb0 u 2026-10-15 20:15:19 +02:00
seb
8e783a571f u 2026-10-15 15:55:30 +02:00
seb
7e5b262d75 u 2026-10-15 11:33:02 +02:00
seb
47bac6024c u 2026-10-15 11:21:29 +02:00
seb
657d184d1c u 2026-10-15 11:17:04 +02:00
seb
a90776bb8b u 2026-10-15 11:12:02 +02:00
seb
670184c633 u 2026-10-15 10:49:47 +02:00
seb
7114258a96 u 2026-10-15 10:38:56 +02:00
seb
40bf70a41c u 2026-10-15 10:20:40 +02:00
seb
37acaf9554 u 2026-10-15 03:42:19 +02:00
seb
9817d4abda u 2026-10-15 03:32:41 +02:00
seb
954f276404 u 2026-10-15 02:36:10 +02:00
seb
77f6a0815f u 2026-10-15 02:33:24 +02:00
seb
360660b850 u 2026-10-15 01:45:53 +02:00
seb
6abadac120 u 2026-10-15 01:02:37 +02:00
275 changed files with 37494 additions and 12064 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -27,14 +27,15 @@ Reference guide for the backend LLM agent when emitting route navigation actions
| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` |
| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |
| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |
| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` |
| **New Quote** | `/auftrag/neuesangebot` | Static draft form (Neues Angebot) | `/auftrag/neuesangebot` |
| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |
| **Order List (Auftrag / Bestellungen)** | `/auftrag` | Static (legacy `/auftrag/bestellung` redirects here) | `/auftrag` |
| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |
| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` |
| **Quote List (Angebote)** | `/auftrag/angebot` | Static | `/auftrag/angebot` |
| **New Quote** | `/auftrag/neuesangebot` | Static draft form (Neues Angebot) | `/auftrag/neuesangebot` |
| **Quote Detail** | `/auftrag/:quoteId` or `/auftrag/angebot/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |
| **Shipments (Ausliefern)** | `/auftrag/ausliefern` | Static (shipment manifests & parcels) | `/auftrag/ausliefern` |
| **Payments (Zahlungen)** | `/auftrag/zahlungen` | Static (order payments ledger) | `/auftrag/zahlungen` |
| **Carriers (Versandarten)** | `/auftrag/versandarten` | Static (Versanddienstleister & Versandarten) | `/auftrag/versandarten` |
| **Customer List** | `/kunde` | Static | `/kunde` |
| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` |
@@ -69,8 +70,8 @@ Reference guide for the backend LLM agent when emitting route navigation actions
### Quotes & Orders (`/auftrag`)
- Distinction:
- Quotes (Angebote) live at `/auftrag` and `/auftrag/:quoteId`.
- Orders (Bestellungen) live at `/auftrag/bestellung` and `/auftrag/bestellung/:orderId`.
- Orders (Auftrag / Bestellungen) live at `/auftrag` and `/auftrag/bestellung/:orderId`.
- Quotes (Angebote) live at `/auftrag/angebot` and `/auftrag/:quoteId` (or `/auftrag/angebot/:quoteId`).
- Shipments (Ausliefern) live at `/auftrag/ausliefern` (connected to shipment manifests and packages).
- Payments (Zahlungen) live at `/auftrag/zahlungen` (payment bookings and payment balances).
- Both IDs (`quoteId`, `orderId`) are document IDs.

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: 255 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',
@@ -106,12 +91,12 @@ const CLUSTERS = [
{
id: 'audit_grids',
label: 'Audit & Virtual Grids',
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat'],
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat', 'ledgerHistoryGridAutomat'],
},
{
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',
@@ -520,8 +389,19 @@ const AUTOMAT_SPECS = [
{ type: 'API', name: 'GET /api/globalhistory', desc: 'Company global audit' },
],
},
{
id: 'ledgerHistoryGridAutomat',
title: 'ledgerHistoryGridAutomat',
subtitle: 'createGridAutomat (Ledger)',
entries: [
{ type: 'STATE', name: 'rows: [] / totalRows', desc: 'Block size 50 virtual rows' },
{ type: 'ACTION', name: 'fetchRows(viewport)', desc: 'Infinite scroll query' },
{ type: 'ACTION', name: 'reload()', desc: 'Reload on company switch' },
{ type: 'API', name: 'GET /api/ledger/history', desc: 'Append-only ledger audit' },
],
},
// 12. UI, Settings & System
// 9. UI, Navigation & System
{
id: 'navigationAutomat',
title: 'navigationAutomat',
@@ -536,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',
@@ -584,27 +452,18 @@ 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' },
{ from: 'authAutomat', to: 'globalHistoryGridAutomat', label: 'reload audit on authed', port: 'p_auth_status' },
{ from: 'authAutomat', to: 'ledgerHistoryGridAutomat', label: 'reload ledger on authed', port: 'p_auth_status' },
// Real-time SSE dispatch
{ from: 'sseAutomat', to: 'categoryAutomat', label: '\'category\' sse trigger', port: 'p_sse_events' },
@@ -612,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' },
@@ -631,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' },
@@ -653,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

View File

@@ -1,8 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="de" class="notranslate" translate="no">
<head>
<meta charset="UTF-8" />
<meta name="google" content="notranslate" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>QuixPOS * Management Interface</title>

2122
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",
@@ -18,6 +19,13 @@
"automat-graph": "node generate-automat-graph.js"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@jsquash/avif": "^2.1.1",
@@ -25,21 +33,19 @@
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1",
"ag-grid-community": "^36.2.0",
"ag-grid-react": "^36.2.0",
"@uiw/react-codemirror": "^4.25.12",
"dashgui": "file:../dashgui",
"flexsearch": "^0.8.212",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.0",
"react": "^19.3.1",
"react-automat": "file:../automat",
"react-canvas-label-editor": "github:sebseb7/react-canvas-label-editor",
"react-dom": "^19.3.0",
"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"
},
@@ -47,7 +53,7 @@
"@eslint/js": "^10.0.1",
"@hpcc-js/wasm-graphviz": "^1.29.2",
"@vitejs/plugin-react": "6.1.2",
"eslint": "^10.12.0",
"eslint": "^10.13.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.13.0",
"qrcode": "^1.5.4",
@@ -56,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

@@ -29,32 +29,45 @@ export const article = {
},
/**
* GET /api/article/history
* History of all articles with paging/sorting/filtering
* GET /api/article/history?offset=0&limit=50&search=...&seekMonths=-1
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
* → { history: ArticleHistoryEntry[], total: number, unfilteredTotal?: number }
*/
getHistory({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
// #region GET /api/article/history — getHistory
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow));
if (endRow != null) params.set('endRow', String(endRow));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
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))));
}
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString();
return http(`/api/article/history${qs ? `?${qs}` : ''}`);
},
// #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',
@@ -70,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)}`, {
@@ -82,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;
@@ -142,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`, {
@@ -153,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`, {
@@ -166,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`, {
@@ -179,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`, {
@@ -192,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`, {
@@ -337,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`, {
@@ -346,16 +359,11 @@ export const article = {
});
},
/**
* GET /api/article/:sku/children → { children: [...] }
*/
getChildren(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/children`);
},
/**
* POST /api/article/:sku/children
* Body: { attributeValueId?: number, value?: string, name?: string }
* → 201 { sku }
*/
addChild(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
@@ -375,12 +383,6 @@ export const article = {
);
},
/**
* GET /api/article/:sku/articlelist → { items: [...] }
*/
getArticlelist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`);
},
/**
* PUT /api/article/:sku/articlelist
@@ -393,12 +395,6 @@ export const article = {
});
},
/**
* GET /api/article/:sku/optionslist → { options: [...] }
*/
getOptionslist(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`);
},
/**
* PUT /api/article/:sku/optionslist

View File

@@ -1,12 +1,6 @@
import { http } from './http.js';
export const attributevalue = {
/** GET /api/attributename/:attributeId/value → { values: AttributeValue[] } */
// #region GET /api/attributename/:attributeId/value — listByAttributeId
listByAttributeId(attributeId) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`);
},
// #endregion
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
// #region POST /api/attributename/:attributeId/value — create

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

49
src/api/carrier.js Normal file
View File

@@ -0,0 +1,49 @@
import { http } from './http.js';
/**
* Company-scoped carriers / Versandarten.
* Shape: Carrier = { id, name, sort, hasPackages, isReverse }
*/
export const carrier = {
/** GET /api/carrier → { carriers: Carrier[] } */
list() {
return http('/api/carrier');
},
/**
* POST /api/carrier
* Body: { name, sort?, hasPackages?, isReverse? }
* → 201 { carrier: Carrier }
*/
create(body) {
return http('/api/carrier', { method: 'POST', body });
},
/** GET /api/carrier/:id → 200 { carrier: Carrier } */
get(id) {
return http(`/api/carrier/${id}`);
},
/**
* PATCH /api/carrier/:id
* Body: { name?, sort?, hasPackages?, isReverse? }
* → 200 { carrier: Carrier }
*/
update(id, body) {
return http(`/api/carrier/${id}`, { method: 'PATCH', body });
},
/**
* PUT /api/carrier/order
* Body: { carrierIds: number[] }
* → 200 { carrierIds: number[] }
*/
order(body) {
return http('/api/carrier/order', { method: 'PUT', body });
},
/** DELETE /api/carrier/:id → 204 · 409 Conflict if in use */
delete(id) {
return http(`/api/carrier/${id}`, { method: 'DELETE' });
},
};

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

View File

@@ -10,13 +10,35 @@ import { http, clientIdHeaders } from './http.js';
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
*/
export const delivery = {
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
/**
* GET /api/delivery
* @param {{ offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number }} [opts]
* → { deliveries: DeliverySummary[], total?: number, unfilteredTotal?: number, offset?: number, limit?: number }
*/
// #region GET /api/delivery — list
list() {
list(opts) {
if (opts && typeof opts === 'object') {
const params = new URLSearchParams();
if (opts.offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(opts.offset) || 0))));
if (opts.limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(opts.limit) || 50))));
if (opts.search && String(opts.search).trim()) params.set('search', String(opts.search).trim());
if (opts.onlyUnbooked === true || opts.onlyUnbooked === 'true') params.set('onlyUnbooked', 'true');
if (opts.supplierId != null) params.set('supplierId', String(opts.supplierId));
if (opts.seekMonths != null && Number(opts.seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(opts.seekMonths))));
}
const qs = params.toString();
return http(`/api/delivery${qs ? `?${qs}` : ''}`);
}
return http('/api/delivery');
},
// #endregion
/** POST /api/delivery/rebuild-summaries → { ok: boolean, companyId: number } */
rebuildSummaries() {
return http('/api/delivery/rebuild-summaries', { method: 'POST' });
},
/** GET /api/delivery/:id → { delivery: Delivery } */
// #region GET /api/delivery/:id — get
get(id) {
@@ -98,81 +120,13 @@ export const delivery = {
// #endregion
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF, TXT or CSV document (max 50 MB)
* Streams SSE events: status, llm_call, tool_call, usage, progress, result, error
* POST /api/delivery/:id/pdf
* Generates the "Wareneingang" PDF and stores it as a manifest attachment.
* → 201 { attachment, fileUrl }
*/
// #region POST /api/delivery/parse — parseSse
async *parseSse(file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try { data = JSON.parse(raw); } catch { data = raw; }
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
// #region POST /api/delivery/:id/pdf — createPdf
createPdf(id) {
return http(`/api/delivery/${id}/pdf`, { method: 'POST' });
},
// #endregion
};

View File

@@ -2,16 +2,20 @@ import { http } from './http.js';
export const globalHistory = {
/**
* GET /api/history
* Global system/entity history with paging/sorting/filtering
* GET /api/history?offset=0&limit=50&search=...&seekMonths=-1
* Global system/entity history with chunk paging and search/seek filtering
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object }} [params]
* → { history: GlobalHistoryEntry[], total: number, unfilteredTotal?: number }
*/
// #region GET /api/history — list
list({ startRow, endRow, offset, limit, sortModel, filterModel } = {}) {
list({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
const params = new URLSearchParams();
if (startRow != null) params.set('startRow', String(startRow));
if (endRow != null) params.set('endRow', String(endRow));
if (offset != null) params.set('offset', String(offset));
if (limit != null) params.set('limit', String(limit));
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
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))));
}
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString();

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,5 +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

@@ -6,14 +6,28 @@ import { http } from './http.js';
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
*/
export const ledger = {
/**
* POST /api/ledger
* Body: { manifestarticle_id, transaction_type, amount, transaction_text }
* → 201 { ledger: LedgerEntry }
* GET /api/ledger/history?offset=0&limit=50&search=...&seekMonths=-1
* Append-only ledger audit history with chunk paging, search, and seek filtering
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number, sortModel?: Array, filterModel?: object, sku?: string, transactionType?: string }} [params]
* → { ledger: LedgerEntry[], history?: LedgerEntry[], total: number, unfilteredTotal?: number }
*/
// #region POST /api/ledger — add
add(body) {
return http('/api/ledger', { method: 'POST', body });
// #region GET /api/ledger/history — getHistory
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel, sku, transactionType } = {}) {
const params = new URLSearchParams();
params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
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))));
}
if (sku != null) params.set('sku', String(sku));
if (transactionType != null) params.set('transaction_type', String(transactionType));
if (sortModel && sortModel.length) params.set('sort', JSON.stringify(sortModel));
if (filterModel && Object.keys(filterModel).length) params.set('filter', JSON.stringify(filterModel));
const qs = params.toString();
return http(`/api/ledger/history${qs ? `?${qs}` : ''}`);
},
// #endregion
@@ -29,31 +43,18 @@ export const ledger = {
},
// #endregion
/**
* GET /api/ledger/sku/:sku
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
// #region GET /api/ledger/sku/:sku — listBySku
listBySku(sku, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/sku/${encodeURIComponent(sku)}${query}`);
},
// #endregion
/**
* GET /api/ledger
* Query: { manifestarticle_id?, sku?, transaction_type? }
* Query: { manifestarticleId?, sku?, transactionType? }
* → 200 { ledger: LedgerEntry[] }
*/
// #region GET /api/ledger — list
list(query) {
list({ manifestarticleId, sku, transactionType } = {}) {
const params = new URLSearchParams();
if (query?.manifestarticle_id != null) params.set('manifestarticle_id', query.manifestarticle_id);
if (query?.manifestarticleId != null) params.set('manifestarticle_id', query.manifestarticleId);
if (query?.sku != null) params.set('sku', query.sku);
if (query?.transaction_type != null) params.set('transaction_type', query.transaction_type);
if (query?.transactionType != null) params.set('transaction_type', query.transactionType);
if (manifestarticleId != null) params.set('manifestarticle_id', String(manifestarticleId));
if (sku != null) params.set('sku', String(sku));
if (transactionType != null) params.set('transaction_type', String(transactionType));
const qs = params.toString();
return http(`/api/ledger${qs ? `?${qs}` : ''}`);
},

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))));
}
@@ -75,4 +76,29 @@ export const order = {
return http(`/api/order/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/order/:id/pdf
* Generates and attaches order confirmation PDF.
* @param {number|string} id Order ID
* → 201 { attachment: Attachment, fileUrl: string }
*/
// #region POST /api/order/:id/pdf — createPdf
createPdf(id) {
return http(`/api/order/${id}/pdf`, { method: 'POST' });
},
// #endregion
/**
* 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, orderLineId?: number, amount?: number }> }
* → 201 { shipment: Shipment }
*/
// #region POST /api/order/:id/shipment — createShipment
createShipment(id, body = {}) {
return http(`/api/order/${id}/shipment`, { method: 'POST', body });
},
// #endregion
};

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

@@ -12,13 +12,6 @@ export const printer = {
},
// #endregion
/** GET /api/printer/:id → { printer: Printer } */
// #region GET /api/printer/:id — getById
getById(id) {
return http(`/api/printer/${id}`);
},
// #endregion
/**
* POST /api/printer
* Body: { address, type, model, serial, sort, desc }
@@ -58,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))));
}
@@ -86,5 +87,18 @@ export const quote = {
return http(`/api/quote/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/quote/:id/order
* Converts quote into sales order.
* @param {number|string} id Quote ID
* @param {object} [body] Optional overrides: { number, date, entityId, externalNumber, lines }
* → 201 { order: Order }
*/
// #region POST /api/quote/:id/order — createOrder
createOrder(id, body = {}) {
return http(`/api/quote/${id}/order`, { method: 'POST', body });
},
// #endregion
};

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

@@ -5,6 +5,27 @@ import { http } from './http.js';
* Manifest number format: <orderNumber><shipmentPrefix><index>, e.g. AU-1001-L-1.
*/
export const shipment = {
// #region GET /api/shipment — list
list(params = {}) {
const q = new URLSearchParams();
if (params.offset) q.set('offset', String(params.offset));
if (params.limit != null) q.set('limit', String(params.limit));
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}` : ''}`);
},
// #endregion
// #region PATCH /api/shipment/:id — update
update(id, body) {
return http(`/api/shipment/${id}`, { method: 'PATCH', body });
},
// #endregion
// #region GET /api/order/:id/shipment — listByOrder
listByOrder(orderId) {
return http(`/api/order/${orderId}/shipment`);
@@ -34,4 +55,16 @@ export const shipment = {
return http(`/api/shipment/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
// #region POST /api/shipment/:id/pdf — createPdf
createPdf(id) {
return http(`/api/shipment/${id}/pdf`, { method: 'POST' });
},
// #endregion
// #region GET /api/shipment/:id/file/:fileId — getFileUrl
getFileUrl(id, fileId) {
return `/api/shipment/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}`;
},
// #endregion
};

View File

@@ -4,11 +4,6 @@ import { http } from './http.js';
* Packages / logistics shipments attached to a shipment manifest.
*/
export const shipping = {
// #region GET /api/shipment/:id/shipping — listByShipment
listByShipment(shipmentId) {
return http(`/api/shipment/${shipmentId}/shipping`);
},
// #endregion
// #region POST /api/shipment/:id/shipping — create
create(shipmentId, body) {

View File

@@ -13,4 +13,24 @@ export const stock = {
return http('/api/stock/adjust', { method: 'POST', body });
},
// #endregion
/**
* POST /api/stock/accept
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { deliveryId, warehouseId? }
*/
// #region POST /api/stock/accept — accept
accept(body) {
return http('/api/stock/accept', { method: 'POST', body });
},
// #endregion
/**
* POST /api/stock/ship
* Body: { manifestarticleId, warehouseId?, text? } or { items: [...] } or { shipmentId, warehouseId? }
*/
// #region POST /api/stock/ship — ship
ship(body) {
return http('/api/stock/ship', { method: 'POST', body });
},
// #endregion
};

View File

@@ -12,6 +12,13 @@ export const supplier = {
},
// #endregion
/** GET /api/supplier/:id → { supplier: Supplier } */
// #region GET /api/supplier/:id — get
get(id) {
return http(`/api/supplier/${encodeURIComponent(id)}`);
},
// #endregion
/**
* POST /api/supplier
* Body: { name } — empty name ⇒ 400 Name required.

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

@@ -144,41 +144,33 @@ export const vfs = {
},
// #endregion
/**
* POST /api/vfs/drafts/response
* Send response to virtual draft request (action: read or list).
* @param {{ requestId: string, content?: object|string, files?: Array<object>, error?: string }} body
* POST /api/vfs/skills/restore
* Restores all system default .SKILL files into the company's VFS (overwrites existing files).
* Requires no request body. Emits vfs SSE event.
* → 200 { success: true, restored: string[], count: number }
*/
// #region POST /api/vfs/drafts/response — respondDraft
async respondDraft(body) {
try {
const res = await fetch('/api/vfs/drafts/response', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify(body),
});
if (!res.ok) {
let errText = '';
try {
const errData = await res.json();
errText = errData.error || errData.message;
} catch {
errText = await res.text().catch(() => '');
}
console.error('[vfs] respondDraft failed:', res.status, errText);
return { ok: false, error: errText };
}
if (res.status === 204) return { ok: true };
const text = await res.text();
return text ? JSON.parse(text) : { ok: true };
} catch (err) {
console.error('[vfs] respondDraft error:', err);
return { ok: false, error: err.message };
}
// #region POST /api/vfs/skills/restore — restoreSkills
restoreSkills() {
return http('/api/vfs/skills/restore', {
method: 'POST',
});
},
// #endregion
/**
* POST /api/vfs/applets/restore
* Restores all system default .applet.js example applets into the company's VFS (overwrites existing files).
* Requires no request body. Emits vfs SSE event.
* → 200 { success: true, restored: string[], count: number }
*/
// #region POST /api/vfs/applets/restore — restoreApplets
restoreApplets() {
return http('/api/vfs/applets/restore', {
method: 'POST',
});
},
// #endregion
};

View File

@@ -12,13 +12,6 @@ export const warehouse = {
},
// #endregion
/** GET /api/warehouse/:id → { warehouse: Warehouse } */
// #region GET /api/warehouse/:id — getById
getById(id) {
return http(`/api/warehouse/${id}`);
},
// #endregion
/**
* POST /api/warehouse
* Body: { name, sort? } — empty name ⇒ 400 Name required.

View File

@@ -1,38 +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';
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);
@@ -71,6 +79,7 @@ const routes = [
{ index: true, element: <ArticleHistoryPage /> },
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
{ path: 'ledgerhistory', element: <LedgerHistoryPage /> },
{ path: 'statistics', element: <StatisticsPage /> },
],
},
@@ -96,16 +105,23 @@ const routes = [
path: 'auftrag',
element: <SectionLayout sectionPath="/auftrag" />,
children: [
{ index: true, element: <AuftragAngebotPage /> },
{ index: true, element: <AuftragBestellungPage /> },
{ path: 'angebot', element: <AuftragAngebotPage /> },
{ path: 'angebot/:quoteId', element: <AuftragAngebotEditPage /> },
{ path: 'neuesangebot', element: <AuftragAngebotNeuPage /> },
{ path: 'angebot/neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
{ path: 'neu', element: <Navigate to="/auftrag/neuesangebot" replace /> },
{ path: 'bestellung', element: <AuftragBestellungPage /> },
{ path: 'bestellung', element: <Navigate to="/auftrag" replace /> },
{ 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 /> },
],
},
{
@@ -118,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 /> },
],
},
@@ -130,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

@@ -1,45 +1,26 @@
/**
* @file articleHistoryAutomat.js
* @description Virtualized grid automat (`articleHistoryGridAutomat`) for
* infinite-scrolling article history via `react-virtual-aggridautomat`.
* @description Double-buffered chunk list automat (`articleHistoryAutomat`) for
* article audit logs and history entries with multi-filter caching and LRU eviction.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
* status reaches `'authed'` with a `selectedCompanyId`.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
import { createChunkListAutomat } from './createChunkListAutomat';
import { article } from '../api';
import authAutomat from './authAutomat';
export const articleHistoryGridAutomat = createGridAutomat({
export const articleHistoryAutomat = createChunkListAutomat({
name: 'articleHistory',
blockSize: 50,
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
try {
const res = await article.getHistory({
startRow,
endRow,
sortModel,
filterModel,
});
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
return { rows, lastRow };
} catch (err) {
console.warn('Failed to fetch article history (backend route pending):', err);
return { rows: [], lastRow: 0 };
}
},
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => article.getHistory(params),
});
// Reload on company switch or auth change
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
articleHistoryGridAutomat.actions.reload();
}
});
// Alias for backwards compatibility
export const articleHistoryGridAutomat = articleHistoryAutomat;
export default articleHistoryGridAutomat;
export default articleHistoryAutomat;

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,18 +99,56 @@ 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
? null
: (article.stock != null && !Number.isNaN(Number(article.stock))
? Number(article.stock)
: (trackStock || warehouses.length > 0
? warehouses.reduce((sum, wh) => sum + (Number(wh.stock) || 0), 0)
: null));
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,
available,
warehouses,
articlewarehousesummary: Array.isArray(article.articlewarehousesummary)
? 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,
};
}
@@ -154,6 +193,33 @@ export function buildArticleIndex(views) {
existing.price = p;
existing.net = article.net ?? p;
}
if (existing.stock == null && (article.warehouses?.length > 0 || article.stock != null)) {
const rec = toIndexRecord(article, categoryId);
if (rec.stock != null) {
existing.stock = rec.stock;
existing.available = rec.available;
existing.warehouses = rec.warehouses;
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;
}
}
}
@@ -230,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;
@@ -258,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).
*
@@ -280,6 +451,41 @@ export function setArticleIndexName(sku, name) {
);
}
/**
* Patches a single record's stock in the derived index (e.g. after detail load or stock adjustment).
*
* @param {string|number} sku Article SKU.
* @param {object} stockData Stock updates.
* @param {number|null} [stockData.stock] Total stock.
* @param {number|null} [stockData.available] Available stock.
* @param {Array<object>} [stockData.warehouses] Warehouse array.
* @param {Array<object>} [stockData.articlewarehousesummary] Summary array.
* @returns {void}
*/
export function setArticleIndexStock(sku, { stock, available, warehouses, articlewarehousesummary } = {}) {
const items = articleIndexAutomat.state.items;
if (!items.length) return;
articleIndexAutomat.actions.replace(
items.map((row) => {
if (String(row.sku) !== String(sku)) return row;
const whs = warehouses ?? row.warehouses ?? [];
const computedStock = stock !== undefined
? stock
: (whs.length > 0 ? whs.reduce((sum, w) => sum + (Number(w.stock) || 0), 0) : row.stock);
const computedAvailable = available !== undefined
? available
: (computedStock !== undefined ? computedStock : row.available);
return {
...row,
stock: computedStock,
available: computedAvailable,
...(warehouses ? { warehouses } : {}),
...(articlewarehousesummary ? { articlewarehousesummary } : {}),
};
}),
);
}
/**
* Selects the derived index records.
*

View File

@@ -14,11 +14,17 @@ import { Automat } from 'react-automat';
import FlexSearch from 'flexsearch';
import articleIndexAutomat from './articleIndexAutomat';
import categoryAutomat from './categoryAutomat';
import companyAutomat from './companyAutomat';
import { parseFilter } from '../components/article/searchFormat';
// #region Types & Initial State
// MARK: typedefs, initial state, and FlexSearch instance variables
/**
* @typedef {Object} ArticleSearchResult
* @property {Record<string,object>} categoryFacets Facet counts per category id.
* @property {Record<string,object>} brandFacets Facet counts per brand.
* @property {Record<string,object>} supplierFacets Facet counts per supplier.
* @property {number} totalCount Number of matching records.
* @property {Array<object>} recordset Sliced matching article index records for the current page.
* @property {number} offset Current 0-based offset.
@@ -33,6 +39,8 @@ import { parseFilter } from '../components/article/searchFormat';
* @property {ArticleSearchResult} result Latest search outcome.
* @property {string} target Active search term.
* @property {string} filter Active category filter string.
* @property {string} brand Active brand filter string.
* @property {string} supplier Active supplier filter string.
* @property {number} offset Current 0-based offset.
* @property {number|null} pageSize Current page size.
* @property {boolean} loading Whether a search or index load is in flight.
@@ -41,6 +49,8 @@ import { parseFilter } from '../components/article/searchFormat';
/** @type {ArticleSearchResult} */
const emptyResult = {
categoryFacets: {},
brandFacets: {},
supplierFacets: {},
totalCount: 0,
recordset: [],
offset: 0,
@@ -55,6 +65,10 @@ const initialState = {
result: emptyResult,
target: '',
filter: '',
brand: '',
supplier: '',
empty: '',
hierarchy: '',
offset: 0,
pageSize: null,
loading: false,
@@ -67,7 +81,51 @@ const { Document, Charset } = FlexSearch;
let flexDocument = null;
let lastIndexedArticles = null;
let articleByInternalId = new Map();
// #endregion
// #region Helpers
// #region expandSearchTerms
// MARK: expands search strings by splitting camelCase and punctuation
/**
* Expands a string by splitting camelCase words and punctuation delimiters
* into separate words so that sub-words (e.g. "BioBizz" -> "Bio Bizz", "Light-Mix" -> "Light Mix")
* can be indexed and searched by prefix or infix.
*
* @param {string} str Raw text string.
* @returns {string} Text with added spaced variants.
*/
function expandSearchTerms(str) {
if (!str) return '';
const camel = str.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
const normalized = camel.replace(/[-_./·+]/g, ' ');
if (normalized === str) return str;
return `${str} ${normalized}`;
}
// #endregion
// #region extractWords
// MARK: extracts normalized word tokens from a string
/**
* Splits text into individual lowercase word tokens, splitting across
* camelCase transitions and punctuation delimiters.
*
* @param {string} str Raw text.
* @returns {Array<string>} Normalized word list.
*/
function extractWords(str) {
if (!str) return [];
return str
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[-_./·+]/g, ' ')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
}
// #endregion
// #region getOrCreateFlexIndex
// MARK: constructs or retrieves FlexSearch document index
/**
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
*
@@ -85,8 +143,8 @@ function getOrCreateFlexIndex(articles) {
index: [
{ field: 'all', tokenize: 'forward', resolution: 9 },
{ field: 'sku', tokenize: 'full', resolution: 9 },
{ field: 'name', tokenize: 'forward', resolution: 9 },
{ field: 'brand', tokenize: 'forward', resolution: 5 },
{ field: 'name', tokenize: 'full', resolution: 9 },
{ field: 'brand', tokenize: 'full', resolution: 5 },
{ field: 'eans', tokenize: 'forward', resolution: 9 },
],
},
@@ -100,8 +158,10 @@ function getOrCreateFlexIndex(articles) {
const docId = i;
byId.set(docId, article);
const sku = String(article.sku ?? '');
const name = String(article.name ?? '');
const brand = String(article.brand?.name || article.brandName || '');
const rawName = String(article.name ?? '');
const rawBrand = String(article.brand?.name || article.brandName || '');
const name = expandSearchTerms(rawName);
const brand = expandSearchTerms(rawBrand);
const eans = (article.eans ?? []).join(' ');
const all = `${sku} ${name} ${brand} ${eans}`.trim();
@@ -120,7 +180,10 @@ function getOrCreateFlexIndex(articles) {
articleByInternalId = byId;
return { doc, byId };
}
// #endregion
// #region queryFlexSearch
// MARK: queries FlexSearch document index
/**
* Searches the FlexSearch index with the query string.
*
@@ -158,7 +221,10 @@ function queryFlexSearch(query, articles) {
}
return matchesList;
}
// #endregion
// #region getArticleRelevanceScore
// MARK: calculates search relevance score for an article
/**
* Calculates a search relevance score for an article record against a query.
*
@@ -170,8 +236,10 @@ export function getArticleRelevanceScore(article, query) {
if (!query) return 0;
const q = String(query).trim().toLowerCase();
const sku = String(article.sku ?? '').toLowerCase();
const name = String(article.name ?? '').toLowerCase();
const brand = String(article.brand?.name || article.brandName || '').toLowerCase();
const rawName = String(article.name ?? '');
const name = rawName.toLowerCase();
const rawBrand = String(article.brand?.name || article.brandName || '');
const brand = rawBrand.toLowerCase();
const eans = (article.eans ?? []).map((e) => String(e).toLowerCase());
// 1. Exact SKU match
@@ -184,8 +252,9 @@ export function getArticleRelevanceScore(article, query) {
if (name.startsWith(q)) return 3000;
// 5. SKU contains query
if (sku.includes(q)) return 2000;
const words = extractWords(rawName);
// 6. Word in name starts with query
const words = name.split(/\s+/);
if (words.some((w) => w.startsWith(q))) return 1500;
// 7. Name contains query
if (name.includes(q)) return 1000;
@@ -193,14 +262,41 @@ export function getArticleRelevanceScore(article, query) {
if (eans.includes(q)) return 800;
// 9. EAN starts with query
if (eans.some((e) => e.startsWith(q))) return 600;
// 10. Brand starts with query
const brandWords = extractWords(rawBrand);
// 10. Brand exact match
if (brand === q) return 500;
// 11. Brand starts with query
if (brand.startsWith(q)) return 400;
// 11. Brand contains query
// 12. Word in brand starts with query
if (brandWords.some((w) => w.startsWith(q))) return 350;
// 13. Brand contains query
if (brand.includes(q)) return 200;
// 14. Multi-word query where all terms match across article fields
const qWords = q.split(/\s+/).filter(Boolean);
if (qWords.length > 1) {
const allMatch = qWords.every((term) =>
words.some((w) => w.startsWith(term)) ||
name.includes(term) ||
brandWords.some((w) => w.startsWith(term)) ||
brand.includes(term) ||
sku.includes(term)
);
if (allMatch) {
const prefixCount = qWords.filter((term) =>
words.some((w) => w.startsWith(term)) || brandWords.some((w) => w.startsWith(term))
).length;
return 1200 + prefixCount * 300;
}
}
return 10;
}
// #endregion
// #region sortArticlesByRelevance
// MARK: sorts articles by relevance score, name, and sku
/**
* Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU.
*
@@ -225,7 +321,10 @@ export function sortArticlesByRelevance(articles, query) {
return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de');
});
}
// #endregion
// #region searchArticlesIndex
// MARK: calls articleIndexAutomat.actions.ensureLoaded
/**
* Synchronously searches the article index using the search automat token matching and relevance order.
*
@@ -242,8 +341,12 @@ export function sortArticlesByRelevance(articles, query) {
export function searchArticlesIndex(query, options = {}) {
const {
excludeSkus = [],
includeSkus = null,
extraArticles = [],
allowParent = false,
categories = [],
hierarchy = '',
empty = '',
offset = 0,
pageSize = 10,
limit,
@@ -253,19 +356,83 @@ export function searchArticlesIndex(query, options = {}) {
void articleIndexAutomat.actions.ensureLoaded().catch(() => {});
}
const articles = articleIndexAutomat.state.items ?? [];
let articles = articleIndexAutomat.state.items ?? [];
if (Array.isArray(extraArticles) && extraArticles.length > 0) {
const existingSkus = new Set(articles.map((a) => String(a.sku || '').toLowerCase()));
const missing = extraArticles.filter((a) => a.sku && !existingSkus.has(String(a.sku).toLowerCase()));
if (missing.length > 0) {
articles = [...articles, ...missing];
}
}
const q = String(query ?? '').trim();
const rawMatches = q ? queryFlexSearch(q, articles) : articles;
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) {
pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase()));
}
if (includeSkus != null) {
const includedSet = new Set((includeSkus ?? []).map((s) => String(s).toLowerCase()));
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;
@@ -294,7 +461,10 @@ export function searchArticlesIndex(query, options = {}) {
return recordset;
}
// #endregion
// #region isRootCategory
// MARK: checks if category id or name represents the root category
/**
* Determines whether a category ID or name represents the root category.
*
@@ -312,7 +482,10 @@ function isRootCategory(id, rootCategoryId, metaName) {
}
return false;
}
// #endregion
// #region getCategoryAndDescendantIds
// MARK: recursively collects category id and descendant category ids
/**
* Recursively collects categoryId and all its descendant category IDs.
*
@@ -390,7 +563,10 @@ export function getCategoryAndDescendantIds(selectedCategories, categoriesMap, f
return allIds;
}
// #endregion
// #region matchesCategory
// MARK: checks whether article matches expanded category ids
/**
* Checks whether an article belongs to at least one of the target categories.
*
@@ -404,7 +580,10 @@ function matchesCategory(row, expandedCategoryIds) {
const rowCats = String(row.categories).split(',').map((s) => s.trim());
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
}
// #endregion
// #region buildCategoryFacets
// MARK: computes category facet counts from matched articles
/**
* Computes facet counts for each category represented in matched articles.
*
@@ -449,8 +628,125 @@ function buildCategoryFacets(matchedArticles) {
return facets;
}
// #endregion
// #region buildBrandFacets
// MARK: computes brand facet counts from matched articles
/**
* Computes facet counts for each brand represented in matched articles.
*
* @param {Array<object>} matchedArticles Articles matching text query.
* @returns {Record<string, {id: string, name: string, count: number, brand: object, thumbHash: string|null}>} Brand facets.
*/
function buildBrandFacets(matchedArticles) {
const facets = {};
const allBrands = companyAutomat.state?.brands ?? [];
const brandById = new Map();
const brandByName = new Map();
for (const b of allBrands) {
if (b.id != null) brandById.set(String(b.id), b);
if (b.name) brandByName.set(String(b.name).toLowerCase(), b);
}
for (const article of matchedArticles) {
const brandObj = article.brand;
const bId = brandObj?.id ?? article.brandId;
const bName = brandObj?.name ?? article.brandName;
if (!bId && !bName) continue;
const brandKey = bId != null ? String(bId) : String(bName);
const knownBrand = (bId != null ? brandById.get(String(bId)) : null) ||
(bName ? brandByName.get(String(bName).toLowerCase()) : null);
const name = knownBrand?.name || bName || `Marke ${brandKey}`;
const id = knownBrand?.id != null ? String(knownBrand.id) : brandKey;
const thumbHash = knownBrand?.pics?.[0]?.thumbHash || brandObj?.thumbHash || article.brandThumbHash || null;
if (!facets[id]) {
facets[id] = {
id,
name,
thumbHash,
brand: knownBrand || brandObj || { id, name, thumbHash },
count: 0,
};
}
facets[id].count += 1;
}
return facets;
}
// #endregion
// #region buildSupplierFacets
// MARK: computes supplier facet counts from matched articles
/**
* Computes facet counts for each supplier represented in matched articles.
*
* @param {Array<object>} matchedArticles Articles matching text query.
* @returns {Record<string, {id: string, name: string, count: number}>} Supplier facets.
*/
function buildSupplierFacets(matchedArticles) {
const facets = {};
const suppliers = companyAutomat.state?.suppliers ?? [];
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const supplierArticleSets = new Map();
for (const s of suppliers) {
const sId = String(s.id);
const list = articlesBySupplier[sId] ?? [];
const skuSet = new Set();
for (const item of list) {
if (item.article?.sku) skuSet.add(String(item.article.sku).toLowerCase());
if (item.sku) skuSet.add(String(item.sku).toLowerCase());
}
supplierArticleSets.set(sId, skuSet);
}
for (const article of matchedArticles) {
const artSku = String(article.sku ?? '').toLowerCase();
if (!artSku) continue;
for (const s of suppliers) {
const sId = String(s.id);
const skuSet = supplierArticleSets.get(sId);
if (skuSet && skuSet.has(artSku)) {
if (!facets[sId]) {
facets[sId] = {
id: sId,
name: s.name,
count: 0,
};
}
facets[sId].count += 1;
}
}
}
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
// #region Actions
const actions = {
// #region runSearch
// MARK: runs in-memory search and derivation synchronously
/**
* Executes in-memory search and derivation synchronously.
*
@@ -461,6 +757,10 @@ const actions = {
const currentState = articleSearchAutomat.state;
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
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;
@@ -472,8 +772,10 @@ const actions = {
? queryFlexSearch(query, articles)
: articles;
// Build facets for all text matches (before category filtering)
// Build facets for all text matches (before facet filtering)
const categoryFacets = buildCategoryFacets(textMatches);
const brandFacets = buildBrandFacets(textMatches);
const supplierFacets = buildSupplierFacets(textMatches);
// Apply category filter if set (expanding to include all child categories)
const selectedCategories = parseFilter(filter);
@@ -486,11 +788,100 @@ const actions = {
categoriesMap,
categoryFacets,
);
filteredArticles = textMatches.filter((row) =>
filteredArticles = filteredArticles.filter((row) =>
matchesCategory(row, expandedCategoryIds),
);
}
// Apply brand filter if set
const selectedBrands = parseFilter(brand);
if (selectedBrands.length > 0) {
const brandSet = new Set(selectedBrands.map((b) => String(b).trim().toLowerCase()));
filteredArticles = filteredArticles.filter((row) => {
const bId = row.brand?.id ?? row.brandId;
const bName = row.brand?.name ?? row.brandName;
if (bId != null && brandSet.has(String(bId).toLowerCase())) return true;
if (bName && brandSet.has(String(bName).trim().toLowerCase())) return true;
return false;
});
}
// Apply supplier filter if set
const selectedSuppliers = parseFilter(supplier);
if (selectedSuppliers.length > 0) {
const articlesBySupplier = companyAutomat.state?.articlesBySupplier ?? {};
const allowedSkus = new Set();
for (const sId of selectedSuppliers) {
const list = articlesBySupplier[String(sId)] ?? [];
for (const item of list) {
if (item.article?.sku) allowedSkus.add(String(item.article.sku).toLowerCase());
if (item.sku) allowedSkus.add(String(item.sku).toLowerCase());
}
}
filteredArticles = filteredArticles.filter((row) => {
const rowSku = String(row.sku ?? '').toLowerCase();
return allowedSkus.has(rowSku);
});
}
// 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;
@@ -503,6 +894,8 @@ const actions = {
const result = {
categoryFacets,
brandFacets,
supplierFacets,
totalCount,
recordset,
offset,
@@ -516,6 +909,10 @@ const actions = {
result,
target,
filter,
brand,
supplier,
empty,
hierarchy,
offset,
pageSize: safePageSize,
loading: articleIndexAutomat.state.status === 'loading',
@@ -523,7 +920,10 @@ const actions = {
return result;
},
// #endregion
// #region search
// MARK: calls articleIndexAutomat.actions.ensureLoaded
/**
* Runs a client-side search over the article index with pagination.
*
@@ -548,7 +948,10 @@ const actions = {
return actions.runSearch(params);
},
// #endregion
// #region setPageSize
// MARK: updates page size and re-derives result set
/**
* Updates the page size and re-derives the result set.
*
@@ -558,7 +961,10 @@ const actions = {
setPageSize(pageSize) {
return actions.runSearch({ pageSize });
},
// #endregion
// #region setPage
// MARK: sets 1-based page number and re-derives result set
/**
* Sets page number (1-based) and re-derives the result set.
*
@@ -570,7 +976,10 @@ const actions = {
const offset = Math.max(0, (page - 1) * safeSize);
return actions.runSearch({ offset });
},
// #endregion
// #region setOffset
// MARK: sets 0-based offset and re-derives result set
/**
* Sets 0-based offset and re-derives the result set.
*
@@ -580,7 +989,10 @@ const actions = {
setOffset(offset) {
return actions.runSearch({ offset });
},
// #endregion
// #region reset
// MARK: resets search state and clears FlexSearch index
/** Reset the search state and clear FlexSearch index. */
reset() {
flexDocument = null;
@@ -588,7 +1000,12 @@ const actions = {
articleByInternalId = new Map();
articleSearchAutomat.setState(initialState);
},
// #endregion
};
// #endregion
// #region Automat Instance & Subscriptions
// MARK: initializes Automat instance and subscribes to articleIndexAutomat & categoryAutomat
/**
* Article search and pagination state for the `/artikel` overview page.
@@ -612,6 +1029,15 @@ categoryAutomat.subscribe((catState) => {
}
});
// Update facets when brands, suppliers, or supplier articles are loaded or updated.
companyAutomat.subscribe(() => {
actions.runSearch();
}, ['brands', 'suppliers', 'articlesBySupplier']);
// #endregion
// #region Selectors & Exports
// MARK: selectSearchResult selector and default export
/**
* Selects the latest search result.
*
@@ -623,3 +1049,4 @@ export function selectSearchResult(state) {
}
export default articleSearchAutomat;
// #endregion

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 } from './articleIndexAutomat';
import articleIndexAutomat, {
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
} from './articleIndexAutomat';
import sseAutomat from './sseAutomat';
/**
* @typedef {Object} ArtikelState
@@ -113,6 +118,7 @@ const actions = {
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
updateArticleIndexRecord(article);
return article;
},
@@ -163,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.
@@ -186,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) };
},
/**
@@ -199,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) };
},
/**
@@ -212,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) };
},
/**
@@ -225,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) };
},
/**
@@ -238,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) };
},
/**
@@ -251,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) };
},
/**
@@ -326,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

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

View File

@@ -87,6 +87,8 @@ const initialState = {
draftConversation: false,
/** Prefilled input text waiting to be consumed by the chat UI. */
pendingInput: null,
/** Staged text extraction snippet for data extraction pill. */
pendingExtraction: null,
conversationState: 'idle',
provider: 'openrouter',
model: DEFAULT_MODEL,
@@ -283,6 +285,17 @@ function closeConversationStream() {
}
activeStreamConversationId = null;
}
/**
* Checks if a tool call or stream tool event represents an interactive tool
* that awaits user intervention (ask_user).
* @param {object} item
* @returns {boolean}
*/
export function isInteractiveTool(item) {
if (!item) return false;
const name = item.name || item.function?.name;
return name === 'ask_user';
}
const actions = {
/**
@@ -327,6 +340,55 @@ const actions = {
void chatAutomat.actions.loadConversations();
},
/**
* Stage an extracted text snippet (e.g. from an invoice or delivery document) as a pill.
* @param {{ text: string, label?: string, instruction?: string }} snippet
*/
insertExtractionSnippet({
text = '',
label = 'parsed invoice',
instruction = "use 'Process Incoming Product and Invoices' SKILL",
} = {}) {
const trimmed = String(text || '').trim();
if (!trimmed) return;
chatOpenedThisSession = true;
chatAutomat.setState({
isOpen: true,
sseEstablished: true,
pendingExtraction: {
text: trimmed,
label: label || 'parsed invoice',
instruction: instruction || "use 'Process Incoming Product and Invoices' SKILL",
},
});
ensureCompanyEventsConnected();
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
chatAutomat.actions.startDraft();
}
void chatAutomat.actions.loadConversations();
},
/**
* Open the chat drawer and append text / prompt to the chat composer.
* @param {string} prompt
*/
insertPrompt(prompt = '') {
const text = String(prompt || '').trim();
if (!text) return;
chatOpenedThisSession = true;
chatAutomat.setState((prev) => ({
isOpen: true,
sseEstablished: true,
pendingInput: prev.pendingInput ? `${prev.pendingInput}\n\n${text}` : text,
}));
ensureCompanyEventsConnected();
if (!chatAutomat.state.selectedConversationId && !chatAutomat.state.draftConversation) {
chatAutomat.actions.startDraft();
}
void chatAutomat.actions.loadConversations();
},
/**
* Start an implicit new conversation with the first preset model. Nothing is
* created on the server until the first message is sent.
@@ -467,13 +529,18 @@ const actions = {
const messages = msgsRes?.messages || [];
const updatedConv = convRes?.conversation;
const hasPendingInteractive = Boolean(
selectPendingAskUser({ messages })
);
chatAutomat.setState((prev) => ({
messages,
loadingMessages: false,
conversationState: updatedConv?.state || prev.conversationState,
conversationState: hasPendingInteractive ? 'idle' : (updatedConv?.state || prev.conversationState),
streamPhase: hasPendingInteractive ? 'idle' : prev.streamPhase,
isSending: hasPendingInteractive ? false : prev.isSending,
conversations: updatedConv
? prev.conversations.map((c) => (c.id === updatedConv.id ? updatedConv : c))
? prev.conversations.map((c) => (c.id === updatedConv.id ? (hasPendingInteractive ? { ...updatedConv, state: 'idle' } : updatedConv) : c))
: prev.conversations,
}));
} catch (err) {
@@ -830,7 +897,7 @@ const actions = {
const updatedTool = {
id: data.id || `tool-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
state: 'green',
name: data.name || '',
name: data.name || toolName || (toolIdx >= 0 ? tools[toolIdx].name : '') || '',
arguments: data.arguments,
content: data.content !== undefined ? data.content : data.arguments || '',
result: data.result,
@@ -844,12 +911,19 @@ const actions = {
lastTurn.tools = tools;
turns[lastIdx] = lastTurn;
const hasAskUser = data.name === 'ask_user';
const isInteractive = isInteractiveTool(data) || isInteractiveTool(updatedTool);
const nextConversations = isInteractive
? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
)
: prev.conversations;
return {
activeTurns: turns,
streamPhase: hasAskUser ? 'idle' : 'working',
conversationState: hasAskUser ? 'idle' : prev.conversationState,
isSending: hasAskUser ? false : prev.isSending,
streamPhase: isInteractive ? 'idle' : 'working',
conversationState: isInteractive ? 'idle' : prev.conversationState,
isSending: isInteractive ? false : prev.isSending,
conversations: nextConversations,
};
});
},
@@ -1146,10 +1220,10 @@ const actions = {
/**
* Submit a tool response (e.g. for ask_user) via POST /api/modelconversation/:id/message.
* Body: { role: 'tool', tool_call_id, content, answer }
* @param {{ tool_call_id: string, answer: string }} params
* Body: { role: 'tool', tool_call_id, name, content, answer }
* @param {{ tool_call_id: string, answer?: string, name?: string, content?: any }} params
*/
async sendToolResponse({ tool_call_id, answer }) {
async sendToolResponse({ tool_call_id, answer, name = 'ask_user', content = undefined }) {
const { selectedConversationId, isSending } = chatAutomat.state;
if (!selectedConversationId) {
throw new Error('Keine Unterhaltung ausgewählt');
@@ -1158,12 +1232,13 @@ const actions = {
return;
}
const toolContent = content !== undefined ? content : answer;
const payload = {
role: 'tool',
tool_call_id,
name: 'ask_user',
content: answer,
answer,
name,
content: toolContent,
...(answer !== undefined ? { answer } : {}),
};
const tempId = `temp-${Date.now()}`;
@@ -1207,7 +1282,7 @@ const actions = {
if (m.role === 'assistant' && !m.turns) {
try {
const p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message;
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === 'ask_user')) {
if (p?.tool_calls?.some((tc) => tc.id === tool_call_id || tc.function?.name === name || tc.function?.name === 'ask_user')) {
foundExisting = true;
return { ...m, turns: sealedTurns };
}
@@ -1316,12 +1391,12 @@ const actions = {
}
const isAssistant = message.role === 'assistant' || message.role === 'model';
let hasAskUser = false;
let hasInteractiveTool = false;
if (isAssistant) {
try {
const payload = typeof message.message === 'string' ? JSON.parse(message.message) : message.message;
if (payload?.tool_calls?.some((tc) => tc.function?.name === 'ask_user')) {
hasAskUser = true;
if (payload?.tool_calls?.some((tc) => isInteractiveTool(tc))) {
hasInteractiveTool = true;
}
if (Array.isArray(payload?.tool_calls)) {
for (const tc of payload.tool_calls) {
@@ -1360,9 +1435,9 @@ const actions = {
}
}
const nextConversationState = hasAskUser ? 'idle' : prev.conversationState;
const nextStreamPhase = hasAskUser ? 'idle' : prev.streamPhase;
const nextConversations = hasAskUser
const nextConversationState = hasInteractiveTool ? 'idle' : prev.conversationState;
const nextStreamPhase = hasInteractiveTool ? 'idle' : prev.streamPhase;
const nextConversations = hasInteractiveTool
? prev.conversations.map((c) =>
c.id === prev.selectedConversationId ? { ...c, state: 'idle' } : c
)
@@ -1374,6 +1449,7 @@ const actions = {
conversationState: nextConversationState,
streamPhase: nextStreamPhase,
conversations: nextConversations,
isSending: hasInteractiveTool ? false : prev.isSending,
};
});
},
@@ -1534,6 +1610,7 @@ export function selectPendingAskUser(state = chatAutomat.state) {
return null;
}
export const selectHasPendingAsk = (state = chatAutomat.state) => Boolean(selectPendingAskUser(state));
export const selectHasPendingAsk = (state = chatAutomat.state) =>
Boolean(selectPendingAskUser(state));
export default chatAutomat;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,928 @@
/**
* @file createChunkListAutomat.js
* @description Factory function for company-scoped, chunk-cached double-buffered
* list automats with LRU memory caching, debounced prefetching, and multi-filter
* retention (like `orderAutomat` and `quoteAutomat`).
*
* Upstream Triggers:
* - `authAutomat`: Subscribes each created automat to auth state — resets on `'anon'`
* or when `selectedCompanyId` changes.
* - Consumer UI (e.g. `FillSpace`): Calls `getItems()` and `requireChunk(start, end)`.
*/
import { Automat } from 'react-automat';
import authAutomat from './authAutomat';
export const DEFAULT_CHUNK_SIZE = 50;
export const DEFAULT_MAX_LRU_CHUNKS = 50;
const PREFETCH_CHUNKS = 1;
const CHUNK_DEBOUNCE_MS = 130;
const MAX_CHUNK_EVENTS = 150;
export function formatNegativeSeek(months) {
if (!months || months >= 0) return '';
const total = Math.abs(months);
const years = Math.floor(total / 12);
const remMonths = total % 12;
const parts = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`);
}
if (remMonths > 0) {
parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`);
}
return `-${parts.join(' ')}`;
}
/**
* Creates a chunk-paginated, double-buffered automat.
*
* @param {object} options
* @param {string} options.name Name for the Automat instance.
* @param {(params: { offset: number, limit: number, search?: string, seekMonths?: number }) => Promise<{ items?: any[], history?: any[], rows?: any[], entries?: any[], total?: number, unfilteredTotal?: number }>} options.fetchPage
* @param {number} [options.chunkSize=50]
* @param {number} [options.maxLruChunks=50]
* @returns {Automat} Configured chunk-list automat.
*/
export function createChunkListAutomat({
name,
fetchPage,
chunkSize = DEFAULT_CHUNK_SIZE,
maxLruChunks = DEFAULT_MAX_LRU_CHUNKS,
}) {
const initialState = {
items: null,
total: 0,
unfilteredTotal: 0,
filter: '',
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
status: 'idle',
error: null,
};
let automat;
let queryEpoch = 0;
let filterDebounceTimer = null;
let chunkDebounceTimer = null;
let currentEventTick = 0;
let lastRequireChunkTick = -1;
const pendingTargetChunks = new Set();
let pendingChunkPromise = null;
let pendingChunkResolve = null;
let pendingChunkReject = null;
// Multi-Filter Cache & Global LRU Management
const filterCaches = new Map();
const lruChunks = new Map();
const inflightChunks = new Map();
// Observability & Stats
let nextEventId = 0;
const chunkEvents = [];
const chunkStatsListeners = new Set();
function makeLruKey(filterKey, chunkIndex) {
return `${filterKey}:::${chunkIndex}`;
}
function parseLruKey(key) {
const [filterKey, chunkStr] = key.split(':::');
return { filterKey, chunkIndex: Number(chunkStr) };
}
function getFilterParams(state = automat?.state || initialState) {
const filter = String(state.filter ?? '').trim();
const negativeSeekMonths =
state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0
? Number(state.negativeSeekMonths)
: 0;
return { filter, negativeSeekMonths };
}
function getFilterKey(state = automat?.state || initialState) {
const { filter, negativeSeekMonths } = getFilterParams(state);
return JSON.stringify({ f: filter, s: negativeSeekMonths });
}
function formatFilterLabel(filterKeyOrState) {
let f, s;
if (typeof filterKeyOrState === 'string') {
try {
const parsed = JSON.parse(filterKeyOrState);
f = parsed.f;
s = parsed.s;
} catch {
return filterKeyOrState;
}
} else if (filterKeyOrState && typeof filterKeyOrState === 'object') {
if ('f' in filterKeyOrState) {
f = filterKeyOrState.f;
s = filterKeyOrState.s;
} else {
const params = getFilterParams(filterKeyOrState);
f = params.filter;
s = params.negativeSeekMonths;
}
}
const parts = [];
if (f) parts.push(`Suche: „${f}“`);
if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`);
if (parts.length === 0) return 'Standard (Alle Einträge)';
return parts.join(' · ');
}
function countNonNullItems(items) {
if (!Array.isArray(items)) return 0;
let count = 0;
for (let i = 0; i < items.length; i++) {
if (items[i] != null) count++;
}
return count;
}
function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) {
let entry = filterCaches.get(filterKey);
if (!entry) {
const params = getFilterParams(filterState);
entry = {
key: filterKey,
label: formatFilterLabel(params),
params,
total: 0,
unfilteredTotal: automat?.state?.unfilteredTotal || 0,
items: null,
loadedChunks: new Set(),
status: 'idle',
error: null,
lastAccessed: Date.now(),
epoch: queryEpoch,
};
filterCaches.set(filterKey, entry);
}
return entry;
}
function touchChunkLRU(filterKey, chunkIndex) {
const key = makeLruKey(filterKey, chunkIndex);
const existing = lruChunks.get(key);
if (existing) {
lruChunks.delete(key);
lruChunks.set(key, { ...existing, accessedAt: Date.now() });
}
}
function registerChunkLRU(filterKey, chunkIndex, itemCount = 0) {
const key = makeLruKey(filterKey, chunkIndex);
if (lruChunks.has(key)) {
lruChunks.delete(key);
}
const now = Date.now();
lruChunks.set(key, {
filterKey,
chunkIndex,
loadedAt: now,
accessedAt: now,
itemCount,
});
while (lruChunks.size > maxLruChunks) {
const oldestKey = lruChunks.keys().next().value;
const oldestInfo = lruChunks.get(oldestKey);
lruChunks.delete(oldestKey);
if (oldestInfo) {
evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru');
}
}
}
function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') {
const entry = filterCaches.get(filterKey);
if (!entry) return;
if (entry.loadedChunks.has(chunkIndex)) {
entry.loadedChunks.delete(chunkIndex);
if (Array.isArray(entry.items)) {
const offset = chunkIndex * chunkSize;
for (let i = offset; i < offset + chunkSize && i < entry.items.length; i++) {
entry.items[i] = null;
}
}
const currentKey = getFilterKey(automat.state);
if (filterKey === currentKey) {
automat.setState({ items: [...entry.items] });
}
recordChunkEvent({
type: reason === 'lru' ? 'lru_evict' : 'evict',
chunkIndex,
filterKey,
message:
reason === 'lru'
? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`
: `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`,
});
notifyChunkStats();
}
}
function recordChunkEvent(entry) {
const event = {
id: ++nextEventId,
time: Date.now(),
epoch: queryEpoch,
filterKey: entry.filterKey ?? getFilterKey(automat?.state),
...entry,
};
chunkEvents.push(event);
if (chunkEvents.length > MAX_CHUNK_EVENTS) {
chunkEvents.shift();
}
notifyChunkStats();
}
function notifyChunkStats() {
if (chunkStatsListeners.size === 0) return;
const stats = getChunkStats();
for (const listener of chunkStatsListeners) {
try {
listener(stats);
} catch (e) {
console.error(`[${name}] chunkStatsListener error`, e);
}
}
}
function getChunkStats() {
const currentKey = getFilterKey(automat?.state);
const activeEntry = filterCaches.get(currentKey);
const total = activeEntry?.total ?? automat?.state?.total ?? 0;
const activeLoaded = activeEntry?.loadedChunks ?? new Set();
const count = total > 0 ? Math.ceil(total / chunkSize) : 0;
const activeInflight = [];
for (const key of inflightChunks.keys()) {
const info = parseLruKey(key);
if (info.filterKey === currentKey) {
activeInflight.push(info.chunkIndex);
}
}
const items = activeEntry?.items ?? automat?.state?.items ?? null;
const loadedItemsCount = countNonNullItems(items);
return {
chunkSize,
maxLruChunks,
lruChunksCount: lruChunks.size,
total,
unfilteredTotal: activeEntry?.unfilteredTotal ?? automat?.state?.unfilteredTotal ?? total,
chunkCount: count,
loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b),
inflightChunks: activeInflight.sort((a, b) => a - b),
pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b),
loadedItemsCount,
status: automat?.state?.status ?? 'idle',
queryEpoch,
filter: automat?.state?.filter ?? '',
negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0,
items,
activeFilterKey: currentKey,
activeFilterLabel: formatFilterLabel(currentKey),
events: [...chunkEvents],
};
}
function subscribeChunkStats(listener) {
chunkStatsListeners.add(listener);
try {
listener(getChunkStats());
} catch (e) {
console.error(e);
}
return () => {
chunkStatsListeners.delete(listener);
};
}
function clearChunkDebounce() {
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
chunkDebounceTimer = null;
}
if (pendingTargetChunks.size > 0) {
pendingTargetChunks.clear();
notifyChunkStats();
}
pendingChunkPromise = null;
pendingChunkResolve = null;
pendingChunkReject = null;
}
function _executeRequiredChunks(
chunks,
targetEpoch,
prefetch = true,
targetFilterKey = getFilterKey(automat.state)
) {
if (targetEpoch !== queryEpoch) {
return Promise.resolve(automat.state.items);
}
const chunkList = Array.from(chunks).sort((a, b) => a - b);
if (chunkList.length === 0) {
return Promise.resolve(automat.state.items);
}
recordChunkEvent({
type: 'execute',
chunks: chunkList,
filterKey: targetFilterKey,
message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`,
});
const promises = [];
for (const c of chunkList) {
promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey));
}
return Promise.all(promises).then(() => {
if (targetEpoch !== queryEpoch) return automat.state.items;
if (prefetch) {
const maxChunk = chunkList[chunkList.length - 1];
const entry = filterCaches.get(targetFilterKey);
const total = entry?.total || automat.state.total || 0;
for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) {
if (total > 0 && c * chunkSize >= total) {
break;
}
recordChunkEvent({
type: 'prefetch',
chunkIndex: c,
filterKey: targetFilterKey,
message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`,
});
void actions.loadChunk(c, targetEpoch, targetFilterKey);
}
}
return automat.state.items;
});
}
function buildQueryParamsFor(filterParams, offset = 0, limit = chunkSize) {
const params = { offset, limit };
const trimmed = String(filterParams.filter ?? '').trim();
if (trimmed) params.search = trimmed;
if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) {
params.seekMonths = Number(filterParams.negativeSeekMonths);
}
return params;
}
async function fetchRecordsPageFor(filterParams, offset = 0, limit = chunkSize) {
const params = buildQueryParamsFor(filterParams, offset, limit);
const res = await fetchPage(params);
const records = Array.isArray(res?.items)
? res.items
: Array.isArray(res?.history)
? res.history
: Array.isArray(res?.ledger)
? res.ledger
: Array.isArray(res?.rows)
? res.rows
: Array.isArray(res?.entries)
? res.entries
: Array.isArray(res)
? res
: [];
const total = Number(res?.total ?? res?.totalRows ?? res?.totalCount ?? records.length);
const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total;
return { records, total, unfilteredTotal };
}
function onFilterChange(immediate = false) {
if (filterDebounceTimer) {
clearTimeout(filterDebounceTimer);
filterDebounceTimer = null;
}
if (immediate) {
applyFilterReload();
} else {
filterDebounceTimer = setTimeout(() => {
filterDebounceTimer = null;
applyFilterReload();
}, 200);
}
}
function applyFilterReload() {
queryEpoch++;
clearChunkDebounce();
const currentKey = getFilterKey(automat.state);
const cached = filterCaches.get(currentKey);
if (cached && cached.items !== null) {
cached.lastAccessed = Date.now();
for (const c of cached.loadedChunks) {
touchChunkLRU(currentKey, c);
}
automat.setState({
items: cached.items,
total: cached.total,
unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0,
status: cached.status || 'ready',
error: cached.error || null,
});
recordChunkEvent({
type: 'filter_hit',
epoch: queryEpoch,
filterKey: currentKey,
message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Einträge im RAM)`,
});
notifyChunkStats();
if (!cached.loadedChunks.has(0)) {
void actions.loadChunk(0, queryEpoch, currentKey);
}
} else {
const entry = getOrCreateFilterCache(currentKey, automat.state);
entry.lastAccessed = Date.now();
automat.setState({
items: null,
total: 0,
unfilteredTotal: automat.state.unfilteredTotal ?? 0,
status: 'loading',
error: null,
});
recordChunkEvent({
type: 'filter_miss',
epoch: queryEpoch,
filterKey: currentKey,
message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`,
});
notifyChunkStats();
void actions.loadChunk(0, queryEpoch, currentKey);
}
}
const actions = {
getItems() {
if (automat.state.items === null) {
void actions.requireChunk(0, 0, { prefetch: false, immediate: true });
return null;
}
return automat.state.items;
},
requireChunk(chunkIndex, endOrOptions = chunkIndex, maybeOptions = {}) {
let start;
let end;
let options = {};
if (Array.isArray(chunkIndex)) {
start = chunkIndex[0] ?? 0;
end = chunkIndex[chunkIndex.length - 1] ?? start;
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
options = endOrOptions;
}
} else {
start = Number(chunkIndex) || 0;
if (typeof endOrOptions === 'object' && endOrOptions !== null) {
end = start;
options = endOrOptions;
} else {
end = Number(endOrOptions ?? start) || start;
if (typeof maybeOptions === 'object' && maybeOptions !== null) {
options = maybeOptions;
}
}
}
start = Math.max(0, Math.floor(start));
end = Math.max(start, Math.floor(end));
const prefetch = options.prefetch !== false;
const currentKey = getFilterKey(automat.state);
if (options.immediate || automat.state.items === null) {
clearChunkDebounce();
const chunks = new Set();
for (let c = start; c <= end; c++) chunks.add(c);
recordChunkEvent({
type: 'require_immediate',
chunks: Array.from(chunks),
filterKey: currentKey,
message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`,
});
return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey);
}
if (lastRequireChunkTick !== currentEventTick) {
pendingTargetChunks.clear();
lastRequireChunkTick = currentEventTick;
if (typeof queueMicrotask === 'function') {
queueMicrotask(() => {
currentEventTick++;
});
} else {
Promise.resolve().then(() => {
currentEventTick++;
});
}
}
for (let c = start; c <= end; c++) {
pendingTargetChunks.add(c);
}
recordChunkEvent({
type: 'pending',
chunks: Array.from(pendingTargetChunks),
filterKey: currentKey,
message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`,
});
notifyChunkStats();
if (chunkDebounceTimer) {
clearTimeout(chunkDebounceTimer);
chunkDebounceTimer = null;
}
if (!pendingChunkPromise) {
pendingChunkPromise = new Promise((resolve, reject) => {
pendingChunkResolve = resolve;
pendingChunkReject = reject;
});
}
const captureEpoch = queryEpoch;
chunkDebounceTimer = setTimeout(() => {
chunkDebounceTimer = null;
const chunksToLoad = new Set(pendingTargetChunks);
pendingTargetChunks.clear();
notifyChunkStats();
const resolve = pendingChunkResolve;
const reject = pendingChunkReject;
pendingChunkPromise = null;
pendingChunkResolve = null;
pendingChunkReject = null;
recordChunkEvent({
type: 'debounce_fire',
chunks: Array.from(chunksToLoad),
filterKey: currentKey,
message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`,
});
_executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey)
.then((res) => resolve?.(res))
.catch((err) => reject?.(err));
}, CHUNK_DEBOUNCE_MS);
return pendingChunkPromise;
},
loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) {
const index = Math.max(0, Number(chunkIndex) || 0);
const entry = getOrCreateFilterCache(targetFilterKey, automat.state);
if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) {
return Promise.resolve(entry.items);
}
if (entry.loadedChunks.has(index)) {
touchChunkLRU(targetFilterKey, index);
recordChunkEvent({
type: 'hit',
chunkIndex: index,
filterKey: targetFilterKey,
message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`,
});
return Promise.resolve(
targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items
);
}
const inflightKey = makeLruKey(targetFilterKey, index);
if (inflightChunks.has(inflightKey)) {
recordChunkEvent({
type: 'inflight_hit',
chunkIndex: index,
filterKey: targetFilterKey,
message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`,
});
return inflightChunks.get(inflightKey);
}
const offset = index * chunkSize;
const limit = chunkSize;
const isActiveFilter = targetFilterKey === getFilterKey(automat.state);
if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) {
entry.status = 'loading';
entry.error = null;
if (isActiveFilter) {
automat.setState({ status: 'loading', error: null });
}
}
const startTime = Date.now();
recordChunkEvent({
type: 'start',
chunkIndex: index,
filterKey: targetFilterKey,
message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`,
});
const filterParams = entry.params || getFilterParams(automat.state);
const promise = fetchRecordsPageFor(filterParams, offset, limit)
.then(({ records, total, unfilteredTotal }) => {
const currentTotal = total > 0 ? total : 0;
entry.total = currentTotal;
entry.unfilteredTotal = unfilteredTotal;
if (entry.items === null || entry.items.length !== currentTotal) {
entry.items = new Array(currentTotal).fill(null);
} else {
entry.items = [...entry.items];
}
for (let i = 0; i < records.length; i++) {
const idx = offset + i;
if (idx < entry.items.length) {
entry.items[idx] = records[i];
}
}
entry.loadedChunks.add(index);
entry.status = 'ready';
entry.error = null;
entry.lastAccessed = Date.now();
registerChunkLRU(targetFilterKey, index, records.length);
const currentActive = targetFilterKey === getFilterKey(automat.state);
if (currentActive) {
automat.setState({
items: entry.items,
total: currentTotal,
unfilteredTotal,
status: 'ready',
error: null,
});
}
const duration = Date.now() - startTime;
recordChunkEvent({
type: 'loaded',
chunkIndex: index,
filterKey: targetFilterKey,
count: records.length,
duration,
message: `Chunk #${index} geladen (${records.length} Einträge in ${duration}ms) für "${entry.label}"`,
});
return currentActive ? automat.state.items : entry.items;
})
.catch((err) => {
entry.status = 'failed';
entry.error = err;
const currentActive = targetFilterKey === getFilterKey(automat.state);
if (currentActive) {
if (index === 0 && automat.state.items === null) {
automat.setState({ status: 'failed', error: err });
}
}
const duration = Date.now() - startTime;
console.error(`[${name}] loadChunk(${index}, ${targetFilterKey}) error:`, err);
recordChunkEvent({
type: 'error',
chunkIndex: index,
filterKey: targetFilterKey,
duration,
error: err?.message || String(err),
message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`,
});
throw err;
})
.finally(() => {
inflightChunks.delete(inflightKey);
notifyChunkStats();
});
inflightChunks.set(inflightKey, promise);
notifyChunkStats();
return promise;
},
filter(query) {
automat.setState({ filter: String(query ?? '') });
onFilterChange(false);
},
addSeekMonth() {
const seekMonths = (automat.state.seekMonths ?? 0) + 1;
const seekYears = automat.state.seekYears ?? 0;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekMonths, negativeSeekMonths: totalMonths });
onFilterChange(true);
},
addSeekYear() {
const seekMonths = automat.state.seekMonths ?? 0;
const seekYears = (automat.state.seekYears ?? 0) + 1;
const totalMonths = -(seekMonths + seekYears * 12);
automat.setState({ seekYears, negativeSeekMonths: totalMonths });
onFilterChange(true);
},
addNegativeSeek(months) {
if (months === -12) {
actions.addSeekYear();
} else if (months === -1) {
actions.addSeekMonth();
} else {
const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0));
const next = current + months;
automat.setState({ negativeSeekMonths: Math.min(0, next) });
onFilterChange(true);
}
},
resetNegativeSeek() {
automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 });
onFilterChange(true);
},
resetFilters() {
automat.setState({
filter: '',
seekMonths: 0,
seekYears: 0,
negativeSeekMonths: 0,
});
onFilterChange(true);
},
load() {
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
},
ensureLoaded() {
if (automat.state.items !== null) {
return Promise.resolve(automat.state.items);
}
return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
},
async reload(onlyCurrent = false) {
queryEpoch++;
clearChunkDebounce();
if (onlyCurrent) {
const currentKey = getFilterKey(automat.state);
actions.clearFilterCache(currentKey);
} else {
actions.clearCache();
}
await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state));
return automat.state.items;
},
replace(items) {
clearChunkDebounce();
const currentKey = getFilterKey(automat.state);
actions.clearFilterCache(currentKey);
const count = Array.isArray(items) ? items.length : 0;
const entry = getOrCreateFilterCache(currentKey, automat.state);
entry.items = items;
entry.total = count;
entry.status = 'ready';
entry.error = null;
const chunkCount = count > 0 ? Math.ceil(count / chunkSize) : 0;
for (let c = 0; c < chunkCount; c++) {
entry.loadedChunks.add(c);
registerChunkLRU(currentKey, c, Math.min(chunkSize, count - c * chunkSize));
}
automat.setState({
items,
total: count,
unfilteredTotal: count,
status: 'ready',
error: null,
});
notifyChunkStats();
},
clearFilterCache(filterKey) {
const entry = filterCaches.get(filterKey);
if (entry) {
for (const c of entry.loadedChunks) {
lruChunks.delete(makeLruKey(filterKey, c));
}
entry.items = null;
entry.loadedChunks.clear();
entry.status = 'idle';
entry.error = null;
if (filterKey === getFilterKey(automat.state)) {
automat.setState({ items: null, status: 'idle', error: null });
}
recordChunkEvent({
type: 'clear_filter',
filterKey,
message: `Filter-Cache "${entry.label}" geleert`,
});
notifyChunkStats();
}
},
clearCache() {
clearChunkDebounce();
lruChunks.clear();
inflightChunks.clear();
for (const entry of filterCaches.values()) {
entry.items = null;
entry.loadedChunks.clear();
entry.status = 'idle';
entry.error = null;
}
recordChunkEvent({
type: 'clear_all',
message: `Gesamter ${name}-Chunk-Cache geleert`,
});
notifyChunkStats();
automat.setState({
items: null,
status: 'idle',
error: null,
});
},
reset() {
if (filterDebounceTimer) {
clearTimeout(filterDebounceTimer);
filterDebounceTimer = null;
}
clearChunkDebounce();
filterCaches.clear();
lruChunks.clear();
inflightChunks.clear();
recordChunkEvent({
type: 'reset',
message: `${name}-Automat und sämtliche Caches zurückgesetzt`,
});
notifyChunkStats();
automat.setState(initialState);
},
};
automat = new Automat(initialState, actions, { name });
automat.getItems = actions.getItems;
automat.requireChunk = actions.requireChunk;
automat.CHUNK_SIZE = chunkSize;
automat.MAX_LRU_CHUNKS = maxLruChunks;
automat.getChunkStats = getChunkStats;
automat.subscribeChunkStats = subscribeChunkStats;
automat.getFilterKey = getFilterKey;
automat.formatFilterLabel = formatFilterLabel;
// Reset on logout or company change
let lastCompanyId = null;
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId ?? null;
if (auth.status === 'anon') {
actions.reset();
lastCompanyId = null;
return;
}
if (lastCompanyId !== null && companyId !== lastCompanyId) {
actions.reset();
}
if (auth.status === 'authed' && companyId != null) {
lastCompanyId = companyId;
}
});
return automat;
}

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;

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
import { Automat } from 'react-automat';
import { article as articleApi, supplier as supplierApi } from '../api';
import { todayInputValue } from '../components/manifest/manifestConfig';
/**
* @typedef {Object} DeliveryLine
@@ -62,6 +63,14 @@ import { article as articleApi, supplier as supplierApi } from '../api';
* @property {string|null} unmatchedSupplierName Parsed supplier name without a matching entity.
*/
function parseNum(value) {
if (value == null || value === '') return null;
const cleaned = String(value).trim().replace('%', '').trim().replace(',', '.');
if (cleaned === '') return null;
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
}
/**
* Normalizes a partial line into an editor line with a stable key.
*
@@ -69,14 +78,29 @@ import { article as articleApi, supplier as supplierApi } from '../api';
* @returns {DeliveryLine} Normalized line.
*/
export function createEmptyLineitem(line = {}) {
const netVal = line.netprice ?? line.net ?? null;
const taxVal = line.taxrate ?? line.tax ?? null;
let netVal = null;
if (line.netprice != null || line.net != null || line.netPrice != null) {
const netRaw = line.netprice ?? line.net ?? line.netPrice;
netVal = netRaw != null ? (parseNum(netRaw) ?? netRaw) : null;
} else if (line.eknetto != null && line.eknetto !== '') {
const parsedCent = parseNum(line.eknetto);
netVal = parsedCent != null ? Math.round((parsedCent / 100) * 10000) / 10000 : null;
}
const taxRaw = line.taxrate ?? line.tax ?? line.mwst ?? line.taxRate ?? null;
const amountRaw = line.amount ?? line.count ?? line.qty ?? line.quantity ?? null;
const puRaw = line.pu ?? line.vpe ?? null;
const taxVal = taxRaw != null ? (parseNum(taxRaw) ?? taxRaw) : null;
const amountVal = amountRaw != null ? (parseNum(amountRaw) ?? amountRaw) : null;
const puVal = puRaw != null ? (parseNum(puRaw) ?? puRaw) : null;
return {
key: line.key ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: line.name ?? null,
sku: line.sku ?? null,
amount: line.amount ?? null,
pu: line.pu ?? null,
name: line.name ?? line.articleName ?? line.title ?? null,
sku: line.sku ?? line.liefArtNr ?? line.articleNumber ?? null,
amount: amountVal,
pu: puVal,
unit: line.unit ?? null,
netprice: netVal,
net: netVal,
@@ -132,7 +156,7 @@ export function buildSupplierArticlesMap(articles = [], existingMap = {}) {
export const initialDelivery = {
id: null,
deliveryId: null,
date: null,
date: todayInputValue(),
deliveryEntity: null,
partyId: null,
externalNumber: null,
@@ -203,12 +227,14 @@ export function createDeliveryEditorAutomat(options = {}) {
: delivery.notes ? [delivery.notes] : [];
const rawAttachments = delivery.attachments ?? delivery.files ?? [];
const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
const date = delivery.date !== undefined ? delivery.date : (id != null ? null : (automat.state.date || todayInputValue()));
automat.setState({
...initialDelivery,
...delivery,
id,
deliveryId: id,
date,
partyId,
deliveryEntity: partyId,
externalNumber: extNum,
@@ -441,7 +467,7 @@ export function createDeliveryEditorAutomat(options = {}) {
});
try {
const res = await supplierApi.getArticles(entityId, skus);
const res = await supplierApi.getArticles(entityId);
const articles = Array.isArray(res?.articles) ? res.articles : [];
const map = buildSupplierArticlesMap(articles);
@@ -647,6 +673,7 @@ export function createDeliveryEditorAutomat(options = {}) {
resetDelivery() {
automat.setState({
...initialDelivery,
date: todayInputValue(),
});
},
@@ -654,6 +681,121 @@ export function createDeliveryEditorAutomat(options = {}) {
resetDeliveryEditor() {
automat.setState({
...initialState,
date: todayInputValue(),
});
},
/**
* Loads a delivery manifest (e.g. from draft_delivery_manifest tool) into the editor.
* @param {Object} manifestData
* @returns {void}
*/
loadDraftManifest(manifestData) {
if (!manifestData || typeof manifestData !== 'object') return;
const data = manifestData.manifest ?? manifestData;
const basedata = data.basedata ?? data.baseData ?? data;
const rawItems =
data.items?.items ??
data.items ??
data.lines ??
data.lineitems ??
[];
const itemsList = Array.isArray(rawItems) ? rawItems : [];
const partyId =
basedata.lieferant_id ??
basedata.lieferantId ??
basedata.partyId ??
basedata.deliveryEntity ??
basedata.supplier_id ??
basedata.supplierId ??
null;
const supplierName =
basedata.lieferantName ??
basedata.supplierName ??
basedata.unmatchedSupplierName ??
null;
const extNum =
basedata.manifestNr ??
basedata.manifestNumber ??
basedata.externalNumber ??
basedata.external_number ??
basedata.invoiceNumber ??
basedata.invoice_number ??
null;
const date = basedata.date ?? automat.state.date ?? todayInputValue();
const type = basedata.manifestType ?? basedata.type ?? 'rechnung';
let matchedPartyId = partyId != null && partyId !== '' ? partyId : null;
if (!matchedPartyId && supplierName && automat.state.deliveryEntities?.length > 0) {
const normName = String(supplierName).trim().toLowerCase();
const found = automat.state.deliveryEntities.find(
(e) =>
(e.name && String(e.name).trim().toLowerCase() === normName) ||
(e.company && String(e.company).trim().toLowerCase() === normName)
);
if (found?.id != null) {
matchedPartyId = found.id;
}
}
const lines = itemsList.map((item, idx) =>
createEmptyLineitem({
...item,
key: item.key ?? `manifest-${Date.now()}-${idx}-${Math.random().toString(36).slice(2, 6)}`,
})
);
// 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,
date,
partyId: matchedPartyId,
deliveryEntity: matchedPartyId,
unmatchedSupplierName: matchedPartyId != null ? null : supplierName,
externalNumber: extNum,
external_number: extNum,
type,
lines,
lineitems: lines,
attachments,
files: attachments,
});
},
@@ -724,6 +866,7 @@ export const {
setId,
setDeliveryEntities,
setDelivery,
loadDraftManifest,
setDate,
setDeliveryEntity,
setPartyId,

View File

@@ -1,190 +0,0 @@
/**
* @file draftSync.js
* @description Virtual Drafts synchronizer for VFS drafts (drafts/quote, drafts/order, drafts/delivery).
* Connects frontend draft automats (quoteDraftAutomat, orderDraftAutomat, deliveryEditorAutomat)
* with backend VFS requests and broadcasts.
*
* Incoming Signals:
* - SSE 'draft:request': Backend requests a draft (read or list).
* - SSE 'draft': Backend broadcasts an external update to a draft.
*
* Outgoing Calls:
* - POST /api/vfs/drafts/response: Responds to backend requests with draft content or file list.
*/
import { deliveryEditorAutomat, createEmptyLineitem } from './deliveryEditorAutomat';
import { quoteDraftAutomat, orderDraftAutomat } from './manifestDraftAutomat';
import { vfs } from '../api/vfs';
import { getStoredClientId } from '../api/http';
import { getSseClientId } from './sseAutomat';
/**
* Normalizes a draft filename/path to its canonical key ('quote', 'order', 'delivery').
* Handles paths like 'drafts/quote', 'drafts/quote.json', '/api/vfs/drafts/order', 'quote', etc.
*
* @param {string} [name]
* @returns {string} Normalized key
*/
export function normalizeDraftName(name) {
if (!name) return '';
return String(name)
.trim()
.replace(/^\/?(api\/)?vfs\//, '')
.replace(/^drafts\//, '')
.replace(/\.json$/, '')
.toLowerCase();
}
/**
* Supported virtual draft names.
*/
export const SUPPORTED_DRAFTS = ['quote', 'order', 'delivery'];
/**
* Timestamps of last modifications per draft.
* @type {Map<string, string>}
*/
const draftTimestamps = new Map();
/**
* Resolves active draft content for a given draft name from its managing automat.
*
* @param {string} rawName
* @returns {object|null}
*/
export function getActiveDraftContent(rawName) {
const name = normalizeDraftName(rawName);
if (name === 'quote') {
return quoteDraftAutomat.actions.getManifest();
}
if (name === 'order') {
return orderDraftAutomat.actions.getManifest();
}
if (name === 'delivery') {
return deliveryEditorAutomat.actions.getManifest();
}
return null;
}
/**
* Generates the list of virtual draft files for the VFS listing.
*
* @returns {Array<{ name: string, size: number, updated_at: string }>}
*/
export function getActiveDraftsList() {
const now = new Date().toISOString();
return SUPPORTED_DRAFTS.map((name) => {
const content = getActiveDraftContent(name);
let size = 0;
if (content != null) {
try {
size = JSON.stringify(content).length;
} catch {}
}
const updatedAt = draftTimestamps.get(name) || (size > 0 ? now : '2026-10-14T00:00:00.000Z');
return {
name,
size,
updated_at: updatedAt,
};
});
}
/**
* Applies incoming draft content update to the managing automat.
*
* @param {string} rawName
* @param {object|string} rawContent
*/
export function applyDraftUpdate(rawName, rawContent) {
const name = normalizeDraftName(rawName);
if (!name) return;
let content = rawContent;
if (typeof content === 'string') {
try {
content = JSON.parse(content);
} catch (err) {
console.error(`[draft] failed to parse incoming draft string for ${name}:`, err);
return;
}
}
draftTimestamps.set(name, new Date().toISOString());
try {
if (name === 'quote') {
quoteDraftAutomat.actions.setManifest(content);
} else if (name === 'order') {
orderDraftAutomat.actions.setManifest(content);
} else if (name === 'delivery') {
const manifest = content?.manifest || content || {};
const rawLines = manifest.lines ?? manifest.lineitems ?? manifest.items ?? [];
const lines = Array.isArray(rawLines)
? rawLines.map((l) => (l && l.key ? l : createEmptyLineitem(l)))
: [];
const partyId = manifest.partyId ?? manifest.deliveryEntity ?? manifest.supplierId ?? null;
const externalNumber = manifest.externalNumber ?? manifest.external_number ?? null;
deliveryEditorAutomat.actions.setDelivery({
...manifest,
partyId,
deliveryEntity: partyId,
externalNumber,
external_number: externalNumber,
lines,
});
}
} catch (err) {
console.error(`[draft] error applying draft update to ${name} automat:`, err);
}
}
/**
* Handles incoming 'draft:request' SSE event from backend.
*
* @param {{ requestId: string, action: 'read'|'list', name?: string, target?: string }} payload
* @returns {Promise<void>}
*/
export async function handleDraftRequest(payload) {
if (!payload || !payload.requestId) return;
const { requestId, action, name, target } = payload;
try {
if (action === 'read') {
const draftName = normalizeDraftName(name || target);
const content = getActiveDraftContent(draftName);
if (content != null) {
await vfs.respondDraft({ requestId, content });
} else {
await vfs.respondDraft({ requestId, error: 'Draft not open' });
}
} else if (action === 'list') {
const files = getActiveDraftsList();
await vfs.respondDraft({ requestId, files });
}
} catch (err) {
console.error('[draft] error handling draft:request:', err);
}
}
/**
* Handles incoming 'draft' SSE broadcast from backend.
*
* @param {{ sender?: string, action: string, name?: string, target?: string, content?: any }} payload
*/
export function handleDraftEvent(payload) {
if (!payload) return;
const { sender, action, name, target, content } = payload;
const myClientId = getStoredClientId() || (typeof getSseClientId === 'function' ? getSseClientId() : null);
if (sender && myClientId && sender === myClientId) {
// Ignore self-echo
return;
}
if (action === 'update') {
const draftName = normalizeDraftName(name || target);
applyDraftUpdate(draftName, content);
}
}

View File

@@ -14,7 +14,7 @@ import { Automat } from 'react-automat';
import { entity as entityApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
import customergroupAutomat from './customergroupAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} Entity
@@ -48,7 +48,7 @@ const initialState = {
const applyFilter = (entities, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
if (!query) return entities;
const groups = customergroupAutomat?.state?.items ?? [];
const groups = companyAutomat?.state?.customergroups ?? [];
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
return entities.filter((e) => {
@@ -200,13 +200,15 @@ entityAutomat = new Automat(initialState, actions, {
export { entityAutomat };
// Load on session/company establishment, reset on logout.
authAutomat.subscribe((auth) => {
const syncEntityWithAuth = (auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {});
} else if (auth.status === 'anon') {
actions.reset();
}
});
};
authAutomat.subscribe(syncEntityWithAuth);
syncEntityWithAuth(authAutomat.state);
export default entityAutomat;

View File

@@ -1,45 +1,26 @@
/**
* @file globalHistoryAutomat.js
* @description Virtualized grid automat (`globalHistoryGridAutomat`) for
* infinite-scrolling global audit/change history via `react-virtual-aggridautomat`.
* @description Double-buffered chunk list automat (`globalHistoryAutomat`) for
* global system and change history logs with multi-filter caching and LRU eviction.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `reload()` when
* status reaches `'authed'` with a `selectedCompanyId`.
* - AG Grid: Triggers `fetchRows()` on viewport scroll, sort, or filter changes.
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createGridAutomat } from 'react-virtual-aggridautomat/createGridAutomat';
import { createChunkListAutomat } from './createChunkListAutomat';
import { globalHistory } from '../api';
import authAutomat from './authAutomat';
export const globalHistoryGridAutomat = createGridAutomat({
export const globalHistoryAutomat = createChunkListAutomat({
name: 'globalHistory',
blockSize: 50,
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
try {
const res = await globalHistory.list({
startRow,
endRow,
sortModel,
filterModel,
});
const rows = res?.rows ?? res?.history ?? res?.entries ?? (Array.isArray(res) ? res : []);
const total = res?.total ?? res?.totalRows ?? res?.totalCount;
const lastRow = total != null ? total : (rows.length < (endRow - startRow) ? startRow + rows.length : undefined);
return { rows, lastRow };
} catch (err) {
console.warn('Failed to fetch global history (backend route pending):', err);
return { rows: [], lastRow: 0 };
}
},
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => globalHistory.list(params),
});
// Reload on company switch or auth change
authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
globalHistoryGridAutomat.actions.reload();
}
});
// Alias for backwards compatibility
export const globalHistoryGridAutomat = globalHistoryAutomat;
export default globalHistoryGridAutomat;
export default globalHistoryAutomat;

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,12 +1,11 @@
/**
* @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.
*/
export * from './deliveryEditorAutomat';
export * from './manifestDraftAutomat';
export * from './draftSync';
export { default as authAutomat } from './authAutomat';
export * from './authAutomat';
@@ -23,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,
@@ -45,9 +41,6 @@ export {
applyPwaUpdate,
} from './pwaAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat';
/** Article search over the derived index (replaces StorageManager.doSearch). */
export {
default as articleSearchAutomat,
@@ -62,10 +55,25 @@ export {
export {
default as articleIndexAutomat,
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex,
selectArticleIndexItems,
} from './articleIndexAutomat';
export { default as deliveryAutomat } from './deliveryAutomat';
export {
default as deliveryAutomat,
CHUNK_SIZE as DELIVERY_CHUNK_SIZE,
MAX_LRU_CHUNKS as DELIVERY_MAX_LRU_CHUNKS,
selectDeliveries,
selectDeliveryTotal,
selectDeliveryUnfilteredTotal,
selectDeliveryPagination,
selectOnlyUnbooked,
isDeliveryBooked,
getDeliveryUnbookedCount,
getChunkStats as getDeliveryChunkStats,
subscribeChunkStats as subscribeDeliveryChunkStats,
} from './deliveryAutomat';
export { default as quoteAutomat } from './quoteAutomat';
export {
default as orderAutomat,
@@ -84,15 +92,35 @@ 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 { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
export {
default as shipmentAutomat,
CHUNK_SIZE as SHIPMENT_CHUNK_SIZE,
selectShipments,
selectShipmentTotal,
selectShipmentUnfilteredTotal,
selectShipmentPagination,
selectStateFilter,
getChunkStats as getShipmentChunkStats,
subscribeChunkStats as subscribeShipmentChunkStats,
} from './shipmentAutomat';
/** 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';
@@ -107,6 +135,7 @@ export {
export { default as companyAutomat } from './companyAutomat';
export {
selectCompanies,
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
@@ -117,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 {
@@ -165,7 +220,8 @@ export {
} from './categoryViewAutomat';
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
export { default as globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as ledgerHistoryAutomat, ledgerHistoryGridAutomat } from './ledgerHistoryAutomat';
export { default as chatAutomat } from './chatAutomat';
export {
@@ -181,83 +237,14 @@ export {
export { default as vfsAutomat } from './vfsAutomat';
/**
* ============================================================================
* 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).
* ============================================================================
*/
export { default as pinnedAppletAutomat } from './pinnedAppletAutomat';
export {
isPinned,
getPinnedForRoute,
pinApplet,
unpinApplet,
togglePin,
executeApplet,
closeExecution,
normalizeRoute,
} from './pinnedAppletAutomat';

View File

@@ -0,0 +1,26 @@
/**
* @file ledgerHistoryAutomat.js
* @description Double-buffered chunk list automat (`ledgerHistoryAutomat`) for
* append-only ledger transactions and stock journal with multi-filter caching and LRU eviction.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import { ledger } from '../api';
export const ledgerHistoryAutomat = createChunkListAutomat({
name: 'ledgerHistory',
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => ledger.getHistory(params),
});
// Alias for consistency with other grid/chunk automats
export const ledgerHistoryGridAutomat = ledgerHistoryAutomat;
export default ledgerHistoryAutomat;

View File

@@ -11,6 +11,7 @@ import {
emptyLine,
lineFromApi,
dateInputValue,
todayInputValue,
parseNum,
} from '../components/manifest/manifestConfig';
@@ -25,11 +26,11 @@ export function createInitialManifestDraftState(kind) {
kind,
mode: 'create',
id: null,
date: '',
date: todayInputValue(),
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
lines: kind === 'order' ? [] : [emptyLine()],
attachments: [],
};
}
@@ -66,7 +67,7 @@ export function createManifestDraftAutomat({ kind, name }) {
/** @param {Array<object>} lines */
setLines(lines) {
const list = Array.isArray(lines) && lines.length > 0 ? lines : [emptyLine()];
const list = Array.isArray(lines) ? lines : [];
automat.setState({ lines: list });
},
@@ -99,11 +100,11 @@ export function createManifestDraftAutomat({ kind, name }) {
removeLine(key) {
const current = automat.state.lines || [];
const next = current.filter((l) => l.key !== key);
automat.setState({ lines: next.length > 0 ? next : [emptyLine()] });
automat.setState({ lines: next });
},
removeAllLines() {
automat.setState({ lines: [emptyLine()] });
automat.setState({ lines: [] });
},
/** @param {Array<object>} attachments */
@@ -223,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;
}
@@ -342,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);
@@ -419,14 +430,16 @@ 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) : '';
const lines = Array.isArray(doc.lines) && doc.lines.length > 0 ? doc.lines : [emptyLine()];
const lines = Array.isArray(doc.lines) ? doc.lines : [];
const attachments = Array.isArray(doc.attachments) ? doc.attachments : [];
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
@@ -439,11 +452,20 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id,
date,
createdAt,
partyId,
number,
externalNumber,
lines,
attachments,
shipments: doc.shipments || [],
payments: doc.payments || [],
shippings: doc.shippings || [],
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,
dirtyFields: { ...emptyFields },
@@ -496,14 +518,16 @@ 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) : '';
const lines = Array.isArray(savedDoc.lines) && savedDoc.lines.length > 0 ? savedDoc.lines : [emptyLine()];
const lines = Array.isArray(savedDoc.lines) ? savedDoc.lines : [];
const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments;
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
@@ -513,6 +537,7 @@ export function createManifestEditAutomat({ kind, name }) {
automat.setState({
date,
createdAt,
partyId,
number,
externalNumber,
@@ -533,6 +558,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
@@ -554,6 +580,7 @@ export function createManifestEditAutomat({ kind, name }) {
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
@@ -579,12 +606,26 @@ export const quoteEditAutomat = createManifestEditAutomat({
name: 'quote-edit',
});
/** Shared singleton automat for the single last edited order. */
export const orderEditAutomat = createManifestEditAutomat({
kind: 'order',
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

@@ -0,0 +1,304 @@
/**
* @file pinnedAppletAutomat.js
* @description State container for applets pinned to browser routes.
* Persists pin assignments in company settings using the 'applet.pinned' namespace,
* with local storage fallback per company.
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth changes — loads pinned applet settings when authed
* with a selectedCompanyId, and resets when anon/logged out.
* - UI / VfsAppletRunner: Calls `pinApplet`, `unpinApplet`, `togglePin`.
* - UI / PinnedAppletFabs: Calls `executeApplet`, `closeExecution`.
*/
import { Automat } from 'react-automat';
import { setting as settingApi } from '../api';
import authAutomat from './authAutomat';
/**
* Normalizes a browser route pathname (strips trailing slashes, hashes, query params).
* @param {string} rawRoute
* @returns {string}
*/
export function normalizeRoute(rawRoute) {
if (!rawRoute) return '/';
const clean = String(rawRoute).split('?')[0].split('#')[0];
const trimmed = clean.replace(/\/+$/, '');
return trimmed || '/';
}
/**
* @typedef {Object} PinnedAppletState
* @property {Record<string, string[]>} pinnedByRoute Mapping of normalized route -> array of applet file paths
* @property {'idle'|'loading'|'ready'} status
* @property {string|null} activeExecution Currently active applet fileName to execute in dialog
*/
/** @type {PinnedAppletState} */
const initialState = {
pinnedByRoute: {},
status: 'idle',
activeExecution: null,
};
let pinnedAppletAutomat;
const actions = {
/**
* Check if an applet is pinned to the specified route.
* @param {string} fileName
* @param {string} rawRoute
* @returns {boolean}
*/
isPinned(fileName, rawRoute) {
if (!fileName) return false;
const route = normalizeRoute(rawRoute);
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
return Array.isArray(list) && list.includes(fileName);
},
/**
* Get all applet file paths pinned to the specified route.
* @param {string} rawRoute
* @returns {string[]}
*/
getPinnedForRoute(rawRoute) {
const route = normalizeRoute(rawRoute);
const list = pinnedAppletAutomat.state.pinnedByRoute[route];
return Array.isArray(list) ? list : [];
},
/**
* Pin an applet to a route.
* @param {string} fileName
* @param {string} rawRoute
*/
pinApplet(fileName, rawRoute) {
if (!fileName) return;
const route = normalizeRoute(rawRoute);
const current = pinnedAppletAutomat.state.pinnedByRoute;
const list = current[route] ? [...current[route]] : [];
if (!list.includes(fileName)) {
list.push(fileName);
const next = { ...current, [route]: list };
pinnedAppletAutomat.setState({ pinnedByRoute: next });
void actions.save(route);
}
},
/**
* Unpin an applet from a route.
* @param {string} fileName
* @param {string} rawRoute
*/
unpinApplet(fileName, rawRoute) {
if (!fileName) return;
const route = normalizeRoute(rawRoute);
const current = pinnedAppletAutomat.state.pinnedByRoute;
const list = current[route] ? current[route].filter((f) => f !== fileName) : [];
const next = { ...current };
if (list.length > 0) {
next[route] = list;
} else {
delete next[route];
}
pinnedAppletAutomat.setState({ pinnedByRoute: next });
void actions.save(route);
},
/**
* Toggle pin status for an applet on a route.
* @param {string} fileName
* @param {string} rawRoute
* @returns {boolean} New pinned status
*/
togglePin(fileName, rawRoute) {
if (!fileName) return false;
const route = normalizeRoute(rawRoute);
const isCurrentlyPinned = actions.isPinned(fileName, route);
if (isCurrentlyPinned) {
actions.unpinApplet(fileName, route);
return false;
}
actions.pinApplet(fileName, route);
return true;
},
/**
* Open dialog to execute the specified applet.
* @param {string} fileName
*/
executeApplet(fileName) {
pinnedAppletAutomat.setState({ activeExecution: fileName });
},
/**
* Close the execution dialog.
*/
closeExecution() {
pinnedAppletAutomat.setState({ activeExecution: null });
},
/**
* Load pinned applets from company settings namespace and localStorage.
*/
async load() {
const companyId = authAutomat.state.user?.selectedCompanyId;
if (!companyId) return;
pinnedAppletAutomat.setState({ status: 'loading' });
const storageKey = `dash_company_${companyId}_applet_pinned`;
let loadedMap = null;
// Fast-path: Synchronous read from localStorage to eliminate blank flash
try {
const rawLocal = localStorage.getItem(storageKey);
if (rawLocal) {
loadedMap = JSON.parse(rawLocal);
}
} catch (err) {
console.warn('[pinnedAppletAutomat] localStorage read failed:', err);
}
if (loadedMap && typeof loadedMap === 'object') {
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
}
// Network read: Try company settings API with 'applet.pinned' namespace
try {
const res = await settingApi.get('applet.pinned');
const val = res?.setting?.value;
if (val) {
const parsed = JSON.parse(val);
if (parsed && typeof parsed === 'object') {
loadedMap = { ...(loadedMap || {}), ...parsed };
pinnedAppletAutomat.setState({ pinnedByRoute: loadedMap, status: 'ready' });
try {
localStorage.setItem(storageKey, JSON.stringify(loadedMap));
} catch {}
}
}
} catch {
// Ignore if key is not yet stored on backend
}
// Also scan company settings list for any keys in namespace 'applet.pinned:'
try {
const listRes = await settingApi.list({ frontendOnly: true });
const settings = Array.isArray(listRes?.settings) ? listRes.settings : [];
let merged = false;
const current = { ...(loadedMap || pinnedAppletAutomat.state.pinnedByRoute) };
for (const s of settings) {
if (s.key === 'applet.pinned' && s.value) {
try {
const parsed = JSON.parse(s.value);
if (parsed && typeof parsed === 'object') {
Object.assign(current, parsed);
merged = true;
}
} catch {}
} else if (s.key && s.key.startsWith('applet.pinned:') && s.value) {
const route = s.key.slice('applet.pinned:'.length);
try {
const parsed = JSON.parse(s.value);
if (Array.isArray(parsed)) {
current[route] = parsed;
merged = true;
}
} catch {}
}
}
if (merged) {
pinnedAppletAutomat.setState({ pinnedByRoute: current, status: 'ready' });
try {
localStorage.setItem(storageKey, JSON.stringify(current));
} catch {}
}
} catch {
// Ignore network errors
}
pinnedAppletAutomat.setState({ status: 'ready' });
},
/**
* Persist pinned applets into company settings namespace and localStorage.
* @param {string} [route] Optional route updated
*/
async save(route = null) {
const companyId = authAutomat.state.user?.selectedCompanyId;
const { pinnedByRoute } = pinnedAppletAutomat.state;
if (companyId) {
const storageKey = `dash_company_${companyId}_applet_pinned`;
try {
localStorage.setItem(storageKey, JSON.stringify(pinnedByRoute));
} catch (err) {
console.warn('[pinnedAppletAutomat] localStorage write failed:', err);
}
// Upsert into company settings namespace 'applet.pinned'
try {
await settingApi.upsert('applet.pinned', {
value: JSON.stringify(pinnedByRoute),
frontend: true,
});
} catch {
// Backend key validation fallback
}
// Also upsert route-specific key in namespace
if (route) {
try {
const routeKey = `applet.pinned:${route}`;
const routeVal = JSON.stringify(pinnedByRoute[route] || []);
await settingApi.upsert(routeKey, {
value: routeVal,
frontend: true,
});
} catch {}
}
}
},
/**
* Reset on logout or company switch.
*/
reset() {
pinnedAppletAutomat.setState(initialState);
},
};
/**
* Pinned Applet state container.
* @type {Automat<PinnedAppletState>}
*/
pinnedAppletAutomat = new Automat(initialState, actions, { name: 'pinnedApplet' });
// Subscribe to auth lifecycle
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void pinnedAppletAutomat.actions.load();
} else if (auth.status === 'anon') {
pinnedAppletAutomat.actions.reset();
}
});
// Auto-load if auth is already established when module loads
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
void pinnedAppletAutomat.actions.load();
}
export default pinnedAppletAutomat;
export const {
isPinned,
getPinnedForRoute,
pinApplet,
unpinApplet,
togglePin,
executeApplet,
closeExecution,
} = pinnedAppletAutomat.actions;

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +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 orderAutomat from './orderAutomat';
import quoteAutomat from './quoteAutomat';
import { handleDraftRequest, handleDraftEvent } from './draftSync';
/**
* @typedef {Object} SseState
@@ -43,10 +29,10 @@ import { handleDraftRequest, handleDraftEvent } from './draftSync';
* @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() {
@@ -60,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.
*/
@@ -78,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} */
@@ -87,6 +70,7 @@ const initialState = {
companyId: null,
clientId: sseClientId,
events: [],
lastEvent: null,
};
/** Event name prefix for per-article signals. */
@@ -95,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();
@@ -118,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 {
@@ -144,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) {
@@ -166,137 +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);
});
});
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);
});
});
addTrackedListener(es, 'quote', () => {
void quoteAutomat.actions.reload().catch((err) => {
console.error('[sse] quote refresh failed', err);
});
});
addTrackedListener(es, 'shipment', () => {
void orderAutomat.actions.reload().catch((err) => {
console.error('[sse] shipment refresh failed', err);
});
});
addTrackedListener(es, 'draft:request', (event) => {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
void handleDraftRequest(payload);
} catch (err) {
console.error('[sse] draft:request handling failed', err);
}
});
addTrackedListener(es, 'draft', (event) => {
try {
const payload = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
handleDraftEvent(payload);
} catch (err) {
console.error('[sse] draft event handling 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);
});
// EventSource reconnects on its own; only surface the state.
es.onerror = () => {
console.error('[sse] event stream error');
recordEvent({ type: 'error', data: '', lastEventId: '' });
addTrackedListener(es, 'message', (event) => {
dispatchSseEvent(event);
});
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}
*/
@@ -305,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) {
@@ -329,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?.();
@@ -339,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' });
@@ -380,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,212 +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: [],
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 });
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 });
return suppliers;
} catch (error) {
supplierAutomat.setState({ error });
throw error;
}
},
/**
* 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,
@@ -20,10 +24,18 @@ import {
NAV_SURFACE,
} from '../colors';
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;
@@ -170,10 +182,12 @@ class AppSidebar extends PureComponent {
</ListItemIcon>
<ListItemText
primary={label}
primaryTypographyProps={{
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
slotProps={{
primary: {
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
},
}}
sx={{
m: { xs: 0, lg: 'initial' },
@@ -185,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>
);
@@ -400,11 +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,
@@ -47,19 +46,21 @@ import {
import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
const NUMBER_SCHEMES = [
{ type: 'sku', label: 'Artikel-SKU', icon: Inventory2Icon },
{ type: 'sku', label: 'Artikelnummer (ArtNr)', icon: Inventory2Icon },
{ type: 'entity', label: 'Kundennummer', icon: PeopleIcon },
{ type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon },
{ 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

@@ -17,11 +17,12 @@ import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import GoogleIcon from '@mui/icons-material/Google';
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';
@@ -29,6 +30,7 @@ import {
articleWithPhotoOrder,
articleWithUploadedPhoto,
articleWithoutPhoto,
buildGoogleSearchQuery,
formatPhotoDimensions,
formatPhotoSizeKb,
sortedPhotos,
@@ -182,6 +184,22 @@ function resolveTitle(props, single) {
}
return single ? 'Bild' : 'Bilder';
}
function resolveGoogleImagesQuery(props) {
if (props.article) {
return buildGoogleSearchQuery(props.article);
}
if (props.brand?.name) {
return String(props.brand.name).trim();
}
if (props.category?.name) {
return String(props.category.name).trim();
}
if (props.attributeValue) {
return String(props.attributeValue.value || props.attributeValue.name || '').trim();
}
return '';
}
// #endregion
/**
@@ -220,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();
@@ -300,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);
@@ -400,6 +418,13 @@ export default class PicturesDialog extends PureComponent {
handleSearchClose = () => {
this.setState({ searchOpen: false });
};
handleOpenGoogleImages = () => {
const query = resolveGoogleImagesQuery(this.props);
if (!query) return;
const url = `https://www.google.com/search?tbm=isch&q=${encodeURIComponent(query)}`;
window.open(url, '_blank', 'noopener,noreferrer');
};
// #endregion
// #region render helpers
@@ -627,6 +652,7 @@ export default class PicturesDialog extends PureComponent {
const pics = resolvePictures(this.props, single);
const title = resolveTitle(this.props, single);
const hasPic = Boolean(pics[0]?.fullHash || pics[0]?.thumbHash);
const googleQuery = resolveGoogleImagesQuery(this.props);
return (
<>
@@ -781,6 +807,16 @@ export default class PicturesDialog extends PureComponent {
>
Bild suchen
</Button>
<Button
size="small"
variant="outlined"
startIcon={<GoogleIcon />}
disabled={busy || disabled || !googleQuery}
onClick={this.handleOpenGoogleImages}
title={googleQuery ? `Google Bilder-Suche: ${googleQuery}` : 'Google'}
>
Google
</Button>
</Box>
<Button
size="small"

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);
@@ -77,14 +80,14 @@ export default function SectionLayout({ sectionPath }) {
const isDetail =
isArticleDetail || isKundeDetail || isLieferungDetail || isAngebotDoc;
const angebotLabel =
subNav?.find((item) => item.segment === '')?.title ?? subNav?.find((item) => item.segment === '')?.label ?? 'Angebot';
let pageTitle = isDetail
? title
: (activeSub?.title ?? activeSub?.label ?? title);
if (pathname === '/auftrag/neuesangebot' || pathname === '/auftrag/neu') pageTitle = 'Neues Angebot';
else if (isAngebotDoc) pageTitle = angebotLabel;
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neue Bestellung';
if (pathname === '/auftrag/neuesangebot' || pathname === '/auftrag/neu' || pathname === '/auftrag/angebot/neu') pageTitle = 'Neues Angebot';
else if (pathname === '/auftrag/bestellung/neu') pageTitle = 'Neuer Auftrag';
else if (pathname.startsWith('/auftrag/bestellung/')) pageTitle = 'Bestellung';
else if (pathname.startsWith('/auftrag/angebot/')) pageTitle = 'Angebot';
else if (isAngebotDoc) pageTitle = 'Angebot';
const showsRenameSwitch = activeSub?.segment === 'kategorien';
@@ -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

@@ -76,10 +76,26 @@ export default class TextInput extends Component {
? (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? false)
: (propReadOnly ?? slotProps?.htmlInput?.readOnly ?? inputProps?.readOnly ?? this.state.readOnly);
const { inputProps: nestedFromInputProps, ...cleanInputProps } = InputProps || {};
const { inputProps: nestedFromSlotInput, ...cleanSlotInput } = slotProps?.input || {};
const rawResolvedInputProps = (InputProps || slotProps?.input) ? {
...cleanSlotInput,
...cleanInputProps,
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
} : undefined;
const htmlInput = isSelect
? {
readOnly: effectiveReadOnly,
...slotProps?.htmlInput,
...nestedFromSlotInput,
...nestedFromInputProps,
...inputProps,
}
: {
@@ -94,20 +110,12 @@ export default class TextInput extends Component {
'data-form-type': 'other',
readOnly: effectiveReadOnly,
...slotProps?.htmlInput,
...nestedFromSlotInput,
...nestedFromInputProps,
...inputProps,
};
const resolvedInputProps = (InputProps || slotProps?.input) ? {
...slotProps?.input,
...InputProps,
startAdornment: (slotProps?.input?.startAdornment && InputProps?.startAdornment)
? <>{slotProps.input.startAdornment}{InputProps.startAdornment}</>
: (InputProps?.startAdornment ?? slotProps?.input?.startAdornment),
endAdornment: (slotProps?.input?.endAdornment && InputProps?.endAdornment)
? <>{InputProps.endAdornment}{slotProps.input.endAdornment}</>
: (InputProps?.endAdornment ?? slotProps?.input?.endAdornment),
} : undefined;
const resolvedInputProps = rawResolvedInputProps;
const { input: _unusedInput, ...restSlotProps } = (slotProps || {});
const hasLabelProps = Boolean(label || InputLabelProps || slotProps?.inputLabel);

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 = (() => {
@@ -167,7 +181,9 @@ class UserMenu extends PureComponent {
<ListItemText
primary="Update verfügbar"
secondary="Jetzt aktualisieren"
primaryTypographyProps={{ fontWeight: 600, color: 'primary.main' }}
slotProps={{
primary: { fontWeight: 600, color: 'primary.main' },
}}
/>
</MenuItem>
)}
@@ -198,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

@@ -0,0 +1,149 @@
import React, { PureComponent } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import pinnedAppletAutomat from '../../automats/pinnedAppletAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
import { vfs as vfsApi } from '../../api';
import VfsAppletRunner from '../vfs/VfsAppletRunner';
import VfsTextEditor from '../vfs/VfsTextEditor';
/**
* @file PinnedAppletDialog.jsx
* @description Dialog modal that executes a pinned applet runner directly on the current route.
*/
export default class PinnedAppletDialog extends PureComponent {
constructor(props) {
super(props);
const activeExecution = pinnedAppletAutomat.state.activeExecution;
this.state = {
activeExecution,
content: '',
loadingContent: Boolean(activeExecution),
loadError: null,
viewMode: 'run',
files: vfsAutomat.state.files,
};
}
componentDidMount() {
this.unsubPinned = pinnedAppletAutomat.subscribe(this, (state) => ({
activeExecution: state.activeExecution,
}));
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
files: state.files,
}));
if (this.state.activeExecution) {
void this.loadAppletContent(this.state.activeExecution);
}
}
componentDidUpdate(prevProps, prevState) {
if (prevState.activeExecution !== this.state.activeExecution) {
if (this.state.activeExecution) {
this.setState({ viewMode: 'run' });
void this.loadAppletContent(this.state.activeExecution);
} else {
this.setState({ content: '', loadingContent: false, loadError: null, viewMode: 'run' });
}
}
}
componentWillUnmount() {
this.unsubPinned?.();
this.unsubVfs?.();
}
loadAppletContent = async (fileName) => {
this.setState({ loadingContent: true, loadError: null });
try {
const text = await vfsApi.getText(fileName);
this.setState({ content: text, loadingContent: false });
} catch (err) {
console.error('[PinnedAppletDialog] failed to load applet content:', err);
this.setState({
loadError: err?.message || 'Fehler beim Laden des Applets',
loadingContent: false,
});
}
};
handleClose = () => {
pinnedAppletAutomat.actions.closeExecution();
};
render() {
const { activeExecution, content, loadingContent, loadError, viewMode, files } = this.state;
if (!activeExecution) return null;
const file = files.find((f) => f.name === activeExecution);
return (
<Dialog
open={Boolean(activeExecution)}
onClose={this.handleClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
height: '85vh',
maxHeight: 800,
borderRadius: 3,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.paper',
},
}}
>
<DialogContent sx={{ p: 0, height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{loadingContent ? (
<Box
sx={{
flexGrow: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 1.5,
color: 'text.secondary',
p: 4,
}}
>
<CircularProgress size={28} />
<Typography variant="body2">Lade Applet {activeExecution}…</Typography>
</Box>
) : loadError ? (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="error" variant="body1">
{loadError}
</Typography>
</Box>
) : viewMode === 'run' ? (
<VfsAppletRunner
fileName={activeExecution}
file={file}
content={content}
loadingContent={false}
onClose={this.handleClose}
onShowCode={() => this.setState({ viewMode: 'code' })}
/>
) : (
<VfsTextEditor
fileName={activeExecution}
file={file}
content={content}
originalContent={content}
isDirty={false}
loadingContent={false}
onClose={this.handleClose}
onRunApplet={() => this.setState({ viewMode: 'run' })}
readOnly
/>
)}
</DialogContent>
</Dialog>
);
}
}

View File

@@ -0,0 +1,154 @@
import React, { PureComponent } from 'react';
import Box from '@mui/material/Box';
import Fab from '@mui/material/Fab';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Badge from '@mui/material/Badge';
import PlayArrowRoundedIcon from '@mui/icons-material/PlayArrowRounded';
import PushPinIcon from '@mui/icons-material/PushPin';
import pinnedAppletAutomat, { normalizeRoute } from '../../automats/pinnedAppletAutomat';
import vfsAutomat from '../../automats/vfsAutomat';
/**
* @file PinnedAppletFabs.jsx
* @description Renders Floating Action Buttons for any applets pinned to the current browser route.
* Clicking a FAB triggers the execution dialog for that applet.
*/
export default class PinnedAppletFabs extends PureComponent {
constructor(props) {
super(props);
const route = normalizeRoute(props.pathname);
this.state = {
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
files: vfsAutomat.state.files,
};
}
componentDidMount() {
this.unsubPinned = pinnedAppletAutomat.subscribe(this, () => {
const route = normalizeRoute(this.props.pathname);
return {
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
};
});
this.unsubVfs = vfsAutomat.subscribe(this, (state) => ({
files: state.files,
}));
}
componentDidUpdate(prevProps) {
if (prevProps.pathname !== this.props.pathname) {
const route = normalizeRoute(this.props.pathname);
this.setState({
pinnedFileNames: pinnedAppletAutomat.actions.getPinnedForRoute(route),
});
}
}
componentWillUnmount() {
this.unsubPinned?.();
this.unsubVfs?.();
}
render() {
const { pinnedFileNames, files } = this.state;
if (!pinnedFileNames || pinnedFileNames.length === 0) {
return null;
}
return (
<Box
sx={(theme) => ({
position: 'fixed',
bottom: { xs: 20, sm: 28 },
right: { xs: 20, sm: 28 },
zIndex: theme.zIndex.speedDial || 1050,
display: 'flex',
flexDirection: 'column-reverse',
alignItems: 'flex-end',
gap: 1.5,
pointerEvents: 'none',
})}
>
{pinnedFileNames.map((fileName) => {
const file = files.find((f) => f.name === fileName);
const baseName = fileName.split('/').pop().replace(/\.applet\.js$/i, '');
const title = file?.description || baseName;
return (
<Tooltip
key={fileName}
placement="left"
arrow
title={
<Box sx={{ p: 0.5, maxWidth: 280 }}>
<Typography variant="subtitle2" fontWeight={700} sx={{ fontSize: '0.82rem' }}>
{title}
</Typography>
<Typography
variant="caption"
sx={{ display: 'block', opacity: 0.8, fontFamily: 'monospace', fontSize: '0.7rem' }}
>
{fileName}
</Typography>
<Typography
variant="caption"
color="primary.light"
sx={{ display: 'block', mt: 0.5, fontStyle: 'italic', fontWeight: 600 }}
>
Klicken zum Ausführen
</Typography>
</Box>
}
>
<Badge
overlap="circular"
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
badgeContent={
<PushPinIcon
sx={{
fontSize: 12,
color: '#fff',
transform: 'rotate(25deg)',
}}
/>
}
sx={{
pointerEvents: 'auto',
'& .MuiBadge-badge': {
bgcolor: 'primary.dark',
border: '2px solid #fff',
borderRadius: '50%',
width: 20,
height: 20,
minWidth: 20,
p: 0,
},
}}
>
<Fab
color="primary"
aria-label={`Applet ${title} ausführen`}
onClick={() => pinnedAppletAutomat.actions.executeApplet(fileName)}
sx={(theme) => ({
width: 52,
height: 52,
background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.25)',
transition: 'transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s ease',
'&:hover': {
transform: 'scale(1.08) translateY(-2px)',
boxShadow: '0 10px 26px rgba(0, 0, 0, 0.35)',
},
})}
>
<PlayArrowRoundedIcon sx={{ fontSize: 28 }} />
</Fab>
</Badge>
</Tooltip>
);
})}
</Box>
);
}
}

View File

@@ -0,0 +1,2 @@
export { default as PinnedAppletFabs } from './PinnedAppletFabs';
export { default as PinnedAppletDialog } from './PinnedAppletDialog';

View File

@@ -280,7 +280,7 @@ export default class ArticleImageSearchDialog extends PureComponent {
suggestions.push({ label: `${brandName} ${articleName}`, value: `${brandName} ${articleName}` });
}
if (sku && sku !== query && sku !== articleName) {
suggestions.push({ label: `SKU: ${sku}`, value: sku });
suggestions.push({ label: `ArtNr: ${sku}`, value: sku });
}
if (firstEan && firstEan !== query) {
suggestions.push({ label: `EAN: ${firstEan}`, value: firstEan });

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,9 +228,24 @@ class SearchResultRow extends PureComponent {
{' · '}
</>
)}
{article.sku}
{article.stock != null && ` · Bestand ${formatStock(article.stock)}`}
{article.available != null && ` · Verfügbar ${formatStock(article.available)}`}
<Box component="span" sx={{ fontFamily: 'monospace' }}>
{article.sku}
</Box>
{article.stock != null && (
<>
{' · '}
<Box
component="span"
title={
article.warehouses?.length > 0
? article.warehouses.map((w) => `${w.name || 'Lager'}: ${formatStock(w.stock)}`).join(' · ')
: undefined
}
>
Bestand {formatStock(article.stock)}
</Box>
</>
)}
{primaryEan ? ` · EAN ${primaryEan}` : ''}
</Typography>
}
@@ -332,6 +347,8 @@ export default class ArticleSearch extends PureComponent {
if (
this.state.indexStatus !== prevState.indexStatus ||
this.props.excludeSkus !== prevProps.excludeSkus ||
this.props.includeSkus !== prevProps.includeSkus ||
this.props.extraArticles !== prevProps.extraArticles ||
this.props.allowParent !== prevProps.allowParent ||
this.props.categories !== prevProps.categories
) {
@@ -355,6 +372,8 @@ export default class ArticleSearch extends PureComponent {
runQuery = (queryText) => {
const {
excludeSkus = [],
includeSkus = null,
extraArticles = [],
allowParent = false,
categories = [],
mode,
@@ -364,6 +383,8 @@ export default class ArticleSearch extends PureComponent {
// full match set; the autocomplete popper keeps a small option cap.
const res = searchArticlesIndex(queryText, {
excludeSkus,
includeSkus,
extraArticles,
allowParent,
categories,
offset: 0,
@@ -762,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

@@ -147,7 +147,7 @@ export default class ArticleSupplierDialog extends PureComponent {
)}
<TextInput
label="Lieferanten-Artikelnummer (SKU)"
label="Lieferanten-Artikelnummer (ArtNr)"
value={sku}
onChange={(e) => this.setState({ sku: e.target.value })}
placeholder="z. B. LF-09412"
@@ -156,7 +156,7 @@ export default class ArticleSupplierDialog extends PureComponent {
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<TextInput
label="Verpackungseinheit (VPE / PU)"
label="Verpackungseinheit (VPE)"
type="number"
required
error={!puValid}

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