Compare commits

..

409 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
seb
ed2ac2c2c3 u 2026-10-11 23:42:20 +02:00
seb
d0fed9a2d1 u 2026-10-11 22:05:15 +02:00
seb
36095d66c9 u 2026-10-11 19:46:20 +02:00
seb
cc914085fc u 2026-10-11 18:55:47 +02:00
seb
d86af21125 u 2026-10-11 17:27:33 +02:00
seb
a96a80a036 u 2026-10-11 15:01:26 +02:00
seb
95523ce9f3 u 2026-10-11 14:24:30 +02:00
seb
31467f106a u 2026-10-11 14:06:13 +02:00
seb
1e2f57e941 u 2026-10-11 13:44:34 +02:00
seb
e8b2ead83f u 2026-10-11 12:49:13 +02:00
seb
5ca1644d0c u 2026-10-11 04:39:09 +02:00
seb
83dc698322 u 2026-10-11 04:21:26 +02:00
seb
5e8037d430 u 2026-10-11 03:37:06 +02:00
seb
45cc69730b u 2026-10-11 03:27:02 +02:00
seb
deb22d8b4f u 2026-10-11 03:00:42 +02:00
seb
3032299257 u 2026-10-11 02:54:44 +02:00
seb
240a5dd3d7 u 2026-10-11 02:52:57 +02:00
seb
e9d8e79cf1 u 2026-10-11 02:49:59 +02:00
seb
c75521dc25 u 2026-10-11 02:41:36 +02:00
seb
3379a63baf u 2026-10-11 02:36:46 +02:00
seb
ce072a3731 u 2026-10-11 01:44:24 +02:00
seb
78994730d1 u 2026-10-10 22:58:46 +02:00
seb
9d578d37f8 u 2026-10-10 22:44:11 +02:00
seb
d32e790540 u 2026-10-10 22:38:29 +02:00
seb
d5aca2b183 u 2026-10-10 22:29:06 +02:00
seb
0dbc7e2f65 u 2026-10-10 22:24:46 +02:00
seb
1833fbd631 u 2026-10-10 18:31:18 +02:00
seb
1a9f77264f u 2026-10-10 18:26:15 +02:00
seb
cef9c4da4f u 2026-10-10 18:23:04 +02:00
seb
a20668cd61 u 2026-10-10 18:17:14 +02:00
seb
004e0de869 u 2026-10-10 06:33:46 +02:00
seb
6af150aea4 u 2026-10-10 05:53:51 +02:00
seb
9bc878eb51 u 2026-10-10 04:53:17 +02:00
seb
f778b2d104 u 2026-10-10 03:08:05 +02:00
seb
454b802943 u 2026-10-10 02:48:03 +02:00
seb
909898a3c7 u 2026-10-10 02:40:45 +02:00
seb
c5b46f11f1 u 2026-10-10 02:37:38 +02:00
seb
859fec808d u 2026-10-10 01:11:43 +02:00
seb
f4ad1763f0 u 2026-10-10 00:48:55 +02:00
seb
4c0691924e u 2026-10-10 00:42:53 +02:00
seb
ff54fe3a18 u 2026-10-10 00:39:23 +02:00
seb
2c36a705c4 u 2026-10-10 00:25:09 +02:00
seb
f2f60d5766 u 2026-10-09 23:27:47 +02:00
seb
86069fa54c u 2026-10-09 23:23:02 +02:00
seb
082771b9c3 u 2026-10-09 21:57:57 +02:00
seb
d04a5f104f u 2026-10-09 21:34:09 +02:00
seb
9a2419e115 u 2026-10-09 21:33:21 +02:00
seb
782bc17bd1 u 2026-10-09 21:31:57 +02:00
seb
8a042ef9f2 u 2026-10-09 21:29:45 +02:00
seb
ac677dd5e3 u 2026-10-09 21:21:54 +02:00
seb
2800fb64e0 u 2026-10-09 21:19:38 +02:00
seb
9b47190d47 u 2026-10-09 20:55:26 +02:00
seb
1bbeeaeff4 u 2026-10-09 20:17:12 +02:00
seb
14ae017b64 u 2026-10-09 19:44:55 +02:00
seb
645a9153a0 u 2026-10-09 19:13:57 +02:00
seb
e9190f1ea9 u 2026-10-09 17:04:32 +02:00
seb
5e62be468f u 2026-10-09 16:44:18 +02:00
seb
56274ecb1d u 2026-10-09 12:08:17 +02:00
seb
5f7db9adf5 u 2026-10-09 11:57:43 +02:00
seb
c0b436f9f2 u 2026-10-09 11:33:12 +02:00
seb
8285936bc9 u 2026-10-09 11:20:00 +02:00
seb
38339b13a2 u 2026-10-09 10:42:24 +02:00
seb
1933ac794d u 2026-10-08 23:39:52 +02:00
seb
3cf0e932b2 u 2026-10-08 22:46:52 +02:00
seb
ee93f71b27 u 2026-10-08 22:44:51 +02:00
seb
22063e6944 u 2026-10-08 21:32:52 +02:00
seb
056e4f63b6 u 2026-10-08 21:25:20 +02:00
seb
6578f7d1ac u 2026-10-08 21:20:40 +02:00
seb
f91c50ef69 u 2026-10-08 21:19:15 +02:00
seb
7f988c9d63 u 2026-10-08 21:17:30 +02:00
seb
064dd2bd31 u 2026-10-08 21:06:34 +02:00
seb
457c41ab1d u 2026-10-08 21:02:50 +02:00
seb
40416cdae5 u 2026-10-08 20:59:40 +02:00
seb
ca4033b626 u 2026-10-08 20:57:34 +02:00
seb
90c502071a u 2026-10-08 20:55:36 +02:00
seb
fa6d910723 u 2026-10-08 20:12:37 +02:00
seb
930bd33072 u 2026-10-08 19:53:01 +02:00
seb
e93e61cfcf u 2026-10-08 19:08:39 +02:00
seb
b165f86811 u 2026-10-08 18:48:28 +02:00
seb
30b573cc63 u 2026-10-08 13:07:44 +02:00
seb
14318cd9c6 u 2026-10-08 13:02:04 +02:00
seb
3658d76c43 u 2026-10-08 12:49:31 +02:00
seb
32325eb4aa u 2026-10-08 05:17:55 +02:00
seb
be02e531c5 u 2026-10-08 04:51:28 +02:00
seb
7829addbdd u 2026-10-08 04:49:40 +02:00
seb
ec238c7b50 u 2026-10-08 04:45:35 +02:00
seb
b01ebccf90 u 2026-10-08 04:41:27 +02:00
seb
ab2e8c92fb u 2026-10-08 02:00:08 +02:00
seb
243eba5fc6 u 2026-10-08 01:44:40 +02:00
seb
8bc912c567 u 2026-10-08 01:44:38 +02:00
seb
cba59532ef u 2026-10-07 22:40:16 +02:00
seb
878f558a3a u 2026-10-07 22:26:40 +02:00
seb
3d1601595e u 2026-10-07 22:10:24 +02:00
seb
d49ec77043 u 2026-10-07 21:28:26 +02:00
seb
ee5a30bb21 u 2026-10-07 20:24:17 +02:00
seb
7bc7c827fb u 2026-10-07 19:57:02 +02:00
seb
79e67f2be6 u 2026-10-07 19:47:19 +02:00
seb
4cc8c49cf1 u 2026-10-07 19:33:26 +02:00
seb
ee2056a159 u 2026-10-07 14:01:34 +02:00
seb
8f46e59f08 u 2026-10-07 13:36:16 +02:00
seb
7045fe5e69 u 2026-10-07 13:23:44 +02:00
seb
6c0b1503e8 u 2026-10-07 13:10:23 +02:00
seb
e4927f5d8a u 2026-10-07 12:06:23 +02:00
seb
92d3d1f5ed u 2026-10-07 12:05:25 +02:00
seb
22115cb583 u 2026-10-07 11:40:55 +02:00
seb
a2c18841c1 u 2026-10-06 12:57:53 +02:00
seb
543382c3e1 u 2026-10-06 12:57:06 +02:00
seb
1b5d8efbc3 u 2026-10-06 12:53:32 +02:00
seb
b99b39f640 u 2026-10-06 12:45:50 +02:00
seb
cbf669f640 u 2026-10-06 11:49:10 +02:00
seb
4769a87c54 u 2026-10-06 11:40:02 +02:00
seb
03608017f3 u 2026-10-06 11:20:11 +02:00
seb
ee49ed29de u 2026-10-06 05:07:13 +02:00
seb
186a9c8ad1 u 2026-10-06 03:40:46 +02:00
seb
9a5d90a99f u 2026-10-06 03:15:58 +02:00
seb
12db70e1ae u 2026-10-06 01:53:19 +02:00
seb
858dfe1280 u 2026-10-06 00:33:41 +02:00
seb
fdb43ecfd8 u 2026-10-06 00:19:28 +02:00
seb
96f8da8c64 u 2026-10-05 23:59:41 +02:00
seb
4445bc259e u 2026-10-05 23:35:51 +02:00
seb
2daeb7ebbf u 2026-10-05 23:22:35 +02:00
seb
dd011a26f2 u 2026-10-05 22:17:57 +02:00
seb
1c6f4e6706 u 2026-10-05 19:04:13 +02:00
seb
863ff20733 u 2026-10-05 18:13:47 +02:00
seb
0a1002227c u 2026-10-05 17:57:33 +02:00
seb
f627ff5b5a u 2026-10-05 17:13:52 +02:00
seb
5c48d1be65 u 2026-10-05 16:09:23 +02:00
seb
bac2d9a202 u 2026-10-05 15:56:41 +02:00
seb
81620521e7 u 2026-10-05 15:11:49 +02:00
seb
26bee398a6 u 2026-10-05 14:54:43 +02:00
seb
358d34ecab u 2026-10-05 12:40:02 +02:00
seb
8c5419c89e u 2026-10-05 10:33:18 +02:00
seb
5b4cf1683a u 2026-10-05 09:17:04 +02:00
seb
8010436157 u 2026-10-05 08:31:13 +02:00
seb
4d653304cd u 2026-10-05 01:07:45 +02:00
seb
b766769634 u 2026-10-05 01:01:03 +02:00
seb
81b59fd309 u 2026-10-04 23:38:46 +02:00
seb
a90069d340 u 2026-10-04 22:47:26 +02:00
seb
8b401fa50b u 2026-10-04 21:05:50 +02:00
seb
f765b87336 u 2026-10-04 21:04:40 +02:00
seb
669954b496 u 2026-10-04 19:12:55 +02:00
seb
9146ebaba1 u 2026-10-04 15:01:16 +02:00
seb
874d6c010f u 2026-10-04 11:25:10 +02:00
seb
dd8566eabc u 2026-10-04 09:37:09 +02:00
seb
da318223c1 u 2026-10-04 09:21:45 +02:00
seb
4edd417dab u 2026-10-04 08:18:36 +02:00
seb
f2fce9097b u 2026-10-04 02:13:50 +02:00
seb
0d0c5dfdfb u 2026-10-03 13:25:11 +02:00
seb
c79ece307c u 2026-10-03 07:32:29 +02:00
seb
bc4a1e0468 u 2026-10-02 14:47:49 +02:00
seb
be111e8f7c u 2026-10-02 08:24:42 +02:00
seb
20e53fee04 u 2026-10-01 19:51:06 +02:00
seb
8900c7a58c u 2026-10-01 19:49:55 +02:00
seb
6ea4f8fb36 u 2026-10-01 08:16:17 +02:00
seb
359efc6481 u 2026-09-30 22:41:35 +02:00
seb
d6dea21f22 u 2026-09-30 21:58:28 +02:00
seb
2f2906b8b5 u 2026-09-30 09:47:47 +02:00
seb
d8f4727f32 u 2026-09-29 18:50:27 +02:00
seb
1b9c39e975 u 2026-09-29 18:26:30 +02:00
seb
43e1ab98b7 u 2026-09-29 13:45:13 +02:00
seb
4cb050c285 u 2026-09-29 11:50:21 +02:00
seb
ada455ea4f u 2026-09-29 10:51:34 +02:00
seb
88c96c94a3 u 2026-09-29 10:25:50 +02:00
seb
447033e53a reoutre 2 2026-09-29 09:30:50 +02:00
seb
18419b1615 reroute 2026-09-29 09:30:43 +02:00
839 changed files with 94633 additions and 12986 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
---
name: coding-style
description: >-
Coding and project style conventions for the Dashfront repository.
---
class componets disconnected from state, in automat.
Reference:
Component: src/routes/artikel/marken.jsx
it's Automat: src/automats/brandAutomat.js
use vs code region markers in code files
use:
// MARK: calls startApi from ./api/index.js
atop important functions
don't use tooltips

View File

@@ -0,0 +1,82 @@
---
name: frontend-routes
description: >-
Guide and reference for constructing valid client-side frontend routes in Dashfront.
Use this skill whenever an agent needs to navigate the user to specific pages, entities, or views.
---
# Dashfront Frontend Routes
Reference guide for the backend LLM agent when emitting route navigation actions (e.g. redirecting or deep-linking the user).
---
## 1. Quick Route Construction Matrix
| Destination | Path Pattern | Parameters / Construction | Example |
|:---|:---|:---|:---|
| **Article Search / Index** | `/artikel` | Query params: `?target={query}&filter={filter}&offset={n}` | `/artikel?target=kabel` |
| **Article Detail** | `/artikel/:sku` | Encode SKU: `/artikel/${encodeURIComponent(sku)}` | `/artikel/ART-10042` |
| **Warehouse / Stock** | `/artikel/lager` | Static | `/artikel/lager` |
| **Brands** | `/artikel/marken` | Static | `/artikel/marken` |
| **Label Templates** | `/artikel/vorlagen` | Static | `/artikel/vorlagen` |
| **Attributes & Features** | `/artikel/merkmale` | Static | `/artikel/merkmale` |
| **Category Tree Root** | `/kategorien` | Static | `/kategorien` |
| **Category Subtree** | `/kategorien/*` | Join encoded slugs: `/kategorien/${slug1}/${slug2}` | `/kategorien/elektronik/kabel` |
| **Delivery List** | `/lieferung` | Static | `/lieferung` |
| **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` |
| **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` |
| **Customer Groups** | `/kunde/gruppen` | Static (pricing & customer tiers) | `/kunde/gruppen` |
| **Article History** | `/infos` or `/infos/articlehistory` | Static | `/infos/articlehistory` |
| **Global History** | `/infos/globalhistory` | Static audit log | `/infos/globalhistory` |
| **Statistics** | `/infos/statistics` | Static KPIs & charts | `/infos/statistics` |
---
## 2. Parameter Details & Rules
### Articles (`/artikel`)
- **SKU detail**: Always use `encodeURIComponent(sku)` if the SKU contains slashes, spaces, or special characters.
- Path: `/artikel/${encodeURIComponent(sku)}`
- Example: SKU `A/123` becomes `/artikel/A%2F123`
- **Search Query**:
- `target`: Search text (min 3 chars to activate search index).
- `filter`: Category or facet filter.
- `offset`: Pagination offset (integer).
- Example: `/artikel?target=schrauben&offset=20`
### Categories (`/kategorien`)
- The route uses hierarchical URL slugs, **not** numeric category IDs.
- Format: `/kategorien/{parentSlug}/{childSlug}`
- Note: Legacy `/artikel/kategorien/*` is automatically redirected to `/kategorien/*`.
### Inbound Deliveries (`/lieferung`)
- `:deliveryId`: ID of the delivery document (manifest).
- To start a new incoming goods intake or receipt reconciliation, route to `/lieferung/neu`.
### Quotes & Orders (`/auftrag`)
- Distinction:
- 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.

View File

@@ -0,0 +1 @@
coding-style

5
.gitignore vendored
View File

@@ -4,4 +4,7 @@ dist-ssr
*.local
.DS_Store
certs/
.dashfront-token
.dashfront-token
# Visual testing rig: generated screenshots (reference is committed, current/diff are not)
test/screenshots/current/
test/screenshots/diff/

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

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

View File

@@ -49,8 +49,8 @@ flow:
component: dashfront
runtime: React 19 (PureComponent & Function Components)
state_layers:
automats: [artikelAutomat, deliveryEditorAutomat, authAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat]
transport: src/api (http(), ApiClient)
automats: [artikelAutomat, articleSearchAutomat, articleIndexAutomat, deliveryEditorAutomat, authAutomat, sseAutomat, supplierAutomat, navigationAutomat, userSettingsAutomat, companyAutomat, categoryAutomat, brandAutomat, unitAutomat, posterminalAutomat, deliveryAutomat, quoteAutomat, orderAutomat, entityAutomat, customergroupAutomat, numberAutomat]
transport: src/api (http(), named namespaces)
egress:
protocol: HTTP (fetch with credentials: "include") & SSE (/api/events)
proxy: Vite dev server (/api -> http://localhost:4208)
@@ -84,7 +84,7 @@ flow:
| **Build & Dev** | Vite 8 (`type: module`) | Dev server on `4209`, HTTPS on `4308`, proxies `/api` → `http://localhost:4208` |
| **Framework** | React 19 (`react`, `react-dom`) | Uses `PureComponent` (with Automats & dashgui) and modern function components |
| **Routing** | `react-router-dom` v7 | Configured in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) with HMR router preservation |
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#c5ddd8` in `src/colors.js` |
| **UI & Theming** | MUI 9 (`@mui/material`, Emotion) | Custom theme in `src/theme/`, base background `#85b7ad` in `src/colors.js` |
| **Shared GUI** | `dashgui` (`file:../dashgui`) | `PanelTile`, `RecordList`, `Tile`, `SingleLineTextEdit`, `ElevatedPill` |
| **State Management** | `react-automat` | Single state layer for all domain & configuration data (articles, categories, company, brands, units, POS terminals, session) |
| **Notifications** | `notistack` | `useSnackbar()` / `<SnackbarProvider>` in [src/main.jsx](file:///home/seb/src/dashfront/src/main.jsx) |
@@ -99,10 +99,7 @@ flow:
```
src/
├── api/ # Backend communication layer
│ ├── index.js # Canonical API module exports (article, category, delivery, etc.)
│ ├── ApiClient.js # Object-oriented API client (this.article, this.delivery, etc.)
│ ├── StorageManager.js # Client-side cache / local storage management
│ └── storageManagerContext.js # React context provider for StorageManager
│ └── index.js # Canonical API module (article, category, delivery, entity, …)
├── automats/ # react-automat state modules (fine-grained subscriptions)
│ ├── artikelAutomat.js # Article cache, deduplicated loads, and reactive single-article state
│ ├── authAutomat.js # Authentication state, session bootstrap, company selection
@@ -152,7 +149,7 @@ src/
In `dashfront`, two state management solutions coexist harmoniously:
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
- **Skill reference**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md).
- **Skill references**: Read [.agents/skills/automat/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/automat/SKILL.md) and [.agents/skills/coding-style/SKILL.md](file:///home/seb/src/dashfront/.agents/skills/coding-style/SKILL.md) (names [src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) as the reference automat).
- Lives outside the React lifecycle in module scope (`src/automats/*.js`).
- Designed for React `PureComponent` to bypass unnecessary React reconciliations.
- **Pattern**:
@@ -191,9 +188,15 @@ In `dashfront`, two state management solutions coexist harmoniously:
### B. Configuration Automats (`src/automats/`)
- Company, hierarchical `category` tree, `brand`, `posterminal`, and `unit` state live in dedicated automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`).
- Company-scoped **lists** (article index, deliveries, quotes, orders, entities, customer groups, number sequences) live in list automats built by `createListAutomat`.
- Reads use the same fine-grained `subscribe`/selector pattern as the domain automats.
- Cross-automat coordination is done by subscribing to `authAutomat`: when it reaches `authed` with a `selectedCompanyId`, these automats load themselves and reset on `anon` (see [§10](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)).
### C. No React Context for Data (`src/api` has no context layer)
- There is **no** store/context provider in this app. Components import the automat or `src/api` namespace they need directly. There is deliberately no `static contextType` for data access.
- Call the API through the namespace functions (`article.getBySku(...)`, `delivery.list()`), and keep shared/cached state in an automat.
- Barrel exports live in [src/automats/index.js](file:///home/seb/src/dashfront/src/automats/index.js); `src/main.jsx` imports it for its side effects so every store is registered before the session bootstraps.
---
## 5. Backend Communication & Authentication Rules
@@ -256,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]
@@ -267,7 +273,7 @@ npm run preview
1. **Do not create dummy/mock backends**: `dashback` runs locally at `http://localhost:4208`. All `/api/*` routes are handled by it.
2. **Keep `dashgui` links working**: `dashgui` is imported via `file:../dashgui`. Vite has deduplication configured (`dedupe: ['react', 'react-dom', ...]` in `vite.config.js`) to avoid dual-instance React conflicts.
3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_father: 1` or `childCount > 0`). The backend returns HTTP 400.
3. **Article Deletion Restriction**: An article CANNOT be deleted if it has child variants (`is_parent: 1` or `childCount > 0`). The backend returns HTTP 400.
4. **Prices and Multipliers**:
- Article prices are tiered by `minmultiplier` and `customergroup_id`.
- Prices sent to the backend are net prices (`net`).
@@ -320,6 +326,33 @@ All domain Automats are located in [src/automats/](file:///home/seb/src/dashfron
### 6. `userSettingsAutomat` ([src/automats/userSettingsAutomat.js](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js))
- **Role**: Stores client-side user layout preferences such as viewer panel coordinates and drawer positions.
### 7. `sseAutomat` ([src/automats/sseAutomat.js](file:///home/seb/src/dashfront/src/automats/sseAutomat.js))
- **Role**: Owns the **single** `EventSource` for `/api/events` and fans signals out into the domain automats. Reconnects whenever the selected company changes; disconnects on logout.
- **Built-in handlers**: `category` → `categoryAutomat.loadTree()` + `invalidateAllCategoryViews()`; `articles` → `articleIndexAutomat.reload()` + category-view invalidation; `warehouse` → `warehouseAutomat.reload()`; `attributename` / `propertyname` → `attributeAutomat.load()` / `propertyAutomat.load()`; `supplier` → `supplierAutomat.reload()`.
- **Extension API**:
- `sseAutomat.actions.on(event, handler)` — register a handler for a named backend event. Returns an unsubscribe callback. Handlers registered while disconnected attach on the next connect.
- `sseAutomat.actions.watchArticle(sku, handler)` — subscribe to `article-<sku>` for one SKU. Exactly one listener is bound per SKU while at least one caller is subscribed; the listener resolves its handler set at call time, so an unwatched SKU is a no-op.
- `invalidateArticle(sku)` — reloads the article plus its keyed history/supplier views. Background refreshes stay silent.
### 8. Company-Scoped List Automats
Built with the shared [src/automats/createListAutomat.js](file:///home/seb/src/dashfront/src/automats/createListAutomat.js) factory, which follows the brand/unit/warehouse pattern (`status: idle | loading | ready | failed`):
| Automat | Backs |
|---|---|
| `articleIndexAutomat` | Article search index (`GET /api/articleIndex`), mapped to the recordset shape; also patched optimistically on rename |
| `deliveryAutomat` | Delivery list |
| `quoteAutomat` / `orderAutomat` | Quote / order lists |
| `entityAutomat` / `customergroupAutomat` | Customer list and customer groups |
| `numberAutomat` | Document number sequences |
Each exposes `load()` (deduplicated), `reload()` (silent), `replace()`, `ensureLoaded()`, and `reset()`. They subscribe to `authAutomat`, so they load on `authed` with a `selectedCompanyId` and reset to `idle` on `anon`.
### 9. `articleSearchAutomat` ([src/automats/articleSearchAutomat.js](file:///home/seb/src/dashfront/src/automats/articleSearchAutomat.js))
- **Role**: Client-side search over the cached article index for the `/artikel` overview. `search(target)` returns `{ categoryFacets, totalCount, recordset, error }`; the page subscribes to `result` instead of receiving a callback.
### 10. `articleDetailAutomat` ([src/automats/articleDetailAutomat.js](file:///home/seb/src/dashfront/src/automats/articleDetailAutomat.js))
- **Role**: Two `KeyedAutomat` stores keyed by SKU — `articleHistoryAutomat` (`GET /api/article/{sku}/history`, paginated client-side) and `articleSupplierAutomat` (`GET /api/article/{sku}/supplier`). Both reload automatically when `sseAutomat` reports an `article-<sku>` signal.
---
## 10. Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)
@@ -395,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)
@@ -458,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

@@ -17,6 +17,10 @@ In `dashfront`, state is managed outside the React component lifecycle using **A
5. **No Context / No Hooks in PureComponents**: Automats bypass React Context providers and hooks, eliminating reconciliation cascades.
6. **Automatic Coordination**: Domain and configuration automats subscribe to [`authAutomat`](./authAutomat.md) to automatically bootstrap on login, switch data when the active company changes, and reset on logout.
## Domain & Data Model Guides
- [**Artikeltypen-Matrix**](./article-types-matrix.md): Spezifikation und Matrix für non-standard Artikeltypen (*Stückliste, Variations-Vater, Variations-Kind, Varianten-Baum*).
---
## Automats Directory
@@ -26,7 +30,8 @@ In `dashfront`, state is managed outside the React component lifecycle using **A
| **`artikelAutomat`** | [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js) | [artikelAutomat.md](./artikelAutomat.md) | Cache and reactive distributor for single article detail payloads (`GET /api/article/:sku`) with inflight request deduplication. |
| **`authAutomat`** | [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js) | [authAutomat.md](./authAutomat.md) | Manages authentication lifecycle, session bootstrap, Google One Tap login, and multi-tenant company selection. |
| **`brandAutomat`** | [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js) | [brandAutomat.md](./brandAutomat.md) | Company-scoped catalog brands / manufacturers list (`GET /api/brand`, upsert). Auto-loads on company auth. |
| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), route view article automats, tree ordering, drag-and-drop moves, and image uploads. |
| **`categoryAutomat`** | [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js) | [categoryAutomat.md](./categoryAutomat.md) | Hierarchical category tree (id → node map), tree ordering, drag-and-drop moves, and image uploads. Triggers the bulk category-view preload once the tree succeeds. |
| **`categoryViewAutomat`** | [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js) | [categoryViewAutomat.md](./categoryViewAutomat.md) | Per-category article views (`KeyedAutomat`) keyed by slug path; all views are warmed right after the tree loads. |
| **`companyAutomat`** | [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js) | [companyAutomat.md](./companyAutomat.md) | Active company configuration, tax rates (standard, reduced), root category ID, and company profile info. |
| **`deliveryEditorAutomat`** | [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js) | [deliveryEditorAutomat.md](./deliveryEditorAutomat.md) | Multi-line inbound delivery editor, OCR/PDF receipt reconciliation, supplier article auto-matching, and draft badge tracking. |
| **`navigationAutomat`** | [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js) | [navigationAutomat.md](./navigationAutomat.md) | Tracks and restores last visited article SKU and delivery paths across top-level section navigation. |

View File

@@ -0,0 +1,139 @@
# Artikeltypen-Matrix (Non-Standard Articles Documentation)
Dieses Dokument definiert das Verhalten, die Geschäftsregeln und das Datenmodell für die verschiedenen Artikeltypen im Dash ERP/POS-System:
- **Standard-Artikel** (einfacher / eigenständiger Artikel)
- **Stückliste** (BOM / Set / Bundle / Baugruppe)
- **Variations-Vater** (Parent Variant / Modell / Abstrakter Gruppenartikel)
- **Variations-Kind** (Child Variant / Konkrete Ausprägung)
---
## 1. Übersicht & Matrix
| Kriterium / Frage | Standard-Artikel | Stückliste (BOM / Set) | Variations-Vater | Variations-Kind |
| :--- | :---: | :---: | :---: | :---: |
| **Kann auch Stückliste sein?** | ✅ Ja *(umwandelbar)* | ℹ️ *Ist Stückliste* (mehrstufig schachtelbar) | ❌ **Nein** *(System blockiert)* | ✅ **Ja** *(Kind-Variante als Bundle)* |
| **Kann auch Variations-Vater sein?** | ✅ Ja *(aktivierbar)* | ❌ **Nein** *(System blockiert)* | ℹ️ *Ist Vater* | ✅ **Ja** *(als Zwischenknoten im Baum)* |
| **Kann auch Variations-Kind sein?** | ✅ Ja *(zuordenbar)* | ⚠️ **Ja** *(als Kind-Stückliste)* | ✅ **Ja** *(als Zwischenknoten im Baum)* | ℹ️ *Ist Kind* |
| **Kann Optionen haben?** (`optionslist`) | ✅ Ja | ✅ **Ja** *(koexistiert)* | ❌ **Nein** *(System blockiert)* | ✅ **Ja** *(als Blattknoten / Verkaufsartikel)* |
| **Hat eigenen physischen Bestand?** | ✅ **Ja** | ⚠️ **Virtuell** *(Komponenten-Minimum)* / Montage | ❌ **Nein** *(auf Variantenebene)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
| **Eigene Eigenschaften** *(Hersteller, Kategorie)* | ✅ **Ja** | ✅ **Ja** *(eigene Kategorie & Marke)* | ✅ **Ja** *(Basis-Attribute, vererbt nach unten)* | ⚠️ **Ja / Geerbt** *(vom Elternknoten)* |
| **Hat Lieferanten?** (`articlesupplier`) | ✅ **Ja** | ✅ **Ja** *(wenn Set als 1 SKU bezogen wird)* | ❌ **Nein** *(auf Variantenebene)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
| **Kann auf Beleg sein?** *(Kasse, Rechnung, etc.)* | ✅ **Ja** | ✅ **Ja** *(als Set-Position verkaufbar)* | ❌ **Nein** *(abstrakt, nicht buchbar)* | ⚠️ **Nur als Blattknoten** *(vollständig spezifiziert)* |
| **Hat eigenen Preis?** (`price`-Tabelle) | ✅ **Ja** | ✅ **Ja** *(fester Bundle-Verkaufspreis)* | ❌ **Nein** *(wird auf Variantenebene geführt)* | ⚠️ **Nur als Blattknoten** *(Zwischenknoten: Nein)* |
| **Hat individuelle Fotos?** (`articlepic`) | ✅ **Ja** | ✅ **Ja** *(Foto des Gesamt-Sets)* | ✅ **Ja** *(Modell-/Familienfoto)* | ✅ **Ja** *(variantenspezifisch z. B. Farbe Rot)* |
---
## 2. Varianten-Baum: Das Rollenmodell (Hierarchische Varianten)
Varianten bilden im System eine **Baum-Struktur** ab (z. B. für mehrdimensionale Varianten wie *Modell $\rightarrow$ Farbe $\rightarrow$ Größe*).
```text
[Wurzel: T-Shirt Classic] <- Reiner Vaterartikel (is_parent = 1, parentarticle_id = NULL)
│
├── [Zwischenknoten: Rot] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1)
│ ├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
│ └── [Größe M] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
│
└── [Zwischenknoten: Blau] <- Kind UND Vater (parentarticle_id != NULL, is_parent = 1)
├── [Größe S] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
└── [Größe L] <- Blattknoten / Reines Kind (Endprodukt / verkaufbare SKU)
```
### Die zwei Zustände von Knoten im Varianten-Baum:
1. **Innere Knoten / Zwischenknoten (Vater & Kind zugleich)**:
- Gruppieren weitere Unterausprägungen (z. B. Farbvariante „Rot“ mit untergeordneten Größen).
- Sind noch **nicht vollständig spezifiziert** $\rightarrow$ verhalten sich wie Vaterartikel:
- **Kein** eigener physischer Lagerbestand.
- **Kein** eigener Preis.
- **Kein** Lieferant.
- **Nicht** direkt auf Belegen / Kasse buchbar.
- **Fotos**: Dienen oft als Träger für Farbfotos, die von allen darunterliegenden Größen geerbt werden.
2. **Blattknoten (Reines Kind am Ende des Astes)**:
- Vollständig spezifizierter physischer Verkaufs- und Einkaufsartikel.
- Eigener Lagerbestand, Barcode/EAN, Lieferantenkonditionen, Verkaufspreise und Belegfähigkeit.
---
## 3. Detail-Spezifikation nach Artikeltyp
### A. Stückliste (BOM / Bill of Materials / Bundle / Set)
*Technischer Bezug*: `article.articlelist_id -> articlelist`
* **Kann auch Stückliste sein?**:
- **Ja (Mehrstufig)**: Eine Stückliste ist bereits eine Stückliste. Komponenten können ihrerseits Stücklisten sein (verschachtelte Baugruppen). Selbstreferenz / Zirkelbezug ist verboten.
* **Kann auch Variations-Vater sein?**:
- **Nein**: Im Frontend und Backend blockiert (`Ein Stücklisten-Artikel kann kein Vaterartikel sein.`). Ein Vater ist ein abstraktes Gruppierungselement, kein Bündel konkreter Teile.
* **Kann auch Variations-Kind sein?**:
- **Ja**: Ein Blattknoten im Variantenbaum kann als Stückliste definiert sein (z. B. *„Geschenkset T-Shirt – Größe L“* besteht aus *T-Shirt L + Box + Karte*).
* **Kann Optionen haben?**:
- **Ja**: `articlelist` und `optionslist` koexistieren harmonisch nebeneinander (z. B. PC-Set mit wählbaren Upgrades).
* **Hat Bestand?**:
- **Virtuell abgeleitet**: Ergibt sich dynamisch aus der kleinsten verfügbaren Komponentenmenge:
$$\text{Bestand}_{\text{Set}} = \min_{i} \left( \left\lfloor \frac{\text{Bestand}_i}{\text{Menge}_i} \right\rfloor \right)$$
Bei Verkauf werden die Komponenten aus dem Lager abgebucht. **Manuelle Bestandsanpassungen (`POST /api/stock/adjust`) auf Stücklisten sind gesperrt** (HTTP 400), um eine künstliche Komponenten-Inflation zu verhindern. Inventur- und Bestandskorrekturen erfolgen direkt an den physischen Komponenten.
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
- **Ja**: Eigenständige Zuweisung von Marke (`brand_id`), Kategorien (`categoryarticle`), Name und Beschreibung.
* **Hat Lieferanten?**:
- **Ja**: Wenn der Lieferant das fertige Set als **1 Artikel** liefert (z. B. Handelsset, Werkzeugkoffer oder Starterkit mit eigener Lieferanten-SKU und Set-EK). Bei interner Montage liegen die Lieferanten stattdessen auf den Komponenten.
* **Kann auf Beleg sein?**:
- **Ja**: Voll belegfähig auf Kasse (POS), Angebot, Auftrag, Lieferschein und Rechnung.
* **Hat einen Preis?**:
- **Ja**: Eigener Verkaufspreis in `price` (Set-Preis mit Steuer und Preisstaffeln).
* **Hat individuelle Fotos?**:
- **Ja**: Zeigt das Set/Bundle als Gesamtheit.
---
### B. Variations-Vater (Parent Variant Article)
*Technischer Bezug*: `article.is_parent = 1`, `parentarticle_id = NULL` *(Wurzelknoten)*
* **Kann auch Stückliste sein?**:
- **Nein**: Vaterartikel können keine Stückliste besitzen.
* **Kann auch Variations-Vater sein?**:
- **Ja**: Ist der Vater.
* **Kann auch Variations-Kind sein?**:
- **Ja, als Zwischenknoten**: Im Variantenbaum kann ein Vaterartikel seinerseits Kind eines übergeordneten Vaters sein (`parentarticle_id != NULL`).
* **Kann Optionen haben?**:
- **Nein**: Optionen werden erst an der konkreten verkaufbaren Variante angehängt.
* **Hat Bestand?**:
- **Nein**: Wird auf Variantenebene geführt.
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
- **Ja**: Definiert gemeinsame Basiseigenschaften (Hersteller, Hauptkategorie, Grundbeschreibung), die von den Kindern geerbt werden.
* **Hat Lieferanten?**:
- **Nein**: Lieferanten und EK-Preise werden auf Variantenebene gepflegt.
* **Kann auf Beleg sein?**:
- **Nein**: Da abstrakt (nicht greifbar / nicht ausgeliefert), kann ein Vater nie auf einem Beleg stehen.
* **Hat einen Preis?**:
- **Nein**: Eigene Preise existieren nur an den konkreten Ausprägungen (im Katalog ggf. nur abgeleitete Anzeige wie *„ab X €“*).
* **Hat individuelle Fotos?**:
- **Ja**: Dient als Haupt-/Familienbild im Produktkatalog.
---
### C. Variations-Kind (Child Variant Article)
*Technischer Bezug*: `article.parentarticle_id -> article.id`
* **Kann auch Stückliste sein?**:
- **Ja**: Als Blattknoten kann die Variante als Set aus mehreren Komponenten aufgebaut sein.
* **Kann auch Variations-Vater sein?**:
- **Ja**: Kann als Zwischenknoten im Baum weitere Unterausprägungen besitzen.
* **Kann auch Variations-Kind sein?**:
- **Ja**: Ist ein Kind des übergeordneten Knotens.
* **Kann Optionen haben?**:
- **Ja**: Als verkaufbarer Blattknoten können Optionen zugewiesen werden.
* **Hat Bestand?**:
- **Ja (als Blattknoten)**: Physischer Lagerbestand pro Lagerplatz mit Zu-/Abgangsbuchungen. *(Als Zwischenknoten: Nein).*
* **Eigene Eigenschaften (Hersteller, Kategorie)?**:
- **Ja / Geerbt**: Erbt standardmäßig Hersteller und Kategorie vom Elternknoten, kann aber abweichende Merkmale und Texte besitzen.
* **Hat Lieferanten?**:
- **Ja (als Blattknoten)**: Eigene Lieferantenbeziehungen, Lieferanten-SKU, Gebindegrößen (PU) und Einkaufspreise.
* **Kann auf Beleg sein?**:
- **Ja (als Blattknoten)**: Ist der eigentliche Verkaufsartikel mit Barcode/EAN für Kasse, Angebot, Lieferschein und Rechnung.
* **Hat einen Preis?**:
- **Ja (als Blattknoten)**: Eigene Preise, Steuerklassen, Staffelpreise und Kundengruppen-Preise.
* **Hat individuelle Fotos?**:
- **Ja**: Spezifisches Foto der Ausprägung (mit Fallback auf Elternknoten/Wurzel, falls keines vorhanden ist).

View File

@@ -1,19 +1,20 @@
# `categoryAutomat`
> **Source File**: [`src/automats/categoryAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)
> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`)
> **Registration Key**: `'category'` (`Automat.get('category')` / `window.__AUTOMATS__.get('category')`)
> **Related**: [`categoryViewAutomat`](./categoryViewAutomat.md) — the keyed per-category article views, extracted into their own module.
---
## 1. Overview & Purpose
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images, POS category visibility flags, and dynamic per-route category article view automats.
`categoryAutomat` manages the hierarchical category catalog tree for the active company (`/kategorien/*`), category breadcrumbs, category images and POS category visibility flags. It owns the tree and all category/article **mutations**; the per-category *article views* live in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and are attached to this automat via `attachCategoryTree()`.
### Core Responsibilities
- **Hierarchical Category Tree**: Normalized `id → node` dictionary (`categories`) representing the parent-child relational tree.
- **Dynamic Per-Route Category View Automats**: Instantiates lightweight route-scoped automats (`category-view:<slug>`) that load category articles on-demand with automatic cache cleanup and disposal.
- **Bulk View Preload**: after `GET /api/category/tree` succeeds, it triggers `loadAllCategoryViews()` so every category's articles are already cached when a route mounts.
- **Mutations & Tree Reordering**: Category creation, batch creation, sibling reordering (`sort`), drag-and-drop hierarchy moves, and in-place renaming.
- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the active route automat's article list.
- **Article Insertion & Synchronous UI Reflection**: When articles are created within a category, `categoryAutomat` immediately patches the matching category views' article lists.
- **Media Management**: Uploading and deleting category cover images and updating AVIF full and thumbnail hashes (`fullHash`, `thumbHash`).
- **POS Integration**: Toggling POS visibility and hardware terminal IDs per category.
@@ -50,6 +51,8 @@ interface CategoryState {
treeStatus: 'idle' | 'loading' | 'succeeded' | 'failed';
/** Last tree error message. */
error: string | null;
/** True while the post-tree preload of all category views is running. */
viewsLoading: boolean;
}
```
@@ -63,29 +66,35 @@ const initialState = {
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
```
---
## 3. Dynamic Category View Automats (`KeyedAutomat`)
## 3. Category View Automats (delegated)
Category views are powered by a `KeyedAutomat` instance (`categoryViews`) indexed by route **slug path** (e.g. `''` for root, `'getraenke'`, `'getraenke/softdrinks'`).
The keyed per-category article views are **no longer defined in this file**. They are implemented in [`categoryViewAutomat.js`](../src/automats/categoryViewAutomat.js) and re-exported here for backward compatibility:
| Symbol | Source |
|---|---|
| `categoryViews` | `categoryViewAutomat.js` |
| `getOrCreateCategoryAutomat({ slugPath })` | `categoryViewAutomat.js` |
| `invalidateAllCategoryViews()` | `categoryViewAutomat.js` |
| `disposeAllCategoryViewAutomats()` | `categoryViewAutomat.js` |
The following facades remain on the tree automat and delegate to `categoryViews`:
### `categoryAutomat.key(slugPath): KeyHandle`
Returns a scoped `KeyHandle` proxy for the given route slug.
- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories` (all child categories having the current as their root/parent).
- **Loader**: Resolves `slugPath` → `categoryId` from the tree, fetches articles via `GET /api/category/:id/articles`, and derives `childCategories`.
- **Handle State**: `{ data: { slug, categoryId, articles, childCategories }, loaded: boolean, loading: boolean, error: string | null }`.
- **`handle.load(options?)`**: Deduplicated background fetch across concurrent mounts.
- **`handle.read()`**: React Suspense-compatible read (throws in-flight promise if unready).
- **`handle.reload()`**: Re-fetches articles and child categories.
- **`handle.subscribe(target, selector)`**: Fine-grained subscription to this slug view with zero cross-talk between different category routes.
### `invalidateAllCategoryViews(): void`
Triggers `reload()` across all active slug view handles.
### `disposeAllCategoryViewAutomats(): void`
Clears all active view handles and frees memory on company switch or logout.
See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`.
---
@@ -94,7 +103,8 @@ Clears all active view handles and frees memory on company switch or logout.
Access actions via `categoryAutomat.actions.<method>()`.
### Tree Operations
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, and updates state.
- **`loadTree(): Promise<void>`**: Reads `rootCategoryId` from `companyAutomat`, queries `GET /api/category/tree`, parses the response into a normalized dictionary, updates state, and then **awaits `loadAllCategoryViews()`** with `viewsLoading: true → false`. Individual category failures are logged as a warning and do not fail the tree load.
- **`loadAllCategoryViews(): Promise<{ loaded: number, failed: Array<{ slug, error }> }>`**: Re-runs the bulk view preload on demand (idempotent — already cached views resolve immediately).
- **`setRenameMode(enabled: boolean): void`**: Toggles inline renaming mode for the category explorer.
- **`reset(): void`**: Disposes all route automats and resets tree state to `initialState`.

136
docs/categoryViewAutomat.md Normal file
View File

@@ -0,0 +1,136 @@
# `categoryViewAutomat`
> **Source File**: [`src/automats/categoryViewAutomat.js`](file:///home/seb/src/dashfront/src/automats/categoryViewAutomat.js)
> **Registration Key**: `'category-views'` (`Automat.get('category-views')` / `window.__AUTOMATS__.get('category-views')`)
> **Related**: [`categoryAutomat`](./categoryAutomat.md) — owns the category **tree** this module resolves against.
---
## 1. Overview & Purpose
`categoryViewAutomat` owns the **per-category article views**: a `KeyedAutomat` (`categoryViews`) indexed by route **slug path** (`''` for root, `'getraenke'`, `'getraenke/softdrinks'`). Each entry holds one category's articles plus its child categories.
This module was extracted from [`categoryAutomat.js`](../src/automats/categoryAutomat.js) so the tree and the views can evolve independently. The dependency stays **one-directional**:
```mermaid
graph LR
CA[categoryAutomat<br/>tree + mutations] -->|attachCategoryTree| CV[categoryViewAutomat<br/>KeyedAutomat views]
CV -->|loadAllCategoryViews| API[/api/category/:id/articles]
CV -->|collectLoadedCategoryViewData| AI[articleIndexAutomat<br/>derived search index]
```
`categoryAutomat` injects itself via `attachCategoryTree(automat)`; `categoryViewAutomat` never imports the tree, which avoids a circular import.
### Core Responsibilities
- **Slug → Category Resolution**: Resolves a slug path against the loaded tree to a `categoryId`.
- **Per-Category Article Payloads**: `GET /api/category/:id/articles` for the resolved category.
- **Bulk Warm-Up**: `loadAllCategoryViews()` loads **every** category in the tree (concurrency-bounded), triggered by `categoryAutomat.loadTree()` the moment the tree succeeds.
- **Child Category Derivation**: Recomputes `childCategories` for all loaded views whenever the tree changes.
- **Incremental Patches**: Appends created articles to, and removes deleted articles from, the affected views.
---
## 2. View Data Structure
```ts
interface CategoryViewData {
/** Cleaned slug path this view is keyed by. */
slug: string;
/** Resolved category id. */
categoryId: number | string;
/** Articles in that category. */
articles: Array<ArticleRow>;
/** Direct child category nodes, sorted by `sort`. */
childCategories: Array<CategoryNode>;
}
```
Each keyed entry wraps this as `ItemState<CategoryViewData>`:
`{ data, loading, loaded, error }`.
---
## 3. Loading Strategy
### `loadAllCategoryViews({ concurrency }): Promise<{ loaded, failed }>`
Builds the slug path of **every** category node from its breadcrumb trail
(`categoryViewSlugPath()`), de-duplicates them, then loads each with
`categoryViews.load(slug)`.
- **Already cached views resolve immediately** (`load()` short-circuits when `loaded && !error`), so the call is idempotent.
- Concurrency defaults to **6**; failures are captured per category and returned in `failed` rather than rejecting the batch.
- **Triggered by**: `categoryAutomat.actions.loadTree()` right after `treeStatus === 'succeeded'`, and available on demand via `categoryAutomat.actions.loadAllCategoryViews()`.
### `reloadLoadedCategoryViews(): Promise<void>`
Re-fetches **only the already cached** views (`setDirty(undefined, false, true)`), keeping the cached set small for SSE-driven refreshes.
### `invalidateAllCategoryViews(): Promise<unknown[]>`
Background-invalidates all loaded views. Called by `sseAutomat` on `'category'` and `'articles'` events.
### `disposeAllCategoryViewAutomats(): void`
Clears every view and frees memory on company switch or logout.
### `waitForCategoryTree(): Promise<CategoryState>`
Resolves once the attached tree reports `treeStatus === 'succeeded'`. Rejects on tree failure or when `authAutomat` turns `'anon'`, so a pending view never hangs forever.
---
## 4. Exported API
| Export | Description |
|---|---|
| `categoryViews` (default) | The `KeyedAutomat` keyed by slug path. |
| `attachCategoryTree(automat)` | Injects the tree automat (called once by `categoryAutomat`). |
| `categoryViewSlugPath(categories, rootCategoryId, categoryId)` | Breadcrumb-derived slug path usable as a key. |
| `collectLoadedCategoryViewData()` | All loaded view payloads — the source for the derived article index. |
| `loadAllCategoryViews({ concurrency })` | Bulk warm-up of every category in the tree. |
| `reloadLoadedCategoryViews()` | Re-fetch only the cached views. |
| `invalidateAllCategoryViews()` | Background-invalidate all loaded views. |
| `disposeAllCategoryViewAutomats()` | Clear all views. |
| `getOrCreateCategoryAutomat({ slugPath })` | Returns the `KeyHandle` for a slug path. |
| `refreshCategoryViewChildren(categories)` | Re-derive `childCategories` for every loaded view. |
| `appendArticlesToCategoryViews({ categoryId, articles })` | Append newly created articles. |
| `removeArticleBySku(sku)` | Strip a deleted article from every view. |
---
## 5. Key Handle Usage
```js
import categoryAutomat from '../automats/categoryAutomat';
const handle = categoryAutomat.key('getraenke/softdrinks');
handle.subscribe(this, (item) => ({
articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? [],
}));
const data = await handle.load(); // deduplicated across concurrent mounts
const data2 = handle.read(); // Suspense: throws the in-flight promise when unready
handle.reload();
handle.setDirty();
```
All four facades (`key`, `read`, `load`, `views`) are attached to `categoryAutomat`
as thin delegates to `categoryViews`, so existing import sites keep working.
---
## 6. Cross-Automat Coordination
| Automat | Interaction |
|---|---|
| `categoryAutomat` | `attachCategoryTree()`, `loadAllCategoryViews()` after the tree succeeds, `refreshCategoryViewChildren()` on every tree mutation, `disposeAllCategoryViewAutomats()` on reset. |
| `authAutomat` | `waitForCategoryTree()` rejects pending views on `'anon'`. |
| `sseAutomat` | `invalidateAllCategoryViews()` on `'category'` / `'articles'` events. |
| `articleIndexAutomat` | Subscribes to `categoryViews` and re-derives the search index from `collectLoadedCategoryViewData()`. |
---
## 7. Gotchas
- **Bulk warm-up cost**: `loadTree()` now issues one request per category. On very large trees this is many requests — `concurrency` (default 6) is the throttle to tune. Individual failures are non-fatal.
- **`viewsLoading`**: While it is `true`, the tree is ready but the views are still warming. Use it to decide between "render a spinner" and "render an empty grid".
- **Keys are slug paths, not ids**: A rename or move changes the slug, so the view key changes and the old entry should be evicted; `refreshCategoryViewChildren()` handles the child lists, but stale keys may linger until the next `disposeAllCategoryViewAutomats()`.
- **Do not import `categoryAutomat` from this file** — that would create the circular dependency the `attachCategoryTree()` injection exists to avoid.

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,56 +1,150 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<meta name="theme-color" content="#3b5bdb" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Dashfront</title>
<style>
html,
body,
#root {
min-height: 100%;
background-color: #c5ddd8;
}
<html lang="de" class="notranslate" translate="no">
html,
body {
overscroll-behavior-y: none;
touch-action: manipulation;
}
<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>
html {
scrollbar-gutter: stable;
<!-- Primary Meta Tags -->
<meta name="title" content="QuixPOS * Management Interface" />
<meta name="description"
content="QuixPOS * Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung, Aufträge und Bestandsführung." />
<meta name="keywords"
content="QuixPOS, POS, Kassensystem, Warenwirtschaft, ERP, Artikelverwaltung, Lagerverwaltung, Kasse, Point of Sale" />
<meta name="author" content="QuixPOS" />
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#1d4ed8" />
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<!-- Open Graph / Facebook / LinkedIn / WhatsApp -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="QuixPOS" />
<meta property="og:title" content="QuixPOS * Management Interface" />
<meta property="og:description"
content="Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung und Bestandsführung." />
<meta property="og:image" content="/og-image.png" />
<meta property="og:image:secure_url" content="/og-image.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="QuixPOS * Point-of-Sale & Warenwirtschaftssystem" />
<meta property="og:locale" content="de_DE" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="QuixPOS * Management Interface" />
<meta name="twitter:description"
content="Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung und Bestandsführung." />
<meta name="twitter:image" content="/og-image.png" />
<meta name="twitter:image:alt" content="QuixPOS * Point-of-Sale & Warenwirtschaftssystem" />
<!-- Structured Data: JSON-LD -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebApplication",
"@id": "#webapp",
"name": "QuixPOS",
"alternateName": "QuixPOS Management Interface",
"applicationCategory": "BusinessApplication",
"operatingSystem": "All",
"browserRequirements": "Requires modern JavaScript browser (Chrome, Firefox, Safari, Edge)",
"description": "Webbasiertes Point-of-Sale- und Warenwirtschaftssystem. Schnelle Kassenabwicklung, intelligente Artikelverwaltung, Aufträge und Bestandsführung.",
"image": "/og-image.png",
"screenshot": "/og-image.png",
"softwareVersion": "1.0",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"featureList": [
"Point of Sale Kassenbetrieb",
"Artikel- & Barcodeverwaltung",
"Lager- & Lieferantenmanagement",
"Angebote & Bestellwesen",
"Kundenverwaltung & Preisgruppen"
]
},
{
"@type": "Organization",
"@id": "#organization",
"name": "QuixPOS",
"logo": "/quixpos-logo.png",
"image": "/og-image.png",
"description": "Point-of-Sale & Warenwirtschaftssystem"
}
</style>
</head>
<body>
<div id="root"></div>
<script>
(function () {
var host = location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') return;
if (!navigator.serviceWorker) return;
var hadSw = false;
navigator.serviceWorker.getRegistrations().then(function (regs) {
hadSw = regs.length > 0;
return Promise.all(regs.map(function (r) { return r.unregister(); }));
}).then(function () {
if (!window.caches) return hadSw;
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
.then(function () { return hadSw; });
});
}).then(function (reload) {
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
sessionStorage.setItem('dashfront-sw-cleared', '1');
location.reload();
}
]
}
</script>
<style>
/* First-paint colors. Mirrored from src/colors.js (MAIN_BACKGROUND /
THEME_COLOR) because this file must paint before the JS bundle loads.
Once the app boots, src/colors.js takes over via CSS custom properties. */
html,
body,
#root {
min-height: 100%;
background-color: #82b8b4;
}
html,
body {
overscroll-behavior-y: none;
touch-action: manipulation;
}
html {
scrollbar-gutter: stable;
}
</style>
</head>
<body>
<noscript>
<div
style="font-family:system-ui,-apple-system,sans-serif;max-width:440px;margin:60px auto;padding:32px 24px;text-align:center;background:#ffffff;border-radius:16px;box-shadow:0 8px 30px rgba(0,0,0,0.12);color:#1e293b;">
<img src="/quixpos-logo.png" alt="QuixPOS Logo" width="96" height="96"
style="border-radius:16px;margin-bottom:16px;box-shadow:0 4px 12px rgba(0,0,0,0.08);" />
<h1 style="margin:0 0 6px;font-size:26px;font-weight:700;color:#0f2f35;">QuixPOS</h1>
<p style="margin:0 0 12px;font-size:16px;font-weight:600;color:#0f766e;">Management Interface</p>
<p style="margin:0;font-size:13px;color:#94a3b8;">Bitte aktivieren Sie JavaScript, um das Management Interface zu
nutzen.</p>
</div>
</noscript>
<div id="root"></div>
<script>
(function () {
var host = location.hostname;
if (host !== 'localhost' && host !== '127.0.0.1') return;
if (!navigator.serviceWorker) return;
var hadSw = false;
navigator.serviceWorker.getRegistrations().then(function (regs) {
hadSw = regs.length > 0;
return Promise.all(regs.map(function (r) { return r.unregister(); }));
}).then(function () {
if (!window.caches) return hadSw;
return caches.keys().then(function (keys) {
return Promise.all(keys.map(function (k) { return caches.delete(k); }))
.then(function () { return hadSw; });
});
})();
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
}).then(function (reload) {
if (reload && !sessionStorage.getItem('dashfront-sw-cleared')) {
sessionStorage.setItem('dashfront-sw-cleared', '1');
location.reload();
}
});
})();
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2218
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,42 +10,59 @@
"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",
"lint": "eslint ."
"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",
"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",
"@jsquash/resize": "^2.1.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1",
"@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-window": "^2.3.4",
"skelehash-wasm": "^0.1.1",
"sortablejs": "^1.15.7",
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-react": "6.1.1",
"eslint": "^10.11.0",
"@hpcc-js/wasm-graphviz": "^1.29.2",
"@vitejs/plugin-react": "6.1.2",
"eslint": "^10.13.0",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.12.0",
"globals": "^17.13.0",
"qrcode": "^1.5.4",
"vite": "^8.3.1",
"vite-plugin-pwa": "^1.3.0"
"vite": "^8.3.4",
"vite-plugin-pwa": "^2.0.0"
},
"allowScripts": {
"canvas@3.2.3": true,
"react-canvas-label-editor@20.0.0": true,
"github:sebseb7/react-canvas-label-editor#7f0e31b66ff22da011e872b201017398b14e9dd9": true
}
}

BIN
public/og-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

BIN
public/quixpos-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -5,7 +5,7 @@
*/
import { readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import decodeAvif, { init as initAvifDecode } from '@jsquash/avif/decode.js';
import encodeAvif, { init as initAvifEncode } from '@jsquash/avif/encode.js';
import encodeJpeg, { init as initJpegEncode } from '@jsquash/jpeg/encode.js';

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: '100vh',
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}
</>
);
}
}

View File

@@ -1,574 +0,0 @@
function enc(value) {
return encodeURIComponent(String(value));
}
function buildUrl(baseUrl, path) {
return new URL(path, baseUrl || window.location.origin).href;
}
function buildPath(path, query) {
if (!query) return path;
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null) continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `${path}?${qs}` : path;
}
/**
* Thin HTTP client for the dashback API (cookie auth, JSON / blob / empty).
*/
export class ApiClient {
#baseUrl;
constructor(baseUrl = window.location.origin) {
this.#baseUrl = baseUrl;
this.auth = {
/** POST /api/auth/login */
login: (body) => this.#json('POST', '/api/auth/login', { body }),
/** POST /api/auth/logout */
logout: () => this.#json('POST', '/api/auth/logout'),
/** GET /api/auth/me */
me: () => this.#json('GET', '/api/auth/me'),
/** PUT /api/auth/selected-company */
selectedCompany: (body) => this.#json('PUT', '/api/auth/selected-company', { body }),
};
this.health = {
/** GET /api/health */
get: () => this.#json('GET', '/api/health'),
};
this.company = {
/** GET /api/company */
list: () => this.#json('GET', '/api/company'),
/** POST /api/company */
create: (body) => this.#json('POST', '/api/company', { body }),
/** PUT /api/company/{id} */
update: (id, body) => this.#json('PUT', `/api/company/${enc(id)}`, { body }),
/** DELETE /api/company/{id} */
delete: (id) => this.#empty('DELETE', `/api/company/${enc(id)}`),
};
this.unit = {
/** GET /api/unit */
list: () => this.#json('GET', '/api/unit'),
/** PUT /api/unit */
upsert: (body) => this.#json('PUT', '/api/unit', { body }),
/** DELETE /api/unit?si=… */
delete: (si) => this.#empty('DELETE', '/api/unit', { query: { si } }),
};
this.brand = {
/** GET /api/brand */
list: () => this.#json('GET', '/api/brand'),
/** PUT /api/brand */
upsert: (body) => this.#json('PUT', '/api/brand', { body }),
/** DELETE /api/brand/{id} */
delete: (id) => this.#empty('DELETE', `/api/brand/${enc(id)}`),
};
this.supplier = {
/** GET /api/supplier → { suppliers: Supplier[] } */
list: () => this.#json('GET', '/api/supplier'),
/** POST /api/supplier → 201 { supplier } */
create: (body) => this.#json('POST', '/api/supplier', { body }),
/** PATCH /api/supplier/{id} → { supplier } */
update: (id, body) => this.#json('PATCH', `/api/supplier/${enc(id)}`, { body }),
/** DELETE /api/supplier/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/supplier/${enc(id)}`),
/** GET /api/supplier/{id}/articles?skus=... → { articles: SupplierArticle[] } */
getArticles: (id, skus) => {
let query = '';
if (skus != null) {
const list = Array.isArray(skus) ? skus : [skus];
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
if (filtered.length > 0) {
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
}
}
return this.#json('GET', `/api/supplier/${enc(id)}/articles${query}`);
},
};
this.warehouse = {
/** GET /api/warehouse → { warehouses: Warehouse[] } */
list: () => this.#json('GET', '/api/warehouse'),
/** GET /api/warehouse/{id} → { warehouse: Warehouse } */
getById: (id) => this.#json('GET', `/api/warehouse/${enc(id)}`),
/** POST /api/warehouse → 201 { warehouse: Warehouse } */
create: (body) => this.#json('POST', '/api/warehouse', { body }),
/** PATCH /api/warehouse/{id} → 200 { warehouse: Warehouse } */
update: (id, body) => this.#json('PATCH', `/api/warehouse/${enc(id)}`, { body }),
/** PUT /api/warehouse/order → 200 { warehouseIds: number[] } */
order: (body) => this.#json('PUT', '/api/warehouse/order', { body }),
/** DELETE /api/warehouse/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/warehouse/${enc(id)}`),
};
this.stock = {
/** POST /api/stock/adjust → 201 { manifest, lines, ledger } */
adjust: (body) => this.#json('POST', '/api/stock/adjust', { body }),
};
this.delivery = {
/** GET /api/delivery → { deliveries: DeliverySummary[] } */
list: () => this.#json('GET', '/api/delivery'),
/** GET /api/delivery/{id} → { delivery: Delivery } */
get: (id) => this.#json('GET', `/api/delivery/${enc(id)}`),
/** POST /api/delivery → 201 { delivery: Delivery } */
create: (body) => this.#json('POST', '/api/delivery', { body }),
/** PATCH /api/delivery/{id} → { delivery: Delivery } */
update: (id, body) => this.#json('PATCH', `/api/delivery/${enc(id)}`, { body }),
/** DELETE /api/delivery/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/delivery/${enc(id)}`),
/** GET /api/delivery/{id}/file/{fileId} → Blob (full binary content) */
getFile: (id, fileId, { download = false } = {}) =>
this.#blob('GET', `/api/delivery/${enc(id)}/file/${enc(fileId)}`, {
query: download ? { download: 'true' } : undefined,
}),
/**
* POST /api/delivery/parse — multipart/form-data, field "file"
* Accepts PDF or text files (max 50 MB), detection via mimetype/filename
* Returns an async generator that yields SSE event objects:
* { event: 'status'|'llm_call'|'tool_call'|'usage'|'progress'|'result'|'error', data: object }
*/
parseSse: async function* (file, { signal } = {}) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
credentials: 'include',
body: formData,
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch { /* ignore */ }
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch { /* ignore */ }
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete last line
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';
}
// blank line or comment: reset
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch { /* ignore */ }
}
},
parsePdfSse(file, opts) {
return this.parseSse(file, opts);
},
};
this.ledger = {
/** POST /api/ledger → 201 { ledger: LedgerEntry } */
add: (body) => this.#json('POST', '/api/ledger', { body }),
/** GET /api/ledger/manifestarticle/{id} → { ledger: LedgerEntry[] } */
listByManifestarticleId: (manifestarticleId, transactionType) =>
this.#json('GET', `/api/ledger/manifestarticle/${enc(manifestarticleId)}`, {
query: transactionType ? { transaction_type: transactionType } : undefined,
}),
/** GET /api/ledger/sku/{sku} → { ledger: LedgerEntry[] } */
listBySku: (sku, transactionType) =>
this.#json('GET', `/api/ledger/sku/${enc(sku)}`, {
query: transactionType ? { transaction_type: transactionType } : undefined,
}),
/** GET /api/ledger → { ledger: LedgerEntry[] } */
list: (query) => this.#json('GET', '/api/ledger', { query }),
};
this.quote = {
/** GET /api/quote → { quotes } */
list: () => this.#json('GET', '/api/quote'),
/** GET /api/quote/{id} → { quote } */
get: (id) => this.#json('GET', `/api/quote/${enc(id)}`),
/** POST /api/quote → 201 { quote } */
create: (body) => this.#json('POST', '/api/quote', { body }),
/** PATCH /api/quote/{id} → { quote } */
update: (id, body) => this.#json('PATCH', `/api/quote/${enc(id)}`, { body }),
/** DELETE /api/quote/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/quote/${enc(id)}`),
};
this.order = {
/** GET /api/order → { orders } */
list: () => this.#json('GET', '/api/order'),
/** GET /api/order/{id} → { order } */
get: (id) => this.#json('GET', `/api/order/${enc(id)}`),
/** POST /api/order → 201 { order } */
create: (body) => this.#json('POST', '/api/order', { body }),
/** PATCH /api/order/{id} → { order } */
update: (id, body) => this.#json('PATCH', `/api/order/${enc(id)}`, { body }),
/** DELETE /api/order/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/order/${enc(id)}`),
};
this.customergroup = {
/** GET /api/customergroup → { customergroups: { id, name, sort, editable }[] } */
list: () => this.#json('GET', '/api/customergroup'),
/** POST /api/customergroup body { name } → 201 { customergroup: { id, name, sort } } */
create: (body) => this.#json('POST', '/api/customergroup', { body }),
/** PATCH /api/customergroup/{id} body { name } → { customergroup: { id, name, sort } } */
update: (id, body) =>
this.#json('PATCH', `/api/customergroup/${enc(id)}`, { body }),
/** PUT /api/customergroup/order body { customergroupIds } → { customergroupIds } */
order: (body) => this.#json('PUT', '/api/customergroup/order', { body }),
/** DELETE /api/customergroup/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/customergroup/${enc(id)}`),
};
this.entity = {
/** GET /api/entity → { entities } · optional query customergroupId */
list: (query) => this.#json('GET', '/api/entity', { query }),
/** GET /api/entity/{id} → { entity } */
get: (id) => this.#json('GET', `/api/entity/${enc(id)}`),
/** POST /api/entity → 201 { entity } */
create: (body) => this.#json('POST', '/api/entity', { body }),
/** PATCH /api/entity/{id} → { entity } */
update: (id, body) => this.#json('PATCH', `/api/entity/${enc(id)}`, { body }),
/** DELETE /api/entity/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/entity/${enc(id)}`),
/** GET /api/entity/next-number → { number: string } */
nextNumber: () => this.#json('GET', '/api/entity/next-number'),
/** GET /api/entity/{entityId}/address → { addresses } */
listAddresses: (entityId) =>
this.#json('GET', `/api/entity/${enc(entityId)}/address`),
/** POST /api/entity/{entityId}/address → 201 { address } */
createAddress: (entityId, body) =>
this.#json('POST', `/api/entity/${enc(entityId)}/address`, { body }),
/** PATCH /api/entity/{entityId}/address/{id} → { address } */
updateAddress: (entityId, id, body) =>
this.#json('PATCH', `/api/entity/${enc(entityId)}/address/${enc(id)}`, {
body,
}),
/** DELETE /api/entity/{entityId}/address/{id} → 204 */
deleteAddress: (entityId, id) =>
this.#empty('DELETE', `/api/entity/${enc(entityId)}/address/${enc(id)}`),
/** GET /api/entity/{entityId}/contact → { contacts } */
listContacts: (entityId) =>
this.#json('GET', `/api/entity/${enc(entityId)}/contact`),
/** POST /api/entity/{entityId}/contact → 201 { contact } */
createContact: (entityId, body) =>
this.#json('POST', `/api/entity/${enc(entityId)}/contact`, { body }),
/** PATCH /api/entity/{entityId}/contact/{id} → { contact } */
updateContact: (entityId, id, body) =>
this.#json('PATCH', `/api/entity/${enc(entityId)}/contact/${enc(id)}`, {
body,
}),
/** DELETE /api/entity/{entityId}/contact/{id} → 204 */
deleteContact: (entityId, id) =>
this.#empty('DELETE', `/api/entity/${enc(entityId)}/contact/${enc(id)}`),
};
this.category = {
/** GET /api/category/tree */
tree: () => this.#json('GET', '/api/category/tree'),
/** GET /api/category/export.zip */
exportZip: () => this.#blob('GET', '/api/category/export.zip'),
/** POST /api/category */
create: (body) => this.#json('POST', '/api/category', { body }),
/** GET /api/category/{id}/articles */
articles: (id) => this.#json('GET', `/api/category/${enc(id)}/articles`),
/** PATCH /api/category/{id} */
update: (id, body) => this.#json('PATCH', `/api/category/${enc(id)}`, { body }),
/** DELETE /api/category/{id} */
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
/** PUT /api/category/order */
order: (body) => this.#json('PUT', '/api/category/order', { body }),
/** POST /api/category/{id}/image — raw AVIF body */
addImage: (id, avifBytes) =>
this.#json('POST', `/api/category/${enc(id)}/image`, {
body: avifBytes,
contentType: 'image/avif',
raw: true,
}),
/** DELETE /api/category/{id}/image */
deleteImage: (id) => this.#json('DELETE', `/api/category/${enc(id)}/image`),
};
this.article = {
/** GET /api/articleIndex */
index: () => this.#json('GET', '/api/articleIndex'),
/** POST /api/article */
create: (body) => this.#json('POST', '/api/article', { body }),
/** GET /api/article/export.zip */
exportZip: (query = {}) => this.#blob('GET', '/api/article/export.zip', { query }),
/** GET /api/article/{sku} */
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
/**
* Load history and refetch on SSE `article-<sku>`.
* Paginates in StorageManager until the HTTP API supports skip/offset.
* `onHistory(history, total)` — returns `{ unwatch, setSkipAndOffset }`.
*/
historyWatch: (sku, onHistory) => this.watchArticleHistory(sku, onHistory),
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi, track_stock } */
update: async (sku, body) => {
const nameChange = body != null && Object.hasOwn(body, 'name');
if (nameChange) {
this.setArticleIndexName(sku, body.name);
this.setArticleName(sku, body.name);
}
try {
const res = await this.#json('PATCH', `/api/article/${enc(sku)}`, { body });
this.setArticle(res.article);
return res;
} catch (err) {
if (nameChange) {
this.clearArticleIndex();
if (this.getArticle(sku)) {
void this.loadArticle(sku).catch(() => {});
}
}
throw err;
}
},
/** DELETE /api/article/{sku} */
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
/** PUT /api/article/{sku}/prices */
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
/** POST /api/article/{sku}/image — raw AVIF body */
addImage: (sku, avifBytes) =>
this.#json('POST', `/api/article/${enc(sku)}/image`, {
body: avifBytes,
contentType: 'image/avif',
raw: true,
}),
/** DELETE /api/article/{sku}/image/{fullHash} */
deleteImage: (sku, fullHash) =>
this.#empty('DELETE', `/api/article/${enc(sku)}/image/${enc(fullHash)}`),
/** PUT /api/article/{sku}/images/order */
orderImages: (sku, body) =>
this.#json('PUT', `/api/article/${enc(sku)}/images/order`, { body }),
/** PUT /api/article/{sku}/eans */
setEans: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/eans`, { body }),
/** PUT /api/article/{sku}/brand */
setBrand: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/brand`, { body }),
/** PUT /api/article/{sku}/weight */
setWeight: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/weight`, { body }),
/** PUT /api/article/{sku}/categories */
setCategories: (sku, body) =>
this.#json('PUT', `/api/article/${enc(sku)}/categories`, { body }),
/** PUT /api/article/{sku}/track_stock */
setTrackStock: async (sku, body) => {
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
const res = await this.#json('PUT', `/api/article/${enc(sku)}/track_stock`, { body: payload });
if (res?.article) {
this.setArticle(res.article);
}
return res;
},
/** GET /api/article/{sku}/supplier */
getSuppliers: (sku) => this.#json('GET', `/api/article/${enc(sku)}/supplier`),
/** POST /api/article/{sku}/supplier */
addSupplier: (sku, body) =>
this.#json('POST', `/api/article/${enc(sku)}/supplier`, { body }),
/** PATCH /api/article/{sku}/supplier/{id} */
updateSupplier: (sku, id, body) =>
this.#json('PATCH', `/api/article/${enc(sku)}/supplier/${enc(id)}`, { body }),
/** PUT /api/article/{sku}/supplier/order */
orderSuppliers: (sku, body) =>
this.#json('PUT', `/api/article/${enc(sku)}/supplier/order`, { body }),
/** DELETE /api/article/{sku}/supplier/{id} */
deleteSupplier: (sku, id) =>
this.#empty('DELETE', `/api/article/${enc(sku)}/supplier/${enc(id)}`),
};
this.posterminal = {
/** GET /api/posterminal */
list: () => this.#json('GET', '/api/posterminal'),
/** PUT /api/posterminal */
upsert: (body) => this.#json('PUT', '/api/posterminal', { body }),
/** DELETE /api/posterminal/{id} */
delete: (id) => this.#empty('DELETE', `/api/posterminal/${enc(id)}`),
/** PUT /api/posterminal/{id}/category/{categoryId} */
setOn: (id, categoryId) =>
this.#json('PUT', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
/** DELETE /api/posterminal/{id}/category/{categoryId} */
setOff: (id, categoryId) =>
this.#json('DELETE', `/api/posterminal/${enc(id)}/category/${enc(categoryId)}`),
/** PUT /api/posterminal/{id}/all */
setOnAll: (id) => this.#json('PUT', `/api/posterminal/${enc(id)}/all`),
/** DELETE /api/posterminal/{id}/all */
setOffAll: (id) => this.#json('DELETE', `/api/posterminal/${enc(id)}/all`),
};
this.number = {
/** GET /api/number → { numbers: [{ id, type, prefix, padding, next }] } */
list: () => this.#json('GET', '/api/number'),
/** PATCH /api/number/{id} — body: { prefix?, padding?, next? } (only present keys patched) */
update: (id, body) => this.#json('PATCH', `/api/number/${enc(id)}`, { body }),
};
this.printer = {
/** GET /api/printer → { printers: Printer[] } */
list: () => this.#json('GET', '/api/printer'),
/** POST /api/printer → 201 { printer } */
create: (body) => this.#json('POST', '/api/printer', { body }),
/** PATCH /api/printer/{id} → { printer } */
update: (id, body) => this.#json('PATCH', `/api/printer/${enc(id)}`, { body }),
/** DELETE /api/printer/{id} → 204 */
delete: (id) => this.#empty('DELETE', `/api/printer/${enc(id)}`),
/** PUT /api/printer/order body { printerIds } → { printerIds } */
order: (body) => this.#json('PUT', '/api/printer/order', { body }),
};
this.setting = {
/** GET /api/company/setting?frontendOnly=1 → { settings: [{ key, value, frontend }] } */
list: (query) => this.#json('GET', '/api/company/setting', { query }),
/** GET /api/company/setting/{key} → { setting: { key, value, frontend } } */
get: (key) => this.#json('GET', `/api/company/setting/${enc(key)}`),
/** PUT /api/company/setting/{key} body { value, frontend } → { setting: { key, value, frontend } } */
upsert: (key, body) =>
this.#json('PUT', `/api/company/setting/${enc(key)}`, { body }),
};
this.image = {
/** GET /api/image/{fullHash} → AVIF blob */
get: (fullHash) => this.#blob('GET', `/api/image/${enc(fullHash)}`),
/** Absolute URL for <img src> (same-origin / cookie auth). */
url: (fullHash) => buildUrl(this.#baseUrl, `/api/image/${enc(fullHash)}`),
};
this.thumb = {
/** GET /api/thumb/{fullHash} → AVIF blob */
get: (fullHash) => this.#blob('GET', `/api/thumb/${enc(fullHash)}`),
/** Absolute URL for <img src> (same-origin / cookie auth). */
url: (fullHash) => buildUrl(this.#baseUrl, `/api/thumb/${enc(fullHash)}`),
};
}
/** Absolute URL for a same-origin API path (e.g. SSE). */
urlFor(path) {
return buildUrl(this.#baseUrl, path);
}
async #json(method, path, { body, query, contentType, raw, headers: extraHeaders } = {}) {
const url = buildPath(path, query);
const headers = { ...extraHeaders };
let payload;
if (body !== undefined) {
if (typeof FormData !== 'undefined' && body instanceof FormData) {
payload = body;
} else if (raw) {
if (contentType) headers['Content-Type'] = contentType;
payload = body;
} else {
headers['Content-Type'] = contentType || 'application/json';
payload = JSON.stringify(body);
}
}
const res = await fetch(url, {
method,
headers,
credentials: 'include',
body: payload,
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = url;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
return res.json();
}
async #empty(method, path, { query } = {}) {
await this.#json(method, path, { query });
return null;
}
async #blob(method, path, { query } = {}) {
const url = buildPath(path, query);
const res = await fetch(url, {
method,
credentials: 'include',
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = url;
err.method = method;
err.data = data;
throw err;
}
return res.blob();
}
}
export default ApiClient;

View File

@@ -1,806 +0,0 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { ApiClient } from './ApiClient';
import artikelAutomat from '../automats/artikelAutomat';
import companyAutomat from '../automats/companyAutomat';
import brandAutomat from '../automats/brandAutomat';
import unitAutomat from '../automats/unitAutomat';
import authAutomat from '../automats/authAutomat';
import categoryAutomat, { invalidateAllCategoryViews } from '../automats/categoryAutomat';
import posterminalAutomat from '../automats/posterminalAutomat';
import warehouseAutomat from '../automats/warehouseAutomat';
/**
* Stale-while-revalidate cache: readers keep the current promise; update()
* swaps only after a successful reload. Failed updates leave the cache intact.
* replace() writes local data without a round-trip.
*/
function createCache(label, load) {
/** Settled value, or `undefined` if not loaded yet / last load failed. */
let current;
let promise = load()
.then((data) => {
current = data;
return data;
})
.catch((err) => {
console.error(`[storageMgr] ${label} failed`, err);
throw err;
});
async function update() {
try {
const data = await load();
current = data;
promise = Promise.resolve(data);
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
throw err;
}
}
function replace(data) {
current = data;
promise = Promise.resolve(data);
return data;
}
function clear() {
current = undefined;
promise = load()
.then((data) => {
current = data;
return data;
})
.catch((err) => {
console.error(`[storageMgr] ${label} failed`, err);
throw err;
});
}
return {
update,
replace,
clear,
get promise() {
return promise;
},
/** Loaded value, or `undefined` if the initial load has not settled successfully. */
get value() {
return current;
},
};
}
/**
* ApiClient plus stale-while-revalidate caches and GET /api/events invalidation.
*/
export class StorageManager extends ApiClient {
/** @type {EventSource | null} */
#events = null;
/** @type {ReturnType<StorageManager['#createDeferredCache']> | null} */
#articleIndexCache = null;
/** @type {Map<string, Set<(article: object) => void>>} sku → article listeners */
#articleWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → history refreshers */
#historyWatchers = new Map();
/** @type {Map<string, Set<() => void>>} sku → supplier refreshers */
#supplierWatchers = new Map();
constructor(baseUrl = window.location.origin) {
super(baseUrl);
this.#initCaches();
this.article.watch = (sku, onArticle) => this.watchArticle(sku, onArticle);
this.article.supplierWatch = (sku, onSuppliers) =>
this.watchArticleSuppliers(sku, onSuppliers);
}
/** Cached article detail from the shared artikelAutomat, or null. */
getArticle(sku) {
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/**
* Keep the shared article cache in sync after GET/PATCH/PUT responses and
* notify any StorageManager-level watchers.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
const stored = artikelAutomat.actions.setArticle(article);
const watchers = this.#articleWatchers.get(key);
if (watchers) {
for (const onArticle of watchers) onArticle(stored);
}
return stored;
}
/**
* GET /api/article/{sku} via the shared cache (deduplicated), notify watchers.
* @param {string} sku
*/
async loadArticle(sku) {
const article = await artikelAutomat.actions.loadArticle(sku);
const watchers = this.#articleWatchers.get(String(sku));
if (watchers) {
for (const onArticle of watchers) onArticle(article);
}
return article;
}
/**
* Subscribe to article detail updates (local setArticle + SSE `article-<sku>`).
* @param {string} sku
* @param {(article: object) => void} onArticle
* @returns {{ article: object | null, unwatch: () => void }}
*/
watchArticle(sku, onArticle) {
const key = String(sku);
const needListen =
!this.#articleWatchers.has(key) &&
!this.#historyWatchers.has(key) &&
!this.#supplierWatchers.has(key);
let watchers = this.#articleWatchers.get(key);
if (!watchers) {
watchers = new Set();
this.#articleWatchers.set(key, watchers);
}
watchers.add(onArticle);
if (needListen) {
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
}
return {
article: this.getArticle(key) ?? null,
unwatch: () => {
const set = this.#articleWatchers.get(key);
if (!set) return;
set.delete(onArticle);
if (set.size === 0) {
this.#articleWatchers.delete(key);
if (
!this.#historyWatchers.has(key) &&
!this.#supplierWatchers.has(key)
) {
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
}
}
},
};
}
/**
* Parsed category map + root id from categoryAutomat, or null if not yet loaded.
* @returns {{ categories: Record<string, object>, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const catState = categoryAutomat.state;
if (catState.categories == null || catState.treeStatus !== 'succeeded') return null;
return {
categories: catState.categories,
rootCategoryId: catState.rootCategoryId,
};
}
/**
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
* Paginates in the store (full list from HTTP; slice via setSkipAndOffset).
* @param {string} sku
* @param {(history: unknown[], total: number) => void} onHistory
* @returns {{ unwatch: () => void, setSkipAndOffset: (skip: number, offset: number) => void }}
*/
watchArticleHistory(sku, onHistory) {
const key = String(sku);
let active = true;
let loaded = false;
/** @type {unknown[]} */
let all = [];
let skip = 0;
/** Page size (how many rows to emit). */
let offset = Number.POSITIVE_INFINITY;
const emit = () => {
if (!active || !loaded) return;
const total = all.length;
const end =
offset === Number.POSITIVE_INFINITY ? total : skip + offset;
onHistory(all.slice(skip, end), total);
};
const refresh = () =>
this.article.history(key).then((res) => {
if (!active) return;
requireObject(res, 'GET /api/article/{sku}/history');
all = requireArray(res.history, 'history');
loaded = true;
emit();
});
const run = () =>
void refresh().catch((err) => {
if (!active) return;
notifyError(err);
});
const needListen =
!this.#articleWatchers.has(key) &&
!this.#historyWatchers.has(key) &&
!this.#supplierWatchers.has(key);
let watchers = this.#historyWatchers.get(key);
if (!watchers) {
watchers = new Set();
this.#historyWatchers.set(key, watchers);
}
watchers.add(run);
if (needListen) {
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
}
run();
return {
unwatch: () => {
active = false;
const set = this.#historyWatchers.get(key);
if (!set) return;
set.delete(run);
if (set.size === 0) {
this.#historyWatchers.delete(key);
if (
!this.#articleWatchers.has(key) &&
!this.#supplierWatchers.has(key)
) {
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
}
}
},
setSkipAndOffset: (nextSkip, nextOffset) => {
skip = nextSkip;
offset = nextOffset;
emit();
},
};
}
/**
* Load suppliers for an article and refetch on SSE `article-<sku>`.
* @param {string} sku
* @param {(suppliers: Array<object>) => void} onSuppliers
* @returns {{ unwatch: () => void, refresh: () => Promise<void> }}
*/
watchArticleSuppliers(sku, onSuppliers) {
const key = String(sku);
let active = true;
const refresh = async () => {
const res = await this.article.getSuppliers(key);
if (!active) return;
requireObject(res, 'GET /api/article/{sku}/supplier');
const list = requireArray(res.suppliers, 'suppliers');
onSuppliers(list);
};
const run = () => {
void refresh().catch((err) => {
if (!active) return;
notifyError(err);
});
};
const needListen =
!this.#articleWatchers.has(key) &&
!this.#historyWatchers.has(key) &&
!this.#supplierWatchers.has(key);
let watchers = this.#supplierWatchers.get(key);
if (!watchers) {
watchers = new Set();
this.#supplierWatchers.set(key, watchers);
}
watchers.add(run);
if (needListen) {
this.#events?.addEventListener(`article-${key}`, this.#onArticleSignal);
}
run();
return {
refresh,
unwatch: () => {
active = false;
const set = this.#supplierWatchers.get(key);
if (!set) return;
set.delete(run);
if (set.size === 0) {
this.#supplierWatchers.delete(key);
if (
!this.#articleWatchers.has(key) &&
!this.#historyWatchers.has(key)
) {
this.#events?.removeEventListener(`article-${key}`, this.#onArticleSignal);
}
}
},
};
}
#onArticleSignal = (event) => {
const sku = event.type.slice('article-'.length);
// Refresh the shared cache; silently when nothing is watching this SKU.
if (this.#articleWatchers.has(sku)) {
void artikelAutomat.actions.reloadArticle(sku).catch((err) => notifyError(err));
} else {
void artikelAutomat.actions.reloadArticle(sku).catch(() => {});
}
const historyWatchers = this.#historyWatchers.get(sku);
if (historyWatchers) {
for (const run of historyWatchers) run();
}
const supplierWatchers = this.#supplierWatchers.get(sku);
if (supplierWatchers) {
for (const run of supplierWatchers) run();
}
};
/** Patch local articleIndex name if already loaded; no-op otherwise. */
setArticleIndexName(sku, name) {
const articles = this.#articleIndexCache.value;
if (!articles) return;
this.#articleIndexCache.replace(
articles.map((a) => (String(a.sku) === String(sku) ? { ...a, name } : a)),
);
}
/** Patch cached article detail name if already cached; no-op otherwise. */
setArticleName(sku, name) {
const current = this.getArticle(sku);
if (!current) return;
this.setArticle({ ...current, name });
}
/** Drop local articleIndex if loaded (forces reload on next read). */
clearArticleIndex() {
if (!this.#articleIndexCache.value) return;
this.#articleIndexCache.clear();
}
#initCaches() {
const authMeCache = createCache('GET /auth/me', () => this.#loadAuthMe());
const deliveryCache = this.#createDeferredCache('GET /delivery', () =>
this.#loadDeliveries(),
);
const customergroupCache = this.#createDeferredCache('GET /customergroup', () =>
this.#loadCustomergroups(),
);
const entityCache = this.#createDeferredCache('GET /entity', () =>
this.#loadEntities(),
);
const numberCache = this.#createDeferredCache('GET /number', () =>
this.#loadNumbers(),
);
const quoteCache = this.#createDeferredCache('GET /quote', () =>
this.#loadQuotes(),
);
const orderCache = this.#createDeferredCache('GET /order', () =>
this.#loadOrders(),
);
// articleIndex is auth-gated — create a deferred cache that only loads after auth
this.#articleIndexCache = this.#createDeferredCache(
'GET /articleIndex',
() => this.#loadArticleIndex(),
);
const articleIndexCache = this.#articleIndexCache;
Object.defineProperties(this, {
authMePromise: { get: () => authMeCache.promise },
updateAuthMe: { value: authMeCache.update },
/** Settled GET /auth/me body, or undefined. */
authMe: { get: () => authMeCache.value },
articleIndexPromise: { get: () => articleIndexCache.promise },
updateArticleIndex: { value: articleIndexCache.update },
// ── Automat bridges (brand, unit, company, posterminal) ──────────────────
/** Brand list from brandAutomat (may be empty until authed). */
brands: { get: () => brandAutomat.state.brands },
/** Promise that resolves when brands are first loaded. */
brandPromise: {
get: () => brandAutomat.state.status === 'succeeded'
? Promise.resolve(brandAutomat.state.brands)
: new Promise((resolve) => {
const unsub = brandAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.brands); }
});
}),
},
/** Reload brands from API. */
updateBrand: { value: () => brandAutomat.actions.load() },
/** Unit list from unitAutomat. */
units: { get: () => unitAutomat.state.units },
/** Promise that resolves when units are first loaded. */
unitPromise: {
get: () => unitAutomat.state.status === 'succeeded'
? Promise.resolve(unitAutomat.state.units)
: new Promise((resolve) => {
const unsub = unitAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.units); }
});
}),
},
/** Reload units from API. */
updateUnit: { value: () => unitAutomat.actions.load() },
/** Posterminal list from posterminalAutomat. */
posterminals: { get: () => posterminalAutomat.state.terminals },
/** Promise that resolves when posterminals are first loaded. */
posterminalPromise: {
get: () => posterminalAutomat.state.status === 'succeeded'
? Promise.resolve(posterminalAutomat.state.terminals)
: new Promise((resolve, reject) => {
const unsub = posterminalAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.terminals); }
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload posterminals from API. */
updatePosterminal: { value: () => posterminalAutomat.actions.load() },
/**
* Current company object from companyAutomat, or null.
* Previously: companies array from GET /api/company.
* Consumers that used companies.find(c => c.id === companyId) can use .company directly.
*/
company: { get: () => companyAutomat.state.current ?? null },
/** Promise that resolves when company data is loaded. */
companyPromise: {
get: () => companyAutomat.state.status === 'succeeded'
? Promise.resolve(companyAutomat.state.current)
: new Promise((resolve, reject) => {
const unsub = companyAutomat.subscribe((s) => {
if (s.status === 'succeeded') { unsub(); resolve(s.current); }
else if (s.status === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload company data from API. */
updateCompany: { value: () => companyAutomat.actions.load() },
// Legacy compat: some components call store.companies.find(c => c.id === companyId).
// Return a single-element array so .find() still works.
companies: { get: () => companyAutomat.state.current ? [companyAutomat.state.current] : [] },
// ── Category tree bridge (delegated to categoryAutomat) ──────────────────
/** Category tree promise resolving when categoryAutomat tree is loaded. */
categoryTreePromise: {
get: () => categoryAutomat.state.treeStatus === 'succeeded'
? Promise.resolve(categoryAutomat.state.categories)
: new Promise((resolve, reject) => {
const unsub = categoryAutomat.subscribe((s) => {
if (s.treeStatus === 'succeeded') { unsub(); resolve(s.categories); }
else if (s.treeStatus === 'failed') { unsub(); reject(new Error(s.error)); }
});
}),
},
/** Reload category tree via categoryAutomat. */
updateCategoryTree: { value: () => categoryAutomat.actions.loadTree() },
/** Current categories map from categoryAutomat, or null. */
categoryTree: { get: () => categoryAutomat.state.categories },
// ── Deferred caches (auth-gated) ─────────────────────────────────────────
deliveryPromise: { get: () => deliveryCache.promise },
updateDelivery: { value: deliveryCache.update },
/** Settled GET /delivery list, or undefined. */
deliveries: { get: () => deliveryCache.value },
customergroupPromise: { get: () => customergroupCache.promise },
updateCustomergroup: { value: customergroupCache.update },
/** Settled GET /customergroup list, or undefined. */
customergroups: { get: () => customergroupCache.value },
entityPromise: { get: () => entityCache.promise },
updateEntity: { value: entityCache.update },
/** Settled GET /entity list, or undefined. */
entities: { get: () => entityCache.value },
numberPromise: { get: () => numberCache.promise },
updateNumber: { value: numberCache.update },
/** Settled GET /number list, or undefined. */
numbers: { get: () => numberCache.value },
quotePromise: { get: () => quoteCache.promise },
updateQuote: { value: quoteCache.update },
/** Settled GET /quote list, or undefined. */
quotes: { get: () => quoteCache.value },
orderPromise: { get: () => orderCache.promise },
updateOrder: { value: orderCache.update },
/** Settled GET /order list, or undefined. */
orders: { get: () => orderCache.value },
/** Close and reopen GET /api/events (required after selected-company change). */
reconnectEvents: { value: () => this.#reconnectEvents() },
});
let lastCompanyId = null;
const syncWithAuth = (auth) => {
const companyId =
auth.status === 'authed' && auth.user?.selectedCompanyId != null
? String(auth.user.selectedCompanyId)
: null;
if (companyId === lastCompanyId) return;
const prevCompanyId = lastCompanyId;
lastCompanyId = companyId;
if (!companyId) {
// Unauthenticated or logged out
this.#disconnectEvents();
articleIndexCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
numberCache.clear();
quoteCache.clear();
orderCache.clear();
artikelAutomat.actions.clear();
} else {
// Company established or switched
if (prevCompanyId) {
articleIndexCache.clear();
deliveryCache.clear();
customergroupCache.clear();
entityCache.clear();
numberCache.clear();
quoteCache.clear();
orderCache.clear();
artikelAutomat.actions.clear();
}
this.#reconnectEvents();
void articleIndexCache.trigger().catch(() => {});
void deliveryCache.trigger().catch(() => {});
void customergroupCache.trigger().catch(() => {});
void entityCache.trigger().catch(() => {});
void numberCache.trigger().catch(() => {});
void quoteCache.trigger().catch(() => {});
void orderCache.trigger().catch(() => {});
}
};
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
}
/**
* Create a cache that does NOT load immediately but waits for an explicit trigger
* or loads on-demand once authenticated.
*/
#createDeferredCache(label, load) {
let current;
let loadPromise = null;
const trigger = () => {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
const data = await load();
current = data;
return data;
} catch (err) {
console.error(`[storageMgr] ${label} failed`, err);
loadPromise = null;
throw err;
}
})();
return loadPromise;
};
const update = async () => {
try {
const data = await load();
current = data;
loadPromise = Promise.resolve(data);
return data;
} catch (err) {
console.error(`[storageMgr] update ${label} failed`, err);
throw err;
}
};
const replace = (data) => {
current = data;
loadPromise = Promise.resolve(data);
return data;
};
const clear = () => {
current = undefined;
loadPromise = null;
};
return {
update,
replace,
clear,
trigger,
get promise() {
if (!loadPromise) {
if (authAutomat.state.status === 'authed' && authAutomat.state.user?.selectedCompanyId) {
return trigger();
}
return new Promise((resolve, reject) => {
const unsub = authAutomat.subscribe((auth) => {
if (auth.status === 'authed' && auth.user?.selectedCompanyId) {
unsub();
trigger().then(resolve, reject);
} else if (auth.status === 'anon') {
unsub();
reject(new Error(`Cannot load ${label}: unauthenticated`));
}
});
});
}
return loadPromise;
},
get value() {
return current;
},
};
}
/**
* GET /api/events — SSE signals for the selected company.
* Stream is bound at connect time; reconnect after company switch.
*/
#reconnectEvents() {
this.#disconnectEvents();
const es = new EventSource(this.urlFor('/api/events'), { withCredentials: true });
es.addEventListener('category', () => {
void categoryAutomat.actions.loadTree().catch((err) => {
console.error('[storageMgr] category tree SSE refresh failed', err);
});
invalidateAllCategoryViews();
});
es.addEventListener('articles', () => {
invalidateAllCategoryViews();
void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles SSE refresh failed', err);
});
});
es.addEventListener('warehouse', () => {
void warehouseAutomat.actions.reload().catch((err) => {
console.error('[storageMgr] warehouse SSE refresh failed', err);
});
});
for (const sku of new Set([
...this.#articleWatchers.keys(),
...this.#historyWatchers.keys(),
...this.#supplierWatchers.keys(),
])) {
es.addEventListener(`article-${sku}`, this.#onArticleSignal);
}
es.onerror = () => {
console.error('[storageMgr] events stream error');
};
this.#events = es;
}
#disconnectEvents() {
if (!this.#events) return;
this.#events.close();
this.#events = null;
}
#syncEvents(user) {
if (user?.selectedCompanyId != null) {
this.#reconnectEvents();
} else {
this.#disconnectEvents();
}
}
async #loadAuthMe() {
try {
const data = await this.auth.me();
requireObject(data, 'GET /api/auth/me');
const session = {
user: data.user,
googleClientId: data.googleClientId,
};
this.#syncEvents(session.user);
return session;
} catch (err) {
if (err.status === 401 || err.status === 403) {
this.#syncEvents(null);
return {
user: null,
googleClientId: err.data?.googleClientId,
forbidden: err.status === 403,
message: err.message,
};
}
throw err;
}
}
async #loadArticleIndex() {
const data = await this.article.index();
requireObject(data, 'GET /api/articleIndex');
const articles = requireArray(data.articles, 'articleIndex.articles');
// Map backend rows to the recordset shape used by the result list.
// `categories` is a comma-joined string (formatCategoryList splits on ',').
return articles.map((a) => ({
id: a.id,
sku: a.sku,
name: a.name,
eans: a.eans ?? [],
categories: Array.isArray(a.categoryIds) ? a.categoryIds.join(',') : null,
}));
}
async #loadDeliveries() {
const data = await this.delivery.list();
requireObject(data, 'GET /api/delivery');
return requireArray(data.deliveries, 'deliveries');
}
async #loadCustomergroups() {
const data = await this.customergroup.list();
requireObject(data, 'GET /api/customergroup');
return requireArray(data.customergroups, 'customergroups');
}
async #loadEntities() {
const data = await this.entity.list();
requireObject(data, 'GET /api/entity');
return requireArray(data.entities, 'entities');
}
async #loadNumbers() {
const data = await this.number.list();
requireObject(data, 'GET /api/number');
return requireArray(data.numbers, 'numbers');
}
async #loadQuotes() {
const data = await this.quote.list();
requireObject(data, 'GET /api/quote');
return requireArray(data.quotes, 'quotes');
}
async #loadOrders() {
const data = await this.order.list();
requireObject(data, 'GET /api/order');
return requireArray(data.orders, 'orders');
}
async doSearch({ target, filter, offset, maxCount, onUpdate }) {
const articles = await this.articleIndexPromise;
const query = target.trim().toLowerCase();
const matches = articles.filter(
(p) =>
p.name.toLowerCase().includes(query) ||
p.sku.toLowerCase().includes(query) ||
(p.eans ?? []).some((ean) => String(ean).includes(query)),
);
onUpdate({
categoryFacets: {},
totalCount: matches.length,
recordset: matches,
error: null,
});
}
}
export default StorageManager;

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

409
src/api/article.js Normal file
View File

@@ -0,0 +1,409 @@
import { http, httpBinary, httpGetBinary } from './http.js';
/**
* NOTE: there is no `articleIndex` namespace anymore.
*
* The whole-company search index (`GET /api/articleIndex`) was abandoned. Article
* lists are fetched per category via `category.getArticles(categoryId)`
* (`GET /api/category/:id/articles`); `categoryAutomat.loadTree()` warms every
* category view right after the tree loads, and `articleIndexAutomat` derives the
* client-side search index from those views.
*/
export const article = {
search({ target, filter, offset, maxCount } = {}) {
const params = new URLSearchParams({ target });
if (filter) params.set('filter', filter);
if (offset) params.set('offset', String(offset));
if (maxCount != null) params.set('maxCount', String(maxCount));
return http(`/api/article/search?${params}`);
},
/**
* GET /api/article/:sku → { article }
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
* (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.)
*/
getBySku(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`);
},
/**
* 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 }
*/
// #region GET /api/article/history — getHistory
getHistory({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
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 (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?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, { offset = 0, limit = 50, search, seekMonths } = {}) {
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 (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 { sku } */
create({ categoryId, name, track_stock }) {
return http('/api/article', {
method: 'POST',
body: {
categoryId: Number(categoryId),
name,
...(track_stock !== undefined ? { track_stock: Boolean(track_stock) } : {}),
},
});
},
/**
* 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
* → 204 (no body; refetch via getBySku)
*/
update(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
method: 'PATCH',
body,
});
},
/**
* PUT /api/article/:sku/track_stock
* Body: { track_stock: boolean }
* → 204 (no body)
*/
setTrackStock(sku, body) {
const payload = typeof body === 'boolean' ? { track_stock: body } : body;
return http(`/api/article/${encodeURIComponent(sku)}/track_stock`, {
method: 'PUT',
body: payload,
});
},
/**
* DELETE /api/article/:sku → 204
* Errors: 400 Article has children, 401, 403, 404 Article not found
*/
delete(sku, { cascade = false } = {}) {
const qs = cascade ? '?cascade=true' : '';
return http(`/api/article/${encodeURIComponent(sku)}${qs}`, {
method: 'DELETE',
});
},
/**
* POST /api/article/:sku/image
* Body: raw image bytes (Content-Type: image/webp, max 1000×1000)
* → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } }
*/
uploadImage(sku, file) {
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
/**
* DELETE /api/article/:sku/image/:fullHash → 204
*/
deleteImage(sku, fullHash) {
return http(
`/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`,
{ method: 'DELETE' },
);
},
/**
* PUT /api/article/:sku/images/order
* Body: { fullHashes: string[] } (every image exactly once)
* → 200 { fullHashes: string[] }
*/
orderImages(sku, fullHashes) {
return http(`/api/article/${encodeURIComponent(sku)}/images/order`, {
method: 'PUT',
body: { fullHashes },
});
},
/**
* PUT /api/article/:sku/prices
* Body: { prices: [{ price (net), tax (rate), minmultiplier? }] }
* Replace-all POS customer group prices. Empty prices clears all POS prices.
* → 204 (no body)
*/
setPrices(sku, prices) {
return http(`/api/article/${encodeURIComponent(sku)}/prices`, {
method: 'PUT',
body: { prices },
});
},
/**
* PUT /api/article/:sku/weight
* Body: { weight } → 204 (no body)
*/
setWeight(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/weight`, {
method: 'PUT',
body,
});
},
/**
* 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.
* → 204 (no body)
*/
setBrandIdBySku(sku, brandId) {
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
method: 'PUT',
body: { brandId: brandId != null ? Number(brandId) : null },
});
},
/**
* PUT /api/article/:sku/eans
* Body: { eans: string[] } — replace-all. Empty array clears all.
* EANs must be non-empty unique strings → 400 Invalid eans.
* → 204 (no body)
*/
setEansBySku(sku, eans) {
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
method: 'PUT',
body: { eans },
});
},
/**
* 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.
* → 204 (no body)
*/
setCategories(sku, categoryIds) {
return http(`/api/article/${encodeURIComponent(sku)}/categories`, {
method: 'PUT',
body: { categoryIds },
});
},
/**
* GET /api/article/:sku/supplier → { suppliers: ArticleSupplier[] }
*/
getSuppliers(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/supplier`);
},
/**
* POST /api/article/:sku/supplier
* Body: { entityId, sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
* → 201 { supplier: ArticleSupplier }
*/
addSupplier(sku, body) {
const payload = {
...body,
entityId: Number(body?.entityId),
};
return http(`/api/article/${encodeURIComponent(sku)}/supplier`, {
method: 'POST',
body: payload,
});
},
/**
* PATCH /api/article/:sku/supplier/:id
* Body: { sku?, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }
* → 200 { supplier: ArticleSupplier }
*/
updateSupplier(sku, id, body) {
return http(
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body,
},
);
},
/**
* PUT /api/article/:sku/supplier/order
* Body: { ids: number[] }
* → 200 { ids: number[] }
*/
orderSuppliers(sku, ids) {
return http(`/api/article/${encodeURIComponent(sku)}/supplier/order`, {
method: 'PUT',
body: { ids },
});
},
/**
* DELETE /api/article/:sku/supplier/:id → 204
*/
deleteSupplier(sku, id) {
return http(
`/api/article/${encodeURIComponent(sku)}/supplier/${encodeURIComponent(id)}`,
{
method: 'DELETE',
},
);
},
/**
* GET /api/article/export.zip → application/zip (binary download)
* `artikel.zip` = artikel.csv (semicolon-separated, Windows-1252) +
* one `<fullHash>.avif` per article image (content-addressed, deduplicated).
* Optional `categoryId` restricts the export to that category and below.
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
*/
exportZip(categoryId) {
const params = new URLSearchParams();
if (categoryId != null) params.set('categoryId', String(categoryId));
const qs = params.toString();
return httpGetBinary(`/api/article/export.zip${qs ? `?${qs}` : ''}`);
},
/**
* POST /api/article/:sku/print
* Body: { printerId, templateId, quantity }
* Prints the given template to the specified printer for this article.
*/
printLabel(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/print`, {
method: 'POST',
body,
});
},
/**
* GET /api/article/:sku/attributevalue → { attributevalues: ArticleAttributeValue[] }
*/
getAttributeValues(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`);
},
/**
* PUT /api/article/:sku/attributevalue
* Body: { attributevalues: number[] | { attributeValueId: number, sort?: number }[] }
* → 200 { attributevalues: ArticleAttributeValue[] }
*/
setAttributeValues(sku, attributevalues) {
return http(`/api/article/${encodeURIComponent(sku)}/attributevalue`, {
method: 'PUT',
body: { attributevalues },
});
},
/**
* GET /api/article/:sku/property → { properties: ArticleProperty[] }
*/
getProperties(sku) {
return http(`/api/article/${encodeURIComponent(sku)}/property`);
},
/**
* PUT /api/article/:sku/property
* Body: { properties: { propertyId: number, value: string, sort?: number }[] }
* → 200 { properties: ArticleProperty[] }
*/
setProperties(sku, properties) {
return http(`/api/article/${encodeURIComponent(sku)}/property`, {
method: 'PUT',
body: { properties },
});
},
/**
* DELETE /api/article/:sku/property/:propertyId → 200 { ok: true }
*/
deleteProperty(sku, propertyId) {
return http(
`/api/article/${encodeURIComponent(sku)}/property/${encodeURIComponent(propertyId)}`,
{ method: 'DELETE' }
);
},
/**
* POST /api/article/:sku/parent
* Body: { isParent: boolean, attributeNameId?: number } → 204 (no body)
*/
toggleParent(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/parent`, {
method: 'POST',
body,
});
},
/**
* POST /api/article/:sku/children
* Body: { attributeValueId?: number, value?: string, name?: string }
* → 201 { sku }
*/
addChild(sku, body) {
return http(`/api/article/${encodeURIComponent(sku)}/children`, {
method: 'POST',
body,
});
},
/**
* DELETE /api/article/:sku/children/:childSku
*/
removeChild(sku, childSku, options = {}) {
const qs = options?.deleteArticle ? '?deleteArticle=true' : '';
return http(
`/api/article/${encodeURIComponent(sku)}/children/${encodeURIComponent(childSku)}${qs}`,
{ method: 'DELETE' }
);
},
/**
* PUT /api/article/:sku/articlelist
* Body: { items: [{ articleId, sku, count, sort }] }
*/
setArticlelist(sku, items) {
return http(`/api/article/${encodeURIComponent(sku)}/articlelist`, {
method: 'PUT',
body: Array.isArray(items) ? { items } : items,
});
},
/**
* PUT /api/article/:sku/optionslist
* Body: { options: [{ articleId, sku, count, sort, mandated }] }
*/
setOptionslist(sku, options) {
return http(`/api/article/${encodeURIComponent(sku)}/optionslist`, {
method: 'PUT',
body: Array.isArray(options) ? { options } : options,
});
},
};

38
src/api/attributename.js Normal file
View File

@@ -0,0 +1,38 @@
import { http } from './http.js';
export const attributename = {
/** GET /api/attributename → { attributenames: Attributename[] } */
// #region GET /api/attributename — list
list() {
return http('/api/attributename');
},
// #endregion
/** POST /api/attributename → 201 { attributename } */
// #region POST /api/attributename — create
create(body) {
return http('/api/attributename', { method: 'POST', body });
},
// #endregion
/** PATCH /api/attributename/:id → 200 { attributename } */
// #region PATCH /api/attributename/:id — update
update(id, body) {
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/attributename/:id → 200 */
// #region DELETE /api/attributename/:id — delete
delete(id) {
return http(`/api/attributename/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #endregion
/** PUT /api/attributename/order → 200 */
// #region PUT /api/attributename/order — order
order(attributenameIds) {
return http('/api/attributename/order', { method: 'PUT', body: { attributenameIds } });
},
// #endregion
};

38
src/api/attributevalue.js Normal file
View File

@@ -0,0 +1,38 @@
import { http } from './http.js';
export const attributevalue = {
/** POST /api/attributename/:attributeId/value → 201 { attributevalue } */
// #region POST /api/attributename/:attributeId/value — create
create(attributeId, body) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value`, {
method: 'POST',
body,
});
},
// #endregion
/** PATCH /api/attributevalue/:id → 200 { attributevalue } */
// #region PATCH /api/attributevalue/:id — update
update(id, body) {
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/attributevalue/:id → 200 */
// #region DELETE /api/attributevalue/:id — delete
delete(id) {
return http(`/api/attributevalue/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #endregion
/** PUT /api/attributename/:attributeId/value/order → 200 */
// #region PUT /api/attributename/:attributeId/value/order — order
order(attributeId, attributevalueIds) {
return http(`/api/attributename/${encodeURIComponent(attributeId)}/value/order`, {
method: 'PUT',
body: { attributevalueIds },
});
},
// #endregion
};

64
src/api/auth.js Normal file
View File

@@ -0,0 +1,64 @@
import { http, logApi } from './http.js';
export const auth = {
// #region GET /api/auth/me — me
async me() {
try {
const data = await http('/api/auth/me');
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,
};
}
throw err;
}
},
// #endregion
// #region POST /api/auth/login — loginWithGoogle
async loginWithGoogle(idToken) {
logApi('loginWithGoogle', {
idTokenLength: idToken?.length ?? 0,
idTokenPrefix: idToken ? `${idToken.slice(0, 12)}…` : null,
});
const data = await http('/api/auth/login', {
method: 'POST',
body: { idToken },
});
// Confirm whether the session cookie actually works (not just login JSON).
const session = await auth.me();
logApi('session-after-login', {
loginUser: data.user?.email ?? data.user?.sub ?? data.user ?? null,
meUser: session.user?.email ?? session.user?.sub ?? session.user ?? null,
meForbidden: session.forbidden ?? false,
meMessage: session.message ?? null,
sessionOk: Boolean(session.user),
});
return { user: data.user, session };
},
// #endregion
// #region POST /api/auth/logout — logout
async logout() {
try {
await http('/api/auth/logout', { method: 'POST' });
} catch (err) {
if (err.status !== 401) throw err;
}
},
// #endregion
};

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

83
src/api/brand.js Normal file
View File

@@ -0,0 +1,83 @@
import { http, httpBinary } from './http.js';
/**
* Company-scoped brands (selectedCompanyId from session).
* Shape: Brand = { id, name, gsprpinfo | null, pics: BrandPic[] }
*/
export const brand = {
/**
* GET /api/brand → { brands: Brand[], total, offset, limit }
* @param {{ offset?: number, limit?: number, includeSmallThumbs?: boolean }} [params]
*/
// #region GET /api/brand — list
list(params) {
const offset = Number(params?.offset ?? 0);
const limit = Number(params?.limit ?? 0);
const includeSmallThumbs = params?.includeSmallThumbs;
const query = new URLSearchParams();
if (Number.isFinite(offset) && offset > 0) query.set('offset', String(offset));
if (Number.isFinite(limit) && limit > 0) query.set('limit', String(limit));
if (includeSmallThumbs != null) query.set('includeSmallThumbs', includeSmallThumbs ? 'true' : 'false');
const qs = query.toString();
return http(qs ? `/api/brand?${qs}` : '/api/brand');
},
// #endregion
/**
* POST /api/brand
* Body: { name, gsprpinfo? } — insert for the selected company.
* Empty name ⇒ 400 Invalid brand.
* → 201 { brand: Brand } · 409 Brand already exists
*/
// #region POST /api/brand — create
create(body) {
return http('/api/brand', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/brand/:id
* Body: { name, gsprpinfo? } — rename / update info.
* → 200 { id, name, gsprpinfo, touchedArticleCount } · 404 Brand not found
*/
// #region PATCH /api/brand/:id — rename
rename(id, body) {
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
// #endregion
/**
* DELETE /api/brand/:id
* → 204 No Content · 404 Brand not found
*/
// #region DELETE /api/brand/:id — delete
delete(id) {
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #endregion
/**
* POST /api/brand/:id/image
* Accepts full-size image body (Content-Type: image/webp).
* → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }
*/
// #region POST /api/brand/:id/image — addImage
addImage(brandId, file) {
return httpBinary(`/api/brand/${brandId}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
// #endregion
/**
* DELETE /api/brand/:id/image
* → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } }
*/
// #region DELETE /api/brand/:id/image — deleteImage
deleteImage(brandId) {
return http(`/api/brand/${brandId}/image`, { method: 'DELETE' });
},
// #endregion
};

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

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

155
src/api/category.js Normal file
View File

@@ -0,0 +1,155 @@
import { http, httpBinary, httpGetBinary } from './http.js';
export const category = {
/** GET /api/category/tree → { languageId, tree, articleCount } (company from session) */
// #region GET /api/category/tree — getTree
getTree() {
return http('/api/category/tree');
},
// #endregion
/**
* GET /api/category/:id/articles → { articles: [...] }
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, childCount, …
*/
// #region GET /api/category/:id/articles — getArticles
getArticles(categoryId) {
return http(`/api/category/${categoryId}/articles`);
},
// #endregion
/** POST /api/category → 201 { category } */
// #region POST /api/category — create
create({ parentId, name }) {
return http('/api/category', {
method: 'POST',
body: { parentId: Number(parentId), name },
});
},
// #endregion
/**
* DELETE /api/category/:id → 204
* Query: ?recursive=true (cascades deletion of subcategories and articles)
*/
// #region DELETE /api/category/:id — delete
delete(categoryId, { recursive = false } = {}) {
const qs = recursive ? '?recursive=true' : '';
return http(`/api/category/${categoryId}${qs}`, { method: 'DELETE' });
},
// #endregion
/**
* PATCH /api/category/:id
* Body: { name } — renames and re-slugifies in the company default language.
* Empty name → 400 name is required.
* → 200 { category: { id, name, slug } }
*/
// #region PATCH /api/category/:id — updateName
updateName(categoryId, name) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { name },
});
},
// #endregion
/**
* PUT /api/category/order
* Body: { parentId, categoryIds: number[] } — sibling order of the children
* under `parentId` (top-down). categoryIds must be exactly the current child
* set of parentId (same count, no foreign/duplicate ids) → 400 Invalid category order.
* → 200 { categoryIds: number[] }
*/
// #region PUT /api/category/order — reorder
reorder(parentId, categoryIds) {
return http('/api/category/order', {
method: 'PUT',
body: { parentId, categoryIds },
});
},
// #endregion
/**
* PATCH /api/category/:id
* Body: { parentId } — moves the category under a new parent.
* → 200 { category: { id, parentId, sort } } (kein name/slug im Response)
*/
// #region PATCH /api/category/:id — move
move(categoryId, parentId) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { parentId },
});
},
// #endregion
/**
* PATCH /api/category/:id
* Body: { pos: boolean } — toggles the POS (Point of Sale) flag.
* → 200 { category: { id, pos } }
*
* PROPOSED — backend needs to implement this field.
*/
// #region PATCH /api/category/:id — updatePos
updatePos(categoryId, pos) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { pos },
});
},
// #endregion
/**
* POST /api/category/:id/image
* Body: raw image bytes (Content-Type: image/webp)
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/
// #region POST /api/category/:id/image — uploadImage
uploadImage(categoryId, file) {
return httpBinary(`/api/category/${categoryId}/image`, {
method: 'POST',
contentType: file?.type || 'image/webp',
body: file,
});
},
// #endregion
/**
* DELETE /api/category/:id/image
* → 200 { category: { id, thumbW: null, thumbH: null, thumbHash: null } }
*/
// #region DELETE /api/category/:id/image — deleteImage
deleteImage(categoryId) {
return http(`/api/category/${categoryId}/image`, { method: 'DELETE' });
},
// #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,
* depth-first, `Ebene N` name column per depth) + one `<fullHash>.avif`
* entry per category image (content-addressed, deduplicated).
* Errors: 400 No company selected, 401, 403, 404 (JSON { error }).
*/
// #region GET /api/category/export.zip — exportZip
exportZip() {
return httpGetBinary('/api/category/export.zip');
},
// #endregion
};

336
src/api/company.js Normal file
View File

@@ -0,0 +1,336 @@
import { http } from './http.js';
export const company = {
/** GET /api/company → { companies: [...] } */
// #region GET /api/company — list
list() {
return http('/api/company');
},
// #endregion
/** Uses GET /api/company until a dedicated endpoint exists. */
// #region GET /api/company — getById
async getById(companyId) {
const data = await http('/api/company');
const found = data.companies?.find((item) => item.id === companyId);
if (!found) throw new Error('Unternehmen nicht gefunden.');
return { company: found };
},
// #endregion
// #region POST /api/company — create
create({ name, iso639, taxrate, reducedtaxrate }) {
return http('/api/company', {
method: 'POST',
body: { name, iso639, taxrate, reducedtaxrate },
});
},
// #endregion
/**
* PUT /api/company/:companyId
* Body: { name, iso639, taxrate, reducedtaxrate }
*/
// #region PUT /api/company/:companyId — update
update(companyId, { name, iso639, taxrate, reducedtaxrate }) {
return http(`/api/company/${companyId}`, {
method: 'PUT',
body: { name, iso639, taxrate, reducedtaxrate },
});
},
// #endregion
// #region PUT /api/auth/selected-company — select
select(companyId) {
return http('/api/auth/selected-company', {
method: 'PUT',
body: { companyId },
});
},
// #endregion
/**
* POST /api/company/import
* Uploads and streams catalog import (.zip) archive via Server-Sent Events.
* Tracks client upload progress (bytes/percentage) and processes incoming SSE events in real-time.
*
* @param {File|Blob|null} file
* @param {object} [callbacks]
* @param {(progress: { loaded: number, total: number, percent: number }) => void} [callbacks.onUploadProgress]
* @param {(message: string) => void} [callbacks.onStatus]
* @param {(progress: { categoriestotal: number, categoriesdone: number, articlestotal: number, articlesdone: number, imagestotal: number, imagesdone: number, receiptstotal: number, receiptsdone: number }) => void} [callbacks.onProgress] Counter-based progress snapshot across all phases.
* @param {(images: string[]) => void} [callbacks.onImages] Batched `images` events (~every 100ms) carrying Base62 SkeleHash strings.
* @param {(stats: object) => void} [callbacks.onDone]
* @param {(error: string) => void} [callbacks.onError]
* @param {object} [options]
* @param {AbortSignal} [options.signal]
*/
// #region POST /api/company/import — importCatalog
importCatalog(file, callbacks = {}, { signal } = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/company/import');
xhr.withCredentials = true;
xhr.setRequestHeader('Accept', 'text/event-stream');
if (signal) {
if (signal.aborted) {
return reject(new DOMException('Aborted', 'AbortError'));
}
signal.addEventListener('abort', () => {
xhr.abort();
reject(new DOMException('Aborted', 'AbortError'));
});
}
// Track request body upload progress
if (file && xhr.upload) {
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && event.total > 0) {
const percent = Math.min(100, Math.round((event.loaded / event.total) * 100));
callbacks.onUploadProgress?.({
loaded: event.loaded,
total: event.total,
percent,
});
}
};
}
// Stream and parse incoming SSE chunks as the server processes the archive
let processedIndex = 0;
let buffer = '';
const processChunks = () => {
const text = xhr.responseText || '';
if (text.length <= processedIndex) return;
const newChunk = text.slice(processedIndex);
processedIndex = text.length;
buffer += newChunk;
const messages = buffer.split('\n\n');
buffer = messages.pop() || '';
for (const msg of messages) {
if (!msg.trim()) continue;
const eventMatch = msg.match(/^event:\s*(\w+)/m);
const dataMatch = msg.match(/^data:\s*(.+)$/m);
const eventType = eventMatch ? eventMatch[1] : 'message';
let eventData = null;
if (dataMatch) {
try {
eventData = JSON.parse(dataMatch[1]);
} catch {
eventData = dataMatch[1];
}
}
switch (eventType) {
case 'status':
callbacks.onStatus?.(eventData?.message);
break;
case 'progress':
callbacks.onProgress?.(eventData);
break;
case 'images': {
// Payload is a plain array of Base62 SkeleHash strings;
// also tolerate the { images: [...] } wrapper shape.
const list = Array.isArray(eventData)
? eventData
: Array.isArray(eventData?.images)
? eventData.images
: [];
if (list.length) callbacks.onImages?.(list);
break;
}
case 'image':
// Legacy: single image event (pre-batching).
if (eventData) callbacks.onImages?.([eventData]);
break;
case 'done':
callbacks.onDone?.(eventData?.stats ?? eventData);
break;
case 'error':
callbacks.onError?.(eventData?.message || 'Fehler beim Importieren');
break;
}
}
};
xhr.onprogress = processChunks;
xhr.onload = () => {
processChunks();
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
let errMsg = `Upload fehlgeschlagen (HTTP ${xhr.status})`;
try {
const parsed = JSON.parse(xhr.responseText);
errMsg = parsed.error || parsed.message || errMsg;
} catch {
// ignore
}
reject(new Error(errMsg));
}
};
xhr.onerror = () => {
reject(new Error('Netzwerkfehler beim Upload der Datei'));
};
xhr.onabort = () => {
reject(new DOMException('Aborted', 'AbortError'));
};
const formData = new FormData();
if (file) {
formData.append('file', file);
}
xhr.send(formData);
});
},
// #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
};

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

@@ -0,0 +1,58 @@
import { http } from './http.js';
/**
* Company-scoped customer groups.
* Shape: CustomerGroup = { id, name, sort }
* List item: CustomerGroup & { editable } — editable false for POS / Lieferanten
* (name locked). Reorder via PUT /api/customergroup/order.
*/
export const customergroup = {
/** GET /api/customergroup → { customergroups: (CustomerGroup & { editable })[] } ordered by sort */
// #region GET /api/customergroup — list
list() {
return http('/api/customergroup');
},
// #endregion
/**
* POST /api/customergroup
* Body: { name } — empty name ⇒ 400 Name required;
* reserved POS / Lieferanten ⇒ 400 Name not allowed; sort appends next index.
* → 201 { customergroup: CustomerGroup }
*/
// #region POST /api/customergroup — create
create(body) {
return http('/api/customergroup', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/customergroup/:id
* Body: { name } — locked groups cannot be renamed (Name cannot be changed).
* → 200 { customergroup: CustomerGroup }
*/
// #region PATCH /api/customergroup/:id — update
update(id, body) {
return http(`/api/customergroup/${id}`, { method: 'PATCH', body });
},
// #endregion
/**
* PUT /api/customergroup/order
* Body: { customergroupIds: number[] } — must be exactly the company set (same ids, no dups)
* else 400 Invalid customer group order. Sets sort to each id’s index 0…n-1.
* → 200 { customergroupIds: number[] }
*/
// #region PUT /api/customergroup/order — order
order(body) {
return http('/api/customergroup/order', { method: 'PUT', body });
},
// #endregion
/** DELETE /api/customergroup/:id → 204 · 409 Customer group in use */
// #region DELETE /api/customergroup/:id — delete
delete(id) {
return http(`/api/customergroup/${id}`, { method: 'DELETE' });
},
// #endregion
};

132
src/api/delivery.js Normal file
View File

@@ -0,0 +1,132 @@
import { http, clientIdHeaders } from './http.js';
/**
* Company-scoped goods receipts (manifest type = delivery).
* DeliverySummary = { id, number, externalNumber, type: 'delivery', supplierId, date }
* - number: server-allocated, read-only manifest number (from nextNumber('delivery')).
* - externalNumber: user-entered reference (nullable).
* DeliveryLine = { id, articleId, sku, amount, net, transactedAmount, rebate, tax, comment }
* Delivery = DeliverySummary & { lines: DeliveryLine[] }
* DeliveryLineInput = { sku, amount, net, tax, rebate?, transactedAmount?, comment? }
*/
export const delivery = {
/**
* 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(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) {
return http(`/api/delivery/${id}`);
},
// #endregion
/**
* POST /api/delivery
* Body: { externalNumber?, date, supplierId?, lines: DeliveryLineInput[] }
* number is NOT sent — the server allocates it automatically. The
* caller-supplied reference goes in externalNumber (nullable).
* Line requires sku, amount, net, tax. rebate→0, transactedAmount→amount.
* → 201 { delivery: Delivery }
*/
// #region POST /api/delivery — create
create(body) {
return http('/api/delivery', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/delivery/:id
* Body: partial { externalNumber?, date?, supplierId?, lines? }.
* number is read-only and cannot be edited. externalNumber is editable and
* can be cleared by sending null or an empty string.
* If lines present, replaces all lines; if omitted, lines unchanged.
* → 200 { delivery: Delivery }
*/
// #region PATCH /api/delivery/:id — update
update(id, body) {
return http(`/api/delivery/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/delivery/:id → 204 */
// #region DELETE /api/delivery/:id — delete
delete(id) {
return http(`/api/delivery/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* GET /api/delivery/:id/file/:fileId → Blob
* Full binary content of attached file (PDF, text, etc.)
*/
// #region GET /api/delivery/:id/file/:fileId — getFile
async getFile(id, fileId, { download = false } = {}) {
const query = download ? '?download=true' : '';
const res = await fetch(`/api/delivery/${encodeURIComponent(id)}/file/${encodeURIComponent(fileId)}${query}`, {
method: 'GET',
credentials: 'include',
});
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;
}
return res.blob();
},
// #endregion
/**
* 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 DELETE /api/delivery/:id/file/:fileId — deleteFile
deleteFile(id, fileId) {
return http(`/api/delivery/${id}/file/${fileId}`, { method: 'DELETE' });
},
// #endregion
/**
* 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
};

126
src/api/entity.js Normal file
View File

@@ -0,0 +1,126 @@
import { http } from './http.js';
/**
* Company-scoped entities (customers / contacts in a customergroup).
* Shape: Entity = { id, customergroupId, firstName, name, createdAt, updatedAt }
*/
export const entity = {
/**
* GET /api/entity → { entities: Entity[] }
* Optional query: customergroupId
*/
// #region GET /api/entity — list
list({ customergroupId } = {}) {
const qs =
customergroupId != null
? `?customergroupId=${encodeURIComponent(customergroupId)}`
: '';
return http(`/api/entity${qs}`);
},
// #endregion
/** GET /api/entity/:id → { entity: Entity } */
// #region GET /api/entity/:id — get
get(id) {
return http(`/api/entity/${id}`);
},
// #endregion
/**
* POST /api/entity
* Body: { customergroupId, name, firstName? }
* → 201 { entity: Entity }
*/
// #region POST /api/entity — create
create(body) {
return http('/api/entity', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/entity/:id
* Body: { name?, firstName? }
* → 200 { entity: Entity }
*/
// #region PATCH /api/entity/:id — update
update(id, body) {
return http(`/api/entity/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/entity/:id → 204 */
// #region DELETE /api/entity/:id — delete
delete(id) {
return http(`/api/entity/${id}`, { method: 'DELETE' });
},
// #endregion
/** GET /api/entity/next-number → { number: string } */
// #region GET /api/entity/next-number — nextNumber
nextNumber() {
return http('/api/entity/next-number');
},
// #endregion
/** GET /api/entity/:entityId/address → { addresses: EntityAddress[] } */
// #region GET /api/entity/:entityId/address — listAddresses
listAddresses(entityId) {
return http(`/api/entity/${entityId}/address`);
},
// #endregion
/** POST /api/entity/:entityId/address → 201 { address } */
// #region POST /api/entity/:entityId/address — createAddress
createAddress(entityId, body) {
return http(`/api/entity/${entityId}/address`, { method: 'POST', body });
},
// #endregion
/** PATCH /api/entity/:entityId/address/:id → { address } */
// #region PATCH /api/entity/:entityId/address/:id — updateAddress
updateAddress(entityId, id, body) {
return http(`/api/entity/${entityId}/address/${id}`, {
method: 'PATCH',
body,
});
},
// #endregion
/** DELETE /api/entity/:entityId/address/:id → 204 */
// #region DELETE /api/entity/:entityId/address/:id — deleteAddress
deleteAddress(entityId, id) {
return http(`/api/entity/${entityId}/address/${id}`, { method: 'DELETE' });
},
// #endregion
/** GET /api/entity/:entityId/contact → { contacts: EntityContact[] } */
// #region GET /api/entity/:entityId/contact — listContacts
listContacts(entityId) {
return http(`/api/entity/${entityId}/contact`);
},
// #endregion
/** POST /api/entity/:entityId/contact → 201 { contact } */
// #region POST /api/entity/:entityId/contact — createContact
createContact(entityId, body) {
return http(`/api/entity/${entityId}/contact`, { method: 'POST', body });
},
// #endregion
/** PATCH /api/entity/:entityId/contact/:id → { contact } */
// #region PATCH /api/entity/:entityId/contact/:id — updateContact
updateContact(entityId, id, body) {
return http(`/api/entity/${entityId}/contact/${id}`, {
method: 'PATCH',
body,
});
},
// #endregion
/** DELETE /api/entity/:entityId/contact/:id → 204 */
// #region DELETE /api/entity/:entityId/contact/:id — deleteContact
deleteContact(entityId, id) {
return http(`/api/entity/${entityId}/contact/${id}`, { method: 'DELETE' });
},
// #endregion
};

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

@@ -0,0 +1,25 @@
import { http } from './http.js';
export const globalHistory = {
/**
* 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({ offset = 0, limit = 50, search, seekMonths, sortModel, filterModel } = {}) {
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 (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/history${qs ? `?${qs}` : ''}`);
},
// #endregion
};

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

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

185
src/api/http.js Normal file
View File

@@ -0,0 +1,185 @@
// Shared HTTP helpers for all backend communication.
// Extracted from src/api/index.js — single source of truth for fetch plumbing.
/** WebView consoles stringify objects as [object Object]; log JSON text. */
export function logApi(tag, details) {
let text;
try {
text = JSON.stringify(details, null, 2);
} catch {
text = String(details);
}
console[tag === 'error-body' ? 'error' : 'log'](`[api] ${tag} ${text}`);
}
export function readableHeaders(headers) {
const out = {};
headers.forEach((value, key) => {
out[key] = value;
});
return out;
}
export const SSE_CLIENT_ID_KEY = 'sse.cid';
export const CLIENT_ID_HEADER = 'X-Client-Id';
export function getStoredClientId() {
try {
if (typeof window === 'undefined') return null;
return window.sessionStorage.getItem(SSE_CLIENT_ID_KEY);
} catch {
return null;
}
}
export function clientIdHeaders() {
const cid = getStoredClientId();
return cid ? { [CLIENT_ID_HEADER]: cid } : {};
}
function isMutatingMethod(method) {
const upper = String(method || 'GET').toUpperCase();
return upper !== 'GET' && upper !== 'HEAD';
}
export async function http(path, { method = 'GET', body } = {}) {
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
const headers = {};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (isMutatingMethod(method)) {
Object.assign(headers, clientIdHeaders());
}
const url = new URL(path, window.location.origin).href;
const res = await fetch(path, {
method,
headers,
credentials: 'include',
body: isFormData ? body : (body ? JSON.stringify(body) : undefined),
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
const json = await res.json();
return json;
}
export async function httpBinary(path, { method = 'POST', body, contentType, headers: extraHeaders } = {}) {
const url = new URL(path, window.location.origin).href;
logApi('request', {
method,
path,
url,
origin: window.location.origin,
credentials: 'include',
contentType,
bodyBytes: body?.byteLength ?? body?.size ?? null,
});
const res = await fetch(path, {
method,
headers: { 'Content-Type': contentType, ...clientIdHeaders(), ...(extraHeaders || {}) },
credentials: 'include',
body,
});
const responseHeaders = readableHeaders(res.headers);
logApi('response', {
method,
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
responseHeaders,
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method, path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = method;
err.data = data;
throw err;
}
if (res.status === 204) return null;
const json = await res.json();
logApi('ok-body', {
method,
path,
keys: json && typeof json === 'object' ? Object.keys(json) : typeof json,
hasArticle: Boolean(json?.article),
});
return json;
}
/** GET a file (e.g. a ZIP download). Returns a Blob on success; JSON errors on failure. */
export async function httpGetBinary(path) {
logApi('request', {
method: 'GET',
path,
url: new URL(path, window.location.origin).href,
origin: window.location.origin,
credentials: 'include',
});
const res = await fetch(path, { method: 'GET', credentials: 'include' });
logApi('response', {
method: 'GET',
path,
status: res.status,
statusText: res.statusText,
ok: res.ok,
contentType: res.headers.get('content-type'),
responseHeaders: readableHeaders(res.headers),
});
if (!res.ok) {
let message = res.statusText;
let data;
try {
data = await res.json();
message = data.error || data.message || message;
} catch {
message = (await res.text().catch(() => message)) || message;
}
logApi('error-body', { method: 'GET', path, status: res.status, message, data });
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
err.path = path;
err.method = 'GET';
err.data = data;
throw err;
}
const blob = await res.blob();
return blob;
}

File diff suppressed because it is too large Load Diff

71
src/api/labeltemplate.js Normal file
View File

@@ -0,0 +1,71 @@
import { http } from './http.js';
/**
* Company-scoped ZPL label templates (selectedCompanyId from session).
* Shape: LabelTemplate = { id, company_id, name, width, height, dpi, type, content, thumb }
*/
export const labeltemplate = {
/** GET /api/labeltemplate(?sku=...) → { labeltemplates: LabelTemplate[] } */
// #region GET /api/labeltemplate — list
list(params) {
const search = params?.sku ? `?sku=${encodeURIComponent(params.sku)}` : '';
return http(`/api/labeltemplate${search}`);
},
// #endregion
/** GET /api/labeltemplate/:id → { labeltemplate: LabelTemplate } */
// #region GET /api/labeltemplate/:id — get
get(id) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`);
},
// #endregion
/**
* POST /api/labeltemplate
* Body: { name, width, height, dpi, type, content }
* → 201 { labeltemplate: LabelTemplate }
*/
// #region POST /api/labeltemplate — create
create(body) {
return http('/api/labeltemplate', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/labeltemplate/:id
* Body: partial { name?, width?, height?, dpi?, type?, content? }
* → 200 { labeltemplate: LabelTemplate }
*/
// #region PATCH /api/labeltemplate/:id — update
update(id, body) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/labeltemplate/:id → 204 */
// #region DELETE /api/labeltemplate/:id — delete
delete(id) {
return http(`/api/labeltemplate/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #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
};

62
src/api/ledger.js Normal file
View File

@@ -0,0 +1,62 @@
import { http } from './http.js';
/**
* Append-only stock and payment ledger.
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }
* Input = { manifestarticle_id, transaction_type, amount, transaction_text }
*/
export const ledger = {
/**
* 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 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
/**
* GET /api/ledger/manifestarticle/:manifestarticleId
* Query: ?transaction_type=...
* → 200 { ledger: LedgerEntry[] }
*/
// #region GET /api/ledger/manifestarticle/:manifestarticleId — listByManifestarticleId
listByManifestarticleId(manifestarticleId, transactionType) {
const query = transactionType ? `?transaction_type=${encodeURIComponent(transactionType)}` : '';
return http(`/api/ledger/manifestarticle/${encodeURIComponent(manifestarticleId)}${query}`);
},
// #endregion
/**
* GET /api/ledger
* Query: { manifestarticleId?, sku?, transactionType? }
* → 200 { ledger: LedgerEntry[] }
*/
// #region GET /api/ledger — list
list({ manifestarticleId, sku, transactionType } = {}) {
const params = new URLSearchParams();
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}` : ''}`);
},
// #endregion
};

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

@@ -0,0 +1,63 @@
import { http } from './http.js';
export const modelconversation = {
/**
* GET /api/modelconversation
* Lists the user's model conversations in the selected company.
* → 200 { conversations: [{ id, companyuser_id, provider, model, created_at, updated_at, state }] }
*/
// #region GET /api/modelconversation — list
list() {
return http('/api/modelconversation');
},
// #endregion
/**
* GET /api/modelconversation/:id
* Fetch conversation details by ID.
* @param {number|string} id
*/
// #region GET /api/modelconversation/:id — get
get(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}`);
},
// #endregion
/**
* POST /api/modelconversation
* Creates a new conversation in state 'idle'.
* Body: { provider, model }
* → 201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }
*/
// #region POST /api/modelconversation — create
create(body) {
return http('/api/modelconversation', { method: 'POST', body });
},
// #endregion
/**
* GET /api/modelconversation/:id/message
* Lists conversation messages, oldest first.
* → 200 { messages: [{ id, conversation_id, created_at, role, message }] }
*/
// #region GET /api/modelconversation/:id/message — listMessages
listMessages(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`);
},
// #endregion
// #region POST /api/modelconversation/:id/message — addMessage
addMessage(id, body) {
return http(`/api/modelconversation/${encodeURIComponent(id)}/message`, { method: 'POST', body });
},
// #endregion
/**
* DELETE /api/modelconversation/:id
*/
// #region DELETE /api/modelconversation/:id — delete
delete(id) {
return http(`/api/modelconversation/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #endregion
};

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

@@ -0,0 +1,24 @@
import { http } from './http.js';
export const number = {
/**
* GET /api/number → { numbers: [{ id, type, prefix, padding, next }] }
* Lists the number sequences for the selected company.
*/
// #region GET /api/number — list
list() {
return http('/api/number');
},
// #endregion
/**
* PATCH /api/number/:id
* Body: at least one of { prefix, padding, next } — only present keys are patched.
* → 200 { number: { id, type, prefix, padding, next } }
*/
// #region PATCH /api/number/:id — update
update(id, body) {
return http(`/api/number/${id}`, { method: 'PATCH', body });
},
// #endregion
};

104
src/api/order.js Normal file
View File

@@ -0,0 +1,104 @@
import { http } from './http.js';
/**
* Sales orders (manifest type = order).
* Summary = { id, number, type: 'order', entityId, date }
* Line shape same as quote (no transactedAmount).
*/
export const order = {
/**
* 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 = 50, search, onlyUnpaid, onlyUndelivered, seekMonths, entityId } = {}) {
const params = new URLSearchParams();
if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0))));
if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50))));
if (search && String(search).trim()) params.set('search', String(search).trim());
if (onlyUnpaid === true || onlyUnpaid === 'true') params.set('onlyUnpaid', 'true');
if (onlyUndelivered === true || onlyUndelivered === 'true') params.set('onlyUndelivered', 'true');
if (entityId != null) params.set('entityId', String(entityId));
if (seekMonths != null && Number(seekMonths) < 0) {
params.set('seekMonths', String(Math.floor(Number(seekMonths))));
}
const qs = params.toString();
return http(`/api/order${qs ? `?${qs}` : ''}`);
},
// #endregion
/** GET /api/order/:id → { order: Order } */
// #region GET /api/order/:id — get
get(id) {
return http(`/api/order/${id}`);
},
// #endregion
/**
* POST /api/order
* Body: { number, date, entityId?, lines }
* → 201 { order: Order }
*/
// #region POST /api/order — create
create(body) {
return http('/api/order', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/order/:id
* Body: partial { number?, date?, entityId?, lines? }
* → 200 { order: Order }
*/
// #region PATCH /api/order/:id — update
update(id, body) {
return http(`/api/order/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/order/:id → 204 */
// #region DELETE /api/order/:id — delete
delete(id) {
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
};

112
src/api/posterminal.js Normal file
View File

@@ -0,0 +1,112 @@
import { http } from './http.js';
export const posterminal = {
/**
* 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() {
return http('/api/posterminal');
},
// #endregion
/**
* PUT /api/posterminal
* 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, sort, enabled, clientversion, ... } }
*/
// #region PUT /api/posterminal — create
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,
});
},
// #endregion
/**
* PATCH /api/posterminal/:id
* Body: { name?: string, rootCategoryId?: number|null, syncRootArticles?: boolean, pairingpin?: string, sort?: number, enabled?: boolean }
* Update posterminal configuration.
* → 200 { posterminal: { id, name, token, pairingpin, sort, enabled, clientversion, ... } }
*/
// #region PATCH /api/posterminal/:id — update
update(terminalId, patch) {
return http(`/api/posterminal/${terminalId}`, {
method: 'PATCH',
body: patch,
});
},
// #endregion
/**
* DELETE /api/posterminal/:id → 204
* Deletes a terminal.
*/
// #region DELETE /api/posterminal/:id — delete
delete(terminalId) {
return http(`/api/posterminal/${terminalId}`, {
method: 'DELETE',
});
},
// #endregion
/**
* PUT /api/posterminal/:id/category/:categoryId
* Enable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
// #region PUT /api/posterminal/:id/category/:categoryId — enableCategory
enableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'PUT',
});
},
// #endregion
/**
* DELETE /api/posterminal/:id/category/:categoryId
* Disable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
// #region DELETE /api/posterminal/:id/category/:categoryId — disableCategory
disableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'DELETE',
});
},
// #endregion
/**
* PUT /api/posterminal/:id/all
* Enable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
// #region PUT /api/posterminal/:id/all — enableAllCategories
enableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'PUT',
});
},
// #endregion
/**
* DELETE /api/posterminal/:id/all
* Disable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
// #region DELETE /api/posterminal/:id/all — disableAllCategories
disableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'DELETE',
});
},
// #endregion
};

79
src/api/printer.js Normal file
View File

@@ -0,0 +1,79 @@
import { http } from './http.js';
/**
* Company-scoped printers (selectedCompanyId from session).
* Shape: Printer = { id, address, type, model, serial, sort, desc }
*/
export const printer = {
/** GET /api/printer → { printers: Printer[] } */
// #region GET /api/printer — list
list() {
return http('/api/printer');
},
// #endregion
/**
* POST /api/printer
* Body: { address, type, model, serial, sort, desc }
* → 201 { printer: Printer }
*/
// #region POST /api/printer — create
create(body) {
return http('/api/printer', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/printer/:id
* Body: partial printer fields
* → 200 { printer: Printer }
*/
// #region PATCH /api/printer/:id — update
update(id, body) {
return http(`/api/printer/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/printer/:id → 204 */
// #region DELETE /api/printer/:id — delete
delete(id) {
return http(`/api/printer/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* PUT /api/printer/order
* Body: { printerIds: number[] }
* → 200 { printerIds: number[] }
*/
// #region PUT /api/printer/order — order
order({ printerIds }) {
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',
});
},
};

38
src/api/propertyname.js Normal file
View File

@@ -0,0 +1,38 @@
import { http } from './http.js';
export const propertyname = {
/** GET /api/propertyname → { propertynames: Propertyname[] } */
// #region GET /api/propertyname — list
list() {
return http('/api/propertyname');
},
// #endregion
/** POST /api/propertyname → 201 { propertyname } */
// #region POST /api/propertyname — create
create(body) {
return http('/api/propertyname', { method: 'POST', body });
},
// #endregion
/** PATCH /api/propertyname/:id → 200 { propertyname } */
// #region PATCH /api/propertyname/:id — update
update(id, body) {
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/propertyname/:id → 200 */
// #region DELETE /api/propertyname/:id — delete
delete(id) {
return http(`/api/propertyname/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
// #endregion
/** PUT /api/propertyname/order → 200 */
// #region PUT /api/propertyname/order — order
order(propertynameIds) {
return http('/api/propertyname/order', { method: 'PUT', body: { propertynameIds } });
},
// #endregion
};

104
src/api/quote.js Normal file
View File

@@ -0,0 +1,104 @@
import { http } from './http.js';
/**
* Sales quotes (manifest type = quote).
* Summary = { id, number, type: 'quote', entityId, date }
* Line = { id, articleId, sku, amount, net, rebate, tax, comment } — no transactedAmount
* LineInput = { sku, amount, net, tax, rebate?, comment? }
*/
export const quote = {
/**
* 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({ 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
/** GET /api/quote/:id → { quote: Quote } */
// #region GET /api/quote/:id — get
get(id) {
return http(`/api/quote/${id}`);
},
// #endregion
/**
* POST /api/quote
* Body: { number, date, entityId?, lines }
* → 201 { quote: Quote }
*/
// #region POST /api/quote — create
create(body) {
return http('/api/quote', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/quote/:id
* Body: partial { number?, date?, entityId?, lines? }
* → 200 { quote: Quote }
*/
// #region PATCH /api/quote/:id — update
update(id, body) {
return http(`/api/quote/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/quote/:id → 204 */
// #region DELETE /api/quote/:id — delete
delete(id) {
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
};

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

@@ -0,0 +1,142 @@
import { http, clientIdHeaders } from './http.js';
/**
* Serper Google Images search and WebP thumbnail streaming.
*/
export const serper = {
/**
* POST /api/serper/images
* Body: { q, gl, hl, num, preferSource }
* Streams SSE events: images, thumb, done, error
*
* @param {object} params
* @param {string} params.q - Search query
* @param {string} [params.gl='de'] - Country code
* @param {string} [params.hl='de'] - Language code
* @param {number} [params.num=100] - Result count (1-100)
* @param {boolean} [params.preferSource=false] - Prefer high-res source image
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort controller signal
* @returns {AsyncGenerator<{event: string, data: any}>}
*/
async *searchImages(
{ q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {},
{ signal } = {},
) {
const res = await fetch('/api/serper/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify({ q, gl, hl, num, preferSource }),
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch {
/* ignore */
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let currentEvent = 'message';
const onAbort = () => {
try {
reader.cancel();
} catch {
/* ignore */
}
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (line.startsWith('event:')) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith('data:')) {
const raw = line.slice(5).trim();
let data;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
yield { event: currentEvent, data };
currentEvent = 'message';
}
}
}
} catch (err) {
if (signal?.aborted || err.name === 'AbortError') {
return;
}
throw err;
} finally {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
try {
reader.releaseLock();
} catch {
/* ignore */
}
}
},
/**
* POST /api/serper/image
* Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob.
*
* @param {string} url - External image URL to crawl and process
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort controller signal
* @returns {Promise<Blob>} AVIF image blob
*/
async fetchImage(url, { signal } = {}) {
const res = await fetch('/api/serper/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...clientIdHeaders(),
},
credentials: 'include',
body: JSON.stringify({ url }),
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;
}
return await res.blob();
},
};

38
src/api/setting.js Normal file
View File

@@ -0,0 +1,38 @@
import { http } from './http.js';
/**
* Company-scoped key/value settings (selectedCompanyId from session).
* Shape: Setting = { key, value, frontend }
*/
export const setting = {
/**
* GET /api/company/setting
* Optional query: { frontendOnly } → 200 { settings: Setting[] }
*/
// #region GET /api/company/setting — list
list({ frontendOnly } = {}) {
const qs = frontendOnly ? '?frontendOnly=1' : '';
return http(`/api/company/setting${qs}`);
},
// #endregion
/** GET /api/company/setting/:key → { setting: Setting } */
// #region GET /api/company/setting/:key — get
get(key) {
return http(`/api/company/setting/${encodeURIComponent(key)}`);
},
// #endregion
/**
* PUT /api/company/setting/:key
* Body: { value, frontend } → 200 { setting: Setting }
*/
// #region PUT /api/company/setting/:key — upsert
upsert(key, body) {
return http(`/api/company/setting/${encodeURIComponent(key)}`, {
method: 'PUT',
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
};

36
src/api/stock.js Normal file
View File

@@ -0,0 +1,36 @@
import { http } from './http.js';
/**
* Stock operations (manual stock adjustment).
*/
export const stock = {
/**
* POST /api/stock/adjust
* Body: { sku, warehouseId, amount, text?, date?, externalNumber?, lines? }
*/
// #region POST /api/stock/adjust — adjust
adjust(body) {
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

@@ -1,33 +0,0 @@
import { createContext, createElement, useContext, useRef } from 'react';
import { StorageManager } from './StorageManager';
let defaultStorageManager = null;
export function getStorageManager() {
if (!defaultStorageManager) {
defaultStorageManager = new StorageManager();
}
return defaultStorageManager;
}
export const StorageManagerContext = createContext(getStorageManager());
/**
* Provider — mount once near the top of the tree.
*/
export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null);
if (!mgrRef.current) {
mgrRef.current = getStorageManager();
}
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
}
/**
* Hook for function components that need the store manager.
*/
export function useStorageManager() {
const ctx = useContext(StorageManagerContext) || getStorageManager();
return ctx;
}

68
src/api/supplier.js Normal file
View File

@@ -0,0 +1,68 @@
import { http } from './http.js';
/**
* Company-scoped suppliers / Lieferanten (selectedCompanyId from session).
* Shape: Supplier = { id, name, createdAt, updatedAt }
*/
export const supplier = {
/** GET /api/supplier → { suppliers: Supplier[] } */
// #region GET /api/supplier — list
list() {
return http('/api/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.
* → 201 { supplier: Supplier }
*/
// #region POST /api/supplier — create
create(body) {
return http('/api/supplier', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/supplier/:id
* Body: { name } — empty name ⇒ 400 Name required.
* → 200 { supplier: Supplier }
*/
// #region PATCH /api/supplier/:id — update
update(id, body) {
return http(`/api/supplier/${id}`, { method: 'PATCH', body });
},
// #endregion
/** DELETE /api/supplier/:id → 204 */
// #region DELETE /api/supplier/:id — delete
delete(id) {
return http(`/api/supplier/${id}`, { method: 'DELETE' });
},
// #endregion
/**
* GET /api/supplier/:id/articles?skus=...
* → 200 { articles: SupplierArticle[] }
*/
// #region GET /api/supplier/:id/articles — getArticles
getArticles(id, skus) {
let query = '';
if (skus != null) {
const list = Array.isArray(skus) ? skus : [skus];
const filtered = list.map((s) => String(s).trim()).filter(Boolean);
if (filtered.length > 0) {
query = `?skus=${encodeURIComponent(filtered.join(','))}`;
}
}
return http(`/api/supplier/${encodeURIComponent(id)}/articles${query}`);
},
// #endregion
};

21
src/api/thumb.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* Content-addressed thumb AVIF by fullHash (8-char alphanumeric, see dashback pic.js).
* Auth: session cookie. Browser-cacheable (private, immutable, ETag).
*/
export const thumb = {
/**
* GET /api/thumb/:fullHash → image/avif (thumb bytes)
* Use as <img src={thumb.url(fullHash)} /> (same-origin; cookies sent).
*/
// #region GET /api/thumb/:fullHash — url
url(fullHash) {
return `/api/thumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
// #region GET /api/smallthumb/:fullHash — smallUrl
smallUrl(fullHash) {
return `/api/smallthumb/${encodeURIComponent(fullHash)}`;
},
// #endregion
};

36
src/api/unit.js Normal file
View File

@@ -0,0 +1,36 @@
import { http } from './http.js';
/**
* Company-scoped units (selectedCompanyId from session).
* Shape: { si, basesi, basesifactor, symbol, name }
*/
export const unit = {
/** GET /api/unit → { units: [...] } · 400 No company selected */
// #region GET /api/unit — list
list() {
return http('/api/unit');
},
// #endregion
/**
* PUT /api/unit
* Body: { si, basesi, basesifactor, symbol, name }
* → 200 { unit }
*/
// #region PUT /api/unit — upsert
upsert(body) {
return http('/api/unit', { method: 'PUT', body });
},
// #endregion
/**
* DELETE /api/unit?si=… → 204
* 409 Unit in use
*/
// #region DELETE /api/unit?si=… — delete
delete(si) {
const params = new URLSearchParams({ si });
return http(`/api/unit?${params}`, { method: 'DELETE' });
},
// #endregion
};

176
src/api/vfs.js Normal file
View File

@@ -0,0 +1,176 @@
import { http, clientIdHeaders } from './http.js';
export const vfs = {
/**
* GET /api/vfs
* List files in the virtual filesystem for the selected company.
* → 200 { files: [{ name, size, mime, updated_at, created_at, isText, isImage, isPdf }] }
*/
// #region GET /api/vfs — list
list() {
return http('/api/vfs');
},
// #endregion
/**
* GET /api/vfs/*path
* Fetch raw content of a file (e.g. text for the editor).
* @param {string} path
*/
// #region GET /api/vfs/*path — getText
async getText(path) {
const clean = String(path || '').replace(/^\/+/, '');
const res = await fetch(`/api/vfs/${clean}?raw=1`, {
credentials: 'include',
});
if (!res.ok) {
throw new Error(`Datei konnte nicht geladen werden (${res.status} ${res.statusText})`);
}
return res.text();
},
// #endregion
/**
* PUT /api/vfs/*path
* Create or update a file.
* @param {string} path
* @param {string} content
* @param {string} [mime]
*/
// #region PUT /api/vfs/*path — save
save(path, content, mime) {
const clean = String(path || '').replace(/^\/+/, '');
return http(`/api/vfs/${clean}`, {
method: 'PUT',
body: { content, mime },
});
},
// #endregion
/**
* DELETE /api/vfs/*path
* Delete a file.
* @param {string} path
*/
// #region DELETE /api/vfs/*path — delete
delete(path) {
const clean = String(path || '').replace(/^\/+/, '');
return http(`/api/vfs/${clean}`, {
method: 'DELETE',
});
},
// #endregion
/**
* POST /api/vfs/upload
* Upload multiple files or a single file to VFS.
* @param {File[]|FileList|File} files
* @param {string} [targetDir]
*/
// #region POST /api/vfs/upload — upload
async upload(files, targetDir = '') {
const formData = new FormData();
if (targetDir) formData.append('targetDir', targetDir);
const list = Array.isArray(files) ? files : files instanceof FileList ? Array.from(files) : [files];
for (const f of list) {
formData.append('files', f);
}
const res = await fetch('/api/vfs/upload', {
method: 'POST',
headers: { ...clientIdHeaders() },
credentials: 'include',
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `Upload fehlgeschlagen (${res.status})`);
}
return res.json();
},
// #endregion
/**
* POST /api/vfs/mkdir
* Create a directory in VFS.
* @param {string} path
*/
// #region POST /api/vfs/mkdir — mkdir
mkdir(path) {
const clean = String(path || '').replace(/^\/+/, '');
return http('/api/vfs/mkdir', {
method: 'POST',
body: { path: clean },
});
},
// #endregion
/**
* POST /api/vfs/rename
* Rename or move a file or directory in VFS.
* @param {string} oldPath
* @param {string} newPath
*/
// #region POST /api/vfs/rename — rename
rename(oldPath, newPath) {
const cleanOld = String(oldPath || '').replace(/^\/+/, '');
const cleanNew = String(newPath || '').replace(/^\/+/, '');
return http('/api/vfs/rename', {
method: 'POST',
body: { oldPath: cleanOld, newPath: cleanNew },
});
},
// #endregion
/**
* POST /api/vfs/*path — run a `.applet.js` file from VFS.
* Path goes in the URL, run params in the JSON body.
* @param {string} path VFS-relative applet path (e.g. `demo.applet.js`)
* @param {{ input?: object, timeout?: number, max_output?: number }} [options]
* → 200 { ok, result, columns, rows, warnings?, stdout, consoleLines, durationMs, truncated? }
* `result` is a typed CSV string (`;`-separated, header row first), `columns` are the
* declared `{ name, type }` pairs, `rows` is the data-row count.
*/
// #region POST /api/vfs/*path — runApplet
runApplet(path, options = {}) {
const clean = String(path || '').replace(/^\/+/, '');
const body = {};
if (options.input !== undefined) body.input = options.input;
if (options.timeout !== undefined) body.timeout = options.timeout;
if (options.max_output !== undefined) body.max_output = options.max_output;
return http(`/api/vfs/${clean}`, {
method: 'POST',
body,
});
},
// #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
};

54
src/api/warehouse.js Normal file
View File

@@ -0,0 +1,54 @@
import { http } from './http.js';
/**
* Company-scoped warehouses / Lager (selectedCompanyId from session).
* Shape: Warehouse = { id, name }
*/
export const warehouse = {
/** GET /api/warehouse → { warehouses: Warehouse[] } */
// #region GET /api/warehouse — list
list() {
return http('/api/warehouse');
},
// #endregion
/**
* POST /api/warehouse
* Body: { name, sort? } — empty name ⇒ 400 Name required.
* → 201 { warehouse: Warehouse }
*/
// #region POST /api/warehouse — create
create(body) {
return http('/api/warehouse', { method: 'POST', body });
},
// #endregion
/**
* PATCH /api/warehouse/:id
* Body: { name?, sort? }
* → 200 { warehouse: Warehouse }
*/
// #region PATCH /api/warehouse/:id — update
update(id, body) {
return http(`/api/warehouse/${id}`, { method: 'PATCH', body });
},
// #endregion
/**
* PUT /api/warehouse/order
* Body: { warehouseIds: number[] }
* → 200 { warehouseIds: number[] }
*/
// #region PUT /api/warehouse/order — order
order(body) {
return http('/api/warehouse/order', { method: 'PUT', body });
},
// #endregion
/** DELETE /api/warehouse/:id → 204 */
// #region DELETE /api/warehouse/:id — delete
delete(id) {
return http(`/api/warehouse/${id}`, { method: 'DELETE' });
},
// #endregion
};

View File

@@ -1,32 +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';
const ArtikelUebersichtPage = lazy(() => import('./pages/artikel/index'));
const ArtikelKategorienPage = lazy(() => import('./pages/artikel/kategorien'));
const RedirectArtikelKategorien = lazy(() => import('./pages/artikel/RedirectArtikelKategorien'));
const ArtikelLagerPage = lazy(() => import('./pages/artikel/lager'));
const ArtikelMarkenPage = lazy(() => import('./pages/artikel/marken'));
const ArtikelVorlagenPage = lazy(() => import('./pages/artikel/vorlagen'));
const ArtikelSkuPage = lazy(() => import('./pages/artikel/sku'));
const LieferungListePage = lazy(() => import('./pages/lieferung/index'));
const LieferungNeuPage = lazy(() => import('./pages/lieferung/neu'));
const LieferungLieferantenPage = lazy(() => import('./pages/lieferung/lieferanten'));
const LieferungEditPage = lazy(() => import('./pages/lieferung/edit'));
const AuftragAngebotPage = lazy(() => import('./pages/auftrag/index'));
const AuftragAngebotNeuPage = lazy(() => import('./pages/auftrag/neu'));
const AuftragAngebotEditPage = lazy(() => import('./pages/auftrag/edit'));
const AuftragBestellungPage = lazy(() => import('./pages/auftrag/bestellung'));
const AuftragBestellungNeuPage = lazy(() => import('./pages/auftrag/bestellungNeu'));
const AuftragBestellungEditPage = lazy(() => import('./pages/auftrag/bestellungEdit'));
const KundeListePage = lazy(() => import('./pages/kunde/index'));
const KundeNeuPage = lazy(() => import('./pages/kunde/neu'));
const KundeGruppenPage = lazy(() => import('./pages/kunde/gruppen'));
const KundeEditPage = lazy(() => import('./pages/kunde/edit'));
const InfosPage = lazy(() => import('./pages/infos/index'));
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 />,
@@ -42,7 +72,17 @@ const routes = [
{ path: ':deliveryId', element: <LieferungEditPage /> },
],
},
{ path: 'infos', element: <InfosPage /> },
{
path: 'infos',
element: <SectionLayout sectionPath="/infos" />,
children: [
{ index: true, element: <ArticleHistoryPage /> },
{ path: 'articlehistory', element: <ArticleHistoryPage /> },
{ path: 'globalhistory', element: <GlobalHistoryPage /> },
{ path: 'ledgerhistory', element: <LedgerHistoryPage /> },
{ path: 'statistics', element: <StatisticsPage /> },
],
},
{
path: 'kategorien/*',
element: <SectionLayout sectionPath="/artikel" />,
@@ -57,6 +97,7 @@ const routes = [
{ path: 'lager', element: <ArtikelLagerPage /> },
{ path: 'marken', element: <ArtikelMarkenPage /> },
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
{ path: 'merkmale', element: <ArtikelMerkmalePage /> },
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
],
},
@@ -64,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 /> },
],
},
@@ -82,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 /> },
],
},
@@ -94,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

BIN
src/assets/quixpos-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -0,0 +1,126 @@
/**
* @file articleDetailAutomat.js
* @description SKU-keyed automats for article detail tabs: article history
* (`articleHistoryAutomat`) and assigned suppliers (`articleSupplierAutomat`).
*
* Upstream Triggers:
* - `sseAutomat`: Calls `articleHistoryAutomat.reload(sku)` and
* `articleSupplierAutomat.reload(sku)` via `invalidateArticle(sku)` when an
* `article-<sku>` SSE event is received.
* - UI / Router: Triggers keyed `.load(sku)` or `.key(sku)` when viewing
* history or supplier details of a specific article.
*/
import { Automat, KeyedAutomat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireArray, requireObject } from '../utils/apiShape';
/**
* @typedef {Object} ArticleHistoryEntry
* @property {string|number} id History entry id.
* @property {string} date Timestamp (ISO).
* @property {string} text Human-readable change description.
*/
/**
* @typedef {Object} ArticleSupplierRow
* @property {string|number} id Supplier link id.
* @property {string|number} entityId Supplier id.
* @property {string|null} sku Supplier SKU.
* @property {string|null} name Supplier name.
* @property {number|null} pu Pack unit.
* @property {number|null} stock Supplier stock.
* @property {number|null} netPrice Net purchase price.
* @property {number|null} taxRate Tax rate in percent.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import authAutomat from './authAutomat';
/**
* Registry of SKU-specific chunk-list automats for article history.
* @type {Map<string, import('react-automat').Automat>}
*/
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`).
*
* @type {KeyedAutomat<Array<ArticleSupplierRow>>}
*/
export const articleSupplierAutomat = new KeyedAutomat(
null,
{},
{
name: 'articleSuppliers',
loader: async (sku) => {
const res = await articleApi.getSuppliers(String(sku));
requireObject(res, 'GET /api/article/:sku/supplier');
const list = requireArray(res.suppliers, 'suppliers');
const comp = Automat.get('company');
if (comp?.actions?.syncArticleSuppliers) {
comp.actions.syncArticleSuppliers(sku, list);
}
return list;
},
},
);

View File

@@ -0,0 +1,26 @@
/**
* @file articleHistoryAutomat.js
* @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 — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import { article } from '../api';
export const articleHistoryAutomat = createChunkListAutomat({
name: 'articleHistory',
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => article.getHistory(params),
});
// Alias for backwards compatibility
export const articleHistoryGridAutomat = articleHistoryAutomat;
export default articleHistoryAutomat;

View File

@@ -0,0 +1,499 @@
/**
* @file articleIndexAutomat.js
* @description Company article search index used by the `/artikel` overview,
* the manifest picker and the article-list pickers.
*
* The index is **derived**, not fetched: it is the de-duplicated union of the
* articles of every loaded category view. `categoryAutomat.loadTree()` warms
* those views right after `GET /api/category/tree` resolves, so the index is
* ready without any additional request.
*
* `GET /api/articleIndex` (whole-company window) and the `/article/:sku/articlelist`
* calls are no longer used by this store.
*
* Upstream Triggers:
* - `categoryViewAutomat`: Subscribes to the keyed view store, so a view load,
* reload, article creation or article removal re-derives the index.
* - `categoryAutomat`: Re-derives once `treeStatus === 'succeeded'` and the
* post-tree view preload (`viewsLoading`) settled; `reset()` on company switch.
* - `authAutomat`: Resets to idle on `'anon'`.
* - `sseAutomat`: `actions.reload()` on an `'articles'` event — now only
* re-derives the index from the invalidated category views.
* - `artikelAutomat`: `setArticleIndexName(sku, name)` on optimistic renames and
* `actions.reset()` on mutation failure rollback.
*
* Downstream Triggers:
* - `articleSearchAutomat`: Reads and searches records via `actions.ensureLoaded()`.
* - `ArticleSearchDialog`, `ArticleViewLists`: `actions.ensureLoaded()` for a
* flat article list.
*/
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';
/**
* @typedef {Object} ArticleIndexRecord
* @property {string|number} id Article id.
* @property {string} sku Article SKU.
* @property {string} name Article name.
* @property {Array<string>} eans Article EANs (not yet served per category — see below).
* @property {string|null} categories Comma-joined category ids.
* @property {boolean} isParent Whether the article has child variants.
*
* @typedef {Object} ArticleIndexState
* @property {Array<ArticleIndexRecord>} items Derived index records.
* @property {'idle'|'loading'|'ready'|'failed'} status Lifecycle state.
* @property {Error|string|null} error Last error.
*/
/** @type {ArticleIndexState} */
const initialState = {
items: [],
status: 'idle',
error: null,
};
let articleIndexAutomat;
/** In-flight `ensureLoaded()` promise so concurrent callers share one rebuild. */
let inflightEnsure = null;
/** Pending coalesced rebuild, scheduled on the microtask queue. */
let rebuildScheduled = false;
/**
* Schedules a rebuild on the next microtask.
*
* Warming every category view fires one notification per view; coalescing keeps
* the derivation at roughly O(articles) instead of O(categories × articles).
*
* @returns {void}
*/
function scheduleRebuild() {
if (rebuildScheduled) return;
rebuildScheduled = true;
queueMicrotask(() => {
rebuildScheduled = false;
actions.rebuild();
});
}
/**
* Normalizes a category-view article row into an index record.
*
* @param {object} article Raw category-view article row.
* @param {number|string|null} categoryId Category the article was loaded from.
* @returns {ArticleIndexRecord} Index record.
*/
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 ?? (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,
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,
};
}
/**
* Merges the articles of all loaded category views into unique index records.
*
* An article assigned to several categories is emitted once, with every category
* it was seen in accumulated in `categories`.
*
* @param {Array<{categoryId: number|string, articles: Array<object>}>} views Loaded view data.
* @returns {Array<ArticleIndexRecord>} Deduplicated index records.
*/
export function buildArticleIndex(views) {
const bySku = new Map();
for (const view of views) {
const categoryId = view?.categoryId;
for (const article of view?.articles ?? []) {
if (article?.sku == null) continue;
const key = String(article.sku);
const existing = bySku.get(key);
if (!existing) {
bySku.set(key, toIndexRecord(article, categoryId));
continue;
}
if (categoryId != null) {
const ids = new Set(String(existing.categories ?? '').split(',').filter(Boolean));
ids.add(String(categoryId));
existing.categories = Array.from(ids).join(',');
}
const childCount = Number(article.childCount) || 0;
if (childCount > (Number(existing.childCount) || 0)) {
existing.childCount = childCount;
}
existing.isParent = Boolean(
existing.isParent ||
(article.isParent ?? article.is_parent) ||
(childCount > 0)
);
if (existing.price == null && (article.price != null || article.net != null)) {
const p = article.price ?? article.net ?? article.vknetto;
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;
}
}
}
return Array.from(bySku.values());
}
const actions = {
/**
* Rebuilds the index from the currently loaded category views.
*
* Does not hit the network: the data comes from the view cache that
* `categoryAutomat.loadTree()` populated.
*
* @returns {Array<ArticleIndexRecord>} Derived records.
*/
rebuild() {
const items = buildArticleIndex(collectLoadedCategoryViewData());
articleIndexAutomat.setState({ items, status: 'ready', error: null });
return items;
},
/**
* Resolves once the index holds data, rebuilding it when necessary.
*
* Waits for the category tree and for the post-tree view preload, so the
* returned list is complete rather than a partial early snapshot.
*
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
*/
async ensureLoaded() {
if (inflightEnsure) return inflightEnsure;
if (articleIndexAutomat.state.status === 'ready' && articleIndexAutomat.state.items.length > 0) {
return articleIndexAutomat.state.items;
}
articleIndexAutomat.setState({ status: 'loading', error: null });
inflightEnsure = (async () => {
await whenCategoryTreeReady();
// loadTree() already warms every view; this is the idempotent safety net
// for a tree that finished loading before this automat subscribed.
if (categoryAutomat.state.viewsLoading) {
await loadAllCategoryViews();
}
return actions.rebuild();
})()
.catch((error) => {
articleIndexAutomat.setState({ status: 'failed', error });
throw error;
})
.finally(() => {
inflightEnsure = null;
});
return inflightEnsure;
},
/**
* Re-derives the index after the category views were invalidated by SSE.
*
* @returns {Promise<Array<ArticleIndexRecord>>} Derived records.
*/
async reload() {
return actions.ensureLoaded();
},
/**
* Replaces the cached list without re-deriving it.
*
* @param {Array<ArticleIndexRecord>} items Records to cache.
* @returns {Array<ArticleIndexRecord>} Cached records.
*/
replace(items) {
articleIndexAutomat.setState({ items, status: 'ready', error: null });
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;
rebuildScheduled = false;
articleIndexAutomat.setState(initialState);
},
};
/**
* Derived company article search index.
*
* @type {Automat<ArticleIndexState>}
*/
articleIndexAutomat = new Automat(initialState, actions, { name: 'articleIndex' });
export { articleIndexAutomat };
// Re-derive whenever a category view loads, reloads or is patched.
categoryViews.subscribe(() => {
scheduleRebuild();
});
// Re-derive once the tree finished loading and its view preload settled.
categoryAutomat.subscribe((state) => {
if (state.treeStatus === 'succeeded' && state.viewsLoading === false) {
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).
*
* @param {string|number} sku Article SKU.
* @param {string} name New article name.
* @returns {void}
*/
export function setArticleIndexName(sku, name) {
const items = articleIndexAutomat.state.items;
if (!items.length) return;
articleIndexAutomat.actions.replace(
items.map((row) =>
String(row.sku) === String(sku) ? { ...row, name } : row,
),
);
}
/**
* 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.
*
* @param {ArticleIndexState} state Automat state.
* @returns {Array<ArticleIndexRecord>} Current records.
*/
export function selectArticleIndexItems(state) {
return state.items;
}
export default articleIndexAutomat;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,31 @@
/**
* @file artikelAutomat.js
* @description In-memory reactive cache and mutator for full single-article
* detail payloads (`GET /api/article/:sku`), with inflight request deduplication.
*
* Upstream Triggers:
* - `sseAutomat`: Calls `reloadArticle(sku)` via `invalidateArticle(sku)` when an
* `article-<sku>` SSE mutation event arrives.
* - `authAutomat`: Subscribes to auth state changes — calls `actions.clear()` when
* status transitions to `'anon'` (logout).
* - UI / Routes: Calls `loadArticle(sku)` or mutations (`setName`, `setEans`,
* `setBrand`, `setCategories`, `setPrices`, `setWeight`, etc.).
*
* Downstream Triggers:
* - `articleIndexAutomat`: Calls `setArticleIndexName(sku, name)` on optimistic
* article renames, and `articleIndexAutomat.actions.reset()` on mutation failure rollback.
*/
import { Automat } from 'react-automat';
import { article as articleApi } from '../api';
import { requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
import articleIndexAutomat, {
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
} from './articleIndexAutomat';
import sseAutomat from './sseAutomat';
/**
* @typedef {Object} ArtikelState
@@ -93,6 +118,7 @@ const actions = {
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
updateArticleIndexRecord(article);
return article;
},
@@ -125,6 +151,110 @@ const actions = {
artikelAutomat.setState({ bySku });
},
/**
* PATCH /api/article/:sku with optimistic name handling.
*
* A `name` in the body is applied to both caches immediately so the tile
* does not flicker, then rolled back from the server on failure.
*
* @param {string} sku Article SKU.
* @param {object} body Patch body (`{ name?, unitamount?, unitSi?, track_stock?, desc?, seo? }`).
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async update(sku, body) {
const key = String(sku);
const nameChange = body != null && Object.hasOwn(body, 'name');
if (nameChange) {
setArticleIndexName(key, body.name);
actions.setArticleName(key, body.name);
}
try {
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.
articleIndexAutomat.actions.reset();
if (hasArticle(key)) {
void actions.loadArticle(key).catch(() => {});
}
}
throw err;
}
},
/**
* PUT /api/article/:sku/eans → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<string>} eans Replacement EAN list.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setEans(sku, eans) {
await articleApi.setEansBySku(String(sku), eans);
return { article: await actions.reloadArticle(sku) };
},
/**
* PUT /api/article/:sku/brand → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {{ brandId: number|null }} body Brand payload.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setBrand(sku, body) {
await articleApi.setBrandIdBySku(String(sku), body?.brandId);
return { article: await actions.reloadArticle(sku) };
},
/**
* PUT /api/article/:sku/categories → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<number>} categoryIds Replacement category ids.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setCategories(sku, categoryIds) {
await articleApi.setCategories(String(sku), categoryIds);
return { article: await actions.reloadArticle(sku) };
},
/**
* PUT /api/article/:sku/prices → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {Array<object>} prices Net price rows.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setPrices(sku, prices) {
await articleApi.setPrices(String(sku), prices);
return { article: await actions.reloadArticle(sku) };
},
/**
* PUT /api/article/:sku/weight → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {object} body Weight payload.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setWeight(sku, body) {
await articleApi.setWeight(String(sku), body);
return { article: await actions.reloadArticle(sku) };
},
/**
* PUT /api/article/:sku/track_stock → refreshes the cached article.
*
* @param {string} sku Article SKU.
* @param {boolean|object} body Flag or full body.
* @returns {Promise<object>} Response body containing the fresh `article`.
*/
async setTrackStock(sku, body) {
await articleApi.setTrackStock(String(sku), body);
return { article: await actions.reloadArticle(sku) };
},
/**
* Forgets every cached article.
*
@@ -187,4 +317,22 @@ export function selectArticle(sku) {
return artikelAutomat.select(createArticleSelector(sku));
}
// Drop cached articles on logout. A company switch keeps them; the SSE hub
// reconnects and revalidates whatever is on screen.
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
actions.clear();
}
});
// 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,5 +1,24 @@
/**
* @file authAutomat.js
* @description Central authentication and multi-tenant session manager.
* 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)`.
* - `CompanySetupDialog.jsx` / `UserMenu.jsx`: Calls `selectCompany(companyId)`,
* `createCompany(payload)`, or `logout()`.
*
* Downstream Triggers:
* - 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
@@ -11,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. */
@@ -25,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.
*
@@ -40,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;
@@ -68,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.
*
@@ -118,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.
*
@@ -143,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.
*
@@ -155,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);
@@ -165,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 `/`.
*
@@ -179,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
};
/**
@@ -191,4 +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, schemaVersion?: number|null}>} Session.
*/
export function bootstrapAuthMe() {
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,112 +0,0 @@
import { Automat } from 'react-automat';
import { brand as brandApi } from '../api';
import authAutomat from './authAutomat';
/**
* @typedef {Object} BrandState
* @property {Array<object>} brands
* @property {'idle'|'loading'|'succeeded'|'failed'} status
* @property {string|null} error
*/
/** @type {BrandState} */
const initialState = {
brands: [],
status: 'idle',
error: null,
};
function sortBrands(brands) {
return [...brands].sort((a, b) =>
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
);
}
let brandAutomat;
const actions = {
/** Load all brands for the selected company. */
async load() {
brandAutomat.setState({ status: 'loading', error: null });
try {
const res = await brandApi.list();
const brands = sortBrands(Array.isArray(res.brands) ? res.brands : []);
brandAutomat.setState({ brands, status: 'succeeded' });
return brands;
} catch (err) {
brandAutomat.setState({ status: 'failed', error: err?.message ?? String(err) });
throw err;
}
},
/**
* Upsert a brand (create or update by name).
* @param {{ name: string, gsprpinfo?: string }} body
*/
async upsert(body) {
const res = await brandApi.upsert(body);
const row = res.brand;
const brands = sortBrands([
...brandAutomat.state.brands.filter((b) => b.id !== row.id),
row,
]);
brandAutomat.setState({ brands });
return row;
},
/**
* Delete a brand by id.
* @param {number} id
*/
async remove(id) {
await brandApi.delete(id);
const brands = brandAutomat.state.brands.filter((b) => b.id !== id);
brandAutomat.setState({ brands });
},
/**
* Add / update brand image.
* @param {number} id
* @param {Blob|File} file
*/
async addImage(id, file) {
const res = await brandApi.addImage(id, file);
await brandAutomat.actions.load();
return res.brand;
},
/**
* Delete brand image.
* @param {number} id
*/
async deleteImage(id) {
const res = await brandApi.deleteImage(id);
await brandAutomat.actions.load();
return res.brand;
},
/** Reset to idle state (called on logout / company switch). */
reset() {
brandAutomat.setState(initialState);
},
};
/**
* Global brand state container.
* Loads automatically when authAutomat reaches 'authed' with a selectedCompanyId.
*
* @type {Automat<BrandState>}
*/
brandAutomat = new Automat(initialState, actions, { name: 'brand' });
// Subscribe to auth changes
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void brandAutomat.actions.load();
} else if (auth.status === 'anon') {
brandAutomat.actions.reset();
}
});
export default brandAutomat;

View File

@@ -1,15 +1,53 @@
import { Automat, KeyedAutomat } from 'react-automat';
/**
* @file categoryAutomat.js
* @description Owns the hierarchical category tree (id -> node map), the root
* category id, breadcrumbs helpers, and all category/article mutations.
*
* Per-category article views (`categoryViews`) live in `categoryViewAutomat.js`
* and attach to this tree automat via `attachCategoryTree()`, keeping the
* dependency one-directional (views -> tree).
*
* Upstream Triggers:
* - `companyAutomat`: Subscribes to company changes — triggers `actions.loadTree()`
* whenever `company.current.id` or `rootCategoryId` changes/becomes available;
* resets when company is unset.
* - `authAutomat`: Subscribes to auth state changes — triggers `actions.reset()` on `'anon'`.
* - `sseAutomat`: Calls `categoryAutomat.actions.loadTree()` and
* `invalidateAllCategoryViews()` upon receiving a `'category'` SSE event.
* - UI / Routes: Category tree operations (`createCategory`, `createArticle`,
* `deleteCategory`, `moveCategory`, `renameCategory`, `reorderCategories`, etc.).
*
* Downstream Triggers:
* - `categoryViewAutomat`: `loadTree()` calls `loadAllCategoryViews()` once
* `treeStatus === 'succeeded'`, so every category's articles are already cached
* when a route mounts. Mutations patch the loaded views through the same module.
* - `articleIndexAutomat`: Derives its client-side search index from the union of
* the loaded category views, so the index costs no requests of its own and
* `GET /api/articleIndex` is no longer used.
*/
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,
loadAllCategoryViews,
disposeAllCategoryViewAutomats,
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
updateArticleInCategoryViews,
removeArticleBySku,
waitForCategoryTree,
} from './categoryViewAutomat';
import {
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
ROOT_CATEGORY_NAME,
getCategoryChildren,
getCategoryDescendantIds,
} from '../utils/categoryUtils';
/**
@@ -18,9 +56,11 @@ 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
* @property {boolean} viewsLoading Whether the post-tree view preload is running.
*/
/** @type {CategoryState} */
@@ -29,88 +69,37 @@ const initialState = {
languageId: null,
categories: null,
rootCategoryId: null,
articleCount: 0,
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
let categoryAutomat;
let _treeLoadingPromise = null;
/**
* KeyedAutomat holding per-route category views indexed by slug path.
* For each slug path, loader resolves the categoryId from the category tree,
* fetches articles in that category, and provides childCategories (children of the category).
* Returns the parsed category map and root id, or `null` while the tree is
* still loading. Replaces the former `StorageManager.getCategoryState()`.
*
* @returns {{ categories: Record<string,object>, rootCategoryId: unknown }|null}
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// Ensure tree is loaded before resolving slug -> categoryId
if (categoryAutomat.state.treeStatus !== 'succeeded') {
await categoryAutomat.actions.loadTree();
}
const { categories, rootCategoryId } = categoryAutomat.state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug || ROOT_CATEGORY_NAME}“ nicht gefunden.`);
}
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
const childCategories = getCategoryChildren(categories, categoryId);
return {
slug,
categoryId,
articles,
childCategories,
};
},
}
);
/**
* Updates childCategories on all loaded category view handles when categories tree changes.
*/
function refreshLoadedCategoryViewChildren(categories) {
if (!categories) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && item.data.categoryId != null) {
const childCategories = getCategoryChildren(categories, item.data.categoryId);
categoryViews.key(slug).set({
...item.data,
childCategories,
});
}
}
export function getCategoryState() {
const state = categoryAutomat.state;
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
}
/**
* Get or create a per-route category view automat for the given slug and categoryId.
* (Backward compatibility helper)
*/
export function getOrCreateCategoryAutomat({ slugPath, categoryId }) {
const slug = cleanCategorySlugPath(slugPath);
return categoryViews.key(slug);
}
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
/**
* Invalidate all active category view automats by triggering background reload.
* Resolves once the category tree has loaded.
*
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
*/
export function invalidateAllCategoryViews() {
return categoryViews.setDirty(undefined, false, true);
}
/**
* Dispose all category view automats and clear registries (e.g. on logout or company switch).
*/
export function disposeAllCategoryViewAutomats() {
categoryViews.clear();
export function whenCategoryTreeReady() {
return waitForCategoryTree();
}
// ─── Actions ───────────────────────────────────────────────────────────────────
@@ -118,31 +107,58 @@ export function disposeAllCategoryViewAutomats() {
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
const company = companyAutomat.state.current;
let company = companyAutomat.state.current;
if (!company && companyAutomat.state.status === 'idle') {
await companyAutomat.actions.load();
company = companyAutomat.state.current;
}
if (!company) return;
const { id: companyId, rootCategoryId: companyRootId } = company;
if (!companyRootId) return;
if (_treeLoadingPromise) return _treeLoadingPromise;
categoryAutomat.setState({ treeStatus: 'loading', error: null });
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
});
refreshLoadedCategoryViewChildren(parsed.categories);
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
_treeLoadingPromise = (async () => {
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
articleCount: parsed.articleCount,
error: null,
viewsLoading: true,
});
refreshCategoryViewChildren(parsed.categories);
// The tree is the source of truth for every category view, so warm all
// of them now: routes mount from cache instead of waiting on a fetch.
// A failing category must not fail the whole tree load.
const { failed } = await loadAllCategoryViews();
if (failed.length) {
console.warn(
`${failed.length} Kategorie(n) konnten nicht vorab geladen werden.`,
failed,
);
}
categoryAutomat.setState({ viewsLoading: false });
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
viewsLoading: false,
error: err?.message ?? String(err),
});
throw err;
}
})().finally(() => {
_treeLoadingPromise = null;
});
return _treeLoadingPromise;
},
/** Toggle rename/edit mode for the category grid. */
@@ -150,6 +166,15 @@ const actions = {
categoryAutomat.setState({ renameMode: Boolean(enabled) });
},
/**
* (Re-)loads every category view of the current tree on demand.
*
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
*/
async loadAllCategoryViews() {
return loadAllCategoryViews();
},
// ─── Category mutations ────────────────────────────────────────────────────
/**
@@ -162,7 +187,7 @@ const actions = {
const { categories } = categoryAutomat.state;
const updated = { ...(categories ?? {}), [String(node.id)]: node };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return node;
},
@@ -181,7 +206,7 @@ const actions = {
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categoryAutomat.setState({ categories: updated });
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return created;
},
@@ -190,18 +215,9 @@ const actions = {
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const res = await articleApi.create({ parentId, name });
const created = res.article;
if (created && parentId != null) {
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), created],
});
}
}
}
const { sku } = await articleApi.create({ categoryId: parentId, name });
const { article: created } = await articleApi.getBySku(sku);
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
},
@@ -214,19 +230,11 @@ const actions = {
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await articleApi.create({ parentId, name });
if (res.article) created.push(res.article);
}
if (created.length > 0 && parentId != null) {
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(parentId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), ...created],
});
}
}
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;
},
@@ -248,7 +256,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -269,7 +277,7 @@ const actions = {
}
}
categoryAutomat.setState({ categories: patched });
refreshLoadedCategoryViewChildren(patched);
refreshCategoryViewChildren(patched);
return ordered;
},
@@ -290,7 +298,7 @@ const actions = {
categoryAutomat.setState({
categories: updated,
});
refreshLoadedCategoryViewChildren(updated);
refreshCategoryViewChildren(updated);
return moved;
},
@@ -305,7 +313,36 @@ const actions = {
const next = { ...categories };
delete next[String(categoryId)];
categoryAutomat.setState({ categories: next });
refreshLoadedCategoryViewChildren(next);
refreshCategoryViewChildren(next);
return categoryId;
},
/**
* Delete a category and all its descendants recursively.
* Calls DELETE /api/category/:id?recursive=true.
*
* @param {number|string} categoryId
*/
async deleteCategoryRecursive(categoryId) {
const { categories, rootCategoryId } = categoryAutomat.state;
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
throw new Error('Root-Kategorie kann nicht gelöscht werden');
}
const removedIds = getCategoryDescendantIds(categories, categoryId);
await categoryApi.delete(categoryId, { recursive: true });
if (categories) {
const next = { ...categories };
for (const id of removedIds) {
delete next[String(id)];
}
categoryAutomat.setState({ categories: next });
refreshCategoryViewChildren(next);
}
return categoryId;
},
@@ -323,7 +360,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return updated;
},
@@ -341,7 +378,7 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
return res.category;
},
@@ -362,23 +399,37 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
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). */
removeArticleBySku(sku) {
if (!sku) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data?.articles?.some((r) => r.sku === sku)) {
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => r.sku !== sku),
});
}
}
removeArticleBySku(sku);
},
/**
@@ -400,12 +451,18 @@ const actions = {
categoryAutomat.setState({
categories: nextCategories,
});
refreshLoadedCategoryViewChildren(nextCategories);
refreshCategoryViewChildren(nextCategories);
},
/** Invalidate all loaded category views. */
invalidateAllCategoryViews() {
invalidateAllCategoryViews();
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
_treeLoadingPromise = null;
categoryAutomat.setState(initialState);
},
};
@@ -418,6 +475,9 @@ const actions = {
*/
categoryAutomat = new Automat(initialState, actions, { name: 'category' });
// Let the per-category views resolve their slug paths against this tree.
attachCategoryTree(categoryAutomat);
// Keyed view accessors on categoryAutomat:
categoryAutomat.key = (slugPath) => categoryViews.key(cleanCategorySlugPath(slugPath));
categoryAutomat.read = (slugPath) => categoryViews.read(cleanCategorySlugPath(slugPath));
@@ -426,19 +486,25 @@ 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;
disposeAllCategoryViewAutomats();
if (isCompanySwitch) {
disposeAllCategoryViewAutomats();
}
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
}
companyAutomat.subscribe(syncCategoryWithCompany);
syncCategoryWithCompany(companyAutomat.state);
// Reset tree on logout
authAutomat.subscribe((auth) => {
@@ -448,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,
@@ -460,4 +534,15 @@ export {
} from '../utils/categoryUtils';
export { categoryDisplayName, categorySlug, normalizeCategoryMap } from '../utils/categoryUtils';
// Re-export category view automats and actions
export {
categoryViews,
getOrCreateCategoryAutomat,
invalidateAllCategoryViews,
disposeAllCategoryViewAutomats,
loadAllCategoryViews,
collectLoadedCategoryViewData,
updateArticleInCategoryViews,
} from './categoryViewAutomat';
export default categoryAutomat;

View File

@@ -0,0 +1,413 @@
/**
* @file categoryViewAutomat.js
* @description Owns the per-category view automats (`categoryViews`), a
* `KeyedAutomat` keyed by category slug path. Each entry holds one category's
* articles (`GET /api/category/:id/articles`) plus its child categories, and is
* resolved from the category slug path against the loaded category tree.
*
* This file holds ONLY the keyed per-category views. The category tree itself
* (`categories` map, `rootCategoryId`, category/article mutations) lives in
* `categoryAutomat.js`; this module receives it through `attachCategoryTree()`
* so the dependency stays one-directional (views → tree).
*
* Upstream Triggers:
* - `categoryAutomat`: The tree automat attaches itself via `attachCategoryTree()`
* and calls `loadAllCategoryViews()` once `treeStatus === 'succeeded'`,
* `reloadLoadedCategoryViews()` when the tree changes, and
* `disposeAllCategoryViewAutomats()` on `reset()` (logout / company switch).
* - `sseAutomat`: Calls `invalidateAllCategoryViews()` on a `'category'` event and
* on an `'articles'` event for the open views.
* - UI / Routes: `categoryAutomat.key(slugPath)` /
* `categoryAutomat.read(slugPath)` / `categoryAutomat.load(slugPath)` facades.
*
* Downstream Triggers:
* - Routes / components: `src/routes/artikel/kategorien.jsx` (grid + drag & drop),
* `src/components/category/CategoryTree.jsx`.
*/
import { KeyedAutomat } from 'react-automat';
import { category as categoryApi } from '../api';
import authAutomat from './authAutomat';
import {
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
} from '../utils/categoryUtils';
/**
* @typedef {Object} CategoryViewData
* @property {string} slug Cleaned slug path this view was keyed by.
* @property {number|string} categoryId Resolved category id.
* @property {Array<object>} articles Articles in that category.
* @property {Array<object>} childCategories Direct child category nodes.
*/
/**
* The tree automat, injected by `categoryAutomat.js` at module-evaluation time.
* @type {import('react-automat').Automat<object>|null}
*/
let treeAutomat = null;
/**
* Injects the category tree automat this module resolves slug paths against.
*
* @param {import('react-automat').Automat<object>} automat Tree automat.
* @returns {void}
*/
export function attachCategoryTree(automat) {
treeAutomat = automat;
}
/**
* Returns the attached tree automat.
*
* @returns {import('react-automat').Automat<object>} Tree automat.
* @throws {Error} When the tree automat has not been attached yet.
*/
function tree() {
if (!treeAutomat) {
throw new Error('categoryAutomat has not attached the category tree yet.');
}
return treeAutomat;
}
/**
* Resolves once the attached category tree has loaded successfully.
*
* Rejects when the tree load fails or the session becomes anonymous, so callers
* waiting on a view never hang forever.
*
* @returns {Promise<object>} Tree state with `treeStatus === 'succeeded'`.
*/
export function waitForCategoryTree() {
const current = tree().state;
if (current.treeStatus === 'succeeded') return Promise.resolve(current);
return new Promise((resolve, reject) => {
let unsubTree = null;
let unsubAuth = null;
const cleanup = () => {
unsubTree?.();
unsubAuth?.();
unsubTree = null;
unsubAuth = null;
};
unsubTree = tree().subscribe((state) => {
if (state.treeStatus === 'succeeded') {
cleanup();
resolve(state);
} else if (state.treeStatus === 'failed') {
cleanup();
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
}
});
unsubAuth = authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
cleanup();
reject(new Error('Nicht authentifiziert.'));
}
});
});
}
/**
* Per-route category view stores, keyed by cleaned category slug path.
*
* @type {KeyedAutomat<CategoryViewData>}
*/
export const categoryViews = new KeyedAutomat(
null,
{},
{
name: 'category-views',
loader: async (slugPath) => {
const slug = cleanCategorySlugPath(slugPath);
// The tree must be loaded before a slug path can be resolved to an id.
const treeState = tree().state;
if (treeState.treeStatus !== 'succeeded') {
if (treeState.treeStatus === 'idle') {
void tree().actions.loadTree();
}
await waitForCategoryTree();
}
const { categories, rootCategoryId } = tree().state;
const categoryId = resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug);
if (categoryId == null) {
throw new Error(`Kategorie für Pfad „${slug}“ nicht gefunden.`);
}
const res = await categoryApi.getArticles(categoryId);
const articles = Array.isArray(res.articles) ? res.articles : [];
const childCategories = getCategoryChildren(categories, categoryId);
return { slug, categoryId, articles, childCategories };
},
},
);
/**
* Runs `worker` over `items` with a bounded number of concurrent calls.
* Failures are captured per item instead of rejecting the whole batch.
*
* @template T, R
* @param {Array<T>} items Work items.
* @param {number} limit Maximum concurrent workers.
* @param {(item: T, index: number) => Promise<R>} worker Async work per item.
* @returns {Promise<Array<{ok: true, value: R}|{ok: false, error: any}>>} Ordered results.
*/
async function mapWithConcurrency(items, limit, worker) {
const results = new Array(items.length);
let cursor = 0;
const runners = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
while (cursor < items.length) {
const index = cursor++;
try {
results[index] = { ok: true, value: await worker(items[index], index) };
} catch (error) {
results[index] = { ok: false, error };
}
}
});
await Promise.all(runners);
return results;
}
/**
* Builds the slug path for a category from its breadcrumb trail.
*
* @param {Record<string,object>} categories id → node map.
* @param {number|string} rootCategoryId Root category id.
* @param {number|string} categoryId Category id.
* @returns {string} Slug path usable as a `categoryViews` key.
*/
export function categoryViewSlugPath(categories, rootCategoryId, categoryId) {
return getCategoryBreadcrumbs(categories, categoryId, rootCategoryId)
.map((crumb) => crumb.slug)
.filter(Boolean)
.join('/');
}
/**
* Collects the data of every currently loaded category view.
*
* Used by `articleIndexAutomat` to derive the search index from the view cache
* without issuing additional requests.
*
* @returns {Array<CategoryViewData>} Loaded view payloads.
*/
export function collectLoadedCategoryViewData() {
const out = [];
for (const item of Object.values(categoryViews.state)) {
if (item?.loaded && item.data) out.push(item.data);
}
return out;
}
/**
* Loads a view for **every** category in the loaded tree.
*
* Called by `categoryAutomat` once the tree finished loading, so navigating to
* any category (and the derived article index) resolves from cache instead of
* waiting for a round-trip. Already-loaded views are skipped.
*
* @param {{ concurrency?: number }} [options] Load tuning.
* @returns {Promise<{ loaded: number, failed: Array<{slug: string, error: any}> }>} Load summary.
*/
export async function loadAllCategoryViews({ concurrency = 6 } = {}) {
const treeState = tree().state;
if (treeState.treeStatus !== 'succeeded') {
return { loaded: 0, failed: [] };
}
const { categories, rootCategoryId } = treeState;
const slugs = [];
const seen = new Set();
for (const node of Object.values(categories ?? {})) {
const slug = categoryViewSlugPath(categories, rootCategoryId, node.id);
if (!slug || seen.has(slug)) continue;
seen.add(slug);
slugs.push(slug);
}
const results = await mapWithConcurrency(slugs, concurrency, (slug) =>
// Force only when not already cached; `load()` resolves immediately otherwise.
categoryViews.load(slug),
);
const failed = [];
let loaded = 0;
results.forEach((result, index) => {
if (result.ok) loaded += 1;
else failed.push({ slug: slugs[index], error: result.error });
});
return { loaded, failed };
}
/**
* Re-fetches only the views that are already cached, keeping the set of cached
* categories small. Used for SSE-driven refreshes.
*
* @returns {Promise<void>} Resolves once all cached views settled.
*/
export async function reloadLoadedCategoryViews() {
await categoryViews.setDirty(undefined, false, true);
}
/**
* Background-invalidates all loaded category views.
*
* @returns {Promise<unknown[]>} Per-view reload promises.
*/
export function invalidateAllCategoryViews() {
return categoryViews.setDirty(undefined, false, true);
}
/**
* Disposes every category view (logout / company switch).
*
* @returns {void}
*/
export function disposeAllCategoryViewAutomats() {
categoryViews.clear();
}
/**
* Get or create the view handle for a slug path (backward compatible helper).
*
* @param {{ slugPath: string }} param0
* @returns {import('react-automat').KeyHandle<CategoryViewData>} Keyed view handle.
*/
export function getOrCreateCategoryAutomat({ slugPath }) {
return categoryViews.key(cleanCategorySlugPath(slugPath));
}
/**
* Re-derives `childCategories` for every loaded view after the tree changed.
*
* @param {Record<string,object>|null} categories Fresh id → node map.
* @returns {void}
*/
export function refreshCategoryViewChildren(categories) {
if (!categories) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && item.data.categoryId != null) {
categoryViews.key(slug).set({
...item.data,
childCategories: getCategoryChildren(categories, item.data.categoryId),
});
}
}
}
/**
* Appends newly created articles to every loaded view of `categoryId`.
*
* @param {{ categoryId: number|string, articles: Array<object> }} param0
* @returns {void}
*/
export function appendArticlesToCategoryViews({ categoryId, articles }) {
if (!articles?.length || categoryId == null) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data && String(item.data.categoryId) === String(categoryId)) {
categoryViews.key(slug).set({
...item.data,
articles: [...(item.data.articles || []), ...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.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
export function removeArticleBySku(sku) {
if (!sku) return;
for (const [slug, item] of categoryViews._items.entries()) {
if (item.data?.articles?.some((r) => r.sku === sku)) {
categoryViews.key(slug).set({
...item.data,
articles: item.data.articles.filter((r) => r.sku !== sku),
});
}
}
}
export default categoryViews;

1616
src/automats/chatAutomat.js Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,20 @@
/**
* @file deliveryEditorAutomat.js
* @description State engine for inbound goods receipt creation and invoice
* reconciliation (lineitems, attachments, PDF/receipt OCR, and supplier article matching).
*
* Upstream Triggers:
* - UI / Delivery Editor: User actions in `/lieferung/neu` or `/lieferung/:id`
* (`addLine`, `updateLine`, `removeLine`, `fetchSupplierArticles`,
* `addAttachment`, `resetDelivery`, etc.).
*
* Downstream Triggers:
* - Navigation badges in `src/nav.js` subscribe to draft line counts.
*/
import { Automat } from 'react-automat';
import { article as articleApi, supplier as supplierApi } from '../api';
import { todayInputValue } from '../components/manifest/manifestConfig';
/**
* @typedef {Object} DeliveryLine
@@ -48,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.
*
@@ -55,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,
@@ -118,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,
@@ -189,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,
@@ -361,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({
@@ -409,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);
@@ -615,6 +673,7 @@ export function createDeliveryEditorAutomat(options = {}) {
resetDelivery() {
automat.setState({
...initialDelivery,
date: todayInputValue(),
});
},
@@ -622,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, {
@@ -661,6 +866,7 @@ export const {
setId,
setDeliveryEntities,
setDelivery,
loadDraftManifest,
setDate,
setDeliveryEntity,
setPartyId,

View File

@@ -0,0 +1,214 @@
/**
* @file entityAutomat.js
* @description Manages cached customer and contact entities (`GET /api/entity`)
* for the active company. Follows the brand/warehouse/customergroup 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`, `filter`).
*/
import { Automat } from 'react-automat';
import { entity as entityApi } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import authAutomat from './authAutomat';
import companyAutomat from './companyAutomat';
/**
* @typedef {Object} Entity
* @property {string|number} id Entity id.
* @property {string|number} customergroupId Owning customer group id.
* @property {string} [number] Customer number.
* @property {string|null} [firstName] First name.
* @property {string} name Last / company name.
* @property {string} [createdAt] Created timestamp.
* @property {string} [updatedAt] Updated timestamp.
*/
/**
* @typedef {Object} EntityState
* @property {Array<Entity>} items Loaded records.
* @property {Array<Entity>} 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 {EntityState} */
const initialState = {
items: [],
filteredItems: [],
filter: '',
status: 'idle',
error: null,
};
const applyFilter = (entities, filter) => {
const query = String(filter ?? '').trim().toLowerCase();
if (!query) return entities;
const groups = companyAutomat?.state?.customergroups ?? [];
const groupsById = Object.fromEntries(groups.map((g) => [g.id, g]));
return entities.filter((e) => {
const name = String(e.name ?? '').toLowerCase();
const firstName = String(e.firstName ?? '').toLowerCase();
const number = String(e.number ?? '').toLowerCase();
const full1 = `${firstName} ${name}`.trim();
const full2 = `${name} ${firstName}`.trim();
const group = String(groupsById[e.customergroupId]?.name ?? '').toLowerCase();
return (
name.includes(query) ||
firstName.includes(query) ||
number.includes(query) ||
full1.includes(query) ||
full2.includes(query) ||
group.includes(query)
);
});
};
const sortEntities = (items) =>
[...items].sort((a, b) => {
const byName = String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de');
if (byName !== 0) return byName;
return String(a.firstName ?? '').localeCompare(String(b.firstName ?? ''), 'de');
});
let entityAutomat;
const updateStateWithEntities = (entities, extra = {}) => {
const list = sortEntities(Array.isArray(entities) ? entities : []);
const filteredItems = applyFilter(list, entityAutomat.state.filter);
entityAutomat.setState({ items: list, filteredItems, ...extra });
return list;
};
async function fetchEntities() {
const data = await entityApi.list();
requireObject(data, 'GET /api/entity');
return requireArray(data.entities, 'entities');
}
let inflightLoad = null;
function loadEntities() {
if (inflightLoad) return inflightLoad;
inflightLoad = (async () => {
entityAutomat.setState({ status: 'loading', error: null });
try {
const entities = await fetchEntities();
updateStateWithEntities(entities, { status: 'ready', error: null });
return entities;
} catch (error) {
entityAutomat.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 ?? '');
entityAutomat.setState({
filter,
filteredItems: applyFilter(entityAutomat.state.items, filter),
});
},
/** Loads the list, deduplicating concurrent callers. */
load() {
return loadEntities();
},
/** Silently refreshes the entities without flashing loading state. */
async reload() {
try {
const entities = await fetchEntities();
updateStateWithEntities(entities, { status: 'ready', error: null });
return entities;
} catch (error) {
entityAutomat.setState({ error });
throw error;
}
},
/** Replaces the cached list without an API call. */
replace(items) {
return updateStateWithEntities(items, { status: 'ready', error: null });
},
/** Returns cached items if ready, otherwise loads. */
ensureLoaded() {
if (entityAutomat.state.status === 'ready') {
return Promise.resolve(entityAutomat.state.items);
}
return actions.load();
},
/** Resets state to initial idle values. */
reset() {
inflightLoad = null;
entityAutomat.setState(initialState);
},
/** Creates a customer entity and refreshes the cache. */
async create(body) {
const data = await entityApi.create(body);
requireObject(data, 'POST /api/entity');
requireObject(data.entity, 'entity');
requireDefined(data.entity.id, 'entity.id');
await entityAutomat.actions.reload();
return data.entity;
},
/** Updates an entity name/fields and refreshes the cache. */
async update(id, body) {
const data = await entityApi.update(id, body);
requireObject(data, 'PATCH /api/entity/:id');
requireObject(data.entity, 'entity');
requireDefined(data.entity.id, 'entity.id');
await entityAutomat.actions.reload();
return data.entity;
},
rename(id, body) {
return actions.update(id, body);
},
/** Deletes an entity and refreshes the cache. */
async remove(id) {
await entityApi.delete(id);
await entityAutomat.actions.reload();
},
};
/**
* Company customers and contacts (`GET /api/entity`).
*
* @type {Automat<EntityState>}
*/
entityAutomat = new Automat(initialState, actions, {
name: 'entity',
});
export { entityAutomat };
// Load on session/company establishment, reset on logout.
const syncEntityWithAuth = (auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void actions.load().catch(() => {});
} else if (auth.status === 'anon') {
actions.reset();
}
};
authAutomat.subscribe(syncEntityWithAuth);
syncEntityWithAuth(authAutomat.state);
export default entityAutomat;

View File

@@ -0,0 +1,26 @@
/**
* @file globalHistoryAutomat.js
* @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 — resets on `'anon'` or when
* `selectedCompanyId` changes.
* - Consumer UI: Calls `getItems()`, `requireChunk(start, end)`, `filter(search)`,
* `addSeekMonth()`, `addSeekYear()`, `resetNegativeSeek()`.
*/
import { createChunkListAutomat } from './createChunkListAutomat';
import { globalHistory } from '../api';
export const globalHistoryAutomat = createChunkListAutomat({
name: 'globalHistory',
chunkSize: 50,
maxLruChunks: 50,
fetchPage: (params) => globalHistory.list(params),
});
// Alias for backwards compatibility
export const globalHistoryGridAutomat = globalHistoryAutomat;
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;

103
src/automats/helpAutomat.js Normal file
View File

@@ -0,0 +1,103 @@
/**
* @file helpAutomat.js
* @description Manages global interactive help badges and active popper state.
* Persisted in IndexedDB across browser reloads.
*
* Upstream Triggers:
* - UI / User interaction: Calls to `toggle()`, `setEnabled()`, `openPopper()`,
* and `closePopper()`.
*/
import { Automat } from 'react-automat';
/**
* @typedef {Object} HelpState
* @property {boolean} enabled Whether interactive help badges are enabled systemwide.
* @property {string|null} activeHelpId ID of the currently open help popper (only one can be open at a time).
*/
/** @type {HelpState} */
const initialState = {
enabled: false,
activeHelpId: null,
};
/**
* Global reactive state for systemwide help badges mode.
* Persisted in IndexedDB so the setting survives page reloads and sessions.
* Registered as a named automat ('help') on window.__AUTOMATS__.
*
* @type {Automat<HelpState>}
*/
const helpAutomat = new Automat(
initialState,
{
/**
* Toggles the systemwide help mode on or off.
*/
toggle() {
helpAutomat.setState((prev) => {
const nextEnabled = !prev?.enabled;
return {
enabled: nextEnabled,
activeHelpId: nextEnabled ? prev?.activeHelpId ?? null : null,
};
});
},
/**
* Explicitly set whether help mode is enabled.
* @param {boolean} enabled
*/
setEnabled(enabled) {
const nextEnabled = Boolean(enabled);
helpAutomat.setState((prev) => ({
enabled: nextEnabled,
activeHelpId: nextEnabled ? prev?.activeHelpId ?? null : null,
}));
},
/**
* Open a specific help popper, automatically closing any other currently open popper.
* @param {string} id
*/
openPopper(id) {
helpAutomat.setState({ activeHelpId: id });
},
/**
* Close the currently active help popper.
*/
closePopper() {
helpAutomat.setState({ activeHelpId: null });
},
/**
* Toggle a specific help popper open/closed.
* @param {string} id
*/
togglePopper(id) {
helpAutomat.setState((prev) => ({
activeHelpId: prev?.activeHelpId === id ? null : id,
}));
},
},
{
name: 'help',
persist: true,
}
);
// Clear active popper on initial boot / persistence hydration
void helpAutomat.ready.then(() => {
if (helpAutomat.state?.activeHelpId) {
helpAutomat.setState({ activeHelpId: null });
}
});
export default helpAutomat;
export const {
toggle: toggleHelp,
setEnabled: setHelpEnabled,
openPopper: openHelpPopper,
closePopper: closeHelpPopper,
togglePopper: toggleHelpPopper,
} = helpAutomat.actions;
export const isHelpEnabled = (state = helpAutomat.state) => Boolean(state?.enabled);
export const selectActiveHelpId = (state = helpAutomat.state) => state?.activeHelpId ?? null;

View File

@@ -1,16 +1,129 @@
/**
* @file index.js
* @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';
export { default as navigationAutomat } from './navigationAutomat';
export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat';
export {
setLastDeliveryPath,
setLastArticleSku,
setLastSearchTerm,
setLastArticleSearchPath,
setLastCategoryId,
setLastCategorySlug,
setLastCategoryPath,
clearNavigation,
} from './navigationAutomat';
export { default as userSettingsAutomat } from './userSettingsAutomat';
export { setViewerPosition } from './userSettingsAutomat';
export { default as helpAutomat } from './helpAutomat';
export {
toggleHelp,
setHelpEnabled,
isHelpEnabled,
openHelpPopper,
closeHelpPopper,
toggleHelpPopper,
selectActiveHelpId,
} from './helpAutomat';
export { default as supplierAutomat } from './supplierAutomat';
export { default as warehouseAutomat } from './warehouseAutomat';
export {
default as pwaAutomat,
initPwa,
openPwaDialog,
closePwaDialog,
applyPwaUpdate,
} from './pwaAutomat';
/** Article search over the derived index (replaces StorageManager.doSearch). */
export {
default as articleSearchAutomat,
selectSearchResult,
searchArticlesIndex,
} from './articleSearchAutomat';
/**
* Derived article search index: the union of every loaded category view.
* Replaces the former `GET /api/articleIndex` list store.
*/
export {
default as articleIndexAutomat,
setArticleIndexName,
setArticleIndexStock,
updateArticleIndexRecord,
buildArticleIndex,
selectArticleIndexItems,
} from './articleIndexAutomat';
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 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, getArticleHistoryAutomat, articleSupplierAutomat } from './articleDetailAutomat';
/** Single shared GET /api/events connection. */
export { default as sseAutomat, invalidateArticle } from './sseAutomat';
export { default as artikelAutomat } from './artikelAutomat';
export {
@@ -22,25 +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 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 {
@@ -52,8 +196,55 @@ export {
categoryDisplayName,
categorySlug,
normalizeCategoryMap,
getCategoryState,
whenCategoryTreeReady,
selectCategoryTreeArticleCount,
} from './categoryAutomat';
/**
* Per-category article views keyed by slug path, warmed in bulk by
* `categoryAutomat.loadTree()` once the tree succeeded.
*/
export {
default as categoryViews,
attachCategoryTree,
categoryViewSlugPath,
collectLoadedCategoryViewData,
loadAllCategoryViews,
reloadLoadedCategoryViews,
getOrCreateCategoryAutomat,
disposeAllCategoryViewAutomats,
invalidateAllCategoryViews,
} from './categoryAutomat';
refreshCategoryViewChildren,
appendArticlesToCategoryViews,
} from './categoryViewAutomat';
export { default as articleHistoryGridAutomat } from './articleHistoryAutomat';
export { default as globalHistoryAutomat, globalHistoryGridAutomat } from './globalHistoryAutomat';
export { default as ledgerHistoryAutomat, ledgerHistoryGridAutomat } from './ledgerHistoryAutomat';
export { default as chatAutomat } from './chatAutomat';
export {
selectChatIsOpen,
selectChatState,
selectChatMessages,
selectChatConversations,
selectSelectedConversationId,
selectSseStatus,
selectPendingAskUser,
selectHasPendingAsk,
} from './chatAutomat';
export { default as vfsAutomat } from './vfsAutomat';
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,20 +1,45 @@
/**
* @file navigationAutomat.js
* @description Stores the user's most recently visited delivery document path,
* article SKU, article search term/path, and category slug/path to preserve
* navigation context across primary and secondary section switches.
*
* Upstream Triggers:
* - UI / Routes: Set by article search, category navigation, and delivery views/routes
* via `setLastDeliveryPath(path)`, `setLastArticleSku(sku)`, `setLastSearchTerm(term, path)`,
* `setLastCategorySlug(slug)`, and `clearNavigation()`.
*/
import { Automat } from 'react-automat';
import categoryAutomat from './categoryAutomat';
import { buildCategoryPath, resolveCategoryIdFromSlugPath } from '../components/category/categoryRoute';
import { buildSearchUrl } from '../components/article/artikelQuery';
/**
* @typedef {Object} NavigationState
* @property {string|null} lastDeliveryPath Last delivery path visited by the user.
* @property {string|null} lastArticleSku Last article SKU visited by the user.
* @property {string|null} lastSearchTerm Last article search query term.
* @property {string|null} lastArticleSearchPath Full last search path (e.g. `/artikel?target=apfel`).
* @property {string|null} lastCategoryId Last visited category ID.
* @property {string|null} lastCategorySlug Last visited category slug (e.g. `obst-gemuese/obst`).
* @property {string|null} lastCategoryPath Full last category path (e.g. `/kategorien/obst-gemuese/obst`).
*/
/** @type {NavigationState} */
const initialState = {
lastDeliveryPath: null,
lastArticleSku: null,
lastSearchTerm: null,
lastArticleSearchPath: null,
lastCategoryId: null,
lastCategorySlug: null,
lastCategoryPath: null,
};
/**
* Stores the user's most recently visited delivery and article so main-menu
* navigation can restore the previous context.
* Stores the user's most recently visited delivery, article, search term, and category
* so main-menu and sub-menu navigation can restore the previous context.
*
* @type {Automat<NavigationState>}
*/
@@ -23,9 +48,9 @@ const navigationAutomat = new Automat(
{
/**
* Set the last visited delivery path.
*
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void}
*
* @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`.
* @returns {void}
*/
setLastDeliveryPath(path) {
navigationAutomat.setState({ lastDeliveryPath: path });
@@ -33,24 +58,128 @@ const navigationAutomat = new Automat(
/**
* Set the last visited article SKU.
*
*
* @param {string|null} sku
* @returns {void}
* @returns {void}
*/
setLastArticleSku(sku) {
navigationAutomat.setState({ lastArticleSku: sku });
},
/**
* Set the last active article search term and optional search URL.
*
* @param {string|null} term Search input term.
* @param {string|null} [path] Full search URL.
* @returns {void}
*/
setLastSearchTerm(term, path = null) {
const trimmed = term != null ? String(term) : '';
const currentCatId = navigationAutomat.state.lastCategoryId ?? '';
const searchPath = path ?? buildSearchUrl(trimmed, currentCatId, 0);
navigationAutomat.setState({
lastSearchTerm: trimmed || null,
lastArticleSearchPath: searchPath,
});
},
/**
* Set the full last article search URL path.
*
* @param {string|null} path Full search URL path (e.g. `/artikel?target=apfel`).
* @param {string|null} [term] Search term.
* @returns {void}
*/
setLastArticleSearchPath(path, term = null) {
navigationAutomat.setState({
lastArticleSearchPath: path,
...(term !== null ? { lastSearchTerm: term } : {}),
});
},
/**
* Set the active category ID and keep categoryPath & articleSearchPath in sync.
*
* @param {string|number|null} categoryId Category ID or null for root.
* @param {string|null} [path] Full category path if already known.
* @returns {void}
*/
setLastCategoryId(categoryId, path = null) {
const id = categoryId != null ? String(categoryId) : null;
const categories = categoryAutomat.state?.categories;
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
const isRoot = !id || (rootCategoryId != null && id === String(rootCategoryId));
let categoryPath = path;
if (!categoryPath) {
if (!isRoot && categories && rootCategoryId != null) {
categoryPath = buildCategoryPath(categories, rootCategoryId, id);
} else {
categoryPath = '/kategorien';
}
}
const cleanSlug = categoryPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
const filter = isRoot ? '' : id;
const searchPath = buildSearchUrl(currentTerm, filter, 0);
navigationAutomat.setState({
lastCategoryId: isRoot ? null : id,
lastCategorySlug: isRoot ? null : cleanSlug,
lastCategoryPath: categoryPath,
lastArticleSearchPath: searchPath,
});
},
/**
* Set the last visited category slug.
*
* @param {string|null} slug Category slug (e.g. `obst-gemuese/obst` or empty string for root).
* @returns {void}
*/
setLastCategorySlug(slug) {
const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : '';
const path = clean ? `/kategorien/${clean}` : '/kategorien';
navigationAutomat.actions.setLastCategoryPath(path);
},
/**
* Set the full last category path.
*
* @param {string|null} path Full category path (e.g. `/kategorien/obst-gemuese/obst`).
* @returns {void}
*/
setLastCategoryPath(path) {
const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien';
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
const categories = categoryAutomat.state?.categories;
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
const resolvedId = slug && categories && rootCategoryId != null
? resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug)
: null;
const isRoot = !resolvedId || (rootCategoryId != null && String(resolvedId) === String(rootCategoryId));
const catId = isRoot ? null : String(resolvedId);
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
const filter = catId ?? '';
const searchPath = buildSearchUrl(currentTerm, filter, 0);
navigationAutomat.setState({
lastCategoryId: catId,
lastCategorySlug: isRoot ? null : slug,
lastCategoryPath: cleanPath,
lastArticleSearchPath: searchPath,
});
},
/**
* Clear all navigation history.
*
* @returns {void}
*
* @returns {void}
*/
clearNavigation() {
navigationAutomat.setState({
lastDeliveryPath: null,
lastArticleSku: null,
});
navigationAutomat.setState(initialState);
},
},
{
@@ -60,4 +189,13 @@ const navigationAutomat = new Automat(
);
export default navigationAutomat;
export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions;
export const {
setLastDeliveryPath,
setLastArticleSku,
setLastSearchTerm,
setLastArticleSearchPath,
setLastCategoryId,
setLastCategorySlug,
setLastCategoryPath,
clearNavigation,
} = navigationAutomat.actions;

1224
src/automats/orderAutomat.js Normal file

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,142 +0,0 @@
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;
},
/**
* 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;

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