Compare commits

..

244 Commits

Author SHA1 Message Date
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
seb
bcd61fcf5e u 2026-10-15 00:45:05 +02:00
seb
6e0b72b34b u 2026-10-15 00:42:46 +02:00
seb
39aa898324 u 2026-10-15 00:20:54 +02:00
seb
7ce2e3216f u 2026-10-15 00:08:21 +02:00
seb
002f63a47a u 2026-10-14 23:58:55 +02:00
seb
ed7bb80006 u 2026-10-14 23:58:37 +02:00
seb
e2a2d02cb4 u 2026-10-14 23:24:45 +02:00
seb
eb4654dfbf u 2026-10-14 23:13:46 +02:00
seb
c62834833d u 2026-10-14 20:46:07 +02:00
seb
fdb67b7221 u 2026-10-14 20:39:42 +02:00
seb
c76777d0b8 u 2026-10-14 20:28:56 +02:00
seb
c54d932e36 u 2026-10-14 15:20:39 +02:00
seb
a0d71f70b7 u 2026-10-14 15:07:47 +02:00
seb
b8fdef4e11 u 2026-10-14 15:00:31 +02:00
seb
4188d8f46f u 2026-10-14 14:46:44 +02:00
seb
49f8f2f93f u 2026-10-14 13:48:45 +02:00
seb
61dd2f3a5d u 2026-10-14 13:45:17 +02:00
seb
62fa58e4ec u 2026-10-14 12:53:33 +02:00
seb
e7b3044903 u 2026-10-14 03:15:43 +02:00
seb
61076a9ef2 u 2026-10-14 03:06:25 +02:00
seb
ee3012670f u 2026-10-14 01:19:27 +02:00
seb
937f434641 u 2026-10-14 01:16:32 +02:00
seb
424609a0f6 u 2026-10-14 01:13:09 +02:00
seb
2dd6533481 u 2026-10-14 00:47:36 +02:00
seb
c0600ecb3b u 2026-10-14 00:22:02 +02:00
seb
56a86fc005 u 2026-10-14 00:10:50 +02:00
seb
f6cb3d3e35 u 2026-10-13 23:20:54 +02:00
seb
e2bf08d340 u 2026-10-13 22:50:36 +02:00
seb
bf7ff349f3 u 2026-10-13 22:05:30 +02:00
seb
bc583a180f u 2026-10-13 17:49:52 +02:00
seb
345d6f5752 u 2026-10-13 17:19:33 +02:00
seb
cd530ed448 u 2026-10-13 13:21:35 +02:00
seb
94fcac0795 u 2026-10-13 02:13:31 +02:00
seb
8f68f7d224 u 2026-10-13 01:50:36 +02:00
seb
d715b9c4e5 u 2026-10-13 01:32:22 +02:00
seb
4c5ccf7f64 u 2026-10-13 00:33:38 +02:00
seb
1ad9b14aa6 u 2026-10-12 21:41:44 +02:00
seb
df22f77369 u 2026-10-12 20:04:48 +02:00
seb
21663e4681 u 2026-10-12 20:03:43 +02:00
seb
6b0839639d u 2026-10-12 18:41:53 +02:00
seb
2cfa263044 u 2026-10-12 18:36:45 +02:00
seb
858e413627 u 2026-10-12 18:17:57 +02:00
seb
a0a40b243a u 2026-10-12 18:10:26 +02:00
seb
15dce3c1cf u 2026-10-12 14:30:26 +02:00
seb
627868eb2e u 2026-10-12 14:10:11 +02:00
seb
d5d45e57d4 u 2026-10-12 12:42:25 +02:00
seb
59c3b901ae u 2026-10-12 01:52:45 +02:00
seb
afff1a948c u 2026-10-12 01:51:06 +02:00
seb
0a073f7cf4 u 2026-10-12 01:31:41 +02:00
299 changed files with 49454 additions and 9828 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -27,12 +27,16 @@ 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/neu` | Static draft form | `/auftrag/neu` |
| **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` |
| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` |
@@ -66,10 +70,13 @@ 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.
### Customers (`/kunde`)
- `:entityId`: Numeric ID of the entity/customer record (e.g. `entity.id`).
- Opens contact addresses, communications, and customer data tiles.

1
.vscode/tmstate.json vendored Normal file
View File

@@ -0,0 +1 @@
{"version":3,"groups":{},"history":{},"addons":{},"selectedGroup":null,"previousSelectedGroup":null,"quickSlots":{}}

View File

@@ -259,6 +259,9 @@ npm run build
# Preview production build
npm run preview
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
npm run automat-graph
```
> [!NOTE]
@@ -425,7 +428,7 @@ The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/d
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
├── /auftrag
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
│ ├── /neu → AuftragAngebotNeuPage (Create quote)
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
@@ -488,6 +491,13 @@ npm run cert # Generates local certs in ./certs/
npm run dev:https # Runs Vite HTTPS server on port 4308
```
### Automat Graph Visualization (`automat-graph.svg` & `automat-graph.dot`)
- Visual topology diagram generated via `npm run automat-graph` (`generate-automat-graph.js`).
- Modeled after `schema-graph.svg` in `dashdbschema` and `api-graph.svg` in `dashback`.
- Visualizes 32 Automats across 12 domain clusters (Session & Real-Time, Catalog, Search, Properties, Purchasing, Sales, Inventory, Finance, POS, AI Workspace, Virtual Grids, System UI).
- Color-coded badges for `STATE` (purple), `ACTION` (green), `SELECT` (blue), `API` (amber), `SSE` (rose), and `HOOK` (orange).
- Traces 60 relational and dataflow links with dedicated arrival ports (`in#`), exit compass directions (`e`/`w`), and arc-fixed self-loops.
---
## 16. Agent Cheatsheet & Code Recipes

View File

@@ -58,10 +58,13 @@ future test cases.
The Vite dev proxy (`/api` → Dashback) satisfies the same-origin cookie
requirement without needing HTTPS on the frontend dev server.
## Architecture
## Architecture & State Management
- `src/automats/*` — reactive state containers powered by `react-automat` (no React Context, hooks, or HOCs).
- `automat-graph.svg` / `automat-graph.dot` — interactive visual topology of all 32 Automats across 12 domain clusters and 60 dataflow relationships.
- `npm run automat-graph` — regenerates the Automat Graph SVG and DOT files using Graphviz Wasm.
- `src/api/index.js` — all backend communication (`credentials: 'include'`).
- `src/auth/googleSignIn.js` — Google Identity Services helper.
- `src/auth/AuthProvider.jsx` — session state + login/logout.
- `src/auth/useAuth.js` — auth context hook.
- `src/components/*` — small function components with local state.
- `src/components/*` — components reading synchronously from automats and subscribing via fine-grained selectors.

121
automat-graph.dot Normal file

File diff suppressed because one or more lines are too long

1676
automat-graph.svg Normal file

File diff suppressed because it is too large Load Diff

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

884
generate-automat-graph.js Normal file
View File

@@ -0,0 +1,884 @@
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Graphviz } from '@hpcc-js/wasm-graphviz';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outputPath = join(__dirname, 'automat-graph.svg');
const dotOutputPath = join(__dirname, 'automat-graph.dot');
const COLORS = {
background: '#1e2430',
tableBorder: '#3f4c5f',
headerBg: '#33415c',
headerText: '#e2e8f0',
rowBg: '#2a3242',
rowBgAlt: '#272e3d',
keyBg: '#2b3a52',
text: '#d8dee9',
muted: '#8b95a7',
clusterFill: '#202a3a',
};
const BADGE_COLORS = {
STATE: { fg: '#c084fc', bg: '#2b1a42' }, // Purple
ACTION: { fg: '#4ade80', bg: '#143826' }, // Green
SELECT: { fg: '#38bdf8', bg: '#182b40' }, // Sky Blue
API: { fg: '#fbbf24', bg: '#3b2c12' }, // Amber
SSE: { fg: '#f87171', bg: '#3a181e' }, // Rose/Red
HOOK: { fg: '#fb923c', bg: '#382414' }, // Orange/Gold
};
const EDGE_COLORS = [
'#5aa7ff', // Blue
'#6bdf9a', // Mint Green
'#f5c15c', // Golden Amber
'#ff7fc2', // Pink
'#c79bff', // Lavender
'#5cd6d6', // Cyan
'#ff9f5a', // Coral
'#8f95ff', // Indigo
'#ff6b6b', // Soft Red
'#58d18a', // Emerald
'#4fc4e8', // Sky
'#a98cff', // Violet
'#ff8f66', // Tangerine
'#4ecb8f', // Seafoam
'#ff7aa8', // Rose
'#e0b25a', // Sand
'#56c8c8', // Aqua
'#ff5f9e', // Fuchsia
'#ffb25a', // Ochre
'#66b3ff', // Periwinkle
];
const CLUSTERS = [
{
id: 'session_realtime',
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'],
},
{
id: 'search_indexing',
label: 'Search & Indexing',
automats: ['articleIndexAutomat', 'articleSearchAutomat'],
},
{
id: 'purchasing_logistics',
label: 'Deliveries & Inbound',
automats: ['deliveryAutomat', 'deliveryEditorAutomat'],
},
{
id: 'sales_crm',
label: 'Sales & CRM',
automats: ['quoteAutomat', 'orderAutomat', 'entityAutomat'],
},
{
id: 'ai_workspace',
label: 'AI Assistant & Workspace',
automats: ['chatAutomat', 'vfsAutomat'],
},
{
id: 'audit_grids',
label: 'Audit & Virtual Grids',
automats: ['articleHistoryGridAutomat', 'globalHistoryGridAutomat', 'ledgerHistoryGridAutomat'],
},
{
id: 'system_ui',
label: 'UI, Navigation & System',
automats: ['navigationAutomat', 'helpAutomat', 'pwaAutomat', 'Legend'],
},
];
function escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function truncate(str, max = 30) {
if (!str) return '';
return str.length > max ? `${str.slice(0, max - 1)}…` : str;
}
/**
* Complete metadata for every Automat in Dashfront.
*/
const AUTOMAT_SPECS = [
// 1. Session & Real-Time Hub
{
id: 'authAutomat',
title: 'authAutomat',
subtitle: 'Always-On Session & Multi-Tenant Gateway',
entries: [
{ 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: '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' },
],
},
{
id: 'sseAutomat',
title: 'sseAutomat',
subtitle: 'Single Persistent SSE Connection',
entries: [
{ type: 'STATE', name: 'status', desc: '\'connected\' | \'error\'' },
{ type: 'STATE', name: 'clientId (cid)', desc: 'Tab-scoped UUID in session' },
{ type: 'ACTION', name: 'reconnect()', desc: 'Cycle on company switch' },
{ type: 'ACTION', name: 'on(event, handler)', desc: 'Register signal listener' },
{ type: 'ACTION', name: 'watchArticle(sku)', desc: 'Attach article-<sku>' },
{ type: 'SELECT', name: 'invalidateArticle(sku)', desc: 'Silent cache refresh' },
{ type: 'SSE', name: 'category / articles', desc: 'Catalog mutation events', port: 'p_sse_events' },
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment events' },
{ type: 'API', name: 'GET /api/events', desc: 'Persistent stream' },
],
},
// 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',
subtitle: 'Hierarchical Category Tree',
entries: [
{ type: 'STATE', name: 'categories: Map', desc: 'ID -> Category Node Map', port: 'p_cat_categories' },
{ type: 'STATE', name: 'rootCategoryId', desc: 'Company root node ID' },
{ type: 'STATE', name: 'articleCount', desc: 'Total parent items' },
{ type: 'ACTION', name: 'loadTree()', desc: 'Fetch tree & warm views', port: 'p_cat_loadTree' },
{ type: 'ACTION', name: 'createCategory()', desc: 'Insert child node' },
{ type: 'ACTION', name: 'moveCategory()', desc: 'Parent reparenting' },
{ type: 'ACTION', name: 'deleteCategory()', desc: 'Prune subtree' },
{ type: 'SELECT', name: 'getCategoryBreadcrumbs()', desc: 'Hierarchical path trace' },
{ type: 'API', name: 'GET /api/category', desc: 'Recursive tree load' },
],
},
{
id: 'categoryViewAutomat',
title: 'categoryViewAutomat',
subtitle: 'KeyedAutomat (Slug Path)',
entries: [
{ type: 'STATE', name: 'views: Map<slug, Data>', desc: 'Articles & direct children' },
{ type: 'ACTION', name: 'loadAllCategoryViews()', desc: 'Prewarm all tree slugs' },
{ type: 'ACTION', name: 'reloadLoadedCategoryViews()', desc: 'Refresh cached views' },
{ type: 'ACTION', name: 'invalidateAllCategoryViews()', desc: 'Clear on SSE signal' },
{ type: 'SELECT', name: 'collectLoadedCategoryViewData()', desc: 'Extract union data', port: 'p_catview_collect' },
{ type: 'SELECT', name: 'getOrCreateCategoryAutomat()', desc: 'Per-slug reactive store' },
{ type: 'API', name: 'GET /api/category/:id/articles', desc: 'Category article batch' },
],
},
{
id: 'artikelAutomat',
title: 'artikelAutomat',
subtitle: 'In-Memory Article Record Cache',
entries: [
{ type: 'STATE', name: 'articles: Map<sku, Object>', desc: 'Deduplicated SKU cache', port: 'p_art_set' },
{ type: 'ACTION', name: 'loadArticle(sku)', desc: 'Fetch or cached promise' },
{ type: 'ACTION', name: 'reloadArticle(sku)', desc: 'Silent background sync' },
{ type: 'ACTION', name: 'setArticleName(sku, name)', desc: 'Optimistic rename', port: 'p_art_setName' },
{ type: 'ACTION', name: 'clearArticle(sku)', desc: 'Evict SKU from cache' },
{ type: 'SELECT', name: 'getArticle(sku)', desc: 'Sync constructor read' },
{ type: 'SELECT', name: 'createArticleSelector(sku)', desc: 'Fine-grained selector' },
{ type: 'API', name: 'GET /api/article/:sku', desc: 'Full article document' },
],
},
{
id: 'articleDetailAutomat',
title: 'articleDetailAutomat',
subtitle: 'KeyedAutomat (SKU)',
entries: [
{ type: 'STATE', name: 'articleHistory: Map<sku>', desc: 'Change history log', port: 'p_artdet_hist' },
{ type: 'STATE', name: 'articleSupplier: Map<sku>', desc: 'Assigned supplier list' },
{ type: 'ACTION', name: 'articleHistoryAutomat.load()', desc: 'Fetch SKU audit trail' },
{ type: 'ACTION', name: 'articleSupplierAutomat.load()', desc: 'Fetch supplier catalog' },
{ type: 'API', name: 'GET /api/article/:sku/history', desc: 'Audit change entries' },
{ type: 'API', name: 'GET /api/article/:sku/supplier', desc: 'Vendor links & prices' },
],
},
// 4. Search & Discovery
{
id: 'articleIndexAutomat',
title: 'articleIndexAutomat',
subtitle: 'Derived Client Search Index',
entries: [
{ type: 'STATE', name: 'items: ArticleIndexRecord[]', desc: 'De-duplicated article union', port: 'p_idx_items' },
{ type: 'STATE', name: 'status: \'ready\'', desc: 'Index lifecycle flag' },
{ type: 'ACTION', name: 'buildArticleIndex()', desc: 'Derive from category views' },
{ type: 'ACTION', name: 'setArticleIndexName()', desc: 'Patch on rename' },
{ type: 'ACTION', name: 'reload()', desc: 'Re-derive from views' },
{ type: 'SELECT', name: 'selectArticleIndexItems()', desc: 'Fast record projection' },
{ type: 'SELECT', name: 'ensureLoaded()', desc: 'Await warm views' },
],
},
{
id: 'articleSearchAutomat',
title: 'articleSearchAutomat',
subtitle: 'In-Memory FlexSearch Engine',
entries: [
{ type: 'STATE', name: 'target: string', desc: 'Active search query' },
{ type: 'STATE', name: 'filter: string', desc: 'Active category filter' },
{ type: 'STATE', name: 'result: ArticleSearchResult', desc: 'Facets, recordset, count' },
{ type: 'ACTION', name: 'search(target, filter)', desc: 'Synchronous indexed query' },
{ type: 'SELECT', name: 'selectSearchResult()', desc: 'Reactive search outcome' },
],
},
// 5. Deliveries & Inbound
{
id: 'deliveryAutomat',
title: 'deliveryAutomat',
subtitle: 'Chunk-Paginated Deliveries',
entries: [
{ type: 'STATE', name: 'items: Delivery[]', desc: 'Company delivery receipts' },
{ type: 'STATE', name: 'status: \'ready\'', desc: 'List lifecycle state' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Deduplicated fetch & sync' },
{ type: 'ACTION', name: 'reset()', desc: 'Clear on session switch' },
{ type: 'API', name: 'GET /api/delivery', desc: 'Inbound delivery log' },
],
},
{
id: 'deliveryEditorAutomat',
title: 'deliveryEditorAutomat',
subtitle: 'Inbound Receipt & OCR Machine',
entries: [
{ type: 'STATE', name: 'lines: DeliveryLine[]', desc: 'Uncommitted draft lines', port: 'p_deled_lines' },
{ type: 'STATE', name: 'deliveryEntity', desc: 'Selected supplier ID', port: 'p_deled_supplier' },
{ type: 'STATE', name: 'attachments: File[]', desc: 'Invoice PDFs & OCR results' },
{ type: 'STATE', name: 'draftCount: number', desc: 'Uncommitted lines for badge' },
{ type: 'ACTION', name: 'addLine() / updateLine()', desc: 'Interactive item editing' },
{ type: 'ACTION', name: 'commitDelivery()', desc: 'Persist goods receipt', port: 'p_deled_commit' },
{ type: 'SELECT', name: 'buildSupplierArticlesMap()', desc: 'Auto-match SKU/EAN' },
{ type: 'API', name: 'POST /api/delivery', desc: 'Create goods receipt' },
],
},
// 6. Sales & CRM
{
id: 'quoteAutomat',
title: 'quoteAutomat',
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' },
{ type: 'ACTION', name: 'reset()', desc: 'Reset on logout' },
{ type: 'API', name: 'GET /api/quote', desc: 'Company quote list' },
],
},
{
id: 'orderAutomat',
title: 'orderAutomat',
subtitle: 'Chunk-Paginated Order Engine',
entries: [
{ type: 'STATE', name: 'items: Order[]', desc: 'Sales orders & lines', port: 'p_order_items' },
{ type: 'STATE', name: 'total: number', desc: 'Unfiltered order count' },
{ type: 'STATE', name: 'onlyUnpaid / onlyUndelivered', desc: 'Fulfillment filters' },
{ type: 'ACTION', name: 'loadChunk(index)', desc: 'Chunk size 50 prefetch' },
{ type: 'ACTION', name: 'reload()', desc: 'Silent orders re-fetch' },
{ type: 'SELECT', name: 'selectOrders() / selectTotal()', desc: 'Filter-applied slice' },
{ type: 'API', name: 'GET /api/order', desc: 'Paged order stream' },
{ type: 'SSE', name: 'order / shipment', desc: 'Fulfillment mutations' },
],
},
{
id: 'entityAutomat',
title: 'entityAutomat',
subtitle: 'Customer & Partner Directory',
entries: [
{ type: 'STATE', name: 'items: Entity[]', desc: 'Customers & partners' },
{ type: 'ACTION', name: 'load() / reload()', desc: 'Customer directory load' },
{ type: 'ACTION', name: 'reset()', desc: 'Reset on session switch' },
{ type: 'API', name: 'GET /api/entity', desc: 'Directory records' },
],
},
// 7. AI Assistant & Workspace
{
id: 'chatAutomat',
title: 'chatAutomat',
subtitle: 'AI Assistant Drawer & SSE',
entries: [
{ type: 'STATE', name: 'isOpen: boolean', desc: 'Docked chat drawer toggle' },
{ type: 'STATE', name: 'conversations: []', desc: 'Conversation sessions' },
{ type: 'STATE', name: 'messages: ModelMessage[]', desc: 'Live message transcript', port: 'p_chat_msg' },
{ type: 'STATE', name: 'sseStatus: \'connected\'', desc: 'Token streaming stream', port: 'p_chat_sse' },
{ type: 'ACTION', name: 'sendMessage(text)', desc: 'Prompt model with tools' },
{ type: 'ACTION', name: 'answerAskUser()', desc: 'Interactive tool modal' },
{ type: 'SELECT', name: 'userEnv / routeTools', desc: 'Client navigation tool', port: 'p_chat_tools' },
{ type: 'API', name: 'POST /api/modelconversation', desc: 'AI conversation session' },
{ type: 'SSE', name: 'modelconversation/events', desc: 'Live token stream' },
],
},
{
id: 'vfsAutomat',
title: 'vfsAutomat',
subtitle: 'Virtual Filesystem Explorer',
entries: [
{ type: 'STATE', name: 'files: VfsFile[]', desc: 'Company VFS workspace' },
{ type: 'STATE', name: 'selectedFileName', desc: 'Current active file' },
{ type: 'STATE', name: 'fileContent / isDirty', desc: 'Editor buffer & diff' },
{ type: 'ACTION', name: 'selectFile() / saveFile()', desc: 'Applet editor buffer' },
{ type: 'ACTION', name: 'uploadFiles() / deleteFile()', desc: 'File manipulations' },
{ type: 'API', name: 'GET /api/vfs', desc: 'VFS directory index' },
{ type: 'SSE', name: 'vfs', desc: 'VFS mutation signal' },
],
},
// 8. Audit & Virtual Grids
{
id: 'articleHistoryGridAutomat',
title: 'articleHistoryGridAutomat',
subtitle: 'createGridAutomat (Article)',
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/article/history', desc: 'Infinite audit window' },
],
},
{
id: 'globalHistoryGridAutomat',
title: 'globalHistoryGridAutomat',
subtitle: 'createGridAutomat (Global)',
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/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' },
],
},
// 9. UI, Navigation & System
{
id: 'navigationAutomat',
title: 'navigationAutomat',
subtitle: 'Cross-Section Navigation Memory',
entries: [
{ type: 'STATE', name: 'lastDeliveryPath', desc: 'Active receipt subpath', port: 'p_nav_delpath' },
{ type: 'STATE', name: 'lastArticleSku', desc: 'Active SKU selection', port: 'p_nav_sku' },
{ type: 'STATE', name: 'lastCategorySlug / Path', desc: 'Active category tree node', port: 'p_nav_catslug' },
{ type: 'STATE', name: 'lastSearchTerm / Path', desc: 'Preserved search query' },
{ type: 'ACTION', name: 'setLastDeliveryPath()', desc: 'Restore on sidebar click' },
{ type: 'ACTION', name: 'setLastArticleSku()', desc: 'Restore SKU on section click' },
{ type: 'ACTION', name: 'clearNavigation()', desc: 'Reset on company switch' },
],
},
{
id: 'helpAutomat',
title: 'helpAutomat',
subtitle: 'IndexedDB Contextual Help',
entries: [
{ type: 'STATE', name: 'enabled: boolean', desc: 'Interactive help badges' },
{ type: 'STATE', name: 'activeHelpId: string|null', desc: 'Active popper ID', port: 'p_help_active' },
{ type: 'ACTION', name: 'toggle() / setEnabled()', desc: 'Toggle badge mode' },
{ type: 'ACTION', name: 'openPopper() / closePopper()', desc: 'Single active popper' },
{ type: 'HOOK', name: 'persist: true', desc: 'Survives page reloads' },
],
},
{
id: 'pwaAutomat',
title: 'pwaAutomat',
subtitle: 'Service Worker & Offline Lifecycle',
entries: [
{ type: 'STATE', name: 'needRefresh: boolean', desc: 'New SW release pending' },
{ type: 'STATE', name: 'offlineReady: boolean', desc: 'Offline assets cached' },
{ type: 'STATE', name: 'dialogOpen / updating', desc: 'Prompt modal state' },
{ type: 'ACTION', name: 'applyUpdate()', desc: 'Skip waiting & reload' },
{ type: 'ACTION', name: 'openDialog() / closeDialog()', desc: 'Modal controls' },
],
},
];
/**
* High-fidelity relationships between Automats:
* - Lifecycle synchronization (Auth / Session)
* - Real-time SSE dispatch & invalidation
* - Hierarchical catalog derivation & indexing
* - Purchasing & inventory workflows
* - AI tools & virtual workspace
*/
const EDGE_DEFS = [
// Session & Auth lifecycle
{ 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: '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' },
{ from: 'sseAutomat', to: 'categoryViewAutomat', label: 'invalidate views on sse', port: 'p_sse_events' },
{ 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: '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 & 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' },
{ from: 'categoryAutomat', to: 'articleSearchAutomat', label: 'categoryFacets', port: 'p_cat_categories' },
{ from: 'artikelAutomat', to: 'articleIndexAutomat', label: 'setArticleIndexName (optimistic)', port: 'p_art_setName' },
{ from: 'artikelAutomat', to: 'categoryAutomat', label: 'articleCount updates', port: 'p_art_set' },
{ from: 'articleDetailAutomat', to: 'artikelAutomat', label: 'watch SKU details', port: 'p_artdet_hist' },
// Purchasing & Deliveries
{ 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' },
// AI Assistant & Workspace
{ from: 'chatAutomat', to: 'vfsAutomat', label: 'inspect / edit applets', port: 'p_chat_msg' },
{ from: 'chatAutomat', to: 'navigationAutomat', label: 'set_user_route tool', port: 'p_chat_tools' },
{ from: 'chatAutomat', to: 'sseAutomat', label: 'getSseClientId()', port: 'p_chat_sse' },
// UI Navigation & Preferences
{ 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: 'companyAutomat', to: 'chatAutomat', label: 'chatDrawerWidth', port: 'p_comp_usr' },
{ from: 'helpAutomat', to: 'navigationAutomat', label: 'route-contextual help', port: 'p_help_active' },
// Self-loops
{ from: 'categoryAutomat', to: 'categoryAutomat', label: 'nested tree hierarchy', port: 'p_cat_categories' },
{ from: 'artikelAutomat', to: 'artikelAutomat', label: 'variants & parent SKU', port: 'p_art_set' },
{ from: 'chatAutomat', to: 'chatAutomat', label: 'stream delta appends', port: 'p_chat_msg' },
];
function edgeKey(edge) {
return `${edge.from}|${edge.to}|${edge.label}`;
}
function assignEdgeColors(edges) {
const colors = new Map();
let next = 0;
for (const edge of edges) {
const key = edgeKey(edge);
if (!colors.has(key)) {
colors.set(key, EDGE_COLORS[next % EDGE_COLORS.length]);
next++;
}
}
return colors;
}
/**
* Spanning ranking forest: only one primary outgoing edge per automat
* is used for rank constraints. Other edges get constraint=false to avoid
* vertical over-stretching.
*/
function selectRankingEdgeKeys(edges) {
const ranking = new Set();
const bySource = new Map();
for (const edge of edges) {
if (edge.from === edge.to) continue;
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const group of bySource.values()) {
group.sort((a, b) => a.label.localeCompare(b.label));
ranking.add(edgeKey(group[0]));
}
return ranking;
}
function buildLegendLabel() {
const badgeRow = (type, desc) => {
const c = BADGE_COLORS[type];
return (
`<TR><TD BGCOLOR="${c.bg}" ALIGN="CENTER" CELLPADDING="2" WIDTH="48">` +
`<FONT COLOR="${c.fg}" POINT-SIZE="9"><B>${type}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${desc}</FONT></TD></TR>`
);
};
const row = (swatch, text) =>
`<TR><TD ALIGN="CENTER" CELLPADDING="2">${swatch}</TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4"><FONT COLOR="${COLORS.text}" POINT-SIZE="9">${text}</FONT></TD></TR>`;
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" ` +
`COLOR="${COLORS.tableBorder}" BGCOLOR="${COLORS.rowBg}">` +
`<TR><TD COLSPAN="2" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER">` +
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="11"><B>Automat Graph Legend</B></FONT></TD></TR>` +
badgeRow('STATE', 'Reactive store state property') +
badgeRow('ACTION', 'State mutation action') +
badgeRow('SELECT', 'Reactive PureComponent selector') +
badgeRow('API', 'Backend REST/Fetch endpoint') +
badgeRow('SSE', 'Inbound Server-Sent Event trigger') +
badgeRow('HOOK', 'Auth / persistence lifecycle hook') +
row(
`<FONT COLOR="${COLORS.headerText}"><B>in#</B></FONT>`,
'Inbound arrival port on Automat header'
) +
row(
`<FONT COLOR="#5aa7ff">&#9642;&#8212;&#9654;</FONT>`,
'Reactive dataflow / upstream trigger'
) +
`</TABLE>>`
);
}
function renderAutomatNode(spec, inboundCount = 0) {
if (spec.id === 'Legend') {
return buildLegendLabel();
}
const titleFont =
`<FONT COLOR="${COLORS.headerText}" POINT-SIZE="12"><B>${escapeHtml(spec.title)}</B></FONT>` +
`<BR/><FONT COLOR="${COLORS.muted}" POINT-SIZE="9">${escapeHtml(spec.subtitle)}</FONT>`;
let headerInner;
if (inboundCount > 0) {
const portCells = [];
for (let i = 0; i < inboundCount; i++) {
portCells.push(
`<TD PORT="in${i}" BGCOLOR="${COLORS.headerBg}" BORDER="0" WIDTH="10" HEIGHT="4" CELLPADDING="0"></TD>`
);
}
headerInner =
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>${portCells.join('')}</TR>` +
`<TR><TD COLSPAN="${inboundCount}" ALIGN="CENTER" CELLPADDING="3">${titleFont}</TD></TR>` +
`</TABLE>`;
} else {
headerInner = titleFont;
}
const rows = [
`<TR><TD COLSPAN="3" BGCOLOR="${COLORS.headerBg}" ALIGN="CENTER" ` +
`CELLPADDING="${inboundCount > 0 ? 1 : 4}" BORDER="1">${headerInner}</TD></TR>`,
];
for (const [i, entry] of spec.entries.entries()) {
const bColor = BADGE_COLORS[entry.type] ?? { fg: COLORS.text, bg: COLORS.rowBg };
const rowBg = i % 2 === 0 ? COLORS.rowBg : COLORS.rowBgAlt;
const portAttr = entry.port ? ` PORT="${escapeHtml(entry.port)}"` : '';
rows.push(
`<TR><TD${portAttr} COLSPAN="3" BGCOLOR="${rowBg}" BORDER="1" CELLPADDING="0">` +
`<TABLE BORDER="0" CELLBORDER="0" CELLSPACING="0" FIXEDSIZE="FALSE">` +
`<TR>` +
`<TD BGCOLOR="${bColor.bg}" ALIGN="CENTER" CELLPADDING="3" WIDTH="48">` +
`<FONT COLOR="${bColor.fg}" POINT-SIZE="8"><B>${escapeHtml(entry.type)}</B></FONT></TD>` +
`<TD ALIGN="LEFT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.text}" POINT-SIZE="10"><B>${escapeHtml(entry.name)}</B></FONT></TD>` +
`<TD ALIGN="RIGHT" CELLPADDING="4">` +
`<FONT COLOR="${COLORS.muted}" POINT-SIZE="8">${escapeHtml(truncate(entry.desc, 30))}</FONT></TD>` +
`</TR></TABLE></TD></TR>`
);
}
return (
`<<TABLE BORDER="0" CELLBORDER="1" CELLSPACING="0" CELLPADDING="4" COLOR="${COLORS.tableBorder}">` +
rows.join('') +
`</TABLE>>`
);
}
function assignEdgeAnchors(edges, positions) {
const headPort = new Map();
const tailCompass = new Map();
const inboundCounts = new Map();
const byTarget = new Map();
const bySource = new Map();
for (const edge of edges) {
if (!byTarget.has(edge.to)) byTarget.set(edge.to, []);
byTarget.get(edge.to).push(edge);
if (!bySource.has(edge.from)) bySource.set(edge.from, []);
bySource.get(edge.from).push(edge);
}
for (const [target, group] of byTarget) {
group.sort((a, b) => {
const ax = positions?.get(a.from)?.x ?? 0;
const bx = positions?.get(b.from)?.x ?? 0;
if (ax !== bx) return ax - bx;
return a.label.localeCompare(b.label);
});
inboundCounts.set(target, group.length);
group.forEach((edge, i) => {
headPort.set(edgeKey(edge), `in${i}`);
});
}
for (const group of bySource.values()) {
group.forEach((edge, i) => {
if (edge.from === edge.to) {
tailCompass.set(edgeKey(edge), i % 2 === 0 ? 'ne' : 'nw');
return;
}
const from = positions?.get(edge.from);
const to = positions?.get(edge.to);
const fromX = from?.x ?? 0;
const toX = to?.x ?? fromX;
const goEast = toX >= fromX;
tailCompass.set(edgeKey(edge), goEast ? 'e' : 'w');
});
}
return { headPort, tailCompass, inboundCounts };
}
function buildDot(specsMap, edges, positions, anchors) {
const edgeColors = assignEdgeColors(edges);
const rankingKeys = selectRankingEdgeKeys(edges);
const { headPort, tailCompass, inboundCounts } =
anchors ?? assignEdgeAnchors(edges, positions);
const lines = [
'digraph automats {',
' graph [rankdir=TB, splines=spline, overlap=false, bgcolor="#1e2430",',
' nodesep=0.95, ranksep=1.0, pad=0.5, fontname="Helvetica",',
' compound=true, mclimit=1.0];',
' node [shape=plaintext, fontname="Helvetica"];',
' edge [fontname="Helvetica", fontsize=9, penwidth=1.4, arrowsize=0.7];',
'',
];
const clusteredAutomats = new Set();
for (const cluster of CLUSTERS) {
const presentInCluster = cluster.automats.filter((id) => specsMap.has(id));
if (presentInCluster.length === 0) continue;
lines.push(` subgraph cluster_${cluster.id} {`);
lines.push(` label="${escapeHtml(cluster.label)}"; labeljust="l"; fontsize=15;`);
lines.push(` style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f";`);
lines.push(` fontcolor="#e2e8f0"; margin=14;`);
for (const autoId of presentInCluster) {
clusteredAutomats.add(autoId);
const spec = specsMap.get(autoId);
const inCount = inboundCounts.get(autoId) ?? 0;
const label = renderAutomatNode(spec, inCount);
lines.push(` ${autoId} [label=${label}];`);
}
lines.push(' }');
}
// Any unclustered automats
for (const [autoId, spec] of specsMap) {
if (clusteredAutomats.has(autoId)) continue;
const inCount = inboundCounts.get(autoId) ?? 0;
const label = renderAutomatNode(spec, inCount);
lines.push(` ${autoId} [label=${label}];`);
}
lines.push('');
for (const edge of edges) {
const key = edgeKey(edge);
const color = edgeColors.get(key) ?? '#5aa7ff';
const isRanking = rankingKeys.has(key);
const hp = headPort.get(key) ?? 'in0';
const tc = tailCompass.get(key) ?? 'e';
const attrs = [
`color="${color}"`,
`fontcolor="${color}"`,
`tooltip="${escapeHtml(`${edge.from} → ${edge.to} (${edge.label})`)}"`,
`label=" ${escapeHtml(edge.label)} "`,
];
if (!isRanking && edge.from !== edge.to) {
attrs.push('constraint=false', 'weight=1');
} else {
attrs.push('weight=3');
}
// Source port from row entry if available, else exit compass
const fromPort = edge.port ? `:${edge.port}:${tc}` : `:${tc}`;
const toPort = `:${hp}:n`;
lines.push(` ${edge.from}${fromPort} -> ${edge.to}${toPort} [${attrs.join(', ')}];`);
}
lines.push('}');
return lines.join('\n');
}
function parsePlainPositions(plain) {
const positions = new Map();
for (const line of plain.split('\n')) {
if (!line.startsWith('node ')) continue;
const match = line.match(/^node\s+(\S+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
if (!match) continue;
positions.set(match[1], {
x: Number(match[2]),
y: Number(match[3]),
width: Number(match[4]),
height: Number(match[5]),
});
}
return positions;
}
function fixSelfLoopSvg(svg) {
return svg.replace(
/<g id="(edge\d+)" class="edge">\s*<title>(\w+)(?::\w+)?&#45;&gt;\2(?::\w+)?<\/title>\s*<g id="(a_edge\d+)"><a ([^>]+)>\s*<path([^>]*) d="([^"]+)"\/>\s*<polygon([^>]*) points="([^"]+)"\/>\s*<\/a>\s*<\/g>\s*<\/g>/g,
(match, edgeId, _table, aId, aAttrs, pathAttrs, d, polyAttrs) => {
const pts = [...d.matchAll(/([-\d.]+)[,\s]+([-\d.]+)/g)].map((m) => [Number(m[1]), Number(m[2])]);
if (pts.length < 2) return match;
const [x0, y0] = pts[0];
const [x1, y1] = pts[pts.length - 1];
const east = Math.max(x0, x1) + 36;
const north = Math.min(y0, y1) - 28;
const tx = x1 - east;
const ty = y1 - north;
const len = Math.hypot(tx, ty) || 1;
const dx = tx / len;
const dy = ty / len;
const arrowLen = 9;
const arrowHalf = 3.5;
const baseX = x1 - dx * arrowLen;
const baseY = y1 - dy * arrowLen;
const px = -dy * arrowHalf;
const py = dx * arrowHalf;
const fmt = (n) => (Math.round(n * 100) / 100).toString();
const newDFmt = `M${fmt(x0)},${fmt(y0)}C${fmt(east)},${fmt(y0)} ${fmt(east)},${fmt(north)} ${fmt(baseX)},${fmt(baseY)}`;
const newPointsFmt =
`${fmt(x1)},${fmt(y1)} ` +
`${fmt(baseX + px)},${fmt(baseY + py)} ` +
`${fmt(baseX - px)},${fmt(baseY - py)} ` +
`${fmt(x1)},${fmt(y1)}`;
return (
`<g id="${edgeId}" class="edge">\n` +
`<title>${_table}&#45;&gt;${_table}</title>\n` +
`<g id="${aId}"><a ${aAttrs}>\n` +
`<path${pathAttrs} d="${newDFmt}"/>\n` +
`<polygon${polyAttrs} points="${newPointsFmt}"/>\n` +
`</a>\n` +
`</g>\n` +
`</g>`
);
}
);
}
console.log('Generating Dashfront Automat Graph…');
const specsMap = new Map();
for (const spec of AUTOMAT_SPECS) {
specsMap.set(spec.id, spec);
}
// Add Legend pseudo-automat
specsMap.set('Legend', {
id: 'Legend',
title: 'Legend',
subtitle: '',
entries: [],
});
const edges = EDGE_DEFS.filter((def) => specsMap.has(def.from) && specsMap.has(def.to));
console.log(`Structured ${specsMap.size - 1} Automats across ${CLUSTERS.length} domain clusters with ${edges.length} relationships`);
const graphviz = await Graphviz.load();
// Pass 1: Initial layout to learn coordinates
console.log('Layout pass 1/2: computing node positions…');
const initialDot = buildDot(specsMap, edges, null, null);
const plain = graphviz.layout(initialDot, 'plain', 'dot');
const positions = parsePlainPositions(plain);
// Pass 2: Anchor assignment using learned positions
console.log('Layout pass 2/2: assigning header arrival ports & exit directions…');
const anchors = assignEdgeAnchors(edges, positions);
const finalDot = buildDot(specsMap, edges, positions, anchors);
const rawSvg = graphviz.layout(finalDot, 'svg', 'dot');
const fixedSvg = fixSelfLoopSvg(rawSvg);
// Ensure background color is set on svg tag
const finalSvg = fixedSvg.replace(
/<svg([^>]*?)>/,
`<svg$1 style="background-color:${COLORS.background};">`
);
writeFileSync(dotOutputPath, finalDot, 'utf8');
writeFileSync(outputPath, finalSvg, 'utf8');
const dim = finalSvg.match(/width="([\d.]+)pt" height="([\d.]+)pt"/);
console.log(`Written ${dotOutputPath}`);
console.log(`Written ${outputPath}`);
if (dim) {
console.log(`Layout ${dim[1]}pt × ${dim[2]}pt`);
}

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>

2124
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,22 @@
"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",
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001"
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
"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",
@@ -24,28 +33,27 @@
"@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"
},
"devDependencies": {
"@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",
@@ -54,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,36 +49,73 @@ 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}`);
}
return <LoginPage />;
}
if (user?.selectedCompanyId == null) {
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}
</>
);
}
}

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

@@ -0,0 +1,24 @@
import { http } from './http.js';
/**
* Company payment accounts.
*/
export const account = {
// #region GET /api/account — list
list() {
return http('/api/account');
},
// #endregion
// #region POST /api/account — create
create(body) {
return http('/api/account', { method: 'POST', body });
},
// #endregion
// #region DELETE /api/account/:id — delete
delete(id) {
return http(`/api/account/${id}`, { method: 'DELETE' });
},
// #endregion
};

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

51
src/api/backup.js Normal file
View File

@@ -0,0 +1,51 @@
import { http } from './http.js';
/**
* Backup API for listing backups, checking service status, inspecting archived VFS files, and recovering VFS files.
*/
export const backup = {
/**
* GET /api/backup
* Returns: 200 { status: BackupStatus, backups: { counts, hourly, daily, monthly } }
*/
// #region GET /api/backup — getStatus
// MARK: calls GET /api/backup from ./api/index.js
getStatus() {
return http('/api/backup');
},
// #endregion
/**
* GET /api/backup/:id/vfs
* Lists VFS files archived in a specific backup for a company.
* @param {string} id Backup identifier (e.g. backup-2026-10-14_02-00-00)
* @param {number} [companyId] Optional company ID (defaults to active company)
* @returns {Promise<{ backupId: string, companyId: number, files: Array<{ name: string, size: number, mime: string, mtime: string }> }>}
*/
// #region GET /api/backup/{id}/vfs — listVfs
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
listVfs(id, companyId) {
const q = companyId != null ? `?companyId=${encodeURIComponent(companyId)}` : '';
return http(`/api/backup/${encodeURIComponent(id)}/vfs${q}`);
},
// #endregion
/**
* POST /api/backup/:id/vfs/recover
* Safely extracts and restores a VFS file from the specified backup into the active VFS.
* @param {string} id Backup identifier
* @param {string} path Relative file path within the VFS archive
* @param {number} [companyId] Optional company ID
* @returns {Promise<{ success: boolean, backupId: string, companyId: number, path: string, sizeBytes: number, mime: string }>}
*/
// #region POST /api/backup/{id}/vfs/recover — recoverVfs
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
recoverVfs(id, path, companyId) {
return http(`/api/backup/${encodeURIComponent(id)}/vfs/recover`, {
method: 'POST',
body: JSON.stringify({ path, companyId }),
});
},
// #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

@@ -1,7 +1,7 @@
import { http, httpBinary, httpGetBinary } from './http.js';
export const category = {
/** GET /api/category/tree → { languageId, tree, articles } (company from session) */
/** GET /api/category/tree → { languageId, tree, articleCount } (company from session) */
// #region GET /api/category/tree — getTree
getTree() {
return http('/api/category/tree');
@@ -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,

View File

@@ -193,4 +193,144 @@ export const company = {
});
},
// #endregion
/**
* GET /api/company/export
* Packages company catalog and streams export (.zip) archive via Server-Sent Events with base64 chunks.
*
* @param {object} [callbacks]
* @param {(message: string) => void} [callbacks.onStatus]
* @param {(progress: { stage: string, percent?: number, [key: string]: any }) => void} [callbacks.onProgress]
* @param {(chunk: string) => void} [callbacks.onChunk]
* @param {(donePayload: { ok: boolean, filename: string, size: number, totalChunks: number, stats?: object }) => void} [callbacks.onDone]
* @param {(error: string) => void} [callbacks.onError]
* @param {object} [options]
* @param {AbortSignal} [options.signal]
* @param {boolean} [options.triggerDownload=true] Automatically trigger browser download when complete
* @returns {Promise<{ blob: Blob, filename: string, size: number, totalChunks: number, stats?: object }>}
*/
// #region GET /api/company/export — exportCatalog
async exportCatalog(callbacks = {}, { signal, triggerDownload = true } = {}) {
const response = await fetch('/api/company/export', {
method: 'GET',
headers: { Accept: 'text/event-stream' },
credentials: 'include',
signal,
});
if (!response.ok) {
let errMessage = 'Export failed';
try {
const error = await response.json();
errMessage = error.error || error.message || errMessage;
} catch {
// ignore JSON parse error
}
throw new Error(errMessage);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const base64Chunks = [];
let filename = 'company-export.zip';
let donePayload = null;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? ''; // keep trailing incomplete message
for (const part of parts) {
const lines = part.split('\n');
let event = 'message';
let data = '';
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7).trim();
else if (line.startsWith('data: ')) data = line.slice(6);
}
if (event === 'status') {
try {
const payload = JSON.parse(data);
callbacks.onStatus?.(payload.message || data);
} catch {
callbacks.onStatus?.(data);
}
} else if (event === 'progress') {
try {
const payload = JSON.parse(data);
callbacks.onProgress?.(payload);
} catch {
// ignore
}
} else if (event === 'chunk') {
base64Chunks.push(data);
callbacks.onChunk?.(data);
} else if (event === 'done') {
try {
const payload = JSON.parse(data);
donePayload = payload;
if (payload.filename) filename = payload.filename;
callbacks.onDone?.(payload);
} catch {
// ignore
}
} else if (event === 'error') {
let errorMsg = 'Export error';
try {
const payload = JSON.parse(data);
errorMsg = payload.message || errorMsg;
} catch {
errorMsg = data || errorMsg;
}
callbacks.onError?.(errorMsg);
throw new Error(errorMsg);
}
}
}
} finally {
reader.releaseLock();
}
// Reconstruct Blob from base64 chunks
const byteArrays = base64Chunks.map((b64) => {
const binaryStr = atob(b64);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return bytes;
});
const blob = new Blob(byteArrays, { type: 'application/zip' });
if (triggerDownload) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
return {
blob,
filename,
size: blob.size,
totalChunks: base64Chunks.length,
stats: donePayload?.stats,
};
},
// #endregion
};
export const exportCompanyCatalog = (callbacks, options) => company.exportCatalog(callbacks, options);

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) {
@@ -85,81 +107,26 @@ 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
* DELETE /api/delivery/:id/file/:fileId
* Deletes delivery file attachment.
* @param {number|string} id Delivery ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #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';
// #region DELETE /api/delivery/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
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 */ }
}
/**
* POST /api/delivery/:id/pdf
* Generates the "Wareneingang" PDF and stores it as a manifest attachment.
* → 201 { attachment, fileUrl }
*/
// #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

@@ -4,7 +4,7 @@ export { attributename } from './attributename.js';
export { attributevalue } from './attributevalue.js';
export { brand } from './brand.js';
export { category } from './category.js';
export { company } from './company.js';
export { company, exportCompanyCatalog } from './company.js';
export { customergroup } from './customergroup.js';
export { delivery } from './delivery.js';
export { entity } from './entity.js';
@@ -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';
@@ -26,3 +30,13 @@ export { vfs } from './vfs.js';
export { warehouse } from './warehouse.js';
export { stock } from './stock.js';
export { globalHistory } from './globalHistory.js';
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}` : ''}`);
},

40
src/api/mcp.js Normal file
View File

@@ -0,0 +1,40 @@
import { http } from './http.js';
export const mcp = {
// #region GET /api/mcp/authorize — getAuthorize
// MARK: calls GET /api/mcp/authorize
async getAuthorize(searchParams) {
const query = typeof searchParams === 'string'
? (searchParams.startsWith('?') ? searchParams.slice(1) : searchParams)
: new URLSearchParams(searchParams).toString();
const url = query ? `/api/mcp/authorize?${query}` : '/api/mcp/authorize';
return await http(url);
},
// #endregion
// #region POST /api/mcp/authorize — postAuthorize
// MARK: calls POST /api/mcp/authorize
async postAuthorize(payload) {
return await http('/api/mcp/authorize', {
method: 'POST',
body: payload,
});
},
// #endregion
// #region GET /api/mcp/authorizations — listAuthorizations
// MARK: calls GET /api/mcp/authorizations
async listAuthorizations() {
return await http('/api/mcp/authorizations');
},
// #endregion
// #region DELETE /api/mcp/authorizations/:clientId — revokeAuthorization
// MARK: calls DELETE /api/mcp/authorizations/:clientId
async revokeAuthorization(clientId) {
return await http(`/api/mcp/authorizations/${encodeURIComponent(clientId)}`, {
method: 'DELETE',
});
},
// #endregion
};

View File

@@ -7,15 +7,22 @@ import { http } from './http.js';
*/
export const order = {
/**
* GET /api/order?offset=0&limit=20
* @param {{ offset?: number, limit?: number }} [params]
* GET /api/order?offset=0&limit=50&search=...&onlyUnpaid=true&onlyUndelivered=true&seekMonths=-1
* @param {{ offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number }} [params]
* → { orders: Order[], total: number, offset: number, limit: number }
*/
// #region GET /api/order — list
list({ offset = 0, limit = 20 } = {}) {
list({ offset = 0, limit = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(offset));
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 (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))));
}
const qs = params.toString();
return http(`/api/order${qs ? `?${qs}` : ''}`);
},
@@ -56,4 +63,42 @@ export const order = {
return http(`/api/order/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* DELETE /api/order/:id/file/:fileId
* Deletes order file attachment.
* @param {number|string} id Order ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/order/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
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
};

33
src/api/payment.js Normal file
View File

@@ -0,0 +1,33 @@
import { http } from './http.js';
/**
* Payments linked to orders (manifests).
*/
export const payment = {
// #region GET /api/order/:id/payment — listByOrder
listByOrder(orderId) {
return http(`/api/order/${orderId}/payment`);
},
// #endregion
// #region POST /api/order/:id/payment — create
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
// #region DELETE /api/payment/:id — delete
delete(id) {
return http(`/api/payment/${id}`, { method: 'DELETE' });
},
// #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

@@ -7,10 +7,23 @@ import { http } from './http.js';
* LineInput = { sku, amount, net, tax, rebate?, comment? }
*/
export const quote = {
/** GET /api/quote → { quotes: QuoteSummary[] } */
/**
* GET /api/quote?offset=0&limit=50&search=...&seekMonths=-1
* @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params]
* → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number }
*/
// #region GET /api/quote — list
list() {
return http('/api/quote');
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))));
}
const qs = params.toString();
return http(`/api/quote${qs ? `?${qs}` : ''}`);
},
// #endregion
@@ -49,4 +62,43 @@ export const quote = {
return http(`/api/quote/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/quote/:id/pdf
* Generates and attaches quote PDF.
* @param {number|string} id Quote ID
* → 201 { attachment: object, fileUrl: string }
*/
// #region POST /api/quote/:id/pdf — createPdf
createPdf(id) {
return http(`/api/quote/${id}/pdf`, { method: 'POST' });
},
// #endregion
/**
* DELETE /api/quote/:id/file/:fileId
* Deletes quote attachment file.
* @param {number|string} id Quote ID
* @param {number|string} fileId File ID
* → 204 void
*/
// #region DELETE /api/quote/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
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
};

70
src/api/shipment.js Normal file
View File

@@ -0,0 +1,70 @@
import { http } from './http.js';
/**
* Outbound shipments (manifest type = shipment).
* 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`);
},
// #endregion
// #region POST /api/order/:id/shipment — create
create(orderId, body) {
return http(`/api/order/${orderId}/shipment`, { method: 'POST', body });
},
// #endregion
// #region GET /api/shipment/:id — get
get(id) {
return http(`/api/shipment/${id}`);
},
// #endregion
// #region DELETE /api/shipment/:id — delete
delete(id) {
return http(`/api/shipment/${id}`, { method: 'DELETE' });
},
// #endregion
// #region DELETE /api/shipment/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
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
};

25
src/api/shipping.js Normal file
View File

@@ -0,0 +1,25 @@
import { http } from './http.js';
/**
* Packages / logistics shipments attached to a shipment manifest.
*/
export const shipping = {
// #region POST /api/shipment/:id/shipping — create
create(shipmentId, body) {
return http(`/api/shipment/${shipmentId}/shipping`, { method: 'POST', body });
},
// #endregion
// #region PATCH /api/shipping/:id — update
update(id, body) {
return http(`/api/shipping/${id}`, { method: 'PATCH', body });
},
// #endregion
// #region DELETE /api/shipping/:id — delete
delete(id) {
return http(`/api/shipping/${id}`, { method: 'DELETE' });
},
// #endregion
};

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

@@ -143,4 +143,34 @@ export const vfs = {
});
},
// #endregion
/**
* 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/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,36 +1,62 @@
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/neu';
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 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';
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'));
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);
const returnTo = params.get('returnTo');
return <Navigate to={returnTo || '/artikel'} replace />;
}
const routes = [
{
path: '/oauth/authorize',
element: <OAuthAuthorizePage />,
},
{
path: '/login',
element: <LoginRedirect />,
},
{
path: '/',
element: <AppShell />,
@@ -53,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 /> },
],
},
@@ -78,11 +105,22 @@ const routes = [
path: 'auftrag',
element: <SectionLayout sectionPath="/auftrag" />,
children: [
{ index: true, element: <AuftragAngebotPage /> },
{ path: 'neu', element: <AuftragAngebotNeuPage /> },
{ path: 'bestellung', element: <AuftragBestellungPage /> },
{ 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: <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 /> },
],
},
@@ -96,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 /> },
],
},
@@ -108,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

@@ -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,203 +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();
},
/**
* 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();
},
/**
* 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

@@ -0,0 +1,229 @@
/**
* @file backupAutomat.js
* @description Manages backup status, archive listings, and VFS file recovery from backup archives (`GET /backup`, `GET /backup/:id/vfs`, `POST /backup/:id/vfs/recover`).
*
* Upstream Triggers:
* - `authAutomat`: Subscribes to auth state changes — calls `actions.load()` on
* `'authed'` with a `selectedCompanyId`, and `actions.clear()` on `'anon'`.
* - `sseAutomat`: Subscribes to `'backup'` event (if dispatched) to reload silently.
* - UI / Settings: Calls to `actions.load()`, `actions.reload()`, `actions.listBackupVfs()`, or `actions.recoverBackupVfs()`.
*/
import { Automat } from 'react-automat';
import { backup as backupApi } from '../api';
import authAutomat from './authAutomat';
import sseAutomat from './sseAutomat';
/**
* @typedef {Object} BackupItem
* @property {string} name Archive file or directory name
* @property {string} path Full storage path
* @property {number} totalSizeBytes Total size in bytes
* @property {string|null} timestamp Creation timestamp
* @property {object|null} [manifest] Backup manifest details
*/
/**
* @typedef {Object} BackupStatus
* @property {boolean} enabled Whether automated backups are enabled
* @property {boolean} isRunning Whether a backup is currently executing
* @property {string} dbProvider Database provider name (e.g. 'sqlite', 'mssql')
* @property {boolean} mssqlWarning Whether an MSSQL warning is present
* @property {string} backupDir Root directory where backups are stored
* @property {{ hourlyMax: number, dailyMax: number, monthly: string }} retention Retention configuration
* @property {{ hourly: number, daily: number, monthly: number }} counts Counts of existing backups
* @property {BackupItem|null} [latestHourly] Most recent hourly backup
* @property {BackupItem|null} [latestDaily] Most recent daily backup
* @property {BackupItem|null} [latestMonthly] Most recent monthly backup
*/
/**
* @typedef {Object} BackupState
* @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state
* @property {string|null} error Last error message
* @property {BackupStatus|null} backupStatus Service status and retention settings
* @property {{ counts: { hourly: number, daily: number, monthly: number }, hourly: BackupItem[], daily: BackupItem[], monthly: BackupItem[] }} backups Grouped backup archives
* @property {Record<string, Array<{ name: string, size: number, mime: string, mtime: string }>>} vfsFilesByBackup
* @property {Record<string, boolean>} vfsLoadingByBackup
*/
/** @type {BackupState} */
const initialState = {
status: 'idle',
error: null,
backupStatus: null,
backups: {
counts: { hourly: 0, daily: 0, monthly: 0 },
hourly: [],
daily: [],
monthly: [],
},
vfsFilesByBackup: {},
vfsLoadingByBackup: {},
};
let backupAutomat;
let inflightLoad = null;
// #region fetch
// MARK: calls GET /api/backup from ./api/index.js
async function fetchBackupData() {
const data = await backupApi.getStatus();
const status = data?.status ?? null;
const rawBackups = data?.backups ?? {};
const hourly = Array.isArray(rawBackups.hourly) ? rawBackups.hourly : [];
const daily = Array.isArray(rawBackups.daily) ? rawBackups.daily : [];
const monthly = Array.isArray(rawBackups.monthly) ? rawBackups.monthly : [];
const counts = rawBackups.counts ?? {
hourly: hourly.length,
daily: daily.length,
monthly: monthly.length,
};
return {
backupStatus: status,
backups: {
counts,
hourly,
daily,
monthly,
},
};
}
// #endregion
const actions = {
// #region load
// MARK: calls GET /api/backup from ./api/index.js
/**
* Loads backup status and archive listing.
* Deduplicates concurrent callers.
*
* @param {boolean} [silent=false] If true, keeps status as 'ready' without flashing 'loading'
* @returns {Promise<{ backupStatus: BackupStatus|null, backups: object }>}
*/
load(silent = false) {
if (inflightLoad) return inflightLoad;
if (!silent) {
backupAutomat.setState({ status: 'loading', error: null });
}
inflightLoad = (async () => {
try {
const { backupStatus, backups } = await fetchBackupData();
backupAutomat.setState({
backupStatus,
backups,
status: 'ready',
error: null,
});
return { backupStatus, backups };
} catch (err) {
const error = err?.message ?? String(err);
backupAutomat.setState(silent ? { error } : { status: 'failed', error });
throw err;
} finally {
inflightLoad = null;
}
})();
return inflightLoad;
},
// #endregion
// #region reload
// MARK: calls GET /api/backup from ./api/index.js
/**
* Silently re-fetches backup status and archives in the background.
*/
async reload() {
return actions.load(true);
},
// #endregion
// #region listBackupVfs
// MARK: calls GET /api/backup/:id/vfs from ./api/index.js
/**
* Retrieves the list of VFS files stored inside a specific backup.
*
* @param {string} backupId Identifier of the backup
* @param {number} [companyId] Optional company ID override
* @returns {Promise<Array<{ name: string, size: number, mime: string, mtime: string }>>}
*/
async listBackupVfs(backupId, companyId) {
if (!backupId) return [];
backupAutomat.setState({
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: true },
});
try {
const res = await backupApi.listVfs(backupId, companyId);
const files = Array.isArray(res?.files) ? res.files : [];
backupAutomat.setState({
vfsFilesByBackup: { ...backupAutomat.state.vfsFilesByBackup, [backupId]: files },
});
return files;
} finally {
backupAutomat.setState({
vfsLoadingByBackup: { ...backupAutomat.state.vfsLoadingByBackup, [backupId]: false },
});
}
},
// #endregion
// #region recoverBackupVfs
// MARK: calls POST /api/backup/:id/vfs/recover from ./api/index.js
/**
* Restores a single VFS file from the specified backup into the active VFS.
*
* @param {string} backupId Identifier of the backup
* @param {string} path Relative file path within the VFS
* @param {number} [companyId] Optional company ID override
* @returns {Promise<object>} Recovery result
*/
async recoverBackupVfs(backupId, path, companyId) {
return backupApi.recoverVfs(backupId, path, companyId);
},
// #endregion
/**
* Resets the automat state to initial empty values.
*/
clear() {
backupAutomat.setState({ ...initialState });
},
};
backupAutomat = new Automat(initialState, actions, { name: 'backup' });
// #region sseListener
if (sseAutomat?.actions?.on) {
sseAutomat.actions.on('backup', () => {
void backupAutomat.actions.reload().catch(() => {});
});
}
// #endregion
// #region authSync
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
let lastAuthKey;
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
backupAutomat.actions.clear();
} else {
void backupAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
// #endregion
export default backupAutomat;

View File

@@ -1,152 +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));
},
// #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';
@@ -54,6 +56,7 @@ import {
* @property {string|null} languageId
* @property {Record<string,object>|null} categories id → node map
* @property {number|string|null} rootCategoryId
* @property {number} articleCount Total parent articles in tree
* @property {boolean} renameMode
* @property {'idle'|'loading'|'succeeded'|'failed'} treeStatus
* @property {string|null} error
@@ -66,6 +69,7 @@ const initialState = {
languageId: null,
categories: null,
rootCategoryId: null,
articleCount: 0,
renameMode: false,
treeStatus: 'idle',
error: null,
@@ -87,6 +91,8 @@ export function getCategoryState() {
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
}
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
/**
* Resolves once the category tree has loaded.
*
@@ -123,6 +129,7 @@ const actions = {
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
articleCount: parsed.articleCount,
error: null,
viewsLoading: true,
});
@@ -208,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;
},
@@ -223,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;
@@ -395,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). */
@@ -424,6 +454,11 @@ const actions = {
refreshCategoryViewChildren(nextCategories);
},
/** Invalidate all loaded category views. */
invalidateAllCategoryViews() {
invalidateAllCategoryViews();
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
@@ -451,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;
@@ -466,7 +501,10 @@ companyAutomat.subscribe((company) => {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
}
companyAutomat.subscribe(syncCategoryWithCompany);
syncCategoryWithCompany(companyAutomat.state);
// Reset tree on logout
authAutomat.subscribe((auth) => {
@@ -476,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,
@@ -496,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,
@@ -375,6 +401,24 @@ export function createDeliveryEditorAutomat(options = {}) {
automat.actions.removeLineitem(payload);
},
/**
* Moves a lineitem from one index to another.
* @param {number} fromIndex Source index.
* @param {number} toIndex Target index.
* @returns {void}
*/
moveLineitem(fromIndex, toIndex) {
const currentLines = automat.state.lines ?? [];
if (fromIndex < 0 || fromIndex >= currentLines.length || toIndex < 0 || toIndex >= currentLines.length) return;
const nextLines = [...currentLines];
const [item] = nextLines.splice(fromIndex, 1);
nextLines.splice(toIndex, 0, item);
automat.setState({
lines: nextLines,
lineitems: nextLines,
});
},
/** Clears all lines from the editor. @returns {void} */
clearLineitems() {
automat.setState({
@@ -423,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);
@@ -629,6 +673,7 @@ export function createDeliveryEditorAutomat(options = {}) {
resetDelivery() {
automat.setState({
...initialDelivery,
date: todayInputValue(),
});
},
@@ -636,8 +681,154 @@ 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,
});
},
/** Serializes the delivery into a VFS draft manifest format. @returns {object} */
getManifest() {
const state = automat.state;
const rawLines = state.lines ?? state.lineitems ?? [];
const lines = rawLines
.filter((l) => Boolean(l && (l.sku || l.name || l.amount != null || l.netprice != null || l.net != null)))
.map((l) => ({
sku: l.sku || null,
name: l.name || null,
amount: l.amount != null ? Number(l.amount) : null,
pu: l.pu != null ? Number(l.pu) : 1,
unit: l.unit || null,
netprice: l.netprice != null ? Number(l.netprice) : (l.net != null ? Number(l.net) : null),
taxrate: l.taxrate != null ? Number(l.taxrate) : (l.tax != null ? Number(l.tax) : null),
}));
return {
manifest: {
kind: 'delivery',
id: null,
date: state.date || null,
partyId: state.partyId ?? state.deliveryEntity ?? null,
externalNumber: state.externalNumber ?? state.external_number ?? null,
lines,
items: lines,
notes: state.notes ?? [],
attachments: (state.attachments ?? []).map((a) => ({ name: a.name, type: a.type })),
},
};
},
};
automat = new Automat(initialState, actions, {
@@ -675,6 +866,7 @@ export const {
setId,
setDeliveryEntities,
setDelivery,
loadDraftManifest,
setDate,
setDeliveryEntity,
setPartyId,

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,10 +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 { default as authAutomat } from './authAutomat';
export * from './authAutomat';
@@ -21,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,
@@ -43,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,
@@ -60,24 +55,72 @@ 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,
selectOrders,
selectOrderTotal,
selectOrderUnfilteredTotal,
selectOrderPagination,
selectOnlyUnpaid,
selectOnlyUndelivered,
selectNegativeSeekMonths,
isOrderPaid,
isOrderDelivered,
getNegativeSeekCutoff,
formatNegativeSeek,
getChunkStats,
subscribeChunkStats,
} from './orderAutomat';
export { default as entityAutomat } from './entityAutomat';
export { default as customergroupAutomat } from './customergroupAutomat';
export { default as numberAutomat } from './numberAutomat';
export { createListAutomat, selectListItems, selectListStatus } from './createListAutomat';
export {
default as retoureAutomat,
selectRetoures,
selectRetoureTotal,
selectRetoureUnfilteredTotal,
isRetourePaid,
isRetoureShipped,
isRetoureDelivered,
getRetoureDate,
getItems as getRetoureItems,
requireChunk as requireRetoureChunk,
getChunkStats as getRetoureChunkStats,
subscribeChunkStats as subscribeRetoureChunkStats,
} from './retoureAutomat';
export { default as backupAutomat } from './backupAutomat';
export {
default as shipmentAutomat,
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';
@@ -92,27 +135,56 @@ export {
export { default as companyAutomat } from './companyAutomat';
export {
selectCompanies,
selectCurrentCompany,
selectCompanyId,
selectCompanyStatus,
selectCompanyName,
selectCompanyIso639,
selectCompanyLanguageId,
selectCompanyTaxrate,
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 {
@@ -126,6 +198,7 @@ export {
normalizeCategoryMap,
getCategoryState,
whenCategoryTreeReady,
selectCategoryTreeArticleCount,
} from './categoryAutomat';
/**
@@ -147,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 {
@@ -163,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

@@ -0,0 +1,631 @@
/**
* @file manifestDraftAutomat.js
* @description Out-of-tree state automats for in-progress quotes and orders (quoteDraftAutomat, orderDraftAutomat).
* Preserves draft state across view navigation and syncs bidirectionally with VFS virtual draft files.
*/
import { Automat } from 'react-automat';
import { selectCompanyTaxrate } from './companyAutomat';
import artikelAutomat, { getArticle } from './artikelAutomat';
import {
emptyLine,
lineFromApi,
dateInputValue,
todayInputValue,
parseNum,
} from '../components/manifest/manifestConfig';
/**
* Creates the initial empty draft state for a manifest kind.
*
* @param {'quote'|'order'} kind
* @returns {object} Initial state
*/
export function createInitialManifestDraftState(kind) {
return {
kind,
mode: 'create',
id: null,
date: todayInputValue(),
partyId: null,
number: '',
externalNumber: '',
lines: kind === 'order' ? [] : [emptyLine()],
attachments: [],
};
}
/**
* Creates an Automat instance managing draft state for a manifest kind.
*
* @param {{ kind: 'quote'|'order', name: string }} options
* @returns {Automat}
*/
export function createManifestDraftAutomat({ kind, name }) {
let automat;
const actions = {
/** @param {string} date */
setDate(date) {
automat.setState({ date: dateInputValue(date) });
},
/** @param {number|string|null} partyId */
setPartyId(partyId) {
automat.setState({ partyId: partyId != null ? partyId : null });
},
/** @param {string} externalNumber */
setExternalNumber(externalNumber) {
automat.setState({ externalNumber: externalNumber != null ? String(externalNumber) : '' });
},
/** @param {string} number */
setNumber(number) {
automat.setState({ number: number != null ? String(number) : '' });
},
/** @param {Array<object>} lines */
setLines(lines) {
const list = Array.isArray(lines) ? lines : [];
automat.setState({ lines: list });
},
/**
* Updates an existing line by key.
* @param {string} key
* @param {string|object} patchOrField
* @param {*} [value]
*/
updateLine(key, patchOrField, value) {
const current = automat.state.lines || [];
const next = current.map((line) => {
if (line.key !== key) return line;
if (typeof patchOrField === 'object' && patchOrField != null) {
return { ...line, ...patchOrField };
}
return { ...line, [patchOrField]: value };
});
automat.setState({ lines: next });
},
/** @param {object} [line] */
addLine(line = null) {
const current = automat.state.lines || [];
const newLine = line ? { ...emptyLine(), ...line } : emptyLine();
automat.setState({ lines: [...current, newLine] });
},
/** @param {string} key */
removeLine(key) {
const current = automat.state.lines || [];
const next = current.filter((l) => l.key !== key);
automat.setState({ lines: next });
},
removeAllLines() {
automat.setState({ lines: [] });
},
/** @param {Array<object>} attachments */
setAttachments(attachments) {
automat.setState({ attachments: Array.isArray(attachments) ? attachments : [] });
},
/** Resets draft state back to clean initial state. */
reset() {
automat.setState(createInitialManifestDraftState(kind));
},
/**
* Applies full manifest update (from external agent, VFS, or SSE).
* @param {object} content
*/
setManifest(content) {
if (!content) return;
const manifest = content.manifest || content;
const updates = {};
if (manifest.date !== undefined) {
updates.date = dateInputValue(manifest.date);
}
const party = manifest.partyId ?? manifest.entityId ?? manifest.party_id ?? manifest.supplierId;
if (party !== undefined) {
updates.partyId = party != null ? party : null;
}
const ext = manifest.externalNumber ?? manifest.external_number;
if (ext !== undefined) {
updates.externalNumber = ext != null ? String(ext) : '';
}
const rawLines = manifest.items ?? manifest.lines ?? manifest.lineitems;
if (Array.isArray(rawLines)) {
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
const lines = rawLines.map((row) => {
const mapped = lineFromApi(row);
if (!mapped.amount) mapped.amount = '1';
if (!mapped.tax) {
mapped.tax = normTax;
mapped.taxrate = normTax;
}
if (!mapped.rebate) mapped.rebate = '0';
if (!mapped.name && mapped.sku) {
const art = getArticle(mapped.sku);
if (art?.name) mapped.name = art.name;
}
return mapped;
});
updates.lines = lines.length > 0 ? lines : [emptyLine()];
for (const l of updates.lines) {
if (l.sku) {
void artikelAutomat.actions.loadArticle(l.sku).catch(() => {});
}
}
}
automat.setState(updates);
},
/**
* Serializes current draft state into VFS draft manifest format.
* @returns {object} Manifest payload
*/
getManifest() {
const state = automat.state;
const normTax = selectCompanyTaxrate() != null ? String(selectCompanyTaxrate()) : '19';
const formattedLines = (state.lines || [])
.filter((l) => Boolean(l && (l.sku || l.name || l.net || l.gross)))
.map((l) => ({
sku: l.sku || null,
name: l.name || null,
amount: parseNum(l.amount) ?? (l.amount ? Number(l.amount) : 1),
net: parseNum(l.net) ?? parseNum(l.netprice) ?? null,
gross: parseNum(l.gross) ?? null,
taxrate: parseNum(l.tax) ?? parseNum(l.taxrate) ?? Number(normTax),
rebate: parseNum(l.rebate) ?? 0,
pu: parseNum(l.pu) ?? 1,
unit: l.unit || null,
}));
return {
manifest: {
kind,
mode: 'create',
id: null,
date: state.date || null,
partyId: state.partyId ?? null,
externalNumber: state.externalNumber || null,
lines: formattedLines,
items: formattedLines,
},
};
},
};
automat = new Automat(createInitialManifestDraftState(kind), actions, {
name,
persist: false,
});
return automat;
}
/** Shared singleton automat for quote draft. */
export const quoteDraftAutomat = createManifestDraftAutomat({
kind: 'quote',
name: 'quote-draft',
});
/** Shared singleton automat for order draft. */
export const orderDraftAutomat = createManifestDraftAutomat({
kind: 'order',
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'|'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;
}
/**
* Computes detailed diff between current manifest draft/edit state and baseline snapshot.
*
* @param {object} current
* @param {object|null} baseline
* @returns {{ isDirty: boolean, fields: { date: boolean, partyId: boolean, externalNumber: boolean, number: boolean, lines: boolean, attachments: boolean }, dirtyLineKeys: string[], summary: string[] }}
*/
export function getManifestDirtyDetails(current, baseline) {
if (!baseline) {
return {
isDirty: false,
fields: {
date: false,
partyId: false,
externalNumber: false,
number: false,
lines: false,
attachments: false,
},
dirtyLineKeys: [],
summary: [],
};
}
const fields = {
date: (current.date || '') !== (baseline.date || ''),
partyId:
(current.partyId != null && current.partyId !== '' ? Number(current.partyId) : null) !==
(baseline.partyId != null && baseline.partyId !== '' ? Number(baseline.partyId) : null),
externalNumber:
String(current.externalNumber || '').trim() !== String(baseline.externalNumber || '').trim(),
number: String(current.number || '').trim() !== String(baseline.number || '').trim(),
lines: false,
attachments: false,
};
const dirtyLineKeys = [];
const dirtyLineFields = {};
const curLines = current.lines || [];
const baseLines = baseline.lines || [];
const baseLinesByKey = new Map();
for (let i = 0; i < baseLines.length; i++) {
const bl = baseLines[i];
if (bl.key) baseLinesByKey.set(bl.key, bl);
if (bl.id != null) baseLinesByKey.set(`id-${bl.id}`, bl);
}
if (curLines.length !== baseLines.length) {
fields.lines = true;
} else {
for (let i = 0; i < curLines.length; i++) {
const cl = curLines[i];
const bl = baseLines[i];
const clIdentifier = cl?.key ?? (cl?.id != null ? `id-${cl.id}` : null);
const blIdentifier = bl?.key ?? (bl?.id != null ? `id-${bl.id}` : null);
if (clIdentifier && blIdentifier && clIdentifier !== blIdentifier) {
fields.lines = true;
break;
}
}
}
for (let i = 0; i < curLines.length; i++) {
const cl = curLines[i];
const key = cl.key ?? `idx-${i}`;
const bl =
(cl.key && baseLinesByKey.get(cl.key)) ||
(cl.id != null && baseLinesByKey.get(`id-${cl.id}`)) ||
(baseLinesByKey.size === 0 ? baseLines[i] : null);
if (!bl) {
dirtyLineKeys.push(key);
dirtyLineFields[key] = { __all__: true };
fields.lines = true;
} else {
const lineFieldDiffs = {};
if (String(cl.sku ?? '').trim() !== String(bl.sku ?? '').trim()) {
lineFieldDiffs.sku = true;
}
if (String(cl.name ?? '').trim() !== String(bl.name ?? '').trim()) {
lineFieldDiffs.name = true;
}
if (String(cl.amount ?? cl.count ?? '').trim() !== String(bl.amount ?? bl.count ?? '').trim()) {
lineFieldDiffs.amount = true;
lineFieldDiffs.count = true;
}
if (String(cl.net ?? cl.netprice ?? '').trim() !== String(bl.net ?? bl.netprice ?? '').trim()) {
lineFieldDiffs.net = true;
lineFieldDiffs.netprice = true;
}
if (String(cl.gross ?? '').trim() !== String(bl.gross ?? '').trim()) {
lineFieldDiffs.gross = true;
}
if (String(cl.rebate ?? '').trim() !== String(bl.rebate ?? '').trim()) {
lineFieldDiffs.rebate = true;
}
if (String(cl.tax ?? cl.taxrate ?? '').trim() !== String(bl.tax ?? bl.taxrate ?? '').trim()) {
lineFieldDiffs.tax = true;
lineFieldDiffs.taxrate = true;
}
if (String(cl.pu ?? cl.vpe ?? '').trim() !== String(bl.pu ?? bl.vpe ?? '').trim()) {
lineFieldDiffs.pu = true;
lineFieldDiffs.vpe = true;
}
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);
dirtyLineFields[key] = lineFieldDiffs;
fields.lines = true;
}
}
}
// Check attachments
const curAtts = current.attachments || [];
const baseAtts = baseline.attachments || [];
if (curAtts.length !== baseAtts.length) {
fields.attachments = true;
} else {
for (const a of curAtts) {
if (!a.isPersisted || a.isDirty) {
fields.attachments = true;
break;
}
}
}
const summary = [];
if (fields.date) summary.push('Datum');
if (fields.partyId) summary.push(current.kind === 'delivery' ? 'Lieferant' : 'Kunde');
if (fields.externalNumber) summary.push('Referenz');
if (fields.number) summary.push('Nummer');
if (fields.lines) {
const count = dirtyLineKeys.length;
summary.push(count > 0 ? `${count} Artikelzeile${count === 1 ? '' : 'n'}` : 'Artikelzeilen');
}
if (fields.attachments && current.kind !== 'quote') summary.push('Dateien');
const isDirty = summary.length > 0;
return {
isDirty,
fields,
dirtyLineKeys,
dirtyLineFields,
summary,
};
}
/**
* Compares current manifest draft/edit state against a baseline snapshot.
*
* @param {object} current
* @param {object|null} baseline
* @returns {boolean} True if any field or line has changed from baseline
*/
export function checkManifestDirty(current, baseline) {
return getManifestDirtyDetails(current, baseline).isDirty;
}
/**
* Creates an Automat managing the single last-edited manifest for that kind.
*
* @param {{ kind: 'quote'|'order', name: string }} options
* @returns {Automat}
*/
export function createManifestEditAutomat({ kind, name }) {
let automat;
const emptyFields = {
date: false,
partyId: false,
externalNumber: false,
number: false,
lines: false,
attachments: false,
};
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 : [];
const attachments = Array.isArray(doc.attachments) ? doc.attachments : [];
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
lines: lines.map((l) => ({ ...l })),
attachments: [...attachments],
};
automat.setState({
kind,
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 },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
update(updates) {
const next = { ...automat.state, ...updates };
const dirtyInfo = getManifestDirtyDetails(next, automat.state.baseline);
automat.setState({
...updates,
isDirty: dirtyInfo.isDirty,
dirtyFields: dirtyInfo.fields,
dirtyLineKeys: dirtyInfo.dirtyLineKeys,
dirtyLineFields: dirtyInfo.dirtyLineFields,
dirtySummary: dirtyInfo.summary,
});
},
setAttachments(attachments) {
const list = Array.isArray(attachments) ? attachments : [];
const base = automat.state.baseline;
const nextBaseline = base ? { ...base, attachments: [...list] } : null;
automat.setState({
attachments: list,
baseline: nextBaseline,
});
},
restoreBaseline() {
if (!automat.state.baseline) return;
const base = automat.state.baseline;
automat.setState({
date: base.date,
partyId: base.partyId,
number: base.number,
externalNumber: base.externalNumber,
lines: base.lines.map((l) => ({ ...l })),
attachments: [...base.attachments],
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
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 : [];
const attachments = Array.isArray(savedDoc.attachments) ? savedDoc.attachments : automat.state.attachments;
const baseline = {
date,
createdAt,
partyId,
number,
externalNumber,
lines: lines.map((l) => ({ ...l })),
attachments: [...attachments],
};
automat.setState({
date,
createdAt,
partyId,
number,
externalNumber,
lines,
attachments,
baseline,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
reset() {
automat.setState({
kind,
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
attachments: [],
baseline: null,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
});
},
};
automat = new Automat(
{
kind,
mode: 'edit',
id: null,
date: '',
createdAt: null,
partyId: null,
number: '',
externalNumber: '',
lines: [emptyLine()],
attachments: [],
baseline: null,
isDirty: false,
dirtyFields: { ...emptyFields },
dirtyLineKeys: [],
dirtyLineFields: {},
dirtySummary: [],
},
actions,
{ name, persist: false },
);
return automat;
}
/** Shared singleton automat for the single last edited quote. */
export const quoteEditAutomat = createManifestEditAutomat({
kind: 'quote',
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'|'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;

File diff suppressed because it is too large Load Diff

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,142 +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();
},
/**
* 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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +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';
/**
* @typedef {Object} SseState
@@ -39,10 +29,10 @@ import supplierAutomat from './supplierAutomat';
* @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() {
@@ -56,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.
*/
@@ -74,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} */
@@ -83,6 +70,7 @@ const initialState = {
companyId: null,
clientId: sseClientId,
events: [],
lastEvent: null,
};
/** Event name prefix for per-article signals. */
@@ -91,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();
@@ -114,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 {
@@ -140,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) {
@@ -162,101 +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);
});
});
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}
*/
@@ -265,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) {
@@ -289,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?.();
@@ -299,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' });
@@ -340,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

@@ -12,6 +12,7 @@
import { Automat } from 'react-automat';
import { vfs as vfsApi } from '../api';
import { isAppletFile, parseAppletSchemas } from '../components/vfs/vfsUtils';
import authAutomat from './authAutomat';
import sseAutomat from './sseAutomat';
@@ -25,6 +26,7 @@ import sseAutomat from './sseAutomat';
* @property {boolean} isText Whether the file can be edited in the text editor
* @property {boolean} isImage Whether the file can be rendered as an image
* @property {boolean} isPdf Whether the file is a PDF
* @property {string} [description] Applet description declared in the @applet-description header
*/
/**
@@ -71,6 +73,7 @@ const initialState = {
};
let vfsAutomat;
const appletDescriptionCache = new Map();
const actions = {
/**
@@ -120,7 +123,17 @@ const actions = {
}
try {
const res = await vfsApi.list();
const files = Array.isArray(res?.files) ? res.files : [];
const rawFiles = Array.isArray(res?.files) ? res.files : [];
const files = rawFiles.map((f) => {
if (f.description) {
appletDescriptionCache.set(f.name, f.description);
return f;
}
if (appletDescriptionCache.has(f.name)) {
return { ...f, description: appletDescriptionCache.get(f.name) };
}
return f;
});
const currentSelected = vfsAutomat.state.selectedFileName;
const updatedSelectedFile = currentSelected
? files.find((f) => f.name === currentSelected) || null
@@ -132,6 +145,10 @@ const actions = {
loadingFiles: false,
loadError: null,
});
// Asynchronously fetch descriptions for any applet files that don't have one yet
void actions.loadAppletDescriptions(files);
return files;
} catch (err) {
console.error('[vfsAutomat] loadFiles failed:', err);
@@ -143,6 +160,68 @@ const actions = {
}
},
/**
* Asynchronously load description headers for applets missing cached descriptions.
* @param {VfsFile[]} [files]
*/
async loadAppletDescriptions(files) {
const list = files || vfsAutomat.state.files;
const missing = list.filter((f) => isAppletFile(f.name) && !f.description);
if (missing.length === 0) return;
let changed = false;
await Promise.all(
missing.map(async (file) => {
try {
const text = await vfsApi.getText(file.name);
const { description } = parseAppletSchemas(text);
if (description) {
appletDescriptionCache.set(file.name, description);
changed = true;
}
} catch {
// Ignore failures reading unreadable files
}
})
);
if (changed) {
const nextFiles = vfsAutomat.state.files.map((f) => {
const desc = appletDescriptionCache.get(f.name);
return desc && f.description !== desc ? { ...f, description: desc } : f;
});
const currentSelected = vfsAutomat.state.selectedFileName;
const updatedSelectedFile = currentSelected
? nextFiles.find((f) => f.name === currentSelected) || null
: null;
vfsAutomat.setState({
files: nextFiles,
selectedFile: updatedSelectedFile,
});
}
},
/**
* Manually record or update an applet file's description in the state & cache.
* @param {string} fileName
* @param {string} description
*/
setAppletDescription(fileName, description) {
if (!fileName || !description) return;
appletDescriptionCache.set(fileName, description);
const nextFiles = vfsAutomat.state.files.map((f) =>
f.name === fileName ? { ...f, description } : f
);
const updatedSelectedFile =
vfsAutomat.state.selectedFileName === fileName
? { ...(vfsAutomat.state.selectedFile || {}), description }
: vfsAutomat.state.selectedFile;
vfsAutomat.setState({
files: nextFiles,
selectedFile: updatedSelectedFile,
});
},
/**
* Select a file for viewing or editing.
* @param {string|null} fileName
@@ -179,9 +258,18 @@ const actions = {
const text = await vfsApi.getText(fileName);
// Only apply if still selected
if (vfsAutomat.state.selectedFileName === fileName) {
let updatedFile = file;
if (isAppletFile(fileName)) {
const { description } = parseAppletSchemas(text);
if (description) {
appletDescriptionCache.set(fileName, description);
updatedFile = { ...file, description };
}
}
vfsAutomat.setState({
fileContent: text,
originalContent: text,
selectedFile: updatedFile,
isDirty: false,
loadingContent: false,
});
@@ -224,6 +312,14 @@ const actions = {
vfsAutomat.setState({ isSaving: true, saveError: null });
try {
await vfsApi.save(selectedFileName, fileContent, selectedFile?.mime);
if (isAppletFile(selectedFileName)) {
const { description } = parseAppletSchemas(fileContent);
if (description) {
appletDescriptionCache.set(selectedFileName, description);
} else {
appletDescriptionCache.delete(selectedFileName);
}
}
vfsAutomat.setState({
originalContent: fileContent,
isDirty: false,
@@ -274,6 +370,7 @@ const actions = {
if (!fileName) return;
try {
await vfsApi.delete(fileName);
appletDescriptionCache.delete(fileName);
if (vfsAutomat.state.selectedFileName === fileName) {
actions.selectFile(null);
}
@@ -320,6 +417,11 @@ const actions = {
if (!cleanOld || !cleanNew) throw new Error('Alter und neuer Pfad dürfen nicht leer sein');
try {
const res = await vfsApi.rename(cleanOld, cleanNew);
if (appletDescriptionCache.has(cleanOld)) {
const desc = appletDescriptionCache.get(cleanOld);
appletDescriptionCache.delete(cleanOld);
appletDescriptionCache.set(cleanNew, desc);
}
const isCurrentSelected = vfsAutomat.state.selectedFileName === cleanOld;
await actions.loadFiles(true);
if (isCurrentSelected) {
@@ -379,6 +481,7 @@ const actions = {
* Reset state (on logout or company switch).
*/
reset() {
appletDescriptionCache.clear();
vfsAutomat.setState({
files: [],
loadingFiles: false,

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

@@ -59,6 +59,7 @@ class AppDialog extends Component {
const {
transitionDuration: _transitionDuration,
TransitionProps: _transitionProps,
transitionProps: _lowerTransitionProps,
TransitionComponent: _transitionComponent,
// Legacy MUI prop (removed in v9) — merge into slotProps.paper instead
// of spreading onto Dialog/DOM where it triggers
@@ -71,6 +72,7 @@ class AppDialog extends Component {
} = this.props;
void _transitionDuration;
void _transitionProps;
void _lowerTransitionProps;
void _transitionComponent;
const userPaper = userSlotProps?.paper ?? {};

View File

@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { PureComponent, lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
@@ -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,6 +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;
@@ -62,19 +78,37 @@ class AppSidebar extends PureComponent {
<Box
component="nav"
sx={(theme) => ({
width: SIDEBAR_WIDTH,
width: { xs: '100%', lg: SIDEBAR_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'stretch' },
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
background: theme.nav.background,
position: 'sticky',
top: 0,
zIndex: theme.zIndex.appBar || 1100,
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
height: { xs: 52, sm: 56, lg: 'var(--df-vh, 100vh)' },
px: { xs: 1, sm: 1.5, lg: 0 },
boxSizing: 'border-box',
boxShadow: { xs: '0 2px 8px rgba(0,0,0,0.18)', lg: 'none' },
})}
>
<List disablePadding sx={{ flexGrow: 1 }}>
<List
disablePadding
sx={{
flexGrow: 1,
display: { xs: 'flex', lg: 'block' },
flexDirection: 'row',
alignItems: 'center',
height: { xs: '100%', lg: 'auto' },
overflowX: { xs: 'auto', lg: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
gap: { xs: 0.5, sm: 1, lg: 0 },
}}
>
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
const active = path === activeNav;
@@ -82,6 +116,8 @@ class AppSidebar extends PureComponent {
<ListItemButton
key={path}
type="button"
aria-label={label}
title={label}
selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
@@ -106,12 +142,22 @@ class AppSidebar extends PureComponent {
}
}}
sx={(theme) => ({
borderRadius: 0,
px: 1.5,
py: 1.25,
borderLeft: '3px solid transparent',
borderRadius: { xs: 1, lg: 0 },
width: { xs: 'auto', lg: '100%' },
height: { xs: '100%', lg: 'auto' },
px: { xs: active ? 1.25 : 1, sm: active ? 1.5 : 1.25, md: 1.5, lg: 1.25 },
py: { xs: 0.75, sm: 1, lg: 1.25 },
borderLeft: { xs: 'none', lg: '3px solid transparent' },
borderBottom: { xs: '3px solid transparent', lg: 'none' },
color: theme.nav.item,
'& .MuiListItemIcon-root': { color: 'inherit' },
whiteSpace: 'nowrap',
flexShrink: 0,
'& .MuiListItemIcon-root': {
color: 'inherit',
minWidth: { xs: 'auto', lg: 32 },
mr: active ? { xs: 0.75, lg: 0 } : { xs: 0, md: 0.75, lg: 0 },
justifyContent: { xs: 'center', lg: 'flex-start' },
},
'&:hover': {
bgcolor: theme.nav.itemHover,
color: theme.nav.itemHoverText,
@@ -120,7 +166,8 @@ class AppSidebar extends PureComponent {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
fontWeight: 600,
borderLeftColor: theme.nav.itemActiveAccent,
borderLeftColor: { xs: 'transparent', lg: theme.nav.itemActiveAccent },
borderBottomColor: { xs: theme.nav.itemActiveAccent, lg: 'transparent' },
'&:hover': {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
@@ -128,18 +175,45 @@ class AppSidebar extends PureComponent {
},
})}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<ListItemIcon>
<HelpBadge title={label} help={help} placement="right">
<Icon />
</HelpBadge>
</ListItemIcon>
<ListItemText primary={label} />
<ListItemText
primary={label}
slotProps={{
primary: {
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
},
}}
sx={{
m: { xs: 0, lg: 'initial' },
display: active ? 'block' : { xs: 'none', md: 'block' },
}}
/>
</ListItemButton>
);
})}
</List>
<UserMenu />
<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>
);
}
@@ -183,10 +257,11 @@ class AppSubNav extends PureComponent {
return (
<Box
sx={{
width: SUB_NAV_WIDTH,
width: { xs: 0, lg: SUB_NAV_WIDTH },
display: { xs: 'none', lg: 'block' },
flexShrink: 0,
ml: 3,
mr: 2,
ml: { xs: 0, lg: 3 },
mr: { xs: 0, lg: 2 },
}}
/>
);
@@ -196,21 +271,34 @@ class AppSubNav extends PureComponent {
<Box
component="nav"
aria-label="Unternavigation"
sx={{
width: SUB_NAV_WIDTH,
sx={(theme) => ({
width: { xs: '100%', sm: 72, md: SUB_NAV_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
gap: 1.5,
gap: { xs: 1, sm: 1.5 },
position: 'sticky',
top: 0,
top: { xs: 52, sm: 56, lg: 0 },
zIndex: { xs: 1090, sm: 'auto' },
alignSelf: 'flex-start',
height: 'var(--df-vh, 100vh)',
py: 4,
ml: 3,
mr: 2,
}}
height: {
xs: 'auto',
sm: 'calc(var(--df-vh, 100vh) - 56px)',
lg: 'var(--df-vh, 100vh)',
},
py: { xs: 0.75, sm: 2, lg: 4 },
px: { xs: 1.5, sm: 0 },
ml: { xs: 0, sm: 2, lg: 3 },
mr: { xs: 0, sm: 1.5, lg: 2 },
overflowX: { xs: 'auto', sm: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
bgcolor: { xs: theme.nav?.mainBackground || 'background.default', sm: 'transparent' },
borderBottom: { xs: '1px solid', sm: 'none' },
borderColor: { xs: 'divider', sm: 'transparent' },
boxSizing: 'border-box',
})}
>
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
const active = segment === activeSub;
@@ -219,6 +307,7 @@ class AppSubNav extends PureComponent {
<ButtonBase
key={segment || 'index'}
type="button"
title={label}
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
@@ -243,15 +332,17 @@ class AppSubNav extends PureComponent {
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
width: SUB_NAV_WIDTH,
py: 1,
px: 0.5,
width: { xs: 'auto', sm: 72, md: SUB_NAV_WIDTH },
py: { xs: 0.5, sm: 1 },
px: { xs: active ? 1.25 : 0.75, sm: 0.5 },
borderRadius: 2,
display: 'flex',
flexDirection: 'column',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
gap: { xs: active ? 0.75 : 0, sm: 0.5 },
flexShrink: 0,
whiteSpace: 'nowrap',
color: active
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
: theme.palette.text.secondary,
@@ -267,18 +358,19 @@ class AppSubNav extends PureComponent {
})}
>
<HelpBadge title={title || label} help={help} placement="right">
<Icon sx={{ fontSize: 32 }} />
<Icon sx={{ fontSize: { xs: 20, sm: 28, md: 32 } }} />
</HelpBadge>
{label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: '0.68rem',
fontSize: { xs: '0.75rem', sm: '0.68rem' },
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
maxWidth: '100%',
display: active ? 'block' : { xs: 'none', sm: 'block' },
}}
>
{label}
@@ -299,29 +391,60 @@ export default function AppShell() {
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
return (
<Box sx={{ display: 'flex', minHeight: 'var(--df-vh, 100vh)', bgcolor: 'background.default' }}>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', lg: 'row' },
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
}}
>
<AppSidebar activeNav={activeNav} navigate={navigate} />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
sx={{
display: 'flex',
flexDirection: 'column',
py: 4,
pr: 3,
bgcolor: theme.nav.mainBackground,
flexDirection: { xs: 'column', sm: 'row' },
flexGrow: 1,
minWidth: 0,
})}
}}
>
<Outlet />
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
py: { xs: 2, md: 3, lg: 4 },
pr: { xs: 1.5, sm: 2, lg: 3 },
pl: { xs: 1.5, sm: 2, lg: 0 },
bgcolor: theme.nav.mainBackground,
minWidth: 0,
})}
>
<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 />
</Suspense>
)}
</Box>
);
}

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

@@ -0,0 +1,72 @@
import { useMemo } from 'react';
import Box from '@mui/material/Box';
import { useTheme } from '@mui/material/styles';
export default function BreakpointOverlay() {
if (!import.meta.env.DEV) {
return null;
}
const theme = useTheme();
const breakpoints = useMemo(() => {
const values = theme?.breakpoints?.values || {};
return [
{ name: 'xs', width: values.xs || 360, color: 'rgba(239, 68, 68, 0.75)' },
{ name: 'sm', width: values.sm || 600, color: 'rgba(245, 158, 11, 0.75)' },
{ name: 'md', width: values.md || 900, color: 'rgba(16, 185, 129, 0.75)' },
{ name: 'lg', width: values.lg || 1200, color: 'rgba(14, 165, 233, 0.75)' },
{ name: 'xl', width: values.xl || 1536, color: 'rgba(168, 85, 247, 0.75)' },
];
}, [theme]);
return (
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 99999,
overflow: 'hidden',
}}
>
{breakpoints.map((bp) => (
<Box
key={bp.name}
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: bp.width,
border: `1px solid ${bp.color}`,
boxSizing: 'border-box',
}}
>
<Box
component="span"
sx={{
position: 'absolute',
top: 0,
right: 0,
px: 0.6,
py: 0.2,
bgcolor: bp.color,
color: '#ffffff',
fontSize: '10px',
fontWeight: 700,
fontFamily: 'monospace',
lineHeight: 1,
borderBottomLeftRadius: 3,
userSelect: 'none',
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
{bp.name}
</Box>
</Box>
))}
</Box>
);
}

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';
@@ -32,11 +32,12 @@ import PeopleIcon from '@mui/icons-material/People';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
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,
@@ -45,22 +46,30 @@ 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-)',
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 },
delivery: { prefix: 'LS-', padding: 6, next: 1 },
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 },
},
},
{
@@ -72,28 +81,40 @@ const PRESETS = [
entity: { prefix: '', padding: 5, next: 20001 },
delivery: { prefix: '', padding: 5, next: 30001 },
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-)',
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 },
delivery: { prefix: 'L-', padding: 5, next: 1 },
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-)',
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 },
delivery: { prefix: 'DEL-', padding: 5, next: 1 },
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 },
},
},
];
@@ -105,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: '',
@@ -129,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 {
@@ -201,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 {
@@ -235,7 +239,6 @@ class CompanySetupDialog extends Component {
async handleLogout() {
try {
await authAutomat.actions.logout();
await bootstrapAuthMe();
} catch (err) {
this.setState({ error: backendErrorFrom(err) });
}
@@ -246,6 +249,10 @@ class CompanySetupDialog extends Component {
}
render() {
if (authAutomat.state.user?.selectedCompanyId != null) {
return null;
}
const {
companies,
loading,
@@ -274,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 }} />
@@ -398,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

@@ -52,6 +52,11 @@ class LoginPage extends PureComponent {
this.setState({ busy: true });
await authAutomat.actions.loginWithGoogle(idToken);
await bootstrapAuthMe();
const params = new URLSearchParams(window.location.search);
const returnTo = params.get('returnTo');
if (returnTo) {
window.location.replace(returnTo);
}
} catch (err) {
if (err instanceof GoogleSignInCancelledError) return;
logError('[login] Google sign-in failed', {

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

@@ -0,0 +1,867 @@
// #region imports
import { PureComponent, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import CloseIcon from '@mui/icons-material/Close';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
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 companyAutomat from '../automats/companyAutomat';
import categoryAutomat from '../automats/categoryAutomat';
import chatAutomat from '../automats/chatAutomat';
import { requireArray, requireObject } from '../utils/apiShape';
import {
articleWithPhotoOrder,
articleWithUploadedPhoto,
articleWithoutPhoto,
buildGoogleSearchQuery,
formatPhotoDimensions,
formatPhotoSizeKb,
sortedPhotos,
} from '../utils/articleDetail';
import { notifyError } from '../utils/notify';
import { readClipboardImage } from '../utils/pasteImage';
import { prepareArticlePhoto } from '../utils/prepareArticlePhoto';
import ThumbHashImage from './article/ThumbHashImage';
import ArticlePhoto from './article/ArticlePhoto';
import BrandImageSearchDialog from './brand/BrandImageSearchDialog';
import ArticleImageSearchDialog from './article/ArticleImageSearchDialog';
import {
CHECKERBOARD,
SCRIM_BUTTON,
SCRIM_LABEL,
SCRIM_SOFT,
SCRIM_STRONG,
SHADOW_THUMB_RING,
VEIL_MUTED,
WHITE,
} from '../colors';
// #endregion
// #region constants & styles
/** Max edge (px) of the single-image preview inside the dialog. */
const MAX_PREVIEW = 240;
const overlayChipSx = {
height: 22,
bgcolor: SCRIM_LABEL,
color: WHITE,
'& .MuiChip-label': { px: 0.75, fontSize: 11 },
};
const overlayIconSx = {
color: WHITE,
bgcolor: SCRIM_BUTTON,
'&:hover': { bgcolor: SCRIM_STRONG },
'&.Mui-disabled': { color: VEIL_MUTED, bgcolor: SCRIM_SOFT },
};
// #endregion
// #region helper functions
function isSingleMode(props) {
if (typeof props.single === 'boolean') {
return props.single;
}
if (props.maxPictures === 1 || props.maxPhotos === 1) {
return true;
}
if (props.brand || props.category || props.attributeValue) {
return true;
}
if (props.article) {
return false;
}
return false;
}
function resolvePictures(props, single) {
if (Array.isArray(props.pictures)) {
return single ? props.pictures.slice(0, 1) : props.pictures;
}
if (Array.isArray(props.pics)) {
return single ? props.pics.slice(0, 1) : props.pics;
}
// Brands: only logo[0] with sort=0 fillable
if (props.brand) {
const rawPics = Array.isArray(props.brand.pics) ? props.brand.pics : [];
const firstPic = rawPics[0] || (props.brand.fullHash ? {
fullHash: props.brand.fullHash,
thumbHash: props.brand.thumbHash,
thumbW: props.brand.thumbW,
thumbH: props.brand.thumbH,
sort: 0,
} : null);
return firstPic ? [firstPic] : [];
}
// Articles: multiple photos allowed
if (props.article) {
const list = sortedPhotos(props.article);
return single ? list.slice(0, 1) : list;
}
// Categories: single photo
if (props.category) {
if (Array.isArray(props.category.pics) && props.category.pics.length > 0) {
return [props.category.pics[0]];
}
if (props.category.fullHash) {
return [{
fullHash: props.category.fullHash,
thumbHash: props.category.thumbHash,
thumbW: props.category.thumbW,
thumbH: props.category.thumbH,
sort: 0,
}];
}
return [];
}
// Attribute values: single photo
if (props.attributeValue) {
if (Array.isArray(props.attributeValue.pics) && props.attributeValue.pics.length > 0) {
return [props.attributeValue.pics[0]];
}
if (props.attributeValue.fullHash) {
return [{
fullHash: props.attributeValue.fullHash,
thumbHash: props.attributeValue.thumbHash,
thumbW: props.attributeValue.thumbW,
thumbH: props.attributeValue.thumbH,
sort: 0,
}];
}
return [];
}
if (props.pic) {
return [props.pic];
}
return [];
}
function resolveTitle(props, single) {
if (props.title) {
return typeof props.title === 'function' ? props.title(props) : props.title;
}
if (props.brand) {
const name = props.brand.name ? ` ${props.brand.name}` : '';
return `Logo:${name}`;
}
if (props.category) {
const name = props.category.name ? ` ${props.category.name}` : '';
return `Kategoriebild:${name}`;
}
if (props.attributeValue) {
const name = props.attributeValue.value || props.attributeValue.name
? ` ${props.attributeValue.value || props.attributeValue.name}`
: '';
return `Bild:${name}`;
}
if (props.article) {
const name = props.article.name || props.article.sku
? ` ${props.article.name || props.article.sku}`
: '';
return `Bilder:${name}`;
}
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
/**
* Unified class component for entity pictures and logos.
* Supports:
* - Single picture mode (Brand logo with sort=0, Category, AttributeValue)
* - Multi picture mode (Article photos with reorder & chat edit)
* - Actions: Löschen, Neues Suchen, Bild einfügen (clipboard), Bild hochladen (file), Schließen.
*/
// #region PicturesDialog
export default class PicturesDialog extends PureComponent {
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.state = {
busy: false,
searchOpen: false,
};
}
// #region dialog close
handleClose = () => {
if (this.state.busy) return;
this.props.onClose?.();
};
// #endregion
// #region upload & paste operations
// MARK: prepares image and calls upload handler or automat action
uploadRawImage = async (rawBlob) => {
const { onUpload, brand, article, category, onArticleUpdated } = this.props;
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
if (onUpload) {
await onUpload(file);
} else if (brand?.id) {
await companyAutomat.actions.addBrandImage(brand.id, file);
enqueueSnackbar(`Markenlogo für „${brand.name || ''}“ gespeichert.`, { variant: 'success' });
} else if (article?.sku) {
const uploadStarted = performance.now();
const res = await articleApi.uploadImage(article.sku, file);
console.log('[article-photo] upload', {
uploadMs: Math.round(performance.now() - uploadStarted),
bytes: file.size,
sku: article.sku,
});
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
const updated = articleWithUploadedPhoto(article, res.image);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
enqueueSnackbar('Artikelbild gespeichert.', { variant: 'success' });
} else if (category?.id) {
await categoryAutomat.actions.uploadCategoryImage({
categoryId: category.id,
file,
});
enqueueSnackbar('Kategoriebild gespeichert.', { variant: 'success' });
}
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
// MARK: reads clipboard image and uploads
handlePasteClick = async () => {
if (this.state.busy || this.props.disabled) return;
try {
const image = await readClipboardImage();
if (image) {
await this.uploadRawImage(image);
} else {
enqueueSnackbar('Kein Bild in der Zwischenablage gefunden.', { variant: 'info' });
}
} catch (err) {
console.warn('Clipboard read error:', err);
enqueueSnackbar('Zugriff auf Zwischenablage nicht möglich.', { variant: 'warning' });
}
};
handleUploadClick = () => {
if (this.state.busy || this.props.disabled) return;
this.fileInputRef.current?.click();
};
handleFileChange = (event) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void this.uploadRawImage(file);
};
// #endregion
// #region deletion operations
// MARK: deletes single image (brand, category, attributeValue)
handleDeleteSingle = async () => {
const { busy } = this.state;
const { disabled, brand, category, attributeValue, onDelete } = this.props;
if (busy || disabled) return;
const single = isSingleMode(this.props);
const pics = resolvePictures(this.props, single);
const pic = pics[0];
if (!pic) return;
const targetName = brand?.name || category?.name || attributeValue?.name || 'dieses Bild';
const label = brand ? `Logo von „${targetName}"` : `Bild von „${targetName}"`;
if (!window.confirm(`${label} wirklich löschen?`)) return;
this.setState({ busy: true });
try {
if (onDelete) {
await onDelete(pic, 0);
} else if (brand?.id) {
await companyAutomat.actions.deleteBrandImage(brand.id);
enqueueSnackbar('Markenlogo gelöscht.', { variant: 'success' });
} else if (category?.id) {
await categoryAutomat.actions.deleteCategoryImage(category.id);
enqueueSnackbar('Kategoriebild gelöscht.', { variant: 'success' });
}
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
// MARK: deletes multi image (article photos)
handleDeleteMulti = async (fullHash) => {
const { busy } = this.state;
const { disabled, article, onDelete, onArticleUpdated } = this.props;
if (busy || disabled) return;
this.setState({ busy: true });
try {
if (onDelete) {
await onDelete(fullHash);
} else if (article?.sku) {
await articleApi.deleteImage(article.sku, fullHash);
const updated = articleWithoutPhoto(article, fullHash);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
enqueueSnackbar('Artikelbild gelöscht.', { variant: 'success' });
}
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
// #endregion
// #region reordering & chat edit (multi mode)
// MARK: calls orderImages via articleApi
handleMoveMulti = async (fullHash, delta) => {
const { busy } = this.state;
const { disabled, article, onReorder, onArticleUpdated } = this.props;
if (busy || disabled) return;
const photos = sortedPhotos(article);
const index = photos.findIndex((row) => row.fullHash === fullHash);
const target = index + delta;
if (index < 0 || target < 0 || target >= photos.length) return;
const next = [...photos];
const [row] = next.splice(index, 1);
next.splice(target, 0, row);
const fullHashes = next.map((item) => item.fullHash);
this.setState({ busy: true });
try {
if (onReorder) {
await onReorder(fullHashes);
} else if (article?.sku) {
const res = await articleApi.orderImages(article.sku, fullHashes);
requireObject(res, 'PUT /api/article/:sku/images/order');
requireArray(res.fullHashes, 'fullHashes');
const updated = articleWithPhotoOrder(article, res.fullHashes);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
}
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
};
handleEditInChat = (photo, index) => {
const { article, onEditInChat } = this.props;
if (onEditInChat) {
onEditInChat(photo, index);
return;
}
const sku = article?.sku ?? '';
const imageNr = index + 1;
const prompt = `Artikelbild Nr ${imageNr} von Artikel SKU ${sku} folgende Änderung: `;
chatAutomat.actions.openWithPrompt(prompt);
};
// #endregion
// #region search dialog handlers
handleSearchOpen = () => {
if (this.state.busy || this.props.disabled) return;
if (this.props.onSearchNew) {
this.props.onSearchNew();
return;
}
this.setState({ searchOpen: true });
};
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
renderSinglePreview(pic, title) {
const { brand, emptyText } = this.props;
const hasPic = Boolean(pic?.fullHash || pic?.thumbHash);
const { fullHash, thumbHash, thumbW, thumbH, fullW, fullH, fullSize } = pic ?? {};
const tw = Number(thumbW || fullW);
const th = Number(thumbH || fullH);
let w = MAX_PREVIEW;
let h = MAX_PREVIEW;
if (Number.isFinite(tw) && Number.isFinite(th) && tw > 0 && th > 0) {
const scale = Math.min(1, MAX_PREVIEW / Math.max(tw, th));
w = Math.max(1, Math.round(tw * scale));
h = Math.max(1, Math.round(th * scale));
}
const hasDimensions = (fullW != null && fullH != null) || (thumbW != null && thumbH != null);
const hasSize = fullSize != null;
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
py: 1,
}}
>
<Box
sx={{
position: 'relative',
width: w,
height: h,
borderRadius: 1.5,
overflow: 'hidden',
bgcolor: WHITE,
backgroundImage: CHECKERBOARD,
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px',
boxShadow: SHADOW_THUMB_RING,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{thumbHash ? <ThumbHashImage thumbHash={thumbHash} /> : null}
{fullHash ? (
<Box
component="img"
src={thumb.url(fullHash)}
alt={title}
draggable={false}
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
display: 'block',
}}
/>
) : (
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 2,
textAlign: 'center',
}}
>
<Typography variant="caption" color="text.secondary">
{emptyText || (brand ? 'Kein Logo hinterlegt.' : 'Kein Bild hinterlegt.')}
</Typography>
</Box>
)}
{/* Dimension and size overlay chip */}
{hasPic && (hasDimensions || hasSize) ? (
<Box
sx={{
position: 'absolute',
top: 6,
left: 6,
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
pointerEvents: 'none',
}}
>
{hasDimensions ? (
<Chip
size="small"
label={formatPhotoDimensions(fullW ?? thumbW, fullH ?? thumbH)}
sx={overlayChipSx}
/>
) : null}
{hasSize ? (
<Chip
size="small"
label={formatPhotoSizeKb(fullSize)}
sx={overlayChipSx}
/>
) : null}
</Box>
) : null}
</Box>
</Box>
);
}
renderMultiPhotoTile(photo, index, total) {
const { busy } = this.state;
const { disabled } = this.props;
const hasDimensions = photo.fullW != null && photo.fullH != null;
const hasSize = photo.fullSize != null;
return (
<Box
key={photo.fullHash || index}
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
>
<ArticlePhoto photo={photo} size={200} sx={{ position: 'absolute', inset: 0 }} />
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 1,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
p: 0.75,
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
pointerEvents: 'none',
}}
>
{hasDimensions ? (
<Chip
size="small"
label={formatPhotoDimensions(photo.fullW, photo.fullH)}
sx={overlayChipSx}
/>
) : null}
{hasSize ? (
<Chip
size="small"
label={formatPhotoSizeKb(photo.fullSize)}
sx={overlayChipSx}
/>
) : null}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pointerEvents: 'auto',
}}
>
<IconButton
type="button"
size="small"
aria-label="Bearbeiten"
disabled={busy || disabled}
onClick={() => this.handleEditInChat(photo, index)}
sx={overlayIconSx}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
<Box sx={{ display: 'flex', gap: 0.25 }}>
<IconButton
type="button"
size="small"
aria-label="Nach vorne"
disabled={busy || disabled || index === 0}
onClick={() => void this.handleMoveMulti(photo.fullHash, -1)}
sx={overlayIconSx}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
type="button"
size="small"
aria-label="Nach hinten"
disabled={busy || disabled || index >= total - 1}
onClick={() => void this.handleMoveMulti(photo.fullHash, 1)}
sx={overlayIconSx}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<IconButton
type="button"
size="small"
aria-label="Löschen"
disabled={busy || disabled}
onClick={() => void this.handleDeleteMulti(photo.fullHash)}
sx={overlayIconSx}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
</Box>
);
}
// #endregion
// #region main render
render() {
const { open, disabled, brand, article, searchDialog: CustomSearchDialog } = this.props;
const { busy, searchOpen } = this.state;
const single = isSingleMode(this.props);
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 (
<>
<AppDialog
open={open}
onClose={busy ? undefined : this.handleClose}
maxWidth={single ? false : 'md'}
fullWidth={!single}
aria-labelledby="entity-pictures-dialog-title"
slotProps={{
paper: {
sx: single
? { width: 360, maxWidth: '90vw' }
: { maxWidth: '95vw' },
},
}}
>
<DialogTitle
id="entity-pictures-dialog-title"
sx={{ px: 2.5, pt: 2, pb: 0.5 }}
>
{title}
</DialogTitle>
<DialogContent dividers={!single} sx={{ px: 2.5, py: single ? 1 : 1.5 }}>
{single ? (
this.renderSinglePreview(pics[0], title)
) : pics.length === 0 ? (
<Box sx={{ color: 'text.secondary', py: 2 }}>Keine Bilder.</Box>
) : (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{pics.map((photo, index) =>
this.renderMultiPhotoTile(photo, index, pics.length),
)}
</Box>
)}
</DialogContent>
{single ? (
<DialogActions
sx={{
px: 2,
pb: 1.5,
pt: 0.5,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
{/* Row 1: Insert from clipboard / Upload from file */}
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentPasteIcon />}
disabled={busy || disabled}
onClick={this.handlePasteClick}
sx={{ flex: 1, whiteSpace: 'nowrap' }}
>
Bild einfügen
</Button>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon />}
disabled={busy || disabled}
onClick={this.handleUploadClick}
sx={{ flex: 1, whiteSpace: 'nowrap' }}
>
Bild hochladen
</Button>
</Box>
{/* Row 2: Delete, Search new, Close */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
width: '100%',
gap: 0.5,
}}
>
<Button
size="small"
color="error"
startIcon={<DeleteOutlinedIcon />}
disabled={busy || disabled || !hasPic}
onClick={this.handleDeleteSingle}
>
Löschen
</Button>
<Button
size="small"
startIcon={<ImageSearchIcon />}
disabled={busy || disabled}
onClick={this.handleSearchOpen}
>
Neues Suchen
</Button>
<Button
size="small"
color="inherit"
startIcon={<CloseIcon />}
disabled={busy}
onClick={this.handleClose}
>
Schließen
</Button>
</Box>
</DialogActions>
) : (
<DialogActions
sx={{
px: 3,
py: 2,
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentPasteIcon />}
disabled={busy || disabled}
onClick={this.handlePasteClick}
>
Bild einfügen
</Button>
<Button
size="small"
variant="outlined"
startIcon={<UploadFileIcon />}
disabled={busy || disabled}
onClick={this.handleUploadClick}
>
Bild hochladen
</Button>
<Button
size="small"
variant="contained"
startIcon={<ImageSearchIcon />}
disabled={busy || disabled}
onClick={this.handleSearchOpen}
>
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"
color="inherit"
disabled={busy}
onClick={this.handleClose}
>
Schließen
</Button>
</DialogActions>
)}
</AppDialog>
{/* Hidden file input for file uploads */}
<input
ref={this.fileInputRef}
type="file"
accept="image/*"
hidden
onChange={this.handleFileChange}
/>
{/* Optional embedded image search dialogs */}
{searchOpen && CustomSearchDialog ? (
<CustomSearchDialog
open={searchOpen}
onClose={this.handleSearchClose}
/>
) : searchOpen && brand ? (
<BrandImageSearchDialog
open={searchOpen}
brand={brand}
onClose={this.handleSearchClose}
/>
) : searchOpen && article ? (
<ArticleImageSearchDialog
open={searchOpen}
article={article}
onClose={this.handleSearchClose}
onArticleUpdated={this.props.onArticleUpdated}
/>
) : null}
</>
);
}
// #endregion
}
// #endregion

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/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 = (() => {
@@ -110,9 +124,21 @@ class UserMenu extends PureComponent {
return `data:${mime};base64,${pic}`;
})();
const isTop = Boolean(
anchorEl && typeof anchorEl.getBoundingClientRect === 'function' && anchorEl.getBoundingClientRect().top < 200
);
return (
<>
<Box sx={{ px: 1.5, py: 1.5, alignSelf: 'flex-start' }}>
<Box
sx={{
px: { xs: 0.5, sm: 1, lg: 1.5 },
py: { xs: 0.5, lg: 1.5 },
alignSelf: { xs: 'center', lg: 'flex-start' },
display: 'flex',
alignItems: 'center',
}}
>
<IconButton
onClick={this.handleOpenMenu}
onContextMenu={this.handleContextMenu}
@@ -138,8 +164,14 @@ class UserMenu extends PureComponent {
anchorEl={anchorEl}
open={open}
onClose={this.handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
anchorOrigin={{
vertical: isTop ? 'bottom' : 'top',
horizontal: isTop ? 'right' : 'left',
}}
transformOrigin={{
vertical: isTop ? 'top' : 'bottom',
horizontal: isTop ? 'right' : 'left',
}}
>
{pwaUpdateAvailable && (
<MenuItem onClick={this.handleOpenPwaUpdate} sx={{ bgcolor: 'action.hover' }}>
@@ -149,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>
)}
@@ -180,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} />
</>
);
}

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