Compare commits

...

442 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
seb
c4f8ebb41b u 2026-09-28 06:49:46 +02:00
seb
0daf9fef3d u 2026-09-27 19:01:20 +02:00
seb
74e932c5cf u 2026-09-27 18:32:39 +02:00
seb
f9383e717f u 2026-09-27 17:18:11 +02:00
seb
0f875f347e u 2026-09-27 16:19:08 +02:00
seb
559307675c u 2026-09-27 07:08:13 +02:00
seb
3ab9d904e6 u 2026-09-26 23:05:48 +02:00
seb
454c5ab6bf u 2026-09-26 14:47:02 +02:00
seb
dcca972ff8 u 2026-09-26 07:09:36 +02:00
seb
e5b43361cc u 2026-09-25 12:22:48 +02:00
seb
a3db1742e7 u 2026-09-25 07:27:37 +02:00
seb
b331f8f535 u 2026-09-23 17:24:33 +02:00
seb
0391e845f0 u 2026-09-23 10:18:31 +02:00
seb
c21a951884 u 2026-09-23 09:49:55 +02:00
seb
b7fdbb3011 u 2026-09-23 09:47:19 +02:00
seb
ab65fb4776 routes bundle 2026-09-23 09:35:46 +02:00
seb
c94ce20b7a no redux 2026-09-23 08:16:14 +02:00
seb
49057dc0d6 u 2026-09-22 13:45:22 +02:00
seb
3ae840b2fe u 2026-09-22 08:50:19 +02:00
seb
8aae27bb87 u 2026-09-22 08:45:14 +02:00
seb
51d01a502b but 2026-09-22 07:59:00 +02:00
seb
5e13b8f9cd no redux 2026-09-22 07:20:31 +02:00
seb
536e7b1e2a u 2026-09-21 20:21:57 +02:00
seb
2046f96768 supplierAutomat 2026-09-21 10:55:17 +02:00
seb
98080f0514 u 2026-09-21 07:43:15 +02:00
seb
1b6e3ec7e2 inspector 2026-09-21 04:30:11 +02:00
seb
366d2517d6 automat 2026-09-21 03:19:32 +02:00
seb
5594c59d88 upload stuff 2026-09-20 07:18:43 +02:00
seb
4011ebfc61 nav 2026-09-19 18:33:27 +02:00
seb
d3b4743b66 abort and search 2026-09-19 08:14:11 +02:00
seb
abe078ff05 file api 2026-09-19 06:37:42 +02:00
seb
02e9a555d6 do not use meta 2026-09-18 08:34:42 +02:00
seb
c0082cea65 fix 2026-09-18 08:24:35 +02:00
866 changed files with 110719 additions and 12328 deletions

View File

@@ -0,0 +1,208 @@
---
name: automat
description: >-
Lightweight out-of-tree state management for React PureComponent with zero hooks, HOCs, or Context Providers.
Use this skill whenever creating, modifying, or consuming Automat stores in React components, setting up
subscriptions or fine-grained selectors, configuring cascading invalidations, handling binary blobs/fetchers, or streaming via SSE.
---
# ⚙ react-automat
> Zero-dependency higher-order state container for React `PureComponent`. State lives in module scope (independent of mounting). Components read state synchronously in `constructor` and subscribe in `componentDidMount`. No Context, hooks, or HOCs.
[![npm version](https://img.shields.io/npm/v/react-automat?style=flat-square&color=crimson)](https://www.npmjs.com/package/react-automat)
[![bundle size](https://img.shields.io/bundlephobia/minzip/react-automat?style=flat-square&color=22c55e&label=minzipped)](https://bundlephobia.com/package/react-automat)
[![Live Interactive Demo](https://img.shields.io/badge/Live%20Demo-sebgreen.net%2Fautomat-6366f1?style=flat-square)](https://sebgreen.net/automat/)
[![License: 0BSD](https://img.shields.io/badge/License-0BSD-blue.svg?style=flat-square)](LICENSE)
```bash
npm install react-automat
# or: pnpm add react-automat | yarn add react-automat | bun add react-automat
```
---
## 1. Complete Type Signatures
```ts
import type { PureComponent } from 'react';
export interface AutomatOptions<T> {
name?: string | null; // window.__AUTOMATS__ key & Automat.get(name)
persist?: boolean; // false = window (default), true = IndexedDB
url?: string | null; // Auto-triggers fetcher(url) on init
fetcher?: (url: string, automat?: Automat<T, any>) => Promise<T>;
}
export type Selector<T, S = any> = keyof T | (keyof T)[] | ((state: T) => S | null | undefined);
export interface ScopedSlice<S> {
readonly state: S;
subscribe(target: PureComponent | ((slice: S) => void)): () => void;
}
export interface BlobResource {
blob: Blob; url: string; size: number; type: string; loadedAt: string;
revoke: () => void; // Auto-called on setDirty() / dispose() to release memory
}
export interface SSEOptions<T = any, A = any> {
onMessage?: (data: any, event: string, automat: Automat<T, A>) => void;
events?: Record<string, (data: any, automat: Automat<T, A>) => void>;
onError?: (err: any, automat: Automat<T, A>) => void;
}
export class Automat<T = any, A = Record<string, Function>> {
constructor(initialState?: T | null, actions?: A, options?: AutomatOptions<T>);
readonly state: T;
getState(): T;
setState(partial: Partial<T> | ((prev: T) => Partial<T>)): T;
readonly actions: A;
readonly isReady: boolean;
readonly isDirty: boolean;
readonly ready: Promise<T>; // Re-awaitable after setDirty()
readonly name: string | null;
readonly persist: boolean;
readonly url: string | null;
readonly subscriberCount: number;
subscribe(target: PureComponent | ((state: T, partial?: Partial<T>) => void), selector?: Selector<T>): () => void;
select<S = any>(selector: Selector<T, S>): ScopedSlice<S>;
unsubscribe(target: any): void;
subscribeTo<U = any>(
upstream: Automat<U, any>,
transform: (upstreamState: U, currentState: T) => Partial<T> | null | undefined,
options?: { cascadeDirty?: boolean | ((upstream: U, current: T) => boolean) }
): this;
invalidateWith<U = any>(upstream: Automat<U, any>, filterFn?: (upstream: U, current: T) => boolean): this;
onDirty(fn: (automat: this) => void): () => void;
setDirty(): Promise<T>; // Resets state to null/default, revokes blobs, cascades, defers if unmounted
reload(): Promise<T>; // Silent background re-fetch; preserves state & isReady=true
refresh(): Promise<T>; // Alias for reload()
read(): T; // For Suspense: throws ready promise if unready; triggers deferred load if dirty
clearPersistence(): Promise<void>;
dispose(): void; // Unsubs all, revokes blobs, deletes from window registry
static get<T = any, A = any>(name: string): Automat<T, A> | undefined;
static combine<M extends Record<string, Automat<any, any>>>(automats: M): Automat<CombinedState<M>, CombinedActions<M>>;
}
// Fetchers & SSE
export function fetchJson<T = any>(url: string, options?: RequestInit): Promise<T>;
export function fetchBlob(url: string, options?: RequestInit): Promise<BlobResource>;
export const fetchImageBlob: typeof fetchBlob;
export const fetchPdfBlob: typeof fetchBlob;
export function connectSSE<T, A>(automat: Automat<T, A>, url: string, options?: SSEOptions<T, A>): { eventSource: EventSource | null; close: () => void };
export function createSSEFetcher(sseUrl: string, baseFetcher?: Function, sseOptions?: SSEOptions): (url: string, automat?: any) => Promise<any>;
```
## 2. Canonical Usage Patterns
### A. PureComponent Subscription
```jsx
import { PureComponent } from 'react';
import { Automat } from 'react-automat';
const store = new Automat({ count: 0 }, { inc: () => store.setState({ count: store.state.count + 1 }) });
class Counter extends PureComponent {
constructor(props) { super(props); this.state = store.state; } // Sync read, never stale
componentDidMount() { this.unsub = store.subscribe(this); } // Auto-calls this.setState(partial)
componentWillUnmount() { this.unsub(); }
render() { return <button onClick={store.actions.inc}>{this.state.count}</button>; }
}
```
### B. Fine-Grained Selectors (Shallow-guarded, 0 renders if slice unchanged)
```js
store.subscribe(this, 'count'); // Single key
store.subscribe(this, ['theme', 'locale']); // Multi key
store.subscribe(this, (s) => ({ items: s.list.length }));// Projection function
```
### C. Unconnected Component via Window (Zero imports of store file)
```js
const store = Automat.get('cart'); // or window.__AUTOMATS__?.get('cart')
this.state = { count: store?.state?.items?.length || 0 };
this.unsub = store?.subscribe(this, (s) => ({ count: s.items.length }));
```
### D. Backend API: React Suspense vs Standard
```jsx
const user = new Automat(null, {}, { url: '/api/user' });
// Suspense: throws promise while unready
const Profile = () => <div>{user.read().name}</div>;
// Standard: check isReady
class ProfileStandard extends PureComponent {
constructor(props) { super(props); this.state = { u: user.state }; }
componentDidMount() { this.unsub = user.subscribe(this, (u) => ({ u })); }
componentWillUnmount() { this.unsub(); }
render() { return !user.isReady ? <Spinner /> : <div>{this.state.u.name}</div>; }
}
```
### E. Cascading Invalidation, Blobs & Memory Freeing
```js
const all = new Automat(null, {}, { name: 'photos/all', url: '/api/photos' });
const p1 = new Automat(null, {}, { name: 'photo/1', url: '/api/photos/1', fetcher: fetchImageBlob });
p1.invalidateWith(all); // p1 cascades setDirty() when all.setDirty() is called
all.setDirty(); // p1 auto-revokes held Blob URL, resets state=null, defers reload if unmounted
```
### F. SSE Remote Trigger
```js
const live = new Automat({}, {
onSSETrigger(data, event, store) {
if (event === 'invalidate') store.setDirty();
if (event === 'reload') store.reload();
}
});
connectSSE(live, '/api/stream');
```
### G. Combine Automats
```js
const app = Automat.combine({
auth: authAutomat,
company: companyAutomat,
});
app.state.auth; // authAutomat.state
app.state.company; // companyAutomat.state
app.actions.auth.login(); // authAutomat.actions.login()
app.actions.company.rename('Acme');
// Notified when either child changes; selectors can isolate a branch.
const unsubscribe = app.subscribe(this, (state) => ({
user: state.auth.user,
companyName: state.company.name,
}));
```
The aggregate is derived: child updates automatically update the matching key. Its
`isReady`, `isDirty`, `ready`, `read()`, `setDirty()`, and `reload()` operations cover
all children. Calling `app.dispose()` only removes aggregate subscriptions; it does
not dispose the child automats.
## 3. Decision Matrix: `setDirty()` vs `reload()`
| Method | `isReady` | State during fetch | Suspense | Unmounted | Use Case |
|---|---|---|---|---|---|
| `setDirty()` | `false` | Resets to `null`/init | Throws fallback | Deferred until mount/read | Cache bust, logout, hard reset |
| `reload()` | `true` | Preserved | Never suspends | Immediate fetch | Polling, pull-to-refresh, sync |
## 4. Invariant Rules
- Read `store.state` synchronously in `constructor(props)` — never in render bodies.
- Subscribe in `componentDidMount()` & store the returned `unsub`; unsubscribe in `componentWillUnmount()`.
- Never mutate `store.state` directly (`store.state.x = 1` ❌); use `store.setState({ x })` or actions.
- Pass selectors to `subscribe(this, selector)` on multi-key stores to skip redundant renders.
- Never wrap components in Context Providers, HOCs, or custom hooks.

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":{}}

577
AGENTS.md Normal file
View File

@@ -0,0 +1,577 @@
# AGENTS.md — Dashfront Developer & Agent Guide
> **Audience**: AI Coding Agents & Pair-Programmers working on `dashfront`.
> **Workspace**: `/home/seb/src/dashfront`
> **Last Updated**: 2026-09-21
---
## Table of Contents
1. [System Overview & Ecosystem Map](#1-system-overview--ecosystem-map)
2. [Technology Stack & Key Libraries](#2-technology-stack--key-libraries)
3. [Directory Layout](#3-directory-layout)
4. [State Management Conventions](#4-state-management-conventions)
5. [Backend Communication & Authentication Rules](#5-backend-communication--authentication-rules)
6. [Route & Navigation Architecture](#6-route--navigation-architecture)
7. [Developer Workflows & Commands](#7-developer-workflows--commands)
8. [Critical Agent Rules & Common Pitfalls](#8-critical-agent-rules--common-pitfalls)
9. [Deep Dive: The Automats (`react-automat`)](#9-deep-dive-the-automats-react-automat)
10. [Deep Dive: Configuration Automats (Company, Category, Brand, Unit & POS Terminals)](#10-deep-dive-configuration-automats-company-category-brand-unit--pos-terminals)
11. [`dashgui` Component Library Integration](#11-dashgui-component-library-integration)
12. [Application Views & Route Hierarchy](#12-application-views--route-hierarchy)
13. [Media, Images & Document Pipelines](#13-media-images--document-pipelines)
14. [Real-time SSE Signals & Invalidation](#14-real-time-sse-signals--invalidation)
15. [Developer Tooling & Debugging](#15-developer-tooling--debugging)
16. [Agent Cheatsheet & Code Recipes](#16-agent-cheatsheet--code-recipes)
---
## 1. System Overview & Ecosystem Map
`dashfront` is the React web client and Progressive Web App (PWA) for the Dash ERP/POS system. It is part of a multi-repository workspace under `/home/seb/src/`:
```
/home/seb/src/
├── dashfront/ # THIS REPO: Vite + React 19 web/PWA client (port 4209 HTTP, 4308 HTTPS)
├── dashback/ # Backend Express REST/SSE API server (port 4208)
├── dashdbschema/ # LibDashStore (MSSQL data layer, migrations, schema graph, API docs)
├── dashgui/ # Reusable custom UI component library (file:../dashgui)
└── automat/ # react-automat state management library (file:../automat or npm)
```
### End-to-End Data & Execution Flow
```yaml
# Machine-readable execution flow:
flow:
- step: 1_client
component: dashfront
runtime: React 19 (PureComponent & Function Components)
state_layers:
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)
target: dashback
- step: 2_api_gateway
component: dashback
runtime: Express 5 API server (src/api/routes/*)
security:
auth_middleware: Cookie auth middleware (validates JWT in HTTP-only "token" cookie)
context_injection:
user_auth_id: req.user.googleAuthId
tenant_company_id: req.selectedCompanyId
events: Dispatches real-time SSE mutation events to subscribed clients via /api/events
egress:
interface: In-process method calls (store.<namespace>.<method>())
target: dashdbschema
- step: 3_data_layer
component: dashdbschema
runtime: LibDashStore (src/store/*)
storage: MSSQL connection pool, migrations, and SQL transactions
```
---
## 2. Technology Stack & Key Libraries
| Technology | Version / Spec | Notes |
|---|---|---|
| **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 `#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) |
| **Media & Images** | `@jsquash/avif`, `@jsquash/resize`, `thumbhash` | Client-side AVIF encoding and instant thumbnail hash generation |
| **Documents** | `pdfconvert-wasm` | In-browser PDF processing for supplier delivery invoices & receipts |
| **Drag & Drop** | `@shopify/draggable`, `sortablejs` | Category re-ordering, article assignments, nested tree nodes |
---
## 3. Directory Layout
```
src/
├── api/ # Backend communication layer
│ └── 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
│ ├── companyAutomat.js # Active company config (tax rates, root category, legal details)
│ ├── categoryAutomat.js # Hierarchical category tree, breadcrumbs, and category articles
│ ├── brandAutomat.js # Catalog brands / manufacturer list
│ ├── unitAutomat.js # Units of measure (Stück, kg, Liter, ...)
│ ├── posterminalAutomat.js # POS terminal config and enabled categories
│ ├── deliveryEditorAutomat.js # Multi-line delivery receipt / invoice editor & draft state
│ ├── navigationAutomat.js # Remembers active paths (last delivery path, last article SKU)
│ ├── supplierAutomat.js # Supplier list and supplier-article catalogs
│ ├── userSettingsAutomat.js # User preferences (viewer positions, layout settings)
│ └── index.js # Barrel exports for all automats and helper selectors
├── components/ # Shared UI components
│ ├── AppShell.jsx # Main application frame (top bar, sidebar, nav buttons)
│ ├── SectionLayout.jsx # Secondary navigation header for sections (/artikel, /lieferung...)
│ ├── AuthBootstrap.jsx # Session bootstrapper, Google One Tap, company setup gate
│ ├── LoginPage.jsx # Google One Tap / credentials login interface
│ ├── UserMenu.jsx # User avatar, company switcher dropdown, logout action
│ ├── BackendError.jsx # Graceful error banner for backend communication failures
│ ├── article/ # Article tiles, editors, dialogs, photos, price tiers
│ ├── category/ # Category navigation, dialogs, and picker components
│ ├── manifest/ # Manifest / delivery line inspection widgets
│ ├── settings/ # Terminal, unit, and brand settings dialogs
│ └── tiles/ # Tile components extending dashgui base Tile
├── devtools/ # Dev-only debug tools
│ └── AutomatInspector.jsx # Docked overlay inspecting all registered window.__AUTOMATS__
├── pages/ # Top-level view routes
│ ├── artikel/ # Article index, category browser, warehouse, SKU detail editor
│ ├── lieferung/ # Delivery list, new delivery editor, supplier manager, edit page
│ ├── auftrag/ # Quotes (Angebote) and orders (Bestellungen)
│ ├── kunde/ # Customer list, new customer, customer groups, edit page
│ └── infos/ # System and company information page
├── theme/ # MUI theme definitions (light/dark palette, typography, components)
├── utils/ # Helpers (apiShape.js, number formatters, notify.js, etc.)
├── appRouter.jsx # react-router-dom route definitions
├── nav.js # Navigation items (NAV_ITEMS, SUB_NAV, badges, active path matchers)
├── colors.js # Shared color constants (MAIN_BACKGROUND)
├── main.jsx # React root mount, providers, dev SW unregistration
└── App.jsx # Main router provider wrapper
```
---
## 4. State Management Conventions
In `dashfront`, two state management solutions coexist harmoniously:
### A. `react-automat` (Recommended for Domain Models & Frequent Updates)
- **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**:
```jsx
import { PureComponent } from 'react';
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
class ArticleView extends PureComponent {
constructor(props) {
super(props);
// 1. Synchronous read in constructor — zero blank flash!
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
// 2. Subscribe with fine-grained selector
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
componentWillUnmount() {
// 3. Always unsubscribe
this.unsub?.();
}
render() {
const { article } = this.state;
if (!article) return <div>Lädt...</div>;
return <div>{article.name}</div>;
}
}
```
### 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
### Authentication & Cookies
1. **HTTP-only cookie**: The session token is stored in an HTTP-only cookie (`token`).
2. **`credentials: 'include'`**: Every `fetch` request MUST set `credentials: 'include'`. The `http()` helper in [src/api/index.js](file:///home/seb/src/dashfront/src/api/index.js) does this automatically.
3. **Never pass `googleAuthId`**: The frontend NEVER passes `googleAuthId` in request bodies or query parameters. The backend middleware extracts the authenticated user directly from the cookie session!
4. **Session Company**: Multi-tenancy is scoped to the user's selected company. When a user switches companies (`PUT /api/auth/selected-company`), the backend updates the cookie session. All subsequent calls operate on that company.
### API Entry Point
Import API namespaces from `src/api`:
```js
import { article, category, delivery, supplier, entity, company } from '../api';
// Examples:
const { article: data } = await article.getBySku('SKU-1001');
const { deliveries } = await delivery.list();
const { suppliers } = await supplier.list();
```
---
## 6. Route & Navigation Architecture
- Main navigation sections are defined in [src/nav.js](file:///home/seb/src/dashfront/src/nav.js):
- `/artikel` — Articles overview, SKU editor, Warehouse stock
- `/kategorien/*` — Drag-and-drop category tree
- `/lieferung` — Inbound deliveries, New delivery editor, Suppliers
- `/auftrag` — Quotes (`/auftrag`) and Orders (`/auftrag/bestellung`)
- `/kunde` — Customers, Customer groups
- `/infos` — System settings & company info
- **Router Singleton**:
- Defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx).
- Preserved across Vite HMR via `import.meta.hot.data.router` to prevent mid-session resets when hot-reloading code.
- **Dynamic Badges**:
- `DeliveryNavIcon` and `DeliveryNeuIcon` in `src/nav.js` subscribe to `deliveryEditorAutomat` to show live uncommitted draft line badges in real time.
---
## 7. Developer Workflows & Commands
Run commands from the repository root `/home/seb/src/dashfront`:
```bash
# Start Vite development server (proxies /api -> http://localhost:4208)
npm run dev
# Start development server with local HTTPS (uses certs/key.pem and certs/cert.pem)
npm run dev:https
# Run full PWA production build + preview (to test service worker / offline caching)
npm run dev:pwa
# Generate local self-signed TLS certificates
npm run cert
# Production build
npm run build
# Preview production build
npm run preview
# Regenerate Automat Graph visualization (automat-graph.svg & automat-graph.dot)
npm run automat-graph
```
> [!NOTE]
> When testing camera-based barcode scanning or Progressive Web App features on mobile devices or local network IP addresses, start the server using `npm run dev:https`. Web browsers require a secure context (HTTPS) to grant access to media devices and service workers.
---
## 8. Critical Agent Rules & Common Pitfalls
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_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`).
5. **Binary / Image Handling**:
- Image uploads convert source images into AVIF variants client-side.
- Frontend components use `thumbhash` for instant blur placeholders while loading full images via `/api/image/{fullHash}` or `/api/thumb/{fullHash}`.
6. **No Hooks in PureComponents**: Never call React hooks (`useState`, `useEffect`) inside `PureComponent` classes. Automats provide direct reactive subscription without hooks.
7. **Always Unsubscribe**: Every `automat.subscribe(this, ...)` in `componentDidMount()` must have its cleanup callback called in `componentWillUnmount()`.
---
## 9. Deep Dive: The Automats (`react-automat`)
All domain Automats are located in [src/automats/](file:///home/seb/src/dashfront/src/automats/). Each automat exposes an observable state container with explicit action methods:
### 1. `artikelAutomat` ([src/automats/artikelAutomat.js](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js))
- **Role**: Cache and reactive distributor for article detail payloads (`GET /api/article/:sku`).
- **Inflight Deduplication**: Uses an internal `Map<string, Promise<object>>` so concurrent UI tiles or navigation transitions requesting the same SKU share a single HTTP call.
- **Key Actions**:
- `loadArticle(sku)`: Fetches if missing, resolves immediately if cached.
- `reloadArticle(sku)`: Silently re-fetches to update cache (used on SSE mutation events).
- `setArticle(article)`: Updates cache after mutation responses.
- `setArticleName(sku, name)`: Optimistic immediate rename.
- `clearArticle(sku)` / `clear()`: Invalidates cache.
- **Helper Selectors**: `getArticle(sku)`, `hasArticle(sku)`, `createArticleSelector(sku)`, `selectArticle(sku)`.
### 2. `authAutomat` ([src/automats/authAutomat.js](file:///home/seb/src/dashfront/src/automats/authAutomat.js))
- **Role**: Manages user authentication lifecycle and multi-tenant company selection.
- **State**: `{ user: AuthUser | null, googleClientId: string | null, status: 'loading' | 'anon' | 'authed' }`.
- **Consumed by other automats**: The company, category, brand, unit, and posterminal automats `subscribe` to `authAutomat` and (re)load or reset on its state changes. `authAutomat` also emits lifecycle events via the registered `setAuthEventDispatcher`:
- `auth/sessionEstablished`
- `auth/companyChanged`
- `auth/loggedOut`
- **Actions**: `bootstrapSession(session)`, `switchCompany(companyId)`, `logout()`.
### 3. `deliveryEditorAutomat` ([src/automats/deliveryEditorAutomat.js](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js))
- **Role**: State engine for the multi-line inbound delivery receiver and invoice reconciler ([src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx)).
- **Features**:
- Line items editor (`lines`, `createEmptyLineitem`).
- Supplier article auto-matching via `buildSupplierArticlesMap(articles)` indexing by supplier SKU, internal SKU, and EANs.
- PDF/Receipt attachments parsing with OCR integration.
- Reactive draft indicators (provides draft counts for badges in `src/nav.js`).
### 4. `supplierAutomat` ([src/automats/supplierAutomat.js](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js))
- **Role**: Caches supplier directory list and maps articles to their corresponding supplier catalogs (`GET /api/supplier`).
### 5. `navigationAutomat` ([src/automats/navigationAutomat.js](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js))
- **Role**: Remembers last active subpaths and last visited article SKU. When a user navigates between the main sidebar sections (e.g. from `/lieferung` to `/artikel` and back), it restores the exact active SKU or delivery document instead of resetting to the root list.
### 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)
The remaining static/relational/configuration state is **also** managed by `react-automat`. These automats live alongside the domain automats in [src/automats/](file:///home/seb/src/dashfront/src/automats/) and follow the same subscribe/selector pattern:
1. **`companyAutomat`** ([src/automats/companyAutomat.js](file:///home/seb/src/dashfront/src/automats/companyAutomat.js)): Active company configuration, tax rates (standard, reduced), root category ID, company legal details. Selectors: `selectCompanyTaxrate()`, `selectCompanyReducedTaxrate()`, `selectCompanyRootCategoryId()`, `selectCompanyName()`, …
2. **`categoryAutomat`** ([src/automats/categoryAutomat.js](file:///home/seb/src/dashfront/src/automats/categoryAutomat.js)): Hierarchical category tree (id → node map), breadcrumbs, category navigation, article lists per category, and category/article mutations (`createCategory`, `createArticle`, `deleteCategory`, `moveCategory`).
3. **`brandAutomat`** ([src/automats/brandAutomat.js](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)): Catalog brands / manufacturer list (`load`, `upsert`).
4. **`posterminalAutomat`** ([src/automats/posterminalAutomat.js](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js)): Registered point-of-sale hardware terminals and their enabled categories.
5. **`unitAutomat`** ([src/automats/unitAutomat.js](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)): Allowed units of measure (Stück, kg, Liter, etc.).
### Automatic Bootstrap via `authAutomat` Subscriptions
Each of these automats subscribes directly to `authAutomat` at module load and coordinates its own loading:
```js
// e.g. src/automats/companyAutomat.js
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
```
When the auth state becomes `authed` with a `selectedCompanyId` (on session bootstrap, login, or company switch), the company, brand, unit, and posterminal automats load automatically; on logout/anon they reset. `categoryAutomat.loadTree()` then reads the company's `rootCategoryId` from `companyAutomat`.
### Automatic Debugging
All automats are observable in the [AutomatInspector](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx), which lists every automat registered in `window.__AUTOMATS__` with live state, subscriber counts, and actions.
---
## 11. `dashgui` Component Library Integration
`dashgui` is a shared, high-performance UI library linked from `file:../dashgui`:
### Core Components
- **`PanelTile`**: Structured card container featuring standard header styling, action slots, collapsible state, and clean borders.
- **`RecordList`**: High-performance list component optimized for large dataset rendering with custom row click, hover, and selection handlers.
- **`Tile`**: Base container element for building responsive dashboard grids.
- **`SingleLineTextEdit`**: Inline editable text field with debounced autosave, blur commit, and escape rollback.
- **`ElevatedPill`**: Compact visual badges for status, tags, and category chips.
### React 19 & Deduplication Warning
Because `dashgui` is symlinked from `../dashgui`, it has its own `node_modules`. To prevent duplicate React, Emotion, or MUI instances from causing runtime context errors (`Cannot read properties of null (reading 'useContext')`), [vite.config.js](file:///home/seb/src/dashfront/vite.config.js) enforces `dedupe`:
```js
resolve: {
dedupe: [
'react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime',
'react-router', 'react-router-dom', '@emotion/react', '@emotion/styled',
'@mui/material', '@mui/icons-material'
]
}
```
---
## 12. Application Views & Route Hierarchy
The application routing is defined in [src/appRouter.jsx](file:///home/seb/src/dashfront/src/appRouter.jsx) and styled with [src/components/AppShell.jsx](file:///home/seb/src/dashfront/src/components/AppShell.jsx):
```
/
├── /artikel
│ ├── index → ArtikelUebersichtPage (Search & filter grid)
│ ├── /kategorien/* → ArtikelKategorienPage (Interactive category tree)
│ ├── /lager → ArtikelLagerPage (Warehouse stock overview)
│ └── /:sku/:tab? → ArtikelSkuPage (SKU detail: info, photos, prices, suppliers, warehouse)
├── /lieferung
│ ├── index → LieferungListePage (Inbound delivery list)
│ ├── /neu → LieferungNeuPage (Receipt editor & OCR reconciliation)
│ ├── /lieferanten → LieferungLieferantenPage (Supplier directory)
│ └── /:deliveryId → LieferungEditPage (Delivery inspection & editing)
├── /auftrag
│ ├── index → AuftragAngebotPage (Quotes / Angebote)
│ ├── /neuesangebot → AuftragAngebotNeuPage (Create quote / Neues Angebot)
│ ├── /:quoteId → AuftragAngebotEditPage (Edit quote)
│ ├── /bestellung → AuftragBestellungPage (Orders / Bestellungen)
│ ├── /bestellung/neu → AuftragBestellungNeuPage (Create order)
│ └── /bestellung/:id → AuftragBestellungEditPage (Edit order)
├── /kunde
│ ├── index → KundeListePage (Customer directory)
│ ├── /neu → KundeNeuPage (Create customer)
│ ├── /gruppen → KundeGruppenPage (Customer groups & price tiers)
│ └── /:entityId → KundeEditPage (Customer profile & address editor)
└── /infos → InfosPage (Company information & POS setup)
```
---
## 13. Media, Images & Document Pipelines
### Client-Side AVIF Conversion
To minimize backend CPU usage and reduce bandwidth:
- When a user selects or drops an article photo, the frontend processes the image in-browser using `@jsquash/avif` and `@jsquash/resize`.
- Images are scaled to target variant dimensions and compressed into AVIF format before transmission to `/api/article/:sku/photo`.
### ThumbHash Placeholders
- For instant visual feedback without layout shift, article photos generate a 25-byte binary hash using `thumbhash`.
- [src/components/article/ThumbHashImage.jsx](file:///home/seb/src/dashfront/src/components/article/ThumbHashImage.jsx) renders the blur preview instantly while the full image loads asynchronously from `/api/thumb/{hash}` or `/api/image/{hash}`.
### PDF & Document Ingestion
- [src/pages/lieferung/neu.jsx](file:///home/seb/src/dashfront/src/pages/lieferung/neu.jsx) utilizes `pdfconvert-wasm` to process supplier PDF delivery notes and invoices directly inside the browser.
- Extracted pages are rendered to canvas, displayed for line-by-line inspection, and passed to OCR recognition to populate delivery line items.
---
## 14. Real-time SSE Signals & Invalidation
The frontend connects to the backend Server-Sent Events stream at `/api/events`:
- **Architecture**: When mutations occur on `dashback` (whether triggered by this client, another terminal, or the POS), SSE events are broadcast with payload information (e.g. `{ type: 'article-update', sku: '1001' }`).
- **Reactive Cache Refresh**:
- `artikelAutomat.actions.reloadArticle(sku)` re-fetches the updated article silently, updating subscribers without flicker.
- Category updates trigger `categoryAutomat` tree/article reloads.
- Delivery and order updates trigger automatic list reloads.
---
## 15. Developer Tooling & Debugging
### AutomatInspector ([src/devtools/AutomatInspector.jsx](file:///home/seb/src/dashfront/src/devtools/AutomatInspector.jsx))
- Docked overlay on the right side of the screen, active only in development mode (`import.meta.env.DEV`).
- Auto-discovers all running automats registered in `window.__AUTOMATS__`.
- Allows developers and agents to:
- Inspect full state JSON trees in real time.
- View subscriber counts and registered actions.
- Trigger actions and test state changes without code modifications.
### Vite API Proxy Logging
[vite.config.js](file:///home/seb/src/dashfront/vite.config.js) includes automatic proxy logging for all `/api` requests and responses. HTTP method, path, request payloads, response statuses, and timing are formatted directly in the Vite terminal.
### TLS / HTTPS Development
For testing hardware barcode scanners, camera video feeds, and PWA offline modes:
```bash
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
### Recipe 1: Consuming an Automat in a React PureComponent
```jsx
import { PureComponent } from 'react';
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
export class ArticleTitle extends PureComponent {
constructor(props) {
super(props);
// 1. Read synchronously to avoid blank flash
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
// 2. Subscribe with fine-grained selector
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
componentDidUpdate(prevProps) {
// 3. Handle prop changes cleanly
if (prevProps.sku !== this.props.sku) {
this.setState({ article: getArticle(this.props.sku) });
this.unsub?.();
this.unsub = artikelAutomat.subscribe(this, (state) => ({
article: createArticleSelector(this.props.sku)(state),
}));
}
}
componentWillUnmount() {
// 4. Always cleanup
this.unsub?.();
}
render() {
const { article } = this.state;
return <h1>{article?.name ?? 'Lädt...'}</h1>;
}
}
```
### Recipe 2: Calling the API and Notifying Errors
```jsx
import { article } from '../api';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
import { artikelAutomat } from '../automats';
async function handleSavePrice(sku, netPrice) {
try {
const res = await article.updatePrice(sku, { net: netPrice });
artikelAutomat.actions.setArticle(res.article);
enqueueSnackbar('Preis erfolgreich aktualisiert', { variant: 'success' });
} catch (err) {
notifyError(err);
}
}
```
### Recipe 3: Calculating Gross Prices with Company Tax Rates
```jsx
// Prices in the database and API are NET prices.
// Gross prices shown to end users must apply company tax rates from companyAutomat
// (selectCompanyTaxrate() / selectCompanyReducedTaxrate()):
export function calculateGrossPrice(netPrice, taxRatePercent) {
const net = Number(netPrice) || 0;
const rate = Number(taxRatePercent) || 0;
return Math.round(net * (1 + rate / 100) * 100) / 100;
}
```

466
NGINX.md Normal file
View File

@@ -0,0 +1,466 @@
# NGINX Configuration Guide for Dashfront & Dashback
This document provides a production-tested, complete NGINX reverse-proxy and static hosting guide for **Dashfront** (Vite + React 19 PWA) and **Dashback** (Express REST + SSE API).
---
## Table of Contents
1. [Architecture & Request Flow](#1-architecture--request-flow)
2. [Key Technical Requirements](#2-key-technical-requirements)
3. [Production Configuration (`dashfront.conf`)](#3-production-configuration-dashfrontconf)
4. [Detailed Breakdown of Critical Directives](#4-detailed-breakdown-of-critical-directives)
- [PWA & Cache Invalidation Strategy](#pwa--cache-invalidation-strategy)
- [SPA Routing (`try_files`)](#spa-routing-try_files)
- [Server-Sent Events (SSE) Buffering & Timeouts](#server-sent-events-sse-buffering--timeouts)
- [File Uploads & Request Body Limits](#file-uploads--request-body-limits)
- [WebAssembly (WASM) MIME Types](#webassembly-wasm-mime-types)
- [Camera & Hardware Permissions](#camera--hardware-permissions)
5. [Alternative: Remote Vite Dev Server Proxy](#5-alternative-remote-vite-dev-server-proxy)
6. [Deployment & Operations Runbook](#6-deployment--operations-runbook)
7. [Troubleshooting Matrix](#7-troubleshooting-matrix)
---
## 1. Architecture & Request Flow
In production, NGINX acts as both the high-performance static web server for Dashfront assets and the secure reverse proxy for the Dashback Express service.
```
┌──────────────────────────────────────────────┐
│ CLIENT │
│ (Desktop Browser, Tablet, Mobile PWA) │
└──────────────────────┬───────────────────────┘
│ HTTPS (:443)
▼
┌──────────────────────────────────────────────┐
│ NGINX │
│ TLS Termination, Gzip, Caching, Auth │
└──────────────┬────────────────┬──────────────┘
│ │
/assets/*, /index.html │ │ /api/*
/sw.js, static files │ │ (REST & SSE)
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Static Build Dir │ │ Dashback │
│ `dashfront/dist` │ │ Express API │
│ │ │ (localhost:4208) │
└───────────────────┘ └───────────────────┘
```
| Path | Handled By | Cache Policy | Notes |
|---|---|---|---|
| `/index.html` & `/` | NGINX static file | `no-cache, no-store, must-revalidate` | Always fresh so users get new script hashes |
| `/sw.js`, `/registerSW.js` | NGINX static file | `no-cache, no-store, must-revalidate` | **Must never cache**; avoids bricking PWA updates |
| `/workbox-*.js` | NGINX static file | `no-cache, no-store, must-revalidate` | Workbox runtime loader |
| `/manifest.webmanifest` | NGINX static file | `public, max-age=3600` | PWA manifest definition |
| `/assets/*` | NGINX static file | `public, max-age=31536000, immutable` | Content-hashed JS, CSS, WASM, and workers |
| `/api/events` | Dashback (`:4208`) | `no-cache, no-buffering` | Server-Sent Events (SSE) live push notifications |
| `/api/*` | Dashback (`:4208`) | Dynamic / Backend controlled | REST endpoints, auth cookies, image & PDF uploads |
| All other paths | NGINX `try_files` | Fallback to `/index.html` | React Router HTML5 History API deep linking |
---
## 2. Key Technical Requirements
1. **PWA Lifecycles**: A Progressive Web App checks for updates by fetching `sw.js`. If NGINX caches `sw.js` or `index.html`, client devices can remain stuck on obsolete bundles indefinitely.
2. **SSE Streaming**: Real-time push updates at `/api/events` require NGINX proxy buffering and proxy caching to be turned **OFF**, with a 24-hour read timeout to prevent arbitrary 60-second connection drops.
3. **Payload Limits**: Inbound delivery invoices (PDF receipts scanned client-side) and high-resolution article photos (AVIF) require a request body limit of at least `50m`–`100m` (matching Dashback's `BODY_LIMIT`).
4. **Cookie Authentication**: Dashback issues HTTP-only `token` session cookies. NGINX must pass the original `Host`, `X-Real-IP`, and `X-Forwarded-Proto https` headers so Express sets and retains cookies correctly.
5. **Camera Access (HTTPS)**: Mobile barcode scanning requires a secure origin (HTTPS) and appropriate `Permissions-Policy` response headers.
---
## 3. Production Configuration (`dashfront.conf`)
Place this configuration file at `/etc/nginx/sites-available/dashfront` (or adapt it to your existing reverse proxy structure):
```nginx
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
# Map Connection header for WebSocket upgrades (if proxying dev server or WebSockets)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dash.example.com; # <-- Replace with your domain or server IP
# Let's Encrypt / ACME challenge directory
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
# NGINX >= 1.25.1 uses 'http2 on;'. For older NGINX, use: listen 443 ssl http2;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dash.example.com; # <-- Replace with your domain or server IP
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dash.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Static File Root ---
# Point directly to the dist/ directory created by `npm run build`
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads ---
# Supports large delivery PDF uploads and client-encoded AVIF photos
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Allow camera access for the built-in barcode scanner
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
# Clear Connection header to maintain persistent HTTP/1.1 stream
proxy_set_header Connection "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering and caching completely for immediate push
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Prevent premature disconnections; keep SSE open for 24 hours
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Retain cookies across domain/path
proxy_cookie_path / /;
# Streaming uploads (PDF receipts, AVIF photos)
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
# Service worker scripts MUST NEVER be cached by HTTP cache
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
# PWA web manifest and browser icons
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
# Vite content-hashed assets: JS, CSS, WASM, Web Workers
# (Example: /assets/index-CutpK3vd.js, /assets/avif_enc-Co4TcJko.wasm)
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
# Ensure WebAssembly MIME type is correctly set
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
# index.html must never be cached so new release deployments take effect immediately
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
# SPA routing: Serve existing static file or directory; otherwise fallback to /index.html
location / {
try_files $uri $uri/ /index.html;
}
}
```
---
## 4. Detailed Breakdown of Critical Directives
### PWA & Cache Invalidation Strategy
PWA update lifecycles depend on byte-for-byte comparisons of `sw.js` when the user visits the page:
1. **`sw.js` and `registerSW.js`**:
```nginx
add_header Cache-Control "no-cache, no-store, must-revalidate";
```
If NGINX serves `sw.js` with a 30-day cache header, browser service worker checks will read from disk cache and never detect new application releases.
2. **`index.html`**:
Must also have `no-cache, no-store, must-revalidate`. When a new build is run, Vite produces chunks with new hashes (e.g., `index-AbCd12.js`). If `index.html` is cached, users continue loading old references that may no longer exist on disk.
3. **`/assets/`**:
Because Vite / Rolldown generates content-addressed filenames (hashes change whenever contents change), these files are completely immutable and safe to cache for 1 year (`max-age=31536000, immutable`).
### SPA Routing (`try_files`)
Dashfront uses React Router with HTML5 History API pushState:
- Direct visits to URLs like `/artikel/10001`, `/kategorien/12`, `/lieferung/neu`, or `/kunde` do not correspond to files on disk.
- The directive:
```nginx
try_files $uri $uri/ /index.html;
```
instructs NGINX to first check if a matching static file exists (e.g. `/favicon.svg` or `/assets/...`). If not found, it internally rewrites the request to `/index.html` without returning an HTTP 404, allowing React Router to resolve the route in-browser.
### Server-Sent Events (SSE) Buffering & Timeouts
The backend provides real-time model invalidation via `GET /api/events`. By default, NGINX buffers HTTP responses. If buffering is enabled:
1. Events sent by Dashback stay in NGINX's internal memory buffer until the buffer fills up (often several kilobytes), causing clients to miss instant UI updates.
2. NGINX will close idle proxy connections after `proxy_read_timeout` (default 60s).
The dedicated `/api/events` block fixes both issues:
```nginx
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_set_header Connection "";
proxy_read_timeout 86400s;
```
### File Uploads & Request Body Limits
In Dashfront:
- Inbound deliveries support multi-page PDF invoices and OCR processing (`pdfconvert-wasm`).
- Article management supports client-side AVIF image generation.
- The default NGINX limit is `1m` (`client_max_body_size 1m;`), which causes HTTP 413 (Payload Too Large) errors on invoice uploads.
- Setting:
```nginx
client_max_body_size 100m;
proxy_request_buffering off;
```
allows uploads up to 100 megabytes and streams the body directly to Express without waiting for NGINX to buffer the entire file on disk.
### WebAssembly (WASM) MIME Types
Dashfront uses WebAssembly for:
- `@jsquash/avif` (image encoding)
- `@jsquash/resize` (squoosh resizing)
- `pdfconvert-wasm` (PDF page extraction and rasterization)
Modern browsers require the HTTP header `Content-Type: application/wasm` for streaming compilation (`WebAssembly.instantiateStreaming`). If missing, NGINX may default to `application/octet-stream`. The WASM block guarantees the correct MIME header:
```nginx
location ~* \.wasm$ {
default_type application/wasm;
}
```
### Camera & Hardware Permissions
The article lookup and delivery receipt features allow scanning barcodes using device cameras. Web browsers enforce two strict rules for camera access:
1. **Secure Origin**: The page must be served over HTTPS or `localhost`. Plain HTTP over local IP addresses (e.g., `http://192.168.1.100/`) will automatically disable `navigator.mediaDevices`.
2. **Permissions Policy**: The header `Permissions-Policy "camera=(self)"` ensures the browser allows Dashfront to access connected webcams or smartphone cameras.
---
## 5. Alternative: Remote Vite Dev Server Proxy
If you want to access the live **Vite development server** (running on `localhost:4209` with HMR) through NGINX from a smartphone, tablet, or external domain:
```nginx
# Map connection upgrade for Vite HMR WebSockets
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name dev-dash.example.com;
ssl_certificate /etc/letsencrypt/live/dev-dash.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev-dash.example.com/privkey.pem;
client_max_body_size 100m;
# 1. API Calls -> Dashback (Port 4208)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 86400s;
}
# 2. Vite Dev Server & HMR -> Vite (Port 4209)
location / {
proxy_pass http://127.0.0.1:4209;
proxy_http_version 1.1;
# Crucial for Vite Hot Module Replacement (HMR) WebSocket
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400s;
}
}
```
> [!NOTE]
> When proxying to Vite dev server, ensure `server.allowedHosts = true` or add your domain to `allowedHosts` in [vite.config.js](file:///home/seb/src/dashfront/vite.config.js).
---
## 6. Deployment & Operations Runbook
### Step 1: Build Dashfront for Production
On the development machine or build server:
```bash
cd /home/seb/src/dashfront
npm run build
```
This compiles optimized bundles and service worker files into `dashfront/dist`.
### Step 2: Install and Verify NGINX Configuration
```bash
# 1. Copy config file to sites-available
sudo cp deploy/nginx-dashfront.conf /etc/nginx/sites-available/dashfront
# 2. Enable site by creating a symlink
sudo ln -s /etc/nginx/sites-available/dashfront /etc/nginx/sites-enabled/
# 3. Test configuration syntax
sudo nginx -t
# 4. Reload NGINX without dropping active connections
sudo systemctl reload nginx
```
### Step 3: Obtain TLS Certificates with Certbot
```bash
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d dash.example.com
```
### Step 4: Ensure Filesystem Permissions
NGINX worker processes run as the `www-data` user by default. Ensure the directory path to `dist/` is readable:
```bash
# Allow www-data execution on parent path components
chmod 755 /home/seb /home/seb/src /home/seb/src/dashfront /home/seb/src/dashfront/dist
```
Or deploy build artifacts into a standard web directory:
```bash
sudo mkdir -p /var/www/dashfront
sudo rsync -av --delete /home/seb/src/dashfront/dist/ /var/www/dashfront/dist/
sudo chown -R www-data:www-data /var/www/dashfront
```
---
## 7. Troubleshooting Matrix
| Symptom | Cause | Solution |
|---|---|---|
| **HTTP 404 on page reload** on sub-routes (`/artikel`, `/kunde`) | Missing SPA fallback in NGINX | Ensure `try_files $uri $uri/ /index.html;` is present in `location /`. |
| **PWA does not update** after deployment | `sw.js` or `index.html` cached in browser | Verify `Cache-Control: no-cache, no-store, must-revalidate` on `sw.js`, `registerSW.js`, and `index.html`. |
| **HTTP 413 (Payload Too Large)** when uploading invoice PDF or image | Default NGINX upload limit is 1MB | Add `client_max_body_size 100m;` to the `server` or `location /api/` block. |
| **Live SSE events disconnect every 60s** | Default `proxy_read_timeout` timeout | Set `proxy_read_timeout 86400s;` and `proxy_buffering off;` on `/api/events`. |
| **Login loop / Cookie not sent** | Secure cookie rejected on plain HTTP, or missing proxy headers | Access via HTTPS; ensure `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` are set. |
| **Barcode camera scanner disabled** | Page accessed over insecure HTTP or missing permissions policy | Use HTTPS; check that `Permissions-Policy "camera=(self)"` header is present. |
| **WASM compilation error** (`incorrect response MIME type`) | NGINX serving `.wasm` as `application/octet-stream` | Add `default_type application/wasm;` for `\.wasm$` extensions. |
| **Vite HMR disconnects immediately** (Dev proxy) | Missing WebSocket upgrade headers | Ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection $connection_upgrade;` are configured. |

View File

@@ -24,6 +24,8 @@ Open `http://localhost:4209`. The dev server proxies `/api` to Dashback at
To test the full PWA precache (production build + preview, no HMR), use
`npm run dev:pwa` instead.
For production deployment with NGINX, see [NGINX.md](file:///home/seb/src/dashfront/NGINX.md).
Dashback must be running and your Google account must be authorized on the
backend.
@@ -56,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.

View File

@@ -1,134 +0,0 @@
# Redux in Dashfront
Übersicht, wie Redux Toolkit bisher im Frontend eingesetzt wird.
## Stack
- **@reduxjs/toolkit** — Store, Slices, Async Thunks, Listener Middleware
- **react-redux** — `Provider` in `src/main.jsx`, Anbindung der UI über `connect()` (kein `useSelector` / `useDispatch`)
Einstieg:
```
src/main.jsx → <Provider store={store}>
src/store/index.js → configureStore + Middleware
src/store/*Slice.js → State, Thunks, Selectors
```
## Store-Form
Drei Slices unter `src/store/`:
| Key | Datei | Verantwortung |
|------------|---------------------|----------------------------------------------------|
| `auth` | `authSlice.js` | Session, Login/Logout, Firmenwahl / -anlage |
| `company` | `companySlice.js` | Aktuelles Unternehmen laden / aktualisieren |
| `category` | `categorySlice.js` | Kategoriebaum, Navigation, Artikel je Kategorie |
```js
// src/store/index.js
configureStore({
reducer: { auth, company, category },
middleware: defaults + listenerMiddleware + (DEV: reduxDevLogger),
});
```
## Typische Muster
### Async Thunks als API-Grenze
Seiten- und Dialog-Logik dispatcht Thunks; die Thunks rufen `src/api` auf und schreiben Ergebnisse in den Slice. Komponenten sprechen die API nicht direkt für diese Domänen an.
Beispiele:
- `auth/bootstrap`, `auth/login`, `auth/selectCompany`, `auth/createCompany`, `auth/logout`
- `company/loadCurrent`, `company/update`
- `category/loadTree`, `category/loadArticles`, `category/create`, `category/createArticle`, `category/delete`
Fehler in Category-Thunks laufen über `catchBackendError` / `rejectWithValue` und landen als `BackendError` in `state.category.error`. Tree- und Artikel-Responses werden mit `requireObject` / `requireArray` / `requireDefined` aus `apiShape.js` validiert.
### Selectors
Jeder Slice exportiert `select…`-Funktionen. UI-Komponenten mappen sie in `mapStateToProps`:
```js
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
needsCompanySetup: selectNeedsCompanySetup(state),
});
export default connect(mapStateToProps)(App);
```
Abgeleitete Category-Selectors (Breadcrumbs, Kinder, „kann zurück“) lesen die Map `categories` und `currentCategoryId` — keine zweite Kopie des Baums in der UI.
### `connect()` statt Hooks
Die Redux-gebundenen Komponenten sind Class- oder Function-Komponenten mit:
```js
connect(mapStateToProps, mapDispatchToProps)(Component)
```
Dispatch-Props wrappen Thunks, oft mit `.unwrap()` in der Komponente für Erfolg/Fehler (Snackbar).
### Listener: Auth → Company
Nach erfolgreichem Bootstrap, Login, Firmenwahl oder Firmenanlage dispatcht ein Listener automatisch `loadCurrentCompany`, wenn `auth.user.selectedCompanyId` gesetzt ist (`companyLoadListener` in `companySlice.js`).
### Cross-Slice-Reset
- `logout.fulfilled` leert `company` und setzt `category` auf `initialState`
- `loadCurrentCompany.fulfilled` mit neuer `companyId` setzt den Category-State zurück (Firmenwechsel)
## Datenfluss (Kurz)
```
App-Start
└─ AuthBootstrap → bootstrapSession
└─ (listener) loadCurrentCompany wenn selectedCompanyId
└─ Category-UI → loadCategoryTree (braucht company.rootCategoryId)
Login / Firmenwahl / -anlage
└─ auth-Thunks → user.selectedCompanyId
└─ (listener) loadCurrentCompany → ggf. Category-Reset
Kategorien-Seite
└─ navigateToCategory / navigateBack (sync)
└─ loadCategoryArticles für Unterkategorien
└─ createCategory / createArticle / deleteCategory (optimistisch im Map-/Listen-State)
```
Status-Felder:
| Slice | Feld(er) | Werte (typisch) |
|----------|----------------------------------|------------------------|
| auth | `status` | `loading` / `anon` / `authed` |
| company | `status` | `idle` / `loading` / `succeeded` / `failed` |
| category | `treeStatus`, `articlesStatus` | `idle` / `loading` / `succeeded` / `failed` |
## Auth-Slice
- **State:** `user`, `googleClientId`, `status`
- **Setup-Gate:** `selectNeedsCompanySetup` → eingeloggt, aber keine `selectedCompanyId` → `CompanySetupDialog`
- `mergeUser` behält beim Partial-Update ggf. die bisherige `selectedCompanyId`
## Company-Slice
- **State:** `current` (normalisiert über `normalizeCompany`), `status`, `error`
- Thunks lesen `auth.user.selectedCompanyId` aus dem Store (`getState`)
- Selectors liefern u. a. Name, `iso639`, Steuersätze, `rootCategoryId` für Category/UI
## Category-Slice
- **Baum:** `categories` als Id→Node-Map, plus `rootCategoryId`, `rootArticles`, `languageId`
- **Navigation:** `currentCategoryId`; Root zeigt `rootArticles`, Unterkategorien laden per `loadCategoryArticles`
- **Sync-Actions:** `navigateToCategory`, `navigateBack`, `resetCategoryState`
- Hilfsfunktionen (`normalizeCategoryNode`, `parseCategoryTreeResponse`, Breadcrumbs, Slugs) leben im Slice und werden u. a. von `categoryRoute.js` für URL-Pfade wiederverwendet
## Dev-Logging
In Development loggt `reduxDevLogger` Actions unter `auth/`, `company/`, `category/` inkl. Payload und kurzem Auth-/Company-Snapshot nach dem Reduce.
## Was (noch) nicht in Redux liegt
Artikel-Detail, Fotos und ähnliche lokale UI-Zustände werden in Komponenten / lokalen Props gehalten, nicht als eigener Slice. Neue Domänen folgen dem bestehenden Muster: API in `src/api`, Thunk + Validierung im Slice, Selectors + `connect` in der UI.

File diff suppressed because it is too large Load Diff

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

179
deploy/nginx-dashfront.conf Normal file
View File

@@ -0,0 +1,179 @@
# ==============================================================================
# Dashfront + Dashback Production NGINX Server Configuration
# ==============================================================================
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ------------------------------------------------------------------------------
# 1. HTTP -> HTTPS Redirect
# ------------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name dashdev.quixpos.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ------------------------------------------------------------------------------
# 2. Main HTTPS Server Block
# ------------------------------------------------------------------------------
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name dashdev.quixpos.com;
# --- SSL / TLS Certificates ---
ssl_certificate /etc/letsencrypt/live/dashdev.quixpos.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dashdev.quixpos.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# --- Header Buffer Limits (prevents HTTP/2 RST_STREAM / ERR_HTTP2_PROTOCOL_ERROR) ---
large_client_header_buffers 4 128k;
# --- Static File Root ---
root /home/seb/src/dashfront/dist;
index index.html;
# --- Request Body & Uploads (PDF receipts, AVIF photos) ---
client_max_body_size 100m;
client_body_timeout 300s;
# --- Compression (Gzip) ---
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/x-javascript
application/json
application/xml
application/manifest+json
application/wasm
image/svg+xml;
# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=()" always;
# ==========================================================================
# API & Backend Routing (Dashback on port 4208)
# ==========================================================================
# Dedicated SSE route: Real-time broadcast events
location = /api/events {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
# Clear Connection & Accept-Encoding to prevent upstream buffering
proxy_set_header Connection "";
proxy_set_header Accept-Encoding "";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering and compression completely for immediate push
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
gzip off;
# Disable HTTP/2 request limits for persistent SSE connection
# (Prevents NGINX from resetting long-lived streams)
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# General API endpoints (REST)
location /api/ {
proxy_pass http://127.0.0.1:4208;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cookie_path / /;
proxy_request_buffering off;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# ==========================================================================
# PWA & Service Worker Rules (Zero Caching)
# ==========================================================================
location ~* ^/(sw\.js|registerSW\.js|workbox-[a-f0-9]+\.js)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
access_log off;
}
location ~* ^/(manifest\.webmanifest|favicon\.svg|icons/.*)$ {
add_header Cache-Control "public, max-age=3600";
add_header Vary "Accept-Encoding";
access_log off;
}
# ==========================================================================
# Static Assets & Hashed Bundles (Aggressive Immutable Caching)
# ==========================================================================
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
access_log off;
}
location ~* \.wasm$ {
default_type application/wasm;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# ==========================================================================
# HTML5 SPA Entry Point & Navigation Fallback
# ==========================================================================
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location / {
try_files $uri $uri/ /index.html;
}
}

91
docs/README.md Normal file
View File

@@ -0,0 +1,91 @@
# Dashfront Automats Documentation
This directory contains reference documentation for all state automats in `dashfront` (`src/automats/`), powered by [`react-automat`](https://github.com/sebgreen/automat).
---
## Architecture Overview
In `dashfront`, state is managed outside the React component lifecycle using **Automats**. An Automat is an observable, fine-grained state store living in module scope.
### Core Principles
1. **Module Scope**: State persists across route changes and component mount/unmount cycles.
2. **Synchronous Reads**: Components read state synchronously in their `constructor` (or initial state) — zero loading flicker or blank frames.
3. **Fine-Grained Subscriptions**: React `PureComponent` instances subscribe in `componentDidMount` with selector functions or key lists, skipping unnecessary re-renders.
4. **Explicit Cleanup**: Every subscription returns an `unsub()` function called in `componentWillUnmount()`.
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
| Automat | State File | Documentation | Description |
|---|---|---|---|
| **`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), 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. |
| **`posterminalAutomat`** | [`src/automats/posterminalAutomat.js`](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js) | [posterminalAutomat.md](./posterminalAutomat.md) | Point-of-Sale hardware terminals and enabled category assignments per terminal. |
| **`supplierAutomat`** | [`src/automats/supplierAutomat.js`](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js) | [supplierAutomat.md](./supplierAutomat.md) | Company-scoped supplier directory (`GET /api/supplier`, create, update, delete) with inflight deduplication. |
| **`unitAutomat`** | [`src/automats/unitAutomat.js`](file:///home/seb/src/dashfront/src/automats/unitAutomat.js) | [unitAutomat.md](./unitAutomat.md) | Standard units of measure (Stück, kg, Liter, etc.) for the active company (`GET /api/unit`, upsert, delete). |
| **`userSettingsAutomat`** | [`src/automats/userSettingsAutomat.js`](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js) | [userSettingsAutomat.md](./userSettingsAutomat.md) | Client-side user UI preferences (e.g. viewer docking position). Persisted to IndexedDB (`persist: true`). |
---
## Canonical PureComponent Consumption Pattern
```jsx
import { PureComponent } from 'react';
import { artikelAutomat, createArticleSelector, getArticle } from '../automats';
class ArticleHeader extends PureComponent {
constructor(props) {
super(props);
// 1. Synchronous read — no initial flash of empty state
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
// 2. Subscribe with fine-grained selector
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(this.props.sku));
}
componentDidUpdate(prevProps) {
// 3. Re-subscribe if props change
if (prevProps.sku !== this.props.sku) {
this.unsub?.();
this.setState({ article: getArticle(this.props.sku) });
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(this.props.sku));
}
}
componentWillUnmount() {
// 4. Always unsubscribe
this.unsub?.();
}
render() {
const { article } = this.state;
if (!article) return <div>Lädt...</div>;
return <h1>{article.name}</h1>;
}
}
```
---
## Debugging
All registered automats are available on `window.__AUTOMATS__` and accessible via `Automat.get(name)`. In development mode, the docked **AutomatInspector** (`src/devtools/AutomatInspector.jsx`) renders live state, subscriber counts, and action triggers.

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).

196
docs/artikelAutomat.md Normal file
View File

@@ -0,0 +1,196 @@
# `artikelAutomat`
> **Source File**: [`src/automats/artikelAutomat.js`](file:///home/seb/src/dashfront/src/automats/artikelAutomat.js)
> **Registration Key**: `'artikel'` (`Automat.get('artikel')` / `window.__AUTOMATS__.get('artikel')`)
---
## 1. Overview & Purpose
`artikelAutomat` serves as the centralized cache and reactive distributor for individual article detail payloads (`GET /api/article/:sku`).
### Core Responsibilities
- **In-flight Deduplication**: Multiple components or route guards requesting the same SKU concurrently share a single HTTP request via an internal `Map<string, Promise<object>>`.
- **Zero-Flicker Navigation**: Navigating between tabs or returning to previously viewed articles reuses the cached detail object instantly without round-trips.
- **Optimistic Updates**: Immediate UI updates (such as renaming an article via `setArticleName`) without awaiting network round-trips.
- **Granular Subscriptions**: Components can subscribe to an individual SKU via `createArticleSelector(sku)` rather than re-rendering on every cache change.
---
## 2. State Structure
```ts
interface ArtikelState {
/**
* Cached article detail payloads keyed by SKU string.
* A missing key indicates the article is not yet loaded into memory.
* Values include full article details: prices, photos, eans, brand, unit,
* categoryIds, manifests, child variants, etc.
*/
bySku: Record<string, object>;
}
```
### Initial State
```js
const initialState = {
bySku: {},
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'artikel' });
```
- **`name`**: `'artikel'` — registers the automat globally.
- **`persist`**: `false` (default in-memory window lifecycle).
---
## 4. Actions & Methods
Access actions via `artikelAutomat.actions.<method>()`.
### `loadArticle(sku: string): Promise<object>`
Loads an article detail into the cache. If already present in `bySku`, returns the cached article immediately. If an HTTP request for this SKU is currently in-flight, it returns that shared promise. Otherwise, dispatches `GET /api/article/:sku`, normalizes the response, stores it in `bySku`, and cleans up the in-flight map.
### `reloadArticle(sku: string): Promise<object>`
Bypasses the cached copy and silently fetches a fresh detail payload from the backend. The existing cache entry is preserved until the response arrives. Typically used during SSE mutation events or user pull-to-refresh.
### `setArticle(article: object): object`
Puts an article detail object directly into `bySku[article.sku]`. Requires `article` to be an object with a defined `sku`. Used after create/update API mutations so that all subscribers immediately observe the latest data.
### `setArticleName(sku: string, name: string): object | null`
Optimistically updates the `name` property of a cached article without making an API call. Returns the updated article object or `null` if the SKU was not found in cache.
### `clearArticle(sku: string): void`
Removes a specific SKU from `bySku`. Useful after deleting an article or when forcing a complete refetch on next access.
### `clear(): void`
Resets the entire `bySku` dictionary to empty (`{}`).
---
## 5. Selectors & Helper Functions
Exported alongside `artikelAutomat`:
### `getArticle(sku: string): object | null`
Synchronous read from `artikelAutomat.state.bySku[sku]`. Returns `null` if not found or if `sku` is nullish.
```js
const article = getArticle('10042');
```
### `hasArticle(sku: string): boolean`
Returns `true` if `sku` is already loaded in `bySku`.
```js
if (!hasArticle('10042')) {
void artikelAutomat.actions.loadArticle('10042');
}
```
### `createArticleSelector(sku: string): (state: ArtikelState) => object | null`
Produces a memoized selector function for a specific SKU. Pass this to `artikelAutomat.subscribe(target, selector)` so your component only re-renders when this specific article's data changes.
### `selectArticle(sku: string): ScopedSlice<object | null>`
Returns a scoped slice for the given SKU (`artikelAutomat.select(createArticleSelector(sku))`).
---
## 6. Usage Examples
### A. React `PureComponent` Detail Header
```jsx
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import artikelAutomat, { getArticle, createArticleSelector } from '../automats/artikelAutomat';
export class ArticleHeader extends PureComponent {
static propTypes = {
sku: PropTypes.string.isRequired,
};
constructor(props) {
super(props);
// Synchronous initial state read prevents layout shift / loading spinner flicker
this.state = {
article: getArticle(props.sku),
};
}
componentDidMount() {
this.subscribe(this.props.sku);
if (!this.state.article) {
artikelAutomat.actions.loadArticle(this.props.sku).catch(console.error);
}
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
this.subscribe(this.props.sku);
if (!hasArticle(this.props.sku)) {
artikelAutomat.actions.loadArticle(this.props.sku).catch(console.error);
}
}
}
componentWillUnmount() {
this.unsub?.();
}
subscribe(sku) {
this.unsub?.();
this.setState({ article: getArticle(sku) });
this.unsub = artikelAutomat.subscribe(this, createArticleSelector(sku));
}
render() {
const { article } = this.state;
if (!article) return <div>Artikel wird geladen...</div>;
return (
<div>
<h2>{article.name}</h2>
<span>SKU: {article.sku}</span>
</div>
);
}
}
```
### B. Inline Optimistic Rename
```js
import { artikelAutomat } from '../automats';
import { article as articleApi } from '../api';
async function updateName(sku, newName) {
// 1. Optimistic update in UI cache
artikelAutomat.actions.setArticleName(sku, newName);
try {
// 2. Persist to backend
const res = await articleApi.update(sku, { name: newName });
// 3. Re-set authoritative response
artikelAutomat.actions.setArticle(res.article);
} catch (err) {
// 4. Rollback on failure
await artikelAutomat.actions.reloadArticle(sku);
throw err;
}
}
```
---
## 7. Best Practices & Gotchas
1. **Always clean up subscriptions**: Save `this.unsub = artikelAutomat.subscribe(...)` and call `this.unsub()` in `componentWillUnmount`.
2. **Use `createArticleSelector`**: Never subscribe an article view component with the raw `artikelAutomat.subscribe(this)` without a selector; otherwise, any other article loaded or modified anywhere in the app will trigger an unneeded re-render.
3. **Do not mutate directly**: Never write `artikelAutomat.state.bySku[sku].name = 'foo'`. Always use `setArticle` or `setArticleName`.

204
docs/authAutomat.md Normal file
View File

@@ -0,0 +1,204 @@
# `authAutomat`
> **Source File**: [`src/automats/authAutomat.js`](file:///home/seb/src/dashfront/src/automats/authAutomat.js)
> **Registration Key**: `'auth'` (`Automat.get('auth')` / `window.__AUTOMATS__.get('auth')`)
---
## 1. Overview & Purpose
`authAutomat` manages the authentication and session lifecycle of the Dashfront client, as well as tenant company selection. It acts as the root coordinator for all other configuration and catalog automats (`companyAutomat`, `categoryAutomat`, `brandAutomat`, `unitAutomat`, `posterminalAutomat`, `supplierAutomat`).
### Core Responsibilities
- **Session Lifecycle**: Handles transitioning through `'loading'` → `'authed'` or `'anon'`.
- **Google One Tap / GIS Integration**: Exchanging Google OAuth JWTs for session cookies via `/api/auth/google`.
- **Multi-Tenant Company Switching**: Updating the active company context via `/api/auth/selected-company` and synchronizing user state.
- **Event Dispatching**: Emits cross-subsystem events for session establishment, company changes, and logout.
- **Automatic Sub-Store Bootstrap**: Downstream configuration automats subscribe to `authAutomat` to load or clear themselves reactively.
---
## 2. State Structure
```ts
interface AuthUser {
id?: string | number;
googleAuthId?: string;
email?: string;
name?: string;
picture?: string | null;
selectedCompanyId?: string | number | null;
companies?: Array<{ id: string | number; name: string }>;
[key: string]: any;
}
interface AuthState {
/** Authenticated user profile, or null when anonymous. */
user: AuthUser | null;
/** Google OAuth Client ID provided by backend bootstrap. */
googleClientId: string | null;
/** Current authentication lifecycle phase. */
status: 'loading' | 'anon' | 'authed';
}
```
### Initial State
```js
const initialState = {
user: null,
googleClientId: null,
status: 'loading',
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'auth' });
```
- **`name`**: `'auth'`
- **`persist`**: `false` (session token resides in secure HTTP-only cookies).
---
## 4. Lifecycle Events & Dispatcher
Constants exported from `authAutomat`:
- **`AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished'`**: Fired when a valid session is recognized.
- **`AUTH_COMPANY_CHANGED = 'auth/companyChanged'`**: Fired when the active company changes or a new company is created.
- **`AUTH_LOGGED_OUT = 'auth/loggedOut'`**: Fired when user logs out.
### Event Dispatcher Registration
```js
import { setAuthEventDispatcher } from '../automats/authAutomat';
// Allows routing events to an external system, logger, or telemetry
setAuthEventDispatcher((event) => {
console.log('[Auth Event]', event.type);
});
```
---
## 5. Actions & Methods
Access actions via `authAutomat.actions.<method>()`.
### `bootstrapSession(session?: object): Promise<object>`
Initializes the session on application startup:
1. If `session` is passed directly, uses it; otherwise fetches `GET /api/auth/me`.
2. Updates `googleClientId`.
3. If no user is returned, sets status to `'anon'`.
4. If a user is returned, merges the profile, normalizes base64 avatar pictures, sets status to `'authed'`, and emits `AUTH_SESSION_ESTABLISHED`.
### `loginWithGoogle(credential: string): Promise<object>`
Submits a Google One Tap JWT to `POST /api/auth/google`. On success, updates user state, sets status to `'authed'`, and emits `AUTH_SESSION_ESTABLISHED`.
### `selectCompany(companyId: string | number): Promise<AuthUser>`
Calls `PUT /api/auth/selected-company` to switch active company context in the backend session cookie. Updates `state.user.selectedCompanyId`, triggers `AUTH_COMPANY_CHANGED`, and returns the updated user.
### `createCompany(payload: object): Promise<AuthUser>`
Creates a new tenant company via `POST /api/company` and automatically selects it for the session. Emits `AUTH_COMPANY_CHANGED`.
### `logout(): Promise<void>`
Calls `POST /api/auth/logout`, replaces history state to `'/'`, clears user state, sets status to `'anon'`, and emits `AUTH_LOGGED_OUT`.
---
## 6. Cross-Automat Coordination
Downstream automats coordinate with `authAutomat` at module load time. For example:
```js
// In companyAutomat.js / brandAutomat.js / unitAutomat.js / posterminalAutomat.js:
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void targetAutomat.actions.load();
} else if (auth.status === 'anon') {
targetAutomat.actions.reset();
}
});
```
When `auth.status` transitions to `'authed'` with a `selectedCompanyId`, all dependent stores load their data. On logout (`'anon'`), they clear their state.
---
## 7. Usage Examples
### A. React `PureComponent` Login Gate / User Menu
```jsx
import { PureComponent } from 'react';
import authAutomat from '../automats/authAutomat';
export class UserMenu extends PureComponent {
constructor(props) {
super(props);
this.state = {
user: authAutomat.state.user,
status: authAutomat.state.status,
};
}
componentDidMount() {
this.unsub = authAutomat.subscribe(this, ['user', 'status']);
}
componentWillUnmount() {
this.unsub?.();
}
handleCompanyChange = (e) => {
authAutomat.actions.selectCompany(e.target.value);
};
handleLogout = () => {
authAutomat.actions.logout();
};
render() {
const { user, status } = this.state;
if (status === 'loading') return <div>Authentifizierung...</div>;
if (status === 'anon' || !user) return <div>Nicht angemeldet</div>;
return (
<div>
<span>{user.name} ({user.email})</span>
<select value={user.selectedCompanyId ?? ''} onChange={this.handleCompanyChange}>
{user.companies?.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<button onClick={this.handleLogout}>Abmelden</button>
</div>
);
}
}
```
### B. Application Bootstrap in `AuthBootstrap.jsx`
```js
import authAutomat from '../automats/authAutomat';
async function initApp() {
try {
await authAutomat.actions.bootstrapSession();
} catch (err) {
console.error('Session bootstrap failed:', err);
}
}
```
---
## 8. Best Practices & Gotchas
1. **Never pass `googleAuthId`**: The frontend must never send `googleAuthId` in request bodies or query params. Identity is extracted from the session cookie on the server.
2. **Handle `'loading'` State**: Do not render authentication-required views while `authAutomat.state.status === 'loading'`.
3. **Picture normalization**: `authAutomat` automatically formats raw base64 picture strings into valid `data:image/...;base64,...` URIs.

168
docs/brandAutomat.md Normal file
View File

@@ -0,0 +1,168 @@
# `brandAutomat`
> **Source File**: [`src/automats/brandAutomat.js`](file:///home/seb/src/dashfront/src/automats/brandAutomat.js)
> **Registration Key**: `'brand'` (`Automat.get('brand')` / `window.__AUTOMATS__.get('brand')`)
---
## 1. Overview & Purpose
`brandAutomat` manages the active company's catalog brands (manufacturers). Brands are referenced by articles for catalog organization, filtering, and GPSR (General Product Safety Regulation) manufacturer compliance info.
### Core Responsibilities
- **Company Scoping**: Caches all brands for the currently selected company.
- **Alphabetical Sorting**: Automatically sorts brands by name using German (`'de'`) collation.
- **Autonomous Bootstrap**: Automatically triggers `load()` when `authAutomat` enters `'authed'` with a selected company, and calls `reset()` when the session ends.
- **Upsert Mutation**: Handles creating or updating a brand with immediate in-memory cache synchronization.
---
## 2. State Structure
```ts
interface Brand {
id: number | string;
name: string;
gsprpinfo?: string | null;
[key: string]: any;
}
interface BrandState {
/** Alphabetically sorted list of company brands. */
brands: Array<Brand>;
/** Async lifecycle status. */
status: 'idle' | 'loading' | 'succeeded' | 'failed';
/** Error message if loading failed. */
error: string | null;
}
```
### Initial State
```js
const initialState = {
brands: [],
status: 'idle',
error: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'brand' });
```
- **`name`**: `'brand'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Access actions via `brandAutomat.actions.<method>()`.
### `load(): Promise<Array<Brand>>`
Fetches all brands for the current company via `GET /api/brand`. Sorts them by name, updates `status` to `'succeeded'`, and returns the array. If the request fails, sets `status` to `'failed'` and records `error`.
### `upsert(body: { name: string, gsprpinfo?: string }): Promise<Brand>`
Creates or updates a brand via `POST /api/brand/upsert`. Merges the updated brand into `state.brands`, re-sorts the list, updates state, and returns the modified brand object.
### `reset(): void`
Resets the state back to `initialState` (`brands: []`, `status: 'idle'`, `error: null`).
---
## 5. Cross-Automat Subscriptions
`brandAutomat` subscribes to `authAutomat` at module initialization:
```js
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();
}
});
```
---
## 6. Usage Examples
### A. React `PureComponent` Brand Selector Dropdown
```jsx
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import brandAutomat from '../automats/brandAutomat';
export class BrandSelect extends PureComponent {
static propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
brands: brandAutomat.state.brands,
status: brandAutomat.state.status,
};
}
componentDidMount() {
this.unsub = brandAutomat.subscribe(this, ['brands', 'status']);
if (this.state.status === 'idle') {
brandAutomat.actions.load().catch(console.error);
}
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { brands, status } = this.state;
const { value, onChange } = this.props;
if (status === 'loading') {
return <select disabled><option>Marken laden...</option></select>;
}
return (
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">-- Keine Marke --</option>
{brands.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
);
}
}
```
### B. Creating or Updating a Brand
```js
import brandAutomat from '../automats/brandAutomat';
async function handleSaveBrand(name, gsprpinfo) {
try {
const brand = await brandAutomat.actions.upsert({ name, gsprpinfo });
console.log('Saved brand:', brand);
} catch (err) {
console.error('Failed to save brand:', err);
}
}
```
---
## 7. Best Practices & Gotchas
1. **Automatic Loading**: In normal usage, you rarely need to invoke `load()` manually; `authAutomat` triggers it as soon as the user logs in or switches company.
2. **Alphabetical Guarantee**: The `brands` array in state is guaranteed to be sorted alphabetically (`localeCompare` with German rules).

288
docs/categoryAutomat.md Normal file
View File

@@ -0,0 +1,288 @@
# `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')`)
> **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 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.
- **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 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.
---
## 2. State Structure
```ts
interface CategoryNode {
id: number | string;
name: string;
parentId: number | string | null;
sort: number;
pos?: boolean;
fullHash?: string | null;
thumbHash?: string | null;
thumbW?: number | null;
thumbH?: number | null;
posterminalIds?: Array<number>;
childCount?: number;
[key: string]: any;
}
interface CategoryState {
companyId: number | string | null;
languageId: string | null;
/** Normalized category map keyed by string ID. */
categories: Record<string, CategoryNode> | null;
/** Root (home) category ID for the current company. */
rootCategoryId: number | string | null;
/** Whether category tiles are in rename/edit mode. */
renameMode: boolean;
/** Category tree loading status. */
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;
}
```
### Initial State
```js
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
```
---
## 3. Category View Automats (delegated)
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`.
- **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.
See [categoryViewAutomat.md](./categoryViewAutomat.md) for the full view API, including `loadAllCategoryViews()`.
---
## 4. Actions & Methods
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, 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`.
### Category Mutations
- **`createCategory({ parentId, name }): Promise<CategoryNode>`**: Creates a category via `POST /api/category` and inserts it into `categories`.
- **`createCategories({ parentId, names }): Promise<CategoryNode[]>`**: Batch-creates multiple categories under `parentId`.
- **`renameCategory({ categoryId, name }): Promise<CategoryNode>`**: Updates a category name via `PATCH /api/category/:id/name`. Protects root category from being renamed.
- **`reorderCategories({ parentId, categoryIds }): Promise<number[]>`**: Updates sibling display ordering via `POST /api/category/:parentId/reorder`.
- **`moveCategory({ categoryId, parentId }): Promise<object>`**: Moves a category under a new parent via `PATCH /api/category/:id/move`.
- **`deleteCategory(categoryId): Promise<number|string>`**: Deletes a category via `DELETE /api/category/:id` and removes it from state.
- **`toggleCategoryPos({ categoryId, pos }): Promise<CategoryNode>`**: Toggles POS visibility via `PATCH /api/category/:id/pos`.
### Article Mutations within Categories
- **`createArticle({ parentId, name }): Promise<object>`**: Dispatches `POST /api/article` and appends the new article to the active category view automat's `articles` list immediately.
- **`createArticles({ parentId, names }): Promise<object[]>`**: Batch creates articles and updates the active route view.
- **`removeArticleBySku(sku: string): void`**: Removes an article from all live category view article lists when deleted.
### Category Media
- **`uploadCategoryImage({ categoryId, file }): Promise<object>`**: Uploads an image blob to `POST /api/category/:id/image` and patches `fullHash`, `thumbHash`, `thumbW`, and `thumbH` in the category node.
- **`deleteCategoryImage(categoryId): Promise<object>`**: Deletes the category image via `DELETE /api/category/:id/image`.
### Hardware POS Terminals
- **`patchCategoryPosterminalIds({ categoryId, terminalId, enabled }): void`**: Updates the `posterminalIds` array on a category node locally.
---
## 5. Exported Tree Utilities
Pure helper functions re-exported from `../utils/categoryUtils`:
- **`ROOT_CATEGORY_NAME`**: Constant for the root category.
- **`getCategoryBreadcrumbs(categories, categoryId): Array<CategoryNode>`**: Traverses parent pointers from node to root.
- **`getCategoryChildren(categories, parentId): Array<CategoryNode>`**: Returns all immediate child nodes sorted by `sort`.
- **`categoryDisplayName(category): string`**: Formats category display label.
- **`categorySlug(name): string`**: Generates a clean URL slug.
- **`normalizeCategoryNode(raw): CategoryNode`**: Normalizes API category payload.
---
## 6. Cross-Automat Coordination
`categoryAutomat` automatically stays in sync with `companyAutomat` and `authAutomat`:
```js
// Sync with companyAutomat:
companyAutomat.subscribe((company) => {
const id = company.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
_lastCompanyId = id;
disposeAllCategoryViewAutomats();
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// Reset on logout:
authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
```
---
## 7. Usage Examples
### A. React `PureComponent` Category Tree Browser
```jsx
import { PureComponent } from 'react';
import categoryAutomat, { getCategoryChildren, getCategoryBreadcrumbs } from '../automats/categoryAutomat';
export class CategoryBrowser extends PureComponent {
constructor(props) {
super(props);
this.state = {
categories: categoryAutomat.state.categories,
rootCategoryId: categoryAutomat.state.rootCategoryId,
treeStatus: categoryAutomat.state.treeStatus,
};
}
componentDidMount() {
this.unsub = categoryAutomat.subscribe(this, ['categories', 'rootCategoryId', 'treeStatus']);
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { categories, rootCategoryId, treeStatus } = this.state;
if (treeStatus === 'loading' || !categories) return <div>Lade Kategorien...</div>;
const currentParentId = this.props.categoryId ?? rootCategoryId;
const subcategories = getCategoryChildren(categories, currentParentId);
const breadcrumbs = getCategoryBreadcrumbs(categories, currentParentId);
return (
<div>
<nav>
{breadcrumbs.map((b) => (
<span key={b.id}>{b.name} / </span>
))}
</nav>
<ul>
{subcategories.map((cat) => (
<li key={cat.id}>{cat.name}</li>
))}
</ul>
</div>
);
}
}
```
### B. Consuming a Per-Route Category View with `categoryAutomat.key()`
```jsx
import { PureComponent } from 'react';
import categoryAutomat from '../automats/categoryAutomat';
export class CategoryArticlesList extends PureComponent {
constructor(props) {
super(props);
this.handle = categoryAutomat.key(props.slugPath);
this.state = {
articles: this.handle.state.data?.articles ?? [],
childCategories: this.handle.state.data?.childCategories ?? [],
loading: this.handle.state.loading,
};
}
componentDidMount() {
this.unsub = this.handle.subscribe(this, (item) => ({
articles: item.data?.articles ?? [],
childCategories: item.data?.childCategories ?? [],
loading: item.loading,
}));
this.handle.load();
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { articles, childCategories, loading } = this.state;
if (loading) return <div>Lade Artikel in dieser Kategorie...</div>;
return (
<div>
<h3>Unterkategorien ({childCategories.length})</h3>
<ul>
{childCategories.map((cat) => (
<li key={cat.id}>{cat.name}</li>
))}
</ul>
<h3>Artikel ({articles.length})</h3>
<ul>
{articles.map((art) => (
<li key={art.sku}>{art.name} ({art.sku})</li>
))}
</ul>
</div>
);
}
}
```
---
## 8. Best Practices & Gotchas
1. **Root category cannot be renamed or deleted**: The backend returns HTTP 400 if you attempt to rename or delete the company root category.
2. **Reverse index updates**: When creating articles with `categoryAutomat.actions.createArticle`, any active route automat matching the parent ID is updated immediately.
3. **Use normalized helpers**: Always use `getCategoryChildren(categories, parentId)` rather than manual filtering; it ensures correct sort order.

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.

179
docs/companyAutomat.md Normal file
View File

@@ -0,0 +1,179 @@
# `companyAutomat`
> **Source File**: [`src/automats/companyAutomat.js`](file:///home/seb/src/dashfront/src/automats/companyAutomat.js)
> **Registration Key**: `'company'` (`Automat.get('company')` / `window.__AUTOMATS__.get('company')`)
---
## 1. Overview & Purpose
`companyAutomat` stores the active company configuration and metadata for the authenticated user's selected tenant. It provides essential company-wide parameters needed throughout Dashfront, such as default tax rates (standard and reduced), legal company details, default language, and the root category ID of the catalog hierarchy.
### Core Responsibilities
- **Company Configuration**: Caches legal details, tax rates (`taxrate`, `reducedtaxrate`), and catalog root category ID (`rootCategoryId`).
- **Data Normalization**: Normalizes backend company payloads via `normalizeCompany()` for predictable shape in forms and calculations.
- **Cross-Automat Triggering**: Changes in `companyAutomat` drive `categoryAutomat` to fetch the company's category tree.
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` has an active `selectedCompanyId` and resets on logout.
---
## 2. State Structure
```ts
interface NormalizedCompany {
id: number | string;
name: string;
rootCategoryId: number | string | null;
taxrate: number; // Standard VAT rate (e.g. 19.00)
reducedtaxrate: number; // Reduced VAT rate (e.g. 7.00)
iso639: string; // Default language code (e.g. 'de')
street?: string;
zip?: string;
city?: string;
country?: string;
iban?: string;
bic?: string;
taxId?: string;
vatId?: string;
[key: string]: any;
}
interface CompanyState {
/** Normalized details of currently selected company. */
current: NormalizedCompany | null;
/** Async lifecycle status. */
status: 'idle' | 'loading' | 'succeeded' | 'failed';
/** Last error message if load/update failed. */
error: string | null;
}
```
### Initial State
```js
const initialState = {
current: null,
status: 'idle',
error: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'company' });
```
- **`name`**: `'company'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Access actions via `companyAutomat.actions.<method>()`.
### `load(): Promise<NormalizedCompany | null>`
Determines the selected company ID from `authAutomat.state.user?.selectedCompanyId`. If none is selected, returns `null`. Otherwise, queries `GET /api/company/:id`, normalizes the data, updates `state.current`, and sets status to `'succeeded'`.
### `update(payload: object): Promise<NormalizedCompany>`
Persists updated company settings via `PUT /api/company/:id`, re-fetches authoritative data, normalizes it, updates `state.current`, and returns the updated company object.
### `reset(): void`
Resets state back to `initialState`.
---
## 5. Exported Standalone Selectors
Exported plain helper functions for querying company state without requiring a React component:
| Selector | Return Type | Description |
|---|---|---|
| **`selectCurrentCompany()`** | `NormalizedCompany \| null` | Returns the entire current company object. |
| **`selectCompanyId()`** | `string \| number \| null` | The ID of the currently selected company. |
| **`selectCompanyStatus()`** | `'idle'\|'loading'\|'succeeded'\|'failed'` | Current status of the company store. |
| **`selectCompanyName()`** | `string \| undefined` | Company legal or trade name. |
| **`selectCompanyIso639()`** | `string \| undefined` | ISO language code (e.g. `'de'`). |
| **`selectCompanyTaxrate()`** | `number \| null` | Standard VAT rate (e.g. `19.0`). |
| **`selectCompanyReducedTaxrate()`** | `number \| null` | Reduced VAT rate (e.g. `7.0`). |
| **`selectCompanyRootCategoryId()`** | `number \| string \| null` | ID of the root category node. |
---
## 6. Cross-Automat Subscriptions
`companyAutomat` automatically tracks authentication changes:
```js
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void companyAutomat.actions.load();
} else if (auth.status === 'anon') {
companyAutomat.actions.reset();
}
});
```
---
## 7. Usage Examples
### A. React `PureComponent` Tax Rate Calculation
```jsx
import { PureComponent } from 'react';
import companyAutomat, { selectCompanyTaxrate } from '../automats/companyAutomat';
export class PriceDisplay extends PureComponent {
constructor(props) {
super(props);
this.state = {
taxrate: selectCompanyTaxrate() ?? 19.0,
};
}
componentDidMount() {
this.unsub = companyAutomat.subscribe(this, (state) => ({
taxrate: state.current?.taxrate ?? 19.0,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { netPrice } = this.props;
const grossPrice = netPrice * (1 + this.state.taxrate / 100);
return (
<div>
<span>Netto: {netPrice.toFixed(2)} €</span>
<strong>Brutto (inkl. {this.state.taxrate}% MwSt): {grossPrice.toFixed(2)} €</strong>
</div>
);
}
}
```
### B. Pure Function / Utility Usage
```js
import { selectCompanyId, selectCompanyRootCategoryId } from '../automats/companyAutomat';
export function getCompanyUploadUrl() {
const companyId = selectCompanyId();
if (!companyId) throw new Error('Kein Unternehmen aktiv.');
return `/api/company/${companyId}/upload`;
}
```
---
## 8. Best Practices & Gotchas
1. **Tax Calculations**: Always query `selectCompanyTaxrate()` or `selectCompanyReducedTaxrate()` instead of hardcoding `19%` or `7%`.
2. **Catalog Roots**: Don't assume root category is `1` or `null`; always use `selectCompanyRootCategoryId()`.

View File

@@ -0,0 +1,237 @@
# `deliveryEditorAutomat`
> **Source File**: [`src/automats/deliveryEditorAutomat.js`](file:///home/seb/src/dashfront/src/automats/deliveryEditorAutomat.js)
> **Registration Keys**: `'delivery-draft'` (draft editor) / `'delivery-{docId}'` (saved delivery editors)
---
## 1. Overview & Purpose
`deliveryEditorAutomat` powers the inbound delivery reception, receipt reconciliation, and invoice editor (`/lieferung/neu` and `/lieferung/:deliveryId`). It manages complex multi-line receipts, OCR attachments, and automatic matching of supplier articles to internal catalog SKUs.
### Core Responsibilities
- **Multi-Instance Architecture**: Supports an unsaved draft editor (`deliveryDraftAutomat`) as well as isolated, named automat instances for each open delivery (`getDeliveryEditorAutomat(docId)`).
- **Line Item Management**: Adding, editing, reordering, and deleting delivery lineitems (`name`, `sku`, `amount`, `pu`, `unit`, `netprice`, `taxrate`).
- **Supplier Article Auto-Matching**: As lines are typed or parsed from receipts, automatically queries `GET /api/supplier/:id/articles` and indexes them by supplier SKU, internal SKU, and barcode EANs.
- **Unlinking & Re-matching**: Supports unlinking mistaken supplier-article associations (`unlinkSupplierArticle`) and refreshing lines.
- **Attachment & Receipt Pipelines**: Manages uploaded PDFs and image receipts, OCR parsing state, and attachment previews.
- **Reactive Navigation Badges**: Used by `src/nav.js` to show live uncommitted line badges on the sidebar navigation items.
---
## 2. State Structure
```ts
interface DeliveryLine {
key: string | number; // Stable client-side unique identifier
name: string | null; // Article description
sku: string | number | null; // Supplier SKU or internal SKU
amount: number | null; // Delivered quantity
pu: number | null; // Price unit (e.g. 1, 100)
unit: string | null; // Unit of measure (e.g. 'Stück', 'kg')
netprice: number | null; // Net unit price
taxrate: number | null; // VAT rate (e.g. 19.0)
}
interface SupplierArticle {
id?: string | number;
sku?: string | number | null;
article?: {
sku?: string | number | null;
eans?: Array<string | number>;
articleean?: Array<string | number>;
};
}
interface DeliveryEditorState {
deliveryEntities: Array<object>; // Available suppliers/parties
id: string | number | null;
deliveryId: string | number | null; // Alias of id
date: string | null; // ISO delivery date string
deliveryEntity: string | number | null; // Selected supplier ID
partyId: string | number | null; // Alias of deliveryEntity
externalNumber: string | null; // Supplier invoice / delivery slip number
external_number: string | null; // Alias of externalNumber
number: string | null; // Internal receipt number
type: string | null;
lines: Array<DeliveryLine>; // Editable line items
lineitems: Array<DeliveryLine>; // Alias of lines
notes: Array<any>; // Delivery notes
attachments: Array<object>; // PDF/image attachments
files: Array<object>; // Alias of attachments
supplierArticles: Array<SupplierArticle>; // Loaded supplier articles
supplierArticlesMap: Record<string, SupplierArticle>; // Lookup map by SKU / EAN
supplierArticlesByLineKey: Record<string, SupplierArticle | null>; // Matched article per line
supplierArticlesLoading: boolean; // Pending article lookup
loadingLineKeys: Record<string, boolean>; // Per-line loading state
busySupplierArticleLineKeys: Record<string, boolean>; // Lines with pending unlinks
supplierArticlesError: string | null;
unmatchedSupplierName: string | null; // Parsed supplier name without match
}
```
---
## 3. Factory & Instance Resolution
### `createDeliveryEditorAutomat(options?: { name?: string } | string): Automat<DeliveryEditorState>`
Factory function creating a new delivery editor automat or retrieving an existing one by name.
### `deliveryDraftAutomat` / `deliveryEditorAutomat`
The singleton automat with name `'delivery-draft'` used for creating new, unsaved deliveries (`/lieferung/neu`).
### `getDeliveryEditorAutomat(docId: string | number | null): Automat<DeliveryEditorState>`
Returns `deliveryDraftAutomat` when `docId` is null/undefined. If `docId` is provided, returns or instantiates `'delivery-${docId}'`.
---
## 4. Helper Utilities
- **`createEmptyLineitem(line?: Partial<DeliveryLine>): DeliveryLine`**: Returns a normalized lineitem with a generated unique `key`.
- **`buildSupplierArticlesMap(articles, existingMap?): Record<string, SupplierArticle>`**: Indexes articles by supplier SKU, internal SKU, and EANs (storing both original and lowercased keys for case-insensitive lookup).
---
## 5. Actions & Methods
Actions can be invoked directly from the automat instance:
### Header & Supplier Details
- **`setId(id)`**: Sets `id` and `deliveryId`.
- **`setDeliveryEntities(entities)`**: Sets available supplier entities dropdown list.
- **`setDelivery(delivery)`**: Replaces the full delivery state, normalizes aliases (`lines`/`lineitems`, `attachments`/`files`), and immediately triggers `fetchSupplierArticles` if a supplier is set.
- **`setDate(date)`**: Sets delivery date.
- **`setDeliveryEntity(entityId)` / `setPartyId(partyId)`**: Sets the active supplier and refreshes supplier articles for the current lines.
- **`setExternalNumber(externalNumber)`**: Sets external supplier invoice/slip number.
- **`setNumber(number)`**: Sets internal receipt number.
- **`setType(type)`**: Sets delivery document type.
- **`setNotes(notes)`**: Sets delivery notes array.
- **`setUnmatchedSupplierName(name)`**: Sets OCR-extracted supplier name before an entity is matched.
### Line Items
- **`setLines(lines)` / `setLineitems(lines)`**: Sets the entire lines array.
- **`addLineitem(line?)` / `addLine(line?)`**: Appends a new line item.
- **`updateLineitem(payload)` / `updateLine(payload)`**: Updates line fields identified by `index` or `key`.
- **`removeLineitem(payload)` / `removeLine(payload)`**: Deletes a line by index or key, and cleans up its matched supplier article entry.
### Supplier Article Matching & Unlinking
- **`fetchSupplierArticles(targetEntityId?, targetLines?): Promise<void>`**: Fetches all supplier articles for the unique SKUs across the lines and builds the matching map.
- **`fetchSupplierArticleForSku(sku, lineKey?): Promise<void>`**: Loads and matches a supplier article for a single line SKU.
- **`unlinkSupplierArticle(internalSku, assignmentId, lineKey?): Promise<void>`**: Calls `DELETE /api/article/:sku/supplier/:id` to unassign an incorrect supplier article match, cleans up the map, and re-resolves the line.
### Attachments & Resets
- **`addAttachment(attachment)`**: Appends a file/receipt attachment.
- **`setAttachments(attachments)`**: Replaces the attachments array.
- **`updateAttachment(payload)`**: Updates attachment fields by `id`.
- **`removeAttachment(idOrPayload)`**: Removes an attachment by ID or index.
- **`resetDelivery()`**: Clears delivery fields while preserving available supplier entities.
- **`resetDeliveryEditor()`**: Resets the complete state back to `initialState`.
---
## 6. Exported Selectors
Plain selectors operating on `deliveryEditorAutomat.state` by default:
```js
selectDeliveryId(state?)
selectDeliveryEntities(state?)
selectDeliveryDate(state?)
selectDeliveryEntity(state?)
selectUnmatchedSupplierName(state?)
selectDeliveryExternalNumber(state?)
selectDeliveryNumber(state?)
selectDeliveryType(state?)
selectDeliveryLineitems(state?)
selectDeliveryNotes(state?)
selectDeliveryAttachments(state?)
selectSupplierArticles(state?)
selectSupplierArticlesMap(state?)
selectSupplierArticlesByLineKey(state?)
selectSupplierArticlesLoading(state?)
selectLoadingLineKeys(state?)
selectBusySupplierArticleLineKeys(state?)
```
---
## 7. Usage Examples
### A. React `PureComponent` Delivery Lines Table
```jsx
import { PureComponent } from 'react';
import deliveryEditorAutomat from '../automats/deliveryEditorAutomat';
export class DeliveryLinesTable extends PureComponent {
constructor(props) {
super(props);
this.state = {
lines: deliveryEditorAutomat.state.lines,
matches: deliveryEditorAutomat.state.supplierArticlesByLineKey,
};
}
componentDidMount() {
this.unsub = deliveryEditorAutomat.subscribe(this, (state) => ({
lines: state.lines,
matches: state.supplierArticlesByLineKey,
}));
}
componentWillUnmount() {
this.unsub?.();
}
handleAddLine = () => {
deliveryEditorAutomat.actions.addLineitem({ amount: 1 });
};
handleSkuChange = (key, sku) => {
deliveryEditorAutomat.actions.updateLineitem({ key, sku });
deliveryEditorAutomat.actions.fetchSupplierArticleForSku(sku, key);
};
render() {
const { lines, matches } = this.state;
return (
<div>
<table>
<thead>
<tr>
<th>SKU</th>
<th>Artikelname</th>
<th>Menge</th>
<th>Zugeordneter Artikel</th>
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<input
value={l.sku ?? ''}
onChange={(e) => this.handleSkuChange(l.key, e.target.value)}
/>
</td>
<td>{l.name}</td>
<td>{l.amount}</td>
<td>{matches[l.key]?.article?.sku ?? 'Nicht zugeordnet'}</td>
</tr>
))}
</tbody>
</table>
<button onClick={this.handleAddLine}>+ Position hinzufügen</button>
</div>
);
}
}
```
---
## 8. Best Practices & Gotchas
1. **Always use line `key`**: Line indices shift when rows are deleted or reordered. Use `l.key` generated by `createEmptyLineitem()` for React keys and lookup indices.
2. **Draft vs Saved Delivery**: Always use `getDeliveryEditorAutomat(deliveryId)` when editing an existing delivery so edits don't contaminate the `/lieferung/neu` draft state.
3. **Sidebar badge count**: `src/nav.js` directly inspects `deliveryEditorAutomat.state.lines.length` to render draft item badges.

115
docs/navigationAutomat.md Normal file
View File

@@ -0,0 +1,115 @@
# `navigationAutomat`
> **Source File**: [`src/automats/navigationAutomat.js`](file:///home/seb/src/dashfront/src/automats/navigationAutomat.js)
> **Registration Key**: `'navigation'` (`Automat.get('navigation')` / `window.__AUTOMATS__.get('navigation')`)
---
## 1. Overview & Purpose
`navigationAutomat` tracks the user's most recently visited delivery document path and article SKU. When navigating across main sidebar sections (for example, switching from `/lieferung` to `/artikel` and back), it enables the navigation system to restore the user's active context instead of resetting them back to the root list page.
### Core Responsibilities
- **Context Preservation**: Remembers the exact active subpath (e.g. `/lieferung/42` or `/lieferung/neu`) and article SKU (e.g. `'SKU-8910'`).
- **Seamless Multitasking**: Users working on a delivery who need to quickly check an article warehouse stock can navigate to `/artikel` and return to `/lieferung` without losing their spot.
- **Session-Scoped History**: In-memory state (`persist: false`) kept during active application usage.
---
## 2. State Structure
```ts
interface NavigationState {
/** Last delivery path visited, e.g. '/lieferung/19' or '/lieferung/neu'. */
lastDeliveryPath: string | null;
/** Last article SKU visited, e.g. '10042'. */
lastArticleSku: string | null;
}
```
### Initial State
```js
const initialState = {
lastDeliveryPath: null,
lastArticleSku: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, {
name: 'navigation',
persist: false,
});
```
- **`name`**: `'navigation'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Actions are exported both on `navigationAutomat.actions` and as standalone named functions:
### `setLastDeliveryPath(path: string | null): void`
Stores the full delivery route path (e.g. `'/lieferung/19'`, `'/lieferung/neu'`).
### `setLastArticleSku(sku: string | null): void`
Stores the SKU of the article currently being edited or viewed.
### `clearNavigation(): void`
Resets both `lastDeliveryPath` and `lastArticleSku` to `null`.
---
## 5. Usage Examples
### A. Recording Active SKU in `ArtikelSkuPage`
```jsx
import { PureComponent } from 'react';
import { setLastArticleSku } from '../automats/navigationAutomat';
export class ArtikelSkuPage extends PureComponent {
componentDidMount() {
setLastArticleSku(this.props.sku);
}
componentDidUpdate(prevProps) {
if (prevProps.sku !== this.props.sku) {
setLastArticleSku(this.props.sku);
}
}
render() {
return <div>Artikel Detail für SKU {this.props.sku}</div>;
}
}
```
### B. Dynamic Navigation Link in `src/nav.js`
```js
import navigationAutomat from './automats/navigationAutomat';
export function getDeliveryNavPath() {
const lastPath = navigationAutomat.state.lastDeliveryPath;
return lastPath || '/lieferung';
}
export function getArticleNavPath() {
const lastSku = navigationAutomat.state.lastArticleSku;
return lastSku ? `/artikel/${encodeURIComponent(lastSku)}` : '/artikel';
}
```
---
## 6. Best Practices & Gotchas
1. **Path Formatting**: Always pass normalized route paths to `setLastDeliveryPath` (with leading slash).
2. **Clear on Logout**: If resetting application state on logout, call `clearNavigation()`.

185
docs/posterminalAutomat.md Normal file
View File

@@ -0,0 +1,185 @@
# `posterminalAutomat`
> **Source File**: [`src/automats/posterminalAutomat.js`](file:///home/seb/src/dashfront/src/automats/posterminalAutomat.js)
> **Registration Key**: `'posterminal'` (`Automat.get('posterminal')` / `window.__AUTOMATS__.get('posterminal')`)
---
## 1. Overview & Purpose
`posterminalAutomat` manages hardware Point-of-Sale (POS) terminals registered for the active company. In Dashfront, POS terminals can have specific category subsets enabled or disabled to control which products appear on specific physical checkouts or handheld POS devices.
### Core Responsibilities
- **Terminal Registry**: Lists all POS terminals (`id`, `name`, `categoryIds`).
- **Category Filter Management**: Enabling or disabling categories for individual terminals (`enableAll`, `disableAll`, `enableCategory`, `disableCategory`).
- **Terminal CRUD**: Creating new hardware terminal entries and deleting retired ones.
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` authenticates with a company and resets on logout.
---
## 2. State Structure
```ts
interface PosTerminal {
id: number;
name: string;
categoryIds?: Array<number>;
[key: string]: any;
}
interface PosterminalState {
/** Registered POS terminals for active company. */
terminals: Array<PosTerminal>;
/** Company ID these terminals belong to. */
companyId: number | string | null;
/** Async lifecycle status. */
status: 'idle' | 'loading' | 'succeeded' | 'failed';
/** Last error message if load failed. */
error: string | null;
}
```
### Initial State
```js
const initialState = {
terminals: [],
companyId: null,
status: 'idle',
error: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'posterminal' });
```
- **`name`**: `'posterminal'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Access actions via `posterminalAutomat.actions.<method>()`.
### `load(): Promise<Array<PosTerminal>>`
Determines the selected company ID from `authAutomat` or `companyAutomat`. Fetches terminals via `GET /api/posterminal`, updates state, and returns the terminals list.
### `create({ name: string }): Promise<PosTerminal>`
Creates a new POS terminal via `POST /api/posterminal` and appends it to `state.terminals`.
### `delete(terminalId: number): Promise<void>`
Deletes a terminal via `DELETE /api/posterminal/:id` and filters it out of `state.terminals`.
### `enableAll({ terminalId: number }): Promise<object>`
Enables all categories on the given terminal via `POST /api/posterminal/:id/category/enable-all`. Patches `categoryIds` on the terminal in state.
### `disableAll({ terminalId: number }): Promise<object>`
Disables all categories on the given terminal via `POST /api/posterminal/:id/category/disable-all`. Patches `categoryIds` on the terminal in state.
### `enableCategory({ terminalId: number, categoryId: number }): Promise<object>`
Enables a specific category on the given terminal via `POST /api/posterminal/:id/category/:catId/enable`.
### `disableCategory({ terminalId: number, categoryId: number }): Promise<object>`
Disables a specific category on the given terminal via `DELETE /api/posterminal/:id/category/:catId/disable`.
### `reset(): void`
Resets the automat back to `initialState`.
---
## 5. Exported Standalone Selectors
- **`selectPosterminals()`**: `() => Array<PosTerminal>` — Returns the list of registered terminals.
- **`selectPosterminalStatus()`**: `() => string` — Returns the current loading status.
- **`selectHasPosterminals()`**: `() => boolean` — Returns `true` if one or more terminals exist.
---
## 6. Cross-Automat Subscriptions
`posterminalAutomat` coordinates with `authAutomat` at module load:
```js
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();
}
});
```
---
## 7. Usage Examples
### A. React `PureComponent` Terminal Management List
```jsx
import { PureComponent } from 'react';
import posterminalAutomat, { selectPosterminals } from '../automats/posterminalAutomat';
export class TerminalSettings extends PureComponent {
constructor(props) {
super(props);
this.state = {
terminals: selectPosterminals(),
status: posterminalAutomat.state.status,
};
}
componentDidMount() {
this.unsub = posterminalAutomat.subscribe(this, ['terminals', 'status']);
}
componentWillUnmount() {
this.unsub?.();
}
handleCreate = async () => {
const name = window.prompt('Name des neuen POS-Terminals:');
if (name) {
await posterminalAutomat.actions.create({ name });
}
};
handleDelete = async (terminalId) => {
if (window.confirm('Terminal wirklich löschen?')) {
await posterminalAutomat.actions.delete(terminalId);
}
};
render() {
const { terminals, status } = this.state;
if (status === 'loading') return <div>Lade POS-Terminals...</div>;
return (
<div>
<h3>Registrierte POS-Terminals</h3>
<ul>
{terminals.map((t) => (
<li key={t.id}>
<strong>{t.name}</strong> (ID: {t.id})
<button onClick={() => this.handleDelete(t.id)}>Löschen</button>
</li>
))}
</ul>
<button onClick={this.handleCreate}>+ Neues Terminal anlegen</button>
</div>
);
}
}
```
---
## 8. Best Practices & Gotchas
1. **Category synchronization**: Terminal category assignments are also referenced inside `categoryAutomat.actions.patchCategoryPosterminalIds`. Both stores stay in sync when configuring POS terminals.
2. **Terminal IDs**: Use integer IDs when calling `delete`, `enableCategory`, or `disableCategory`.

192
docs/supplierAutomat.md Normal file
View File

@@ -0,0 +1,192 @@
# `supplierAutomat`
> **Source File**: [`src/automats/supplierAutomat.js`](file:///home/seb/src/dashfront/src/automats/supplierAutomat.js)
> **Registration Key**: `'supplier'` (`Automat.get('supplier')` / `window.__AUTOMATS__.get('supplier')`)
---
## 1. Overview & Purpose
`supplierAutomat` manages the supplier directory for the active company (`GET /api/supplier`). Suppliers are used when receiving deliveries, reconciling purchase invoices, and organizing supplier-specific article catalogs.
### Core Responsibilities
- **Company Scoping & Auth Sync**: Caches suppliers for the selected company and automatically invalidates or re-fetches when switching companies or logging out.
- **In-flight Deduplication**: Multiple components invoking `load()` concurrently share a single HTTP request promise (`inflightLoad`).
- **Full CRUD Support**: Creating, updating (renaming), and deleting suppliers with automatic cache refresh.
- **Silent Background Refreshes**: `reload()` updates supplier lists in the background without clearing current state.
---
## 2. State Structure
```ts
interface Supplier {
id: string | number;
name: string;
[key: string]: any;
}
interface SupplierState {
/** Company suppliers list. */
suppliers: Array<Supplier>;
/** Async lifecycle phase. */
status: 'idle' | 'loading' | 'ready' | 'failed';
/** Last error object if load failed. */
error: Error | null;
}
```
### Initial State
```js
const initialState = {
suppliers: [],
status: 'idle',
error: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'supplier' });
```
- **`name`**: `'supplier'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Access actions via `supplierAutomat.actions.<method>()`.
### `load(): Promise<Array<Supplier>>`
Loads the supplier list via `GET /api/supplier`. Deduplicates concurrent callers: if a request is already in flight, it returns the existing promise. Sets `status` to `'ready'` on success.
### `reload(): Promise<Array<Supplier>>`
Silently re-fetches the supplier list without setting `status` back to `'loading'`. Preserves the current list until the new response arrives.
### `create(body: { name: string }): Promise<Supplier>`
Creates a new supplier via `POST /api/supplier`, waits for `reload()`, and returns the newly created supplier object.
### `update(id: string | number, body: { name: string }): Promise<Supplier>`
Updates supplier details via `PATCH /api/supplier/:id`, waits for `reload()`, and returns the updated supplier.
### `remove(id: string | number): Promise<void>`
Deletes a supplier via `DELETE /api/supplier/:id` and triggers `reload()`.
### `setSuppliers(suppliers: Array<Supplier>): void`
Directly replaces the cached supplier array without a network round-trip.
### `clear(): void`
Resets the state back to `initialState`.
---
## 5. Cross-Automat Subscriptions
`supplierAutomat` tracks `authAutomat` through `syncWithAuth`:
```js
function authKey(authState) {
if (authState?.status !== 'authed' || !authState.user) return null;
const companyId = authState.user.selectedCompanyId;
return companyId == null ? null : String(companyId);
}
function syncWithAuth(authState) {
const key = authKey(authState);
if (key === lastAuthKey) return;
lastAuthKey = key;
if (key == null) {
supplierAutomat.actions.clear();
} else {
void supplierAutomat.actions.load().catch(() => {});
}
}
authAutomat.subscribe(syncWithAuth);
syncWithAuth(authAutomat.state);
```
Whenever the user authenticates with a company, suppliers load automatically; when logging out, they clear.
---
## 6. Usage Examples
### A. React `PureComponent` Supplier Dropdown
```jsx
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import supplierAutomat from '../automats/supplierAutomat';
export class SupplierDropdown extends PureComponent {
static propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
suppliers: supplierAutomat.state.suppliers,
status: supplierAutomat.state.status,
};
}
componentDidMount() {
this.unsub = supplierAutomat.subscribe(this, ['suppliers', 'status']);
if (this.state.status === 'idle') {
supplierAutomat.actions.load().catch(console.error);
}
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { suppliers, status } = this.state;
const { value, onChange } = this.props;
if (status === 'loading') {
return <select disabled><option>Lieferanten laden...</option></select>;
}
return (
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">-- Lieferant wählen --</option>
{suppliers.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
);
}
}
```
### B. Creating a Supplier
```js
import supplierAutomat from '../automats/supplierAutomat';
async function addNewSupplier(name) {
try {
const supplier = await supplierAutomat.actions.create({ name });
console.log('Supplier created:', supplier);
} catch (err) {
console.error('Failed to create supplier:', err);
}
}
```
---
## 7. Best Practices & Gotchas
1. **Automatic Refresh**: `create`, `update`, and `remove` automatically call `reload()`, so subscribers will receive the updated list without manual re-querying.
2. **Inflight Safety**: You can safely call `supplierAutomat.actions.load()` in multiple components mounted at the same time without causing duplicate HTTP requests.

173
docs/unitAutomat.md Normal file
View File

@@ -0,0 +1,173 @@
# `unitAutomat`
> **Source File**: [`src/automats/unitAutomat.js`](file:///home/seb/src/dashfront/src/automats/unitAutomat.js)
> **Registration Key**: `'unit'` (`Automat.get('unit')` / `window.__AUTOMATS__.get('unit')`)
---
## 1. Overview & Purpose
`unitAutomat` manages the active company's allowed units of measure (e.g. Stück, kg, Meter, Liter). Units are attached to articles and delivery lineitems to ensure consistent units of measure and price calculation across the catalog and invoices.
### Core Responsibilities
- **Company Scoping**: Caches all measurement units for the currently selected company.
- **Alphabetical Sorting**: Automatically sorts units by their display symbol or SI code using German collation.
- **Autonomous Bootstrap**: Automatically loads whenever `authAutomat` transitions to `'authed'` with a selected company, and resets when the session ends.
- **Full Unit Maintenance**: Upserting (creating or modifying) and deleting units by SI key.
---
## 2. State Structure
```ts
interface Unit {
si: string; // International standard code (e.g. 'C62', 'KGM', 'LTR')
symbol?: string; // Display symbol (e.g. 'Stk', 'kg', 'l')
name?: string; // Full name (e.g. 'Stück', 'Kilogramm')
[key: string]: any;
}
interface UnitState {
/** Alphabetically sorted list of measurement units. */
units: Array<Unit>;
/** Async lifecycle status. */
status: 'idle' | 'loading' | 'succeeded' | 'failed';
/** Last error message if load failed. */
error: string | null;
}
```
### Initial State
```js
const initialState = {
units: [],
status: 'idle',
error: null,
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, { name: 'unit' });
```
- **`name`**: `'unit'`
- **`persist`**: `false`
---
## 4. Actions & Methods
Access actions via `unitAutomat.actions.<method>()`.
### `load(): Promise<Array<Unit>>`
Fetches all units for the active company via `GET /api/unit`. Sorts them, updates state, and sets status to `'succeeded'`.
### `upsert(body: object): Promise<Array<Unit>>`
Creates or updates a unit via `POST /api/unit/upsert`. Merges the updated unit into `state.units` by matching on its `si` property, re-sorts the list, updates state, and returns the full updated list.
### `delete(si: string): Promise<void>`
Deletes a unit by SI code via `DELETE /api/unit/:si` and filters it out of `state.units`.
### `reset(): void`
Resets the state back to `initialState`.
---
## 5. Cross-Automat Subscriptions
`unitAutomat` tracks authentication changes automatically:
```js
authAutomat.subscribe((auth) => {
const companyId = auth.user?.selectedCompanyId;
if (auth.status === 'authed' && companyId) {
void unitAutomat.actions.load();
} else if (auth.status === 'anon') {
unitAutomat.actions.reset();
}
});
```
---
## 6. Usage Examples
### A. React `PureComponent` Unit Select Dropdown
```jsx
import { PureComponent } from 'react';
import PropTypes from 'prop-types';
import unitAutomat from '../automats/unitAutomat';
export class UnitSelect extends PureComponent {
static propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
this.state = {
units: unitAutomat.state.units,
status: unitAutomat.state.status,
};
}
componentDidMount() {
this.unsub = unitAutomat.subscribe(this, ['units', 'status']);
if (this.state.status === 'idle') {
unitAutomat.actions.load().catch(console.error);
}
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { units, status } = this.state;
const { value, onChange } = this.props;
if (status === 'loading') {
return <select disabled><option>Einheiten laden...</option></select>;
}
return (
<select value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">-- Einheit wählen --</option>
{units.map((u) => (
<option key={u.si} value={u.symbol || u.si}>
{u.symbol || u.si} {u.name ? `(${u.name})` : ''}
</option>
))}
</select>
);
}
}
```
### B. Upserting a Unit
```js
import unitAutomat from '../automats/unitAutomat';
async function addCustomUnit(si, symbol, name) {
try {
await unitAutomat.actions.upsert({ si, symbol, name });
console.log('Unit saved successfully');
} catch (err) {
console.error('Failed to save unit:', err);
}
}
```
---
## 7. Best Practices & Gotchas
1. **Unique Key**: Units are uniquely identified by their `si` property (not an integer ID).
2. **Sort Guarantee**: Units in `state.units` are sorted alphabetically based on `u.symbol ?? u.si`.

116
docs/userSettingsAutomat.md Normal file
View File

@@ -0,0 +1,116 @@
# `userSettingsAutomat`
> **Source File**: [`src/automats/userSettingsAutomat.js`](file:///home/seb/src/dashfront/src/automats/userSettingsAutomat.js)
> **Registration Key**: `'userSettings'` (`Automat.get('userSettings')` / `window.__AUTOMATS__.get('userSettings')`)
---
## 1. Overview & Purpose
`userSettingsAutomat` manages client-side user interface preferences and layout options. Unlike most other automats in Dashfront that synchronize with the backend, `userSettingsAutomat` is persisted locally using IndexedDB (`persist: true`).
### Core Responsibilities
- **Local Persistence**: Preferences are saved across browser reloads and restarts without requiring server calls or database migrations.
- **Docking Preferences**: Remembers the user's preferred layout docking position for attachment viewers (such as the PDF receipt preview and text editor) relative to the article list.
- **Zero Latency**: State is instantly available in local storage/IndexedDB on client startup.
---
## 2. State Structure
```ts
type ViewerPosition = 'top' | 'right' | 'below';
interface UserSettingsState {
/**
* Preferred docking position of the attachment viewer
* (text editor / PDF preview) relative to the article list.
*/
viewerPosition: ViewerPosition;
}
```
### Initial State
```js
const initialState = {
viewerPosition: 'top',
};
```
---
## 3. Configuration & Options
```js
new Automat(initialState, actions, {
name: 'userSettings',
persist: true,
});
```
- **`name`**: `'userSettings'`
- **`persist`**: `true` — `react-automat` automatically reads and writes this state to IndexedDB.
---
## 4. Actions & Methods
Actions are available on `userSettingsAutomat.actions` and exported as standalone functions:
### `setViewerPosition(position: 'top' | 'right' | 'below'): void`
Validates that `position` is one of `'top'`, `'right'`, or `'below'`. If valid, updates `state.viewerPosition` and automatically commits the change to IndexedDB.
---
## 5. Usage Examples
### A. React `PureComponent` Layout Container
```jsx
import { PureComponent } from 'react';
import userSettingsAutomat, { setViewerPosition } from '../automats/userSettingsAutomat';
export class DeliveryEditorLayout extends PureComponent {
constructor(props) {
super(props);
this.state = {
viewerPosition: userSettingsAutomat.state.viewerPosition,
};
}
componentDidMount() {
this.unsub = userSettingsAutomat.subscribe(this, 'viewerPosition');
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { viewerPosition } = this.state;
const isHorizontal = viewerPosition === 'right';
return (
<div className={`editor-container ${viewerPosition}`}>
<div className="toolbar">
<span>Viewer Position:</span>
<button onClick={() => setViewerPosition('top')}>Oben</button>
<button onClick={() => setViewerPosition('right')}>Rechts</button>
<button onClick={() => setViewerPosition('below')}>Unten</button>
</div>
<div className={`layout-body ${isHorizontal ? 'row' : 'column'}`}>
<div className="viewer-panel">PDF / Beleg-Vorschau</div>
<div className="table-panel">Positionen-Tabelle</div>
</div>
</div>
);
}
}
```
---
## 6. Best Practices & Gotchas
1. **Validation**: Invalid values passed to `setViewerPosition` are ignored and will not update state.
2. **Persistence Reset**: To clear persisted settings programmatically, `react-automat` provides `userSettingsAutomat.clearPersistence()`.

118
eslint.config.js Normal file
View File

@@ -0,0 +1,118 @@
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: [
'dist/**',
'dist-ssr/**',
'node_modules/**',
'certs/**',
'tmp/**',
'.dashfront-token',
],
},
js.configs.recommended,
{
files: ['**/*.{js,jsx,mjs}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.node,
React: 'readonly',
},
},
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',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^(React|_)',
ignoreRestSiblings: true,
},
],
'no-empty': ['warn', { allowEmptyCatch: true }],
'no-debugger': '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>

2585
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,34 +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",
"lint": "eslint ."
"preview:https": "VITE_HTTPS=1 vite preview",
"rig:collect": "node test/collect.mjs",
"rig:reference": "node test/capture.mjs --out=test/screenshots/reference",
"rig:compare": "node test/capture.mjs --out=test/screenshots/current && node test/compare.mjs --tolerance=0.001",
"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",
"@reduxjs/toolkit": "^2.12.0",
"@mui/icons-material": "^9.5.0",
"@mui/material": "^9.5.0",
"@shopify/draggable": "^1.2.1",
"automat": "file:../automat",
"@uiw/react-codemirror": "^4.25.12",
"dashgui": "file:../dashgui",
"flexsearch": "^0.8.212",
"jsbarcode": "^3.12.3",
"notistack": "^3.0.2",
"pdfjs-dist": "^6.3.289",
"pdftotext-wasm": "file:../pdftotext-wasm",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-redux": "^9.3.0",
"pdfconvert-wasm": "^0.3.0",
"react": "^19.3.1",
"react-automat": "file:../automat",
"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": {
"@vitejs/plugin-react": "6.1.1",
"@eslint/js": "^10.0.1",
"@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.13.0",
"qrcode": "^1.5.4",
"vite": "^8.3.0",
"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,12 +1,16 @@
import { Component, Suspense } from 'react';
import { connect } from 'react-redux';
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 { selectAuthStatus, selectNeedsCompanySetup } from './store/authSlice';
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 (
@@ -24,45 +28,96 @@ function PageFallback() {
);
}
class App extends Component {
class App extends PureComponent {
constructor(props) {
super(props);
this.state = authAutomat.state;
}
componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, [
'status',
'user',
'hasAuth',
'hasCompany',
'retryNotification',
]);
}
componentWillUnmount() {
this.unsubscribe?.();
}
render() {
const { status, needsCompanySetup } = this.props;
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 (needsCompanySetup) {
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}
</>
);
}
}
const mapStateToProps = (state) => ({
status: selectAuthStatus(state),
needsCompanySetup: selectNeedsCompanySetup(state),
});
export default connect(mapStateToProps)(App);
export default App;

View File

@@ -1,479 +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.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)}`),
/**
* 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) {
const formData = new FormData();
formData.append('file', file, file.name);
const res = await fetch('/api/delivery/parse', {
method: 'POST',
credentials: 'include',
body: formData,
});
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';
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
}
}
},
};
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 } */
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 }),
/** 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.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,634 +0,0 @@
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { notifyError } from '../utils/notify';
import { parseCategoryTreeResponse } from '../store/categorySlice';
import { ApiClient } from './ApiClient';
/**
* 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<typeof createCache> | null} */
#articleIndexCache = null;
/** @type {Map<string, object>} sku → article detail */
#articlesBySku = new Map();
/** @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);
}
/** Settled article detail for sku, or undefined. */
getArticle(sku) {
return this.#articlesBySku.get(String(sku));
}
/** Keep local article detail in sync after GET/PATCH/PUT responses. */
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
this.#articlesBySku.set(key, article);
const watchers = this.#articleWatchers.get(key);
if (watchers) {
for (const onArticle of watchers) onArticle(article);
}
return article;
}
/**
* GET /api/article/{sku}, cache detail, return article.
* @param {string} sku
*/
async loadArticle(sku) {
const res = await this.article.get(sku);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
return this.setArticle(res.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 settled caches, or null if any piece missing.
* @returns {{ categories: object, rootCategoryId: unknown } | null}
*/
getCategoryState() {
const treeData = this.categoryTree;
const companies = this.companies;
const session = this.authMe;
if (treeData == null || companies == null || session == null) return null;
const companyId = session.user?.selectedCompanyId;
if (companyId == null) return null;
const company = companies.find((c) => c.id === companyId);
if (company == null) return null;
const parsed = parseCategoryTreeResponse(treeData, company.rootCategoryId);
return {
categories: parsed.categories,
rootCategoryId: parsed.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);
if (this.#articleWatchers.has(sku)) {
void this.loadArticle(sku).catch((err) => notifyError(err));
}
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 local article detail name if already cached; no-op otherwise. */
setArticleName(sku, name) {
const current = this.#articlesBySku.get(String(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());
this.#articleIndexCache = createCache('GET /articleIndex', () => this.#loadArticleIndex());
const articleIndexCache = this.#articleIndexCache;
const brandCache = createCache('GET /brand', () => this.#loadBrands());
const unitCache = createCache('GET /unit', () => this.#loadUnits());
const companyCache = createCache('GET /company', () => this.#loadCompanies());
const categoryTreeCache = createCache('GET /category/tree', () => this.#loadCategoryTree());
const posterminalCache = createCache('GET /posterminal', () => this.#loadPosterminals());
const supplierCache = createCache('GET /supplier', () => this.#loadSuppliers());
const deliveryCache = createCache('GET /delivery', () => this.#loadDeliveries());
const customergroupCache = createCache('GET /customergroup', () =>
this.#loadCustomergroups(),
);
const entityCache = createCache('GET /entity', () => this.#loadEntities());
const numberCache = createCache('GET /number', () => this.#loadNumbers());
const quoteCache = createCache('GET /quote', () => this.#loadQuotes());
const orderCache = createCache('GET /order', () => this.#loadOrders());
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 },
brandPromise: { get: () => brandCache.promise },
updateBrand: { value: brandCache.update },
/** Settled GET /brand list, or undefined. */
brands: { get: () => brandCache.value },
unitPromise: { get: () => unitCache.promise },
updateUnit: { value: unitCache.update },
/** Settled GET /unit list, or undefined. */
units: { get: () => unitCache.value },
companyPromise: { get: () => companyCache.promise },
updateCompany: { value: companyCache.update },
/** Settled GET /company list, or undefined. */
companies: { get: () => companyCache.value },
categoryTreePromise: { get: () => categoryTreeCache.promise },
updateCategoryTree: { value: categoryTreeCache.update },
/** Settled GET /category/tree body, or undefined. */
categoryTree: { get: () => categoryTreeCache.value },
posterminalPromise: { get: () => posterminalCache.promise },
updatePosterminal: { value: posterminalCache.update },
supplierPromise: { get: () => supplierCache.promise },
updateSupplier: { value: supplierCache.update },
/** Settled GET /supplier list, or undefined. */
suppliers: { get: () => supplierCache.value },
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() },
});
}
/**
* 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 this.updateCategoryTree().catch((err) => {
console.error('[storageMgr] category SSE refresh failed', err);
});
});
es.addEventListener('articles', () => {
void this.updateArticleIndex().catch((err) => {
console.error('[storageMgr] articles 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 #loadBrands() {
const data = await this.brand.list();
requireObject(data, 'GET /api/brand');
return requireArray(data.brands, 'brands');
}
async #loadUnits() {
const data = await this.unit.list();
requireObject(data, 'GET /api/unit');
return requireArray(data.units, 'units');
}
async #loadCompanies() {
const data = await this.company.list();
requireObject(data, 'GET /api/company');
return requireArray(data.companies, 'companies');
}
async #loadCategoryTree() {
const data = await this.category.tree();
requireObject(data, 'GET /api/category/tree');
requireArray(data.tree, 'category/tree.tree');
requireArray(data.articles, 'category/tree.articles');
requireDefined(data.languageId, 'category/tree.languageId');
return data;
}
async #loadPosterminals() {
const data = await this.posterminal.list();
requireObject(data, 'GET /api/posterminal');
return requireArray(data.posterminals, 'posterminals');
}
async #loadSuppliers() {
const data = await this.supplier.list();
requireObject(data, 'GET /api/supplier');
return requireArray(data.suppliers, 'suppliers');
}
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,27 +0,0 @@
import { createContext, createElement, useContext, useRef } from 'react';
import { StorageManager } from './StorageManager';
export const StorageManagerContext = createContext(null);
/**
* Provider — mount once near the top of the tree.
*/
export function StorageManagerProvider({ children }) {
const mgrRef = useRef(null);
if (!mgrRef.current) {
mgrRef.current = new StorageManager();
}
return createElement(StorageManagerContext.Provider, { value: mgrRef.current }, children);
}
/**
* Hook for function components that need the store manager.
*/
export function useStorageManager() {
const ctx = useContext(StorageManagerContext);
if (!ctx) {
throw new Error('useStorageManager must be used within a <StorageManagerProvider>');
}
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,30 +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 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 />,
@@ -40,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" />,
@@ -53,6 +95,9 @@ const routes = [
{ index: true, element: <ArtikelUebersichtPage /> },
{ path: 'kategorien/*', element: <RedirectArtikelKategorien /> },
{ path: 'lager', element: <ArtikelLagerPage /> },
{ path: 'marken', element: <ArtikelMarkenPage /> },
{ path: 'vorlagen', element: <ArtikelVorlagenPage /> },
{ path: 'merkmale', element: <ArtikelMerkmalePage /> },
{ path: ':sku/:tab?', element: <ArtikelSkuPage /> },
],
},
@@ -60,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 /> },
],
},
@@ -78,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 /> },
],
},
@@ -90,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

@@ -0,0 +1,338 @@
/**
* @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
* @property {Object<string, object>} bySku Cached article detail keyed by SKU.
* Missing keys mean "not loaded"; a value is the full `GET /api/article/{sku}`
* detail payload (prices, photos, eans, brand, unit, categoryIds, …).
*/
/** @type {ArtikelState} */
const initialState = {
bySku: {},
};
/**
* In-flight detail requests keyed by SKU. Concurrent consumers (view tiles,
* route guards, SSE refreshes) share a single `GET /api/article/{sku}`.
*
* @type {Map<string, Promise<object>>}
*/
const inflight = new Map();
/** @type {Automat<ArtikelState>} */
let artikelAutomat;
const actions = {
/**
* Loads an article into the shared cache, deduplicating concurrent callers.
* Resolves with the cached article when it is already present.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Loaded article detail.
*/
loadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) {
return Promise.reject(new Error('artikelAutomat.loadArticle: SKU fehlt'));
}
const cached = artikelAutomat.state.bySku[key];
if (cached) return Promise.resolve(cached);
const pending = inflight.get(key);
if (pending) return pending;
const request = articleApi
.getBySku(key)
.then((res) => {
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
})
.finally(() => {
inflight.delete(key);
});
inflight.set(key, request);
return request;
},
/**
* Silently re-fetches an article, keeping the cached copy until the new one
* arrives. Use for SSE invalidation and pull-to-refresh.
*
* @param {string} sku Article SKU.
* @returns {Promise<object>} Fresh article detail.
*/
async reloadArticle(sku) {
const key = String(sku ?? '').trim();
if (!key) throw new Error('artikelAutomat.reloadArticle: SKU fehlt');
const res = await articleApi.getBySku(key);
requireObject(res, 'GET /api/article/:sku');
requireObject(res.article, 'article');
requireDefined(res.article.sku, 'article.sku');
return artikelAutomat.actions.setArticle(res.article);
},
/**
* Stores an article detail payload in the cache. Call after any GET/PATCH/PUT
* response so every subscriber sees the same object.
*
* @param {object} article Article detail with at least a `sku`.
* @returns {object} The stored article.
*/
setArticle(article) {
requireObject(article, 'article');
requireDefined(article.sku, 'article.sku');
const key = String(article.sku);
artikelAutomat.setState({
bySku: { ...artikelAutomat.state.bySku, [key]: article },
});
updateArticleIndexRecord(article);
return article;
},
/**
* Patches the cached article name without a round-trip (optimistic update).
*
* @param {string} sku Article SKU.
* @param {string} name New name.
* @returns {object|null} Updated article, or `null` when not cached.
*/
setArticleName(sku, name) {
const key = String(sku);
const current = artikelAutomat.state.bySku[key];
if (!current) return null;
return artikelAutomat.actions.setArticle({ ...current, name });
},
/**
* Drops an article from the cache (e.g. after deletion), forcing the next
* `loadArticle` to hit the backend.
*
* @param {string} sku Article SKU.
* @returns {void}
*/
clearArticle(sku) {
const key = String(sku);
if (!(key in artikelAutomat.state.bySku)) return;
const bySku = { ...artikelAutomat.state.bySku };
delete bySku[key];
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.
*
* @returns {void}
*/
clear() {
artikelAutomat.setState({ bySku: {} });
},
};
/**
* Shared cache for article detail payloads. State lives in module scope so
* navigating between articles (and remounting the view) reuses the cache
* instead of re-fetching.
*
* @type {Automat<ArtikelState>}
*/
artikelAutomat = new Automat(initialState, actions, { name: 'artikel' });
/**
* Reads a cached article synchronously.
*
* @param {string} sku Article SKU.
* @returns {object|null} Cached article detail, or `null`.
*/
export function getArticle(sku) {
if (sku == null) return null;
return artikelAutomat.state.bySku[String(sku)] ?? null;
}
/**
* Whether an article detail payload is currently cached.
*
* @param {string} sku Article SKU.
* @returns {boolean} `true` when cached.
*/
export function hasArticle(sku) {
return getArticle(sku) != null;
}
/**
* Builds a selector for one SKU, so a component can subscribe to just that
* article instead of every cache change.
*
* @param {string} sku Article SKU.
* @returns {(state: ArtikelState) => object|null} Selector function.
*/
export function createArticleSelector(sku) {
const key = sku == null ? null : String(sku);
return (state) => (key == null ? null : state.bySku[key] ?? null);
}
/**
* Shorthand for `artikelAutomat.select(createArticleSelector(sku))`.
*
* @param {string} sku Article SKU.
* @returns {import('react-automat').ScopedSlice<object|null>} Scoped slice.
*/
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;

385
src/automats/authAutomat.js Normal file
View File

@@ -0,0 +1,385 @@
/**
* @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, number as numberApi } from '../api';
/**
* @typedef {Object} AuthUser
* @property {string|number|null} [selectedCompanyId] Currently selected company.
* @property {*} [key] Additional user properties returned by the 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. */
export const AUTH_SESSION_ESTABLISHED = 'auth/sessionEstablished';
/** Emitted after the active company has changed. */
export const AUTH_COMPANY_CHANGED = 'auth/companyChanged';
/** Emitted after the current user has logged out. */
export const AUTH_LOGGED_OUT = 'auth/loggedOut';
/** @type {AuthState} */
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.
*
* @param {(event: {type: string}) => void} dispatch Event dispatcher.
* @returns {void}
*/
export function setAuthEventDispatcher(dispatch) {
emit = dispatch;
}
export function normalizePicture(picture) {
if (!picture || typeof picture !== 'string') return null;
if (picture.startsWith('http://') || picture.startsWith('https://') || picture.startsWith('data:')) {
return picture;
}
const mime = picture.startsWith('/9j/') ? 'image/jpeg' : 'image/png';
return `data:${mime};base64,${picture}`;
}
/**
* Merges API user data while retaining the current company selection when the
* response does not contain one.
*
* @param {AuthUser|null} current Current user.
* @param {AuthUser|null|undefined} incoming User data returned by the API.
* @returns {AuthUser|null} Merged user.
*/
function mergeUser(current, incoming) {
if (!incoming) return current;
const selectedCompanyId =
incoming.selectedCompanyId ?? current?.selectedCompanyId ?? null;
const merged = { ...current, ...incoming, selectedCompanyId };
if (merged.picture) {
merged.picture = normalizePicture(merged.picture);
}
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) {
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.
*
* @param {string} credential Google Identity Services credential.
* @returns {Promise<Object>} Authenticated session.
*/
async loginWithGoogle(credential) {
try {
const { session } = await auth.loginWithGoogle(credential);
if (!session?.user) {
throw new Error('Login ok but session cookie not accepted (/api/auth/me still unauthorized)');
}
const user = mergeUser(authAutomat.state.user, session.user);
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) {
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.
*
* @param {string|number} companyId Company identifier.
* @returns {Promise<AuthUser>} Updated user.
*/
async selectCompany(companyId) {
const data = await company.select(companyId);
const user = mergeUser(
authAutomat.state.user,
data.user ?? { selectedCompanyId: companyId },
);
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.
*
* @param {Object} payload Company creation payload.
* @returns {Promise<AuthUser>} Updated user.
*/
async createCompany(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);
incoming = selected.user ?? { selectedCompanyId: data.company.id };
}
if (!incoming) {
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);
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 `/`.
*
* @returns {Promise<void>}
*/
async logout() {
await auth.logout();
window.history.replaceState(null, '', '/');
const nextState = authAutomat.setState(buildAuthState(null, 'anon'));
syncSse(nextState);
emit({ type: AUTH_LOGGED_OUT });
},
// #endregion
};
/**
* Global authentication state container.
*
* @type {Automat<AuthState>}
*/
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

@@ -0,0 +1,548 @@
/**
* @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,
cleanCategorySlugPath,
getCategoryChildren,
getCategoryDescendantIds,
} from '../utils/categoryUtils';
/**
* @typedef {Object} CategoryState
* @property {number|string|null} companyId
* @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} */
const initialState = {
companyId: null,
languageId: null,
categories: null,
rootCategoryId: null,
articleCount: 0,
renameMode: false,
treeStatus: 'idle',
error: null,
viewsLoading: false,
};
let categoryAutomat;
let _treeLoadingPromise = null;
/**
* 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 function getCategoryState() {
const state = categoryAutomat.state;
if (state.categories == null || state.treeStatus !== 'succeeded') return null;
return { categories: state.categories, rootCategoryId: state.rootCategoryId };
}
export const selectCategoryTreeArticleCount = () => categoryAutomat.state.articleCount;
/**
* Resolves once the category tree has loaded.
*
* @returns {Promise<CategoryState>} State with `treeStatus === 'succeeded'`.
*/
export function whenCategoryTreeReady() {
return waitForCategoryTree();
}
// ─── Actions ───────────────────────────────────────────────────────────────────
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
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 });
_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. */
setRenameMode(enabled) {
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 ────────────────────────────────────────────────────
/**
* Create a single category.
* @param {{ parentId: number|string, name: string }} param0
*/
async createCategory({ parentId, name }) {
const res = await categoryApi.create({ parentId, name });
const node = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
const updated = { ...(categories ?? {}), [String(node.id)]: node };
categoryAutomat.setState({ categories: updated });
refreshCategoryViewChildren(updated);
return node;
},
/**
* Create multiple categories under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createCategories({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
const res = await categoryApi.create({ parentId, name });
created.push(normalizeCategoryNode(res.category));
}
const patch = Object.fromEntries(created.map((n) => [String(n.id), n]));
const updated = { ...(categoryAutomat.state.categories ?? {}), ...patch };
categoryAutomat.setState({ categories: updated });
refreshCategoryViewChildren(updated);
return created;
},
/**
* Create a single article.
* @param {{ parentId: number|string, name: string }} param0
*/
async createArticle({ parentId, name }) {
const { sku } = await articleApi.create({ categoryId: parentId, name });
const { article: created } = await articleApi.getBySku(sku);
if (created) appendArticlesToCategoryViews({ categoryId: parentId, articles: [created] });
return created;
},
/**
* Create multiple articles under the same parent.
* @param {{ parentId: number|string, names: string[] }} param0
*/
async createArticles({ parentId, names }) {
const list = (names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean);
if (list.length === 0) throw new Error('Keine Namen übergeben.');
const created = [];
for (const name of list) {
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;
},
/**
* Rename a category.
* @param {{ categoryId: number|string, name: string }} param0
*/
async renameCategory({ categoryId, name }) {
if (String(categoryId) === String(categoryAutomat.state.rootCategoryId)) {
throw new Error('Home-Kategorie kann nicht umbenannt werden.');
}
const trimmed = String(name ?? '').trim();
if (!trimmed) throw new Error('Name required');
const res = await categoryApi.updateName(categoryId, trimmed);
const updated = normalizeCategoryNode(res.category);
const key = String(updated.id);
const { categories } = categoryAutomat.state;
const nextCategories = categories ? { ...categories, [key]: { ...categories[key], ...updated } } : null;
categoryAutomat.setState({
categories: nextCategories,
});
refreshCategoryViewChildren(nextCategories);
return updated;
},
/**
* Reorder sibling categories.
* @param {{ parentId?: number|string, categoryIds: number[] }} param0
*/
async reorderCategories({ parentId, categoryIds }) {
const targetParentId = parentId != null ? parentId : categoryAutomat.state.rootCategoryId;
const res = await categoryApi.reorder(targetParentId, categoryIds);
const ordered = res.categoryIds;
const parentKey = String(targetParentId);
const idToSort = new Map(ordered.map((id, index) => [String(id), index]));
const patched = { ...categoryAutomat.state.categories };
for (const node of Object.values(patched)) {
if (node.parentId != null && String(node.parentId) === parentKey && idToSort.has(String(node.id))) {
patched[String(node.id)] = { ...node, sort: idToSort.get(String(node.id)) };
}
}
categoryAutomat.setState({ categories: patched });
refreshCategoryViewChildren(patched);
return ordered;
},
/**
* Move a category under a new parent.
* @param {{ categoryId: number|string, parentId: number|string }} param0
*/
async moveCategory({ categoryId, parentId }) {
const res = await categoryApi.move(categoryId, parentId);
const moved = res.category;
const { categories } = categoryAutomat.state;
if (!categories || !moved) return;
const key = String(moved.id);
const updated = {
...categories,
[key]: { ...categories[key], parentId: moved.parentId, sort: moved.sort },
};
categoryAutomat.setState({
categories: updated,
});
refreshCategoryViewChildren(updated);
return moved;
},
/**
* Delete a category by id.
* @param {number|string} categoryId
*/
async deleteCategory(categoryId) {
await categoryApi.delete(categoryId);
const { categories } = categoryAutomat.state;
if (!categories) return;
const next = { ...categories };
delete next[String(categoryId)];
categoryAutomat.setState({ categories: 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;
},
/**
* Toggle the POS flag on a category.
* @param {{ categoryId: number|string, pos: boolean }} param0
*/
async toggleCategoryPos({ categoryId, pos }) {
const res = await categoryApi.updatePos(categoryId, pos);
const updated = normalizeCategoryNode(res.category);
const { categories } = categoryAutomat.state;
if (!categories) return;
const key = String(updated.id);
const nextCategories = { ...categories, [key]: { ...categories[key], pos: updated.pos } };
categoryAutomat.setState({
categories: nextCategories,
});
refreshCategoryViewChildren(nextCategories);
return updated;
},
/**
* Upload an image for a category.
* @param {{ categoryId: number|string, file: Blob }} param0
*/
async uploadCategoryImage({ categoryId, file }) {
const res = await categoryApi.uploadImage(categoryId, file);
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;
},
/**
* Delete the image for a category.
* @param {number|string} categoryId
*/
async deleteCategoryImage(categoryId) {
const res = await categoryApi.deleteImage(categoryId);
const { id } = res.category;
const { categories } = categoryAutomat.state;
if (!categories) return res.category;
const key = String(id);
const nextCategories = {
...categories,
[key]: { ...categories[key], fullHash: null, thumbW: null, thumbH: null, thumbHash: null },
};
categoryAutomat.setState({
categories: 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) {
removeArticleBySku(sku);
},
/**
* Patch posterminalIds on a category node.
* @param {{ categoryId: number|string, terminalId: number, enabled: boolean }} param0
*/
patchCategoryPosterminalIds({ categoryId, terminalId, enabled }) {
const { categories } = categoryAutomat.state;
const cat = categories?.[String(categoryId)];
if (!cat) return;
const current = Array.isArray(cat.posterminalIds) ? cat.posterminalIds : [];
const next = enabled
? current.includes(terminalId) ? current : [...current, terminalId]
: current.filter((id) => id !== terminalId);
const nextCategories = {
...categories,
[String(categoryId)]: { ...cat, posterminalIds: next },
};
categoryAutomat.setState({
categories: nextCategories,
});
refreshCategoryViewChildren(nextCategories);
},
/** Invalidate all loaded category views. */
invalidateAllCategoryViews() {
invalidateAllCategoryViews();
},
/** Reset state to idle (logout / company switch). */
reset() {
disposeAllCategoryViewAutomats();
_treeLoadingPromise = null;
categoryAutomat.setState(initialState);
},
};
/**
* Global category tree state container.
* Loads automatically when companyAutomat has a company with a rootCategoryId.
*
* @type {Automat<CategoryState>}
*/
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));
categoryAutomat.load = (slugPath, options) => categoryViews.load(cleanCategorySlugPath(slugPath), options);
categoryAutomat.views = categoryViews;
// Load tree whenever the company data changes (or first becomes available)
let _lastCompanyId = 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;
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) => {
if (auth.status === 'anon') {
_lastCompanyId = null;
categoryAutomat.actions.reset();
}
});
// 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,
parseCategoryTreeResponse,
normalizeCategoryNode,
getCategoryBreadcrumbs,
getCategoryChildren,
cleanCategorySlugPath,
resolveCategoryIdFromSlugPath,
} 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

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