Compare commits
553 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89656ac2c6 | ||
|
|
94030230e7 | ||
|
|
d859b5bbf7 | ||
|
|
5683912b76 | ||
|
|
3ec512faf1 | ||
|
|
9f9f359acb | ||
|
|
593dfc356d | ||
|
|
0172c9a442 | ||
|
|
81b4cb51e7 | ||
|
|
a274a49e90 | ||
|
|
9c192e79e7 | ||
|
|
4d0ec53514 | ||
|
|
87e644eef8 | ||
|
|
6428044624 | ||
|
|
67ddfb2d5d | ||
|
|
81293ae543 | ||
|
|
237ebf3ef8 | ||
|
|
983a1408f1 | ||
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 | ||
|
|
4674ff3389 | ||
|
|
4bb7de7247 | ||
|
|
719d1f2659 | ||
|
|
3787cc4dd0 | ||
|
|
1ee59b54bc | ||
|
|
907ee574e6 | ||
|
|
b51ea2505e | ||
|
|
fd2e613aa5 | ||
|
|
1f2aff8325 | ||
|
|
e11765bf0e | ||
|
|
8f74efba97 | ||
|
|
b9103c59f0 | ||
|
|
bf33d0a543 | ||
|
|
64bb7f9c37 | ||
|
|
6f3f1c40e7 | ||
|
|
164ce54adf | ||
|
|
6877919133 | ||
|
|
5ec70599aa | ||
|
|
7e4469b29a | ||
|
|
08af4cd0ae | ||
|
|
eb279a87a9 | ||
|
|
ea3b595b32 | ||
|
|
e9b432bec6 | ||
|
|
741c8109fa | ||
|
|
07d9c8d9f7 | ||
|
|
0928b3636e | ||
|
|
738f852168 | ||
|
|
aa7b97689b | ||
|
|
5cd5a9e37d | ||
|
|
27b734f6d0 | ||
|
|
91072548d4 | ||
|
|
5b380863cf | ||
|
|
1293da7f25 | ||
|
|
d9cf8f53e8 | ||
|
|
f509e24942 | ||
|
|
4159ae57a4 | ||
|
|
4c8ed39d85 | ||
|
|
06519d521f | ||
|
|
3c6d175857 | ||
|
|
7582cf88f3 | ||
|
|
4deabbfcc0 | ||
|
|
302a0f8b3f | ||
|
|
78f39e9f82 | ||
|
|
949bf6c86f | ||
|
|
c8fc662552 | ||
|
|
10c8454c9b | ||
|
|
9496944ad2 | ||
|
|
608ebb02bd | ||
|
|
3a3485d029 | ||
|
|
674b44d047 | ||
|
|
2dbacc3bec | ||
|
|
c21f02e5d1 | ||
|
|
f87e110359 | ||
|
|
f1b1329c1b | ||
|
|
88d5d43448 | ||
|
|
4745af3639 | ||
|
|
4258699b73 | ||
|
|
bfd2c59ad3 | ||
|
|
0b69dbfde0 | ||
|
|
db7a5c3d15 | ||
|
|
f1ce76d21d | ||
|
|
575c07aac8 | ||
|
|
4bf245d35c | ||
|
|
79d026e756 | ||
|
|
40a859f5e1 | ||
|
|
ffae1a4e74 | ||
|
|
5c9ebddf50 | ||
|
|
f268907848 | ||
|
|
dd8c699420 | ||
|
|
268a947c85 | ||
|
|
75dc97516e | ||
|
|
d1533ffc4d | ||
|
|
1c1b9dca17 | ||
|
|
7b20c47937 | ||
|
|
7398cad466 | ||
|
|
396a1d37ec | ||
|
|
ca1f07671f | ||
|
|
638264fc7c | ||
|
|
f68a4dafc4 | ||
|
|
700fb47bbc | ||
|
|
c2746d78c9 | ||
|
|
4e534471d9 | ||
|
|
975a555af5 | ||
|
|
6a22195c7d | ||
|
|
74ce24e3fa | ||
|
|
8ee5a443ef | ||
|
|
b3e2abf9b2 | ||
|
|
1826fc7976 | ||
|
|
d08e55a41a | ||
|
|
97101c4db7 | ||
|
|
1e4dd1fbcc | ||
|
|
5462fcf944 | ||
|
|
78cd7cf2d8 | ||
|
|
cb82349c86 | ||
|
|
d445384408 | ||
|
|
3268cf2100 | ||
|
|
841ab676ec | ||
|
|
313d9218c1 | ||
|
|
c270cb22e9 | ||
|
|
113edd9a0d | ||
|
|
753c16423c | ||
|
|
11b71501ee | ||
|
|
5c03570f07 | ||
|
|
ed9ea52a44 | ||
|
|
9e9aacdf0b | ||
|
|
52ff0a6ffa | ||
|
|
a15fa68c3b | ||
|
|
e86c2e9a80 | ||
|
|
ce636215dc | ||
|
|
473f7c4a8c | ||
|
|
28186397a0 | ||
|
|
a3948c7da1 | ||
|
|
53c8359acc | ||
|
|
c2d7a96718 | ||
|
|
61673247bf | ||
|
|
9fe5113c5b | ||
|
|
e868ecf769 | ||
|
|
20177e8f87 | ||
|
|
39565767ef | ||
|
|
c4668357aa | ||
|
|
b4f1b6ce2b | ||
|
|
66235ff567 | ||
|
|
d341db2e51 | ||
|
|
430ec3e76b | ||
|
|
f295af4a14 | ||
|
|
db11999c85 | ||
|
|
afef0e6759 | ||
|
|
62f50d31af | ||
|
|
3d3df6db85 | ||
|
|
162f9b9fdf | ||
|
|
3b48bd0523 | ||
|
|
2b7b480144 | ||
|
|
e26fce092a | ||
|
|
d7e0ba933d | ||
|
|
8b90cfed1f | ||
|
|
8476ffebd6 | ||
|
|
63ccf8fda7 | ||
|
|
9f4b8a897a | ||
|
|
2a3df15565 | ||
|
|
b1f48df30c | ||
|
|
f00fcf95fd | ||
|
|
4093664936 | ||
|
|
ecb83d4654 | ||
|
|
a06ea2e1cd | ||
|
|
15a2da38cc | ||
|
|
4072197f4a | ||
|
|
1914fddb4d | ||
|
|
63192dc781 | ||
|
|
6147131aad | ||
|
|
b4f859ea6e | ||
|
|
519edce373 | ||
|
|
c454d1a3fc | ||
|
|
b1f21a1f48 | ||
|
|
b6cd5046d5 | ||
|
|
25c8c39ba6 | ||
|
|
4b2770a000 | ||
|
|
434a12b4a8 | ||
|
|
28fb399217 | ||
|
|
c0ff7b4549 | ||
|
|
1e21c12f33 | ||
|
|
ab12f9d791 | ||
|
|
cf442b9a99 | ||
|
|
2e7caea5a6 | ||
|
|
702a4984ee | ||
|
|
769767c33d | ||
|
|
f18e2e0f2c | ||
|
|
d62abe81fd | ||
|
|
ac7c220bb1 | ||
|
|
b7ebf04001 | ||
|
|
441f46a471 | ||
|
|
f011a67ff3 | ||
|
|
92594f89c3 | ||
|
|
6664e3bc4d | ||
|
|
c5cfd855ba | ||
|
|
2565e0de21 | ||
|
|
817f0b2f00 | ||
|
|
e4ec08a5ef | ||
|
|
049ee492c0 | ||
|
|
f4bc038f8b | ||
|
|
58e5fd2b1d | ||
|
|
0226cdd52b | ||
|
|
b665ea1d9b | ||
|
|
ec74ded953 | ||
|
|
bcf63d00d1 | ||
|
|
096784768f | ||
|
|
3623b441ce | ||
|
|
4bf7bf4ed6 | ||
|
|
92252382ac | ||
|
|
8bed920de1 | ||
|
|
9d2992b722 | ||
|
|
1ddc0feccd | ||
|
|
cce2c9cfaa | ||
|
|
2610301258 | ||
|
|
13d1fc2be3 | ||
|
|
95ca258718 | ||
|
|
6db87bf4ae | ||
|
|
c6a146d57a | ||
|
|
1a262508d4 | ||
|
|
23ac924472 | ||
|
|
9370423fb0 | ||
|
|
a146fc26a3 | ||
|
|
72888bf9cd | ||
|
|
80dc8a5c69 | ||
|
|
2cfa28dc47 | ||
|
|
628cd54a81 | ||
|
|
9ae69e09a3 | ||
|
|
df83eb2091 | ||
|
|
9c862034ca | ||
|
|
67d62a6df0 | ||
|
|
512f1fd92b | ||
|
|
941caf9d85 | ||
|
|
b00d18d0b3 | ||
|
|
decadd895e | ||
|
|
54f3c414f5 | ||
|
|
ca092c6166 | ||
|
|
c7f9d9aa36 | ||
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 | ||
|
|
fdf180809d | ||
|
|
52f3da73ad | ||
|
|
83aba93a98 | ||
|
|
744baec2ec | ||
|
|
1f05ef6a55 | ||
|
|
94a9fb8b5a | ||
|
|
9c582e1a43 | ||
|
|
9ae49c0d6a | ||
|
|
522afefc80 | ||
|
|
c5f986adc1 | ||
|
|
379725a096 | ||
|
|
05596642bc | ||
|
|
58bf0348d7 | ||
|
|
55a2c50f79 | ||
|
|
86e4cbf345 | ||
|
|
852c127c00 | ||
|
|
71a0016a4a | ||
|
|
88c9b7c2b8 | ||
|
|
4a78d31a7f | ||
|
|
e845400fca | ||
|
|
fce27633a9 | ||
|
|
4d47897e27 | ||
|
|
aef4ab92ec | ||
|
|
5f36c34d48 | ||
|
|
b94f4879e3 | ||
|
|
dab5ad8aa5 | ||
|
|
8b77a0a905 | ||
|
|
4b1c42b17f | ||
|
|
a55356d417 | ||
|
|
c983747418 | ||
|
|
3db257faa9 | ||
|
|
d3d7f7e199 | ||
|
|
084ec9289b | ||
|
|
4a9c283789 | ||
|
|
2f76fe0bc1 | ||
|
|
9a11a8f001 | ||
|
|
f0816b126b | ||
|
|
f31797fb5b | ||
|
|
9c110abf46 | ||
|
|
a0f351b0aa | ||
|
|
916cf34efd | ||
|
|
003807a074 | ||
|
|
ca911bf96c | ||
|
|
9c10ff9f22 | ||
|
|
38a58b55c0 | ||
|
|
4d8fd62961 | ||
|
|
ef404e4362 | ||
|
|
fb022802ae | ||
|
|
795d4d5af9 | ||
|
|
74113035ae | ||
|
|
42f9561337 | ||
|
|
f79967d433 | ||
|
|
d410e7433e | ||
|
|
0711bafac0 | ||
|
|
5273f88272 | ||
|
|
182d4ca4b3 | ||
|
|
4773b5be22 | ||
|
|
6074d6163b | ||
|
|
652e3f91fc | ||
|
|
5233db2002 | ||
|
|
dac45baaa8 | ||
|
|
f389c4b3cf | ||
|
|
fdc7037e60 | ||
|
|
b02f6afe93 | ||
|
|
1b2ab7b4eb | ||
|
|
29975ecad9 | ||
|
|
e846f15126 | ||
|
|
86bebf9776 | ||
|
|
7b7c6bde68 | ||
|
|
935fb235a6 | ||
|
|
d2d1cdbb92 | ||
|
|
5f08b0e086 | ||
|
|
637499a8ec | ||
|
|
f413bae348 | ||
|
|
2b1de583fd | ||
|
|
5500cdb118 | ||
|
|
2254a13ad0 | ||
|
|
1f5885de84 | ||
|
|
705f58e3d1 | ||
|
|
0330453ad6 | ||
|
|
66b98e2765 | ||
|
|
55648c9a30 | ||
|
|
cd6d3daf43 | ||
|
|
1db5060c42 | ||
|
|
4cabba395d | ||
|
|
59b478f262 | ||
|
|
e4f14a24b7 | ||
|
|
3bd0d055d9 | ||
|
|
746d17e182 | ||
|
|
e96e51598a | ||
|
|
9abec36f07 | ||
|
|
ecd8e3679f | ||
|
|
ba95723b61 | ||
|
|
12289bdce3 | ||
|
|
d1c5234a03 | ||
|
|
27cc876e82 | ||
|
|
82919d39f6 | ||
|
|
3481b97d47 | ||
|
|
d7c7fbad88 | ||
|
|
7f07032bf2 | ||
|
|
d873c96ae3 | ||
|
|
c4f6723042 | ||
|
|
9e699c4dd4 | ||
|
|
ea81380225 | ||
|
|
a9bc82fac5 | ||
|
|
8c278be941 | ||
|
|
aa6c1b5094 | ||
|
|
4f4b12f039 | ||
|
|
528e55991b | ||
|
|
122eee175e | ||
|
|
5a28f75303 | ||
|
|
07cb428287 | ||
|
|
b197017644 | ||
|
|
e9f07a4a39 | ||
|
|
44d389201c | ||
|
|
3106aaf314 | ||
|
|
90e797b8fa | ||
|
|
1f7362c8af | ||
|
|
fe44a2b12d | ||
|
|
8a9239311d | ||
|
|
cd25cd6ee4 | ||
|
|
967fbba2a4 | ||
|
|
41fe65c7fc | ||
|
|
09d345a312 | ||
|
|
1a13d745f1 | ||
|
|
ce184771a6 | ||
|
|
7b6365f6b3 | ||
|
|
44867c79f8 | ||
|
|
09a9e8c2f0 | ||
|
|
b26a6f40b9 | ||
|
|
40cb5a4d76 | ||
|
|
ecd97ae5a3 | ||
|
|
d14e97d7bd | ||
|
|
ef891f8e01 | ||
|
|
96ba5d034f | ||
|
|
2402b7cbc8 | ||
|
|
79b2fa5570 | ||
|
|
35fa172d36 |
@@ -1,14 +1,23 @@
|
|||||||
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
// Block direct .env file edits (not .env.example, .env.production, .env.test)
|
||||||
let data = '';
|
//
|
||||||
process.stdin.on('data', chunk => data += chunk);
|
// Hook exit-code contract (Claude Code PreToolUse):
|
||||||
process.stdin.on('end', () => {
|
// exit 0 = allow (stdout JSON is only parsed on exit 0)
|
||||||
|
// exit 2 = BLOCK — the reason must be written to STDERR
|
||||||
|
// any other exit code = non-blocking error (the tool call proceeds)
|
||||||
|
// So to actually block, we must print to stderr and exit 2.
|
||||||
|
let data = "";
|
||||||
|
process.stdin.on("data", (chunk) => (data += chunk));
|
||||||
|
process.stdin.on("end", () => {
|
||||||
try {
|
try {
|
||||||
const input = JSON.parse(data);
|
const input = JSON.parse(data);
|
||||||
const filePath = input.tool_input?.file_path || '';
|
const filePath = input.tool_input?.file_path || "";
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const basename = filePath.split("/").pop().split("\\").pop();
|
||||||
if (basename === '.env') {
|
if (basename === ".env") {
|
||||||
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
|
console.error("Direct .env edits are blocked. Use .env.example instead.");
|
||||||
process.exit(1);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2
|
||||||
|
// here would block EVERY Edit/Write if the hook payload format changed.
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
name: compare-php
|
|
||||||
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
|
|
||||||
---
|
|
||||||
|
|
||||||
# Compare PHP vs TS Implementation
|
|
||||||
|
|
||||||
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
The user will specify what to compare. Examples:
|
|
||||||
- `/compare-php attendance print`
|
|
||||||
- `/compare-php invoice PDF generation`
|
|
||||||
- `/compare-php offer numbering logic`
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
|
|
||||||
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
|
|
||||||
3. Compare:
|
|
||||||
- API endpoints and request/response shape
|
|
||||||
- Business logic and calculations
|
|
||||||
- Database queries
|
|
||||||
- Frontend behavior
|
|
||||||
4. Report differences found — what's missing, what's different, what's extra
|
|
||||||
|
|
||||||
## Key directories
|
|
||||||
|
|
||||||
**PHP project:**
|
|
||||||
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
|
|
||||||
- API routes: `D:\cortex\boha-app\api\admin\`
|
|
||||||
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
|
|
||||||
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
|
|
||||||
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
|
|
||||||
- Includes/utils: `D:\cortex\boha-app\api\includes\`
|
|
||||||
|
|
||||||
**TS project:**
|
|
||||||
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
|
|
||||||
- Services: `D:\cortex\boha-app-ts\src\services\`
|
|
||||||
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
|
|
||||||
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
|
|
||||||
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`
|
|
||||||
65
.env.example
65
.env.example
@@ -1,32 +1,63 @@
|
|||||||
# Database
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
|
||||||
|
# Required vars throw at boot if missing; optional vars show their defaults.
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Required ──────────────────────────────────────────────────────────────
|
||||||
DATABASE_URL=mysql://user:password@localhost:3306/app
|
DATABASE_URL=mysql://user:password@localhost:3306/app
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=local
|
|
||||||
|
|
||||||
# Auth — MUST regenerate for production: openssl rand -hex 32
|
# Auth — MUST regenerate for production: openssl rand -hex 32
|
||||||
JWT_SECRET=generate-with-openssl-rand-hex-32
|
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
|
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
||||||
|
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# ── Server ────────────────────────────────────────────────────────────────
|
||||||
|
PORT=3001 # production port (dev is hardcoded to 3050)
|
||||||
|
HOST=127.0.0.1
|
||||||
|
APP_ENV=local # local | production — controls CSP/CORS/HSTS
|
||||||
|
APP_URL= # base URL used in email links
|
||||||
|
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
|
||||||
|
|
||||||
|
# ── Tokens (seconds) ──────────────────────────────────────────────────────
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
ACCESS_TOKEN_EXPIRY=900
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
||||||
|
|
||||||
# TOTP — MUST regenerate for production: openssl rand -hex 32
|
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
|
||||||
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
|
# TOTP_ALGORITHM=SHA1
|
||||||
|
# TOTP_DIGITS=6
|
||||||
|
# TOTP_PERIOD=30
|
||||||
|
# LOGIN_TOKEN_EXPIRY_MINUTES=5
|
||||||
|
|
||||||
# File storage
|
# ── Rate limiting (optional) ──────────────────────────────────────────────
|
||||||
|
# RATE_LIMIT_MAX=300
|
||||||
|
# RATE_LIMIT_WINDOW=1 minute
|
||||||
|
# RATE_LIMIT_LOGIN=20
|
||||||
|
# RATE_LIMIT_TOTP=5
|
||||||
|
# RATE_LIMIT_REFRESH=10
|
||||||
|
|
||||||
|
# ── File storage (NAS network shares) ─────────────────────────────────────
|
||||||
NAS_PATH=Z:/02_PROJEKTY
|
NAS_PATH=Z:/02_PROJEKTY
|
||||||
MAX_UPLOAD_SIZE=52428800
|
# NAS_INVOICES= # invoice PDFs — {base}/Vydané|Přijaté/YYYY/MM/ (falls back to NAS_FINANCIALS_PATH if unset)
|
||||||
|
# NAS_ORDERS= # issued-order PDF archival — {base}/Vydané/YYYY/MM/ (off until set)
|
||||||
|
# NAS_OFFERS_PATH=
|
||||||
|
MAX_UPLOAD_SIZE=52428800 # 50 MB
|
||||||
|
|
||||||
# Email
|
# ── Email ─────────────────────────────────────────────────────────────────
|
||||||
CONTACT_EMAIL_TO=
|
CONTACT_EMAIL_TO=
|
||||||
CONTACT_EMAIL_FROM=
|
CONTACT_EMAIL_FROM=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
# SMTP_FROM_NAME=
|
||||||
LEAVE_NOTIFY_EMAIL=
|
LEAVE_NOTIFY_EMAIL=
|
||||||
|
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
|
||||||
|
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
|
||||||
|
# SMTP_HOST=
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=
|
||||||
|
# SMTP_PASS=
|
||||||
|
|
||||||
# App URL (for email links)
|
# ── CORS (production only, comma-separated) ───────────────────────────────
|
||||||
APP_URL=
|
|
||||||
|
|
||||||
# CORS (production only, comma-separated)
|
|
||||||
CORS_ORIGINS=
|
CORS_ORIGINS=
|
||||||
|
|
||||||
|
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
|
||||||
|
# ANTHROPIC_API_KEY=
|
||||||
|
|||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -7,9 +7,28 @@ dist/
|
|||||||
dist-client/
|
dist-client/
|
||||||
*.css.map
|
*.css.map
|
||||||
|
|
||||||
|
# Build / tooling artifacts
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Local scratch
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
# Release archives
|
# Release archives
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
|
|
||||||
# Claude worktrees
|
# Claude worktrees
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Local Claude tooling (personal, not shared)
|
||||||
|
.claude/commands/
|
||||||
|
.claude/skills/
|
||||||
|
.claude/workflows/
|
||||||
|
CLAUDE-FABLE-5.md
|
||||||
|
|
||||||
|
# Personal scratch (planning docs, etc.)
|
||||||
|
simon/
|
||||||
|
|
||||||
|
# Superpowers brainstorm mockups
|
||||||
|
.superpowers/
|
||||||
|
|||||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dist/
|
||||||
|
dist-client/
|
||||||
|
node_modules/
|
||||||
|
prisma/migrations/
|
||||||
|
src/admin/bones/
|
||||||
|
*.tsbuildinfo
|
||||||
|
package-lock.json
|
||||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
334
AUDIT-2026-06-12.md
Normal file
334
AUDIT-2026-06-12.md
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
# Codebase Audit — boha-app-ts — 2026-06-12
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Severity | Count |
|
||||||
|
| ------------ | ----: |
|
||||||
|
| **Critical** | 3 |
|
||||||
|
| **High** | 6 |
|
||||||
|
| **Medium** | 11 |
|
||||||
|
| **Low** | 8 |
|
||||||
|
| **Total** | 28 |
|
||||||
|
|
||||||
|
> 2026-06-12: M3 (offer PDF footer) withdrawn as a false positive after user
|
||||||
|
> verification — see the struck section below. Counts updated 12→11 / 29→28.
|
||||||
|
|
||||||
|
## Methodology
|
||||||
|
|
||||||
|
- **Fan-out discovery:** 27 independent finder passes swept the codebase across domain modules (auth/sessions, attendance/leave, documents — offers/orders/issued-orders/invoices, warehouse, projects, trips/vehicles, plan, users/roles, AI/Odin, settings) and cross-cutting dimensions (Zod-cap-vs-DB-column alignment, date/timezone boundaries, pagination determinism, React Query cache invalidation, transaction atomicity, FK-error→4xx mapping, privilege boundaries).
|
||||||
|
- **Dedupe vs prior backlog:** candidate findings were de-duplicated against the existing audit backlog (`docs/codebase-audit-findings-2026-06-06.md`, `docs/cross-module-consistency-audit-2026-06-09.md`, `REVIEW_FINDINGS.md`). Already-fixed items (e.g. the 2026-06-06 sessions audit-logging finding) and already-tracked duplicates were dropped; only net-new defects survived.
|
||||||
|
- **3-lens adversarial verification:** every surviving finding was independently re-checked through three lenses — (1) **trace-to-refute** (follow the full code path end-to-end and try to break each step), (2) **convention veto** (confirm it genuinely VIOLATES a documented `CLAUDE.md` convention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-time `toJSON`, local-noon `@db.Date` writes, lenient date schemas, net-only documents, gross `received_invoices.amount`, shared `orders.*` permission family), and (3) **executable repro** (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independent `real` verdicts. Findings the convention lens vetoed (e.g. one warehouse over-issue variant whose exact numbers are blocked by an upstream aggregate guard — kept only after a corrected, reachable repro was supplied) were re-scoped, not waved through.
|
||||||
|
|
||||||
|
Order below is by severity, then by blast radius within a severity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### C1. Deleting a vacation/sick attendance record never restores the leave balance — `vacation_used` stays permanently inflated
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/attendance.service.ts:1751`
|
||||||
|
- **Failure scenario:** `createLeave` increments `leave_balances.vacation_used`/`sick_used` when booking leave (lines 1304–1331), but `deleteAttendance` (1751–1772) only runs `prisma.attendance.delete` — it never decrements the balance. `getStatus` (line 246) and `getBalances` (line 530) derive `vacation_remaining = vacation_total - vacation_used` from the stored counter, so cancelling a booked leave by deleting its rows permanently removes entitlement the employee never actually took. The leave-request approval path (`leave-requests.ts`) has the identical asymmetric increment-on-approve / no-decrement-on-delete.
|
||||||
|
- **Repro:**
|
||||||
|
1. As an admin (`attendance.manage`), `POST /api/admin/attendance?action=leave` for user 5, `date_from=2026-07-06`, `date_to=2026-07-10`, `leave_type=vacation`, `leave_hours=8` → creates 5 rows and adds 40h to `vacation_used`.
|
||||||
|
2. Read balances: `vacation_used=40`, `vacation_remaining = total-40`.
|
||||||
|
3. `DELETE /api/admin/attendance/:id` for each of the 5 rows.
|
||||||
|
4. Read balances again: rows are gone but `vacation_used` is still 40 and `vacation_remaining` is still down 40h. Only a manual admin `handleBalances` reset fixes it.
|
||||||
|
- **Pinning test:** Book a 5-day vacation, assert `vacation_used` rose by 40h, delete all 5 attendance rows, then assert `vacation_used` returned to its pre-booking value (fails today: stays 40).
|
||||||
|
|
||||||
|
### C2. `confirmIssue`: duplicate issue lines on the same batch pass per-line validation but decrement cumulatively, driving batch quantity negative (silent over-issue)
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/warehouse.service.ts:626`
|
||||||
|
- **Failure scenario:** The confirm path validates and decrements stock **per line**, re-reading the batch fresh each iteration with no cross-line running tally and no `if (newBatchQty < 0) throw` guard. Two issue lines for the same item that resolve to the same batch each pass the independent `batch.quantity >= line.quantity` check, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes `3-5 = -2`, flagging `is_consumed=true`. The negative/consumed batch drops out of `getItemTotalStock` (`is_consumed:false` filter), so the over-issue is silently hidden from all stock totals. No `(issue_id, batch_id)` unique constraint exists on `sklad_issue_lines`.
|
||||||
|
- **Repro (corrected — the single-batch case is blocked by the per-item aggregate guard in `validateIssueReservationRules`; use two batches so the per-item total check passes while one batch still goes negative):**
|
||||||
|
1. Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16.
|
||||||
|
2. `POST /issues` with two lines, both `item_id=X`, no `batch_id`, each `quantity=5`. `selectFifoBatches` is called once per line against the live DB with nothing persisted, so both resolve to B1; the per-item availability check (16 < 10 is false) passes. Draft stores two lines both `batch_id=B1`.
|
||||||
|
3. `POST /issues/:id/confirm` → 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to `-2` with `is_consumed=true`.
|
||||||
|
4. 10 units issued from a batch that held 8; B1 persists at `quantity=-2`, drops from stock totals — 2 units leave with no accounting trail and FIFO is broken, with no error raised.
|
||||||
|
- **Pinning test:** Confirm an issue containing two same-item lines whose combined quantity exceeds a single resolved batch; assert the confirm is rejected (400) and no batch row is left with `quantity < 0`.
|
||||||
|
|
||||||
|
### C3. `confirmInventorySession` swallows an in-transaction throw with `return` instead of `throw`, committing partial stock writes the route reports as failed
|
||||||
|
|
||||||
|
- **Severity:** Critical
|
||||||
|
- **File:** `src/services/warehouse.service.ts:1275`
|
||||||
|
- **Failure scenario:** The function body is `return prisma.$transaction(async (tx) => { ... })` (line 1108). A single loop over `session.items` interleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upserts `sklad_item_locations`; a later deficit line makes `selectFifoBatches` throw. The catch at line 1275 **returns** `{ error, status: 400 }` from inside the transaction callback. In an interactive Prisma transaction, returning a value (rather than throwing) **commits** — so the surplus item's writes persist while the session→CONFIRMED update (line 1293) is skipped, leaving the session DRAFT. The route sees `"error" in result` and returns HTTP 400 ("nothing changed"). Re-confirming the still-DRAFT session re-runs the loop and re-creates the same surplus batch — repeatable phantom inventory and corrupted valuation on every retry.
|
||||||
|
- **Repro:**
|
||||||
|
1. ItemA exists; ItemB has a location row `quantity=10` but fewer unconsumed batches (documented location/batch drift at lines 1244–1250 — also reproducible by consuming batches between session create and confirm).
|
||||||
|
2. `POST /admin/inventory-sessions` with items ordered `[{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}]` (ItemA's line gets the lower id, processed first).
|
||||||
|
3. `POST /admin/inventory-sessions/<id>/confirm` → HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumped `item_location`, and the session is still DRAFT.
|
||||||
|
4. Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure.
|
||||||
|
- **Pinning test:** Confirm an inventory session whose item list has a valid surplus line before an over-deficit line; assert the response is an error AND no `sklad_receipts`/`sklad_batches` rows were created (transaction rolled back) and the session is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### H1. Terminating one session force-logs-out ALL of the user's sessions on the terminated device's next refresh
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/services/auth.ts:280`
|
||||||
|
- **Failure scenario:** Session termination (`sessions.ts:94-97` DELETE `/:id`, `sessions.ts:124-131` action=all, `profile.ts:98-101` password-change) sets ONLY `replaced_at` (no `replaced_by_hash`, row not deleted). Legitimate rotation (`auth.ts:315-318`) sets BOTH. The reuse-detection condition at `auth.ts:280-282` is `storedToken.replaced_at || storedToken.replaced_by_hash`, evaluated BEFORE the expiry check at line 292 — so a manually-terminated token presented on a later refresh is misclassified as theft and runs `tx.refresh_tokens.deleteMany({ where: { user_id } })` (line 285), wiping every session for that user, including the laptop the user is actively on, plus a false "reuse detected" warning.
|
||||||
|
- **Repro:**
|
||||||
|
1. Log in from two devices → Session A (laptop), Session B (phone), both `replaced_at=null`.
|
||||||
|
2. From the laptop, `DELETE /api/admin/sessions/<B.id>` → B gets `replaced_at` set only.
|
||||||
|
3. After B's access token expires (~15 min), the phone calls `POST /api/admin/refresh` with B's still-present cookie.
|
||||||
|
4. `refreshAccessToken` hits the reuse branch (B's `replaced_at` truthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh.
|
||||||
|
- **Pinning test:** Create two refresh-token sessions, terminate one via the sessions DELETE route, present the terminated token to `refreshAccessToken`; assert the OTHER session's row still exists (fails today: it is deleted).
|
||||||
|
|
||||||
|
### H2. `order_items.description` Zod cap (8000) far exceeds DB column `VarChar(500)` → Prisma 500 on long descriptions
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:12`
|
||||||
|
- **Failure scenario:** `OrderItemSchema.description = z.string().max(8000)` but `order_items.description` is `@db.VarChar(500)` (`schema.prisma:327`). `createOrder`/`updateOrder` map the value straight into `tx.order_items.createMany` with no truncation. A 501–8000 char description passes Zod, overflows the column under MySQL strict mode (P2000), and `createOrder`'s catch only re-maps errors carrying a `status` property — so it surfaces as HTTP 500 "Interní chyba serveru" instead of a clean Czech 400, with the whole order transaction rolled back.
|
||||||
|
- **Repro:** `POST /api/admin/orders` (Content-Type application/json so the manual-JSON branch runs), `items:[{ description: "x".repeat(600), quantity:1, unit_price:100 }]` → HTTP 500 instead of 400; no order persisted. Same on `PUT /api/admin/orders/:id` for an order in `prijata`/`v_realizaci`.
|
||||||
|
- **Pinning test:** `parseBody(OrderItemSchema, { description: "x".repeat(600), ... })` should fail Zod with a 400-class error (fails today: passes, then 500s at Prisma). Fix: `.max(500)`.
|
||||||
|
|
||||||
|
### H3. Issued-order status transition silently discards unsaved item/section edits and marks them clean
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/pages/IssuedOrderDetail.tsx:524`
|
||||||
|
- **Failure scenario:** A `sent` issued order is both editable AND has valid transitions. `handleStatusChange` (line 516) sends ONLY `{ status: newStatus }`; the server's `replaceItems = editable && Array.isArray(body.items)` is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 calls `markClean({ form, items, sections })` with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard so `isDirty` flips false and the `beforeunload` warning never fires. After the transition the order is `confirmed` → read-only, so the lost edits cannot be re-applied. (Contrast: `OfferDetail`/`InvoiceDetail` do NOT call `markClean` on transition.)
|
||||||
|
- **Repro:** Open a `sent` issued order with `orders.edit`, edit a line item / section / supplier (form dirty), click "Potvrdit" and confirm. The transition succeeds, the edits are silently dropped, and on refetch the items/sections revert to stored values with no warning.
|
||||||
|
- **Pinning test:** Render `IssuedOrderDetail` for a `sent` order, mutate an item, invoke a status transition; assert the PUT payload includes the edited `items` (or that the guard stays dirty / a warning is shown) — fails today.
|
||||||
|
|
||||||
|
### H4. Received-invoices list is paginated server-side (25/page) but the UI sends no `page` param and renders no Pagination control — rows 26+ are permanently hidden
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/pages/ReceivedInvoices.tsx:265`
|
||||||
|
- **Failure scenario:** `receivedInvoiceListOptions` hits `/received-invoices`, which applies `skip`/`take` with `parsePagination`'s default limit of 25. The component builds the query with no `page`/`perPage`, reads only `listQuery.data?.data` (the first 25 rows), and renders NO `<Pagination>`. The "Celkem" footer is sourced from the separate `/received-invoices/list-totals` over the FULL filtered set, so with 26+ rows the displayed total visibly disagrees with the on-screen rows. The sibling issued-invoices tab (`Invoices.tsx`) correctly uses `usePaginatedQuery` + `<Pagination>`.
|
||||||
|
- **Repro:** Seed 26 CZK received invoices in one month. `GET /received-invoices?month=6&year=2026` returns `data.length=25`, `pagination.total=26`; `GET /received-invoices/list-totals?...` returns 26000 CZK. In the UI (Faktury → Přijaté, June 2026) the table shows 25 rows summing to 25000 while the footer shows 26000 — and the 26th invoice is unreachable.
|
||||||
|
- **Pinning test:** With 26 received invoices in a month, assert the page requests page 2 (or renders a pager) and that all 26 are reachable — fails today.
|
||||||
|
|
||||||
|
### H5. Odin invoice import: Czech-format dates silently corrupt month/year (wrong month or NaN) in `received_invoices`
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/admin/components/odin/InvoiceReviewCard.tsx:79`
|
||||||
|
- **Failure scenario:** The review card exposes "Datum vystavení"/"Datum splatnosti" as free-text fields; `OdinChat.saveInvoice` sends the raw string. The multipart schema validates `issue_date` only as `z.string().max(255).nullish()`, so it passes. The route derives `invoiceMonth = new Date(issue_date).getMonth()+1` / `getFullYear()` with no validity guard. `new Date("15.03.2026")` → Invalid Date → `NaN` (day>12); `new Date("12.6.2026")` → December 2026 (wrong month). NAS save (line 410) runs BEFORE the DB create (line 420), so for the NaN case Prisma rejects into the `UnsignedTinyInt`/`UnsignedSmallInt` columns → HTTP 500 with an orphaned NAS file; for day≤12 it silently files under the wrong month/year.
|
||||||
|
- **Repro:** In Odin, edit an extracted invoice's issue date to `15.03.2026` and Save → 500 + orphaned NAS file. Use `12.6.2026` → invoice silently filed under month 12. Equivalent: `POST /api/admin/received-invoices` (multipart) with `issue_date:"15.03.2026"`.
|
||||||
|
- **Pinning test:** `POST /received-invoices` with `issue_date="15.03.2026"` should return a 400 (or normalize) — fails today (500 + NaN write). Fix: use `isoDateString` coercion / validity guard.
|
||||||
|
|
||||||
|
### H6. Leave-request approval charges vacation/sick balance for Czech public holidays that fall inside the range
|
||||||
|
|
||||||
|
- **Severity:** High
|
||||||
|
- **File:** `src/routes/admin/leave-requests.ts:222`
|
||||||
|
- **Failure scenario:** Both the request-creation loop (lines 90–97) and the approval loop (lines 222–244) count business days with a weekend-only filter (`dow !== 0 && dow !== 6`); the file never imports `isHoliday`. So weekday Czech public holidays inside the range are counted as charged leave days, and approval increments `vacation_used`/`sick_used` by `totalBusinessDays * 8` including them. The sibling `attendance.service.createLeave` (line 1270) correctly skips `isHoliday(...)`, and `getBusinessDaysInMonth` excludes holidays from the work fund — so the deduction is a double charge for a day already paid/free.
|
||||||
|
- **Repro:** Employee `POST /api/admin/leave-requests` `{leave_type:"vacation", date_from:"2026-05-01", date_to:"2026-05-09"}` (1.5. and 8.5. are weekday holidays in 2026) → `total_hours=48`. Approver `PUT /.../{id}` `{status:"approved"}` → `vacation_used` grows by 48h instead of 32h; holiday dates are recorded as consumed vacation in `attendance`.
|
||||||
|
- **Pinning test:** Approve a vacation request spanning a weekday public holiday; assert `vacation_used` increased only by the non-holiday business hours (fails today: includes the holiday).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### M1. Invoice date fields accept arbitrary strings and write a day early to `@db.Date` when a datetime value is submitted
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/invoices.service.ts:533`
|
||||||
|
- **Failure scenario:** `CreateInvoiceSchema`/`UpdateInvoiceSchema` type `issue_date`/`due_date`/`tax_date`/`paid_date` as plain `z.string().max(255).nullish()` (not `isoDateString`). `createInvoice`/`updateInvoice` do `new Date(String(...))` straight into the `@db.Date` columns. A round-tripped API value like `"2025-03-15T00:00:00"` (the `toJSON` override emits local time, no `Z`) parses as local Prague → `2025-03-14 22:00 UTC` → Prisma truncates to `2025-03-14`. The invoice then also lands in the wrong month bucket in `getInvoiceStats`/`buildInvoiceWhere`. Every other `@db.Date`-writing module uses `isoDateString` (which slices the time off); invoices is the lone outlier.
|
||||||
|
- **Repro:** `POST /api/admin/invoices` with `issue_date:"2025-03-01T00:00:00"` → DB stores `2025-02-28`; `GET .../stats?month=3&year=2025` excludes it and inflates February.
|
||||||
|
- **Pinning test:** Create an invoice with `issue_date="2025-03-01T00:00:00"`; assert the stored date is `2025-03-01` (fails today: `2025-02-28`). Fix: `isoDateString.nullish()`.
|
||||||
|
|
||||||
|
### M2. `order_items.unit` Zod cap (255) exceeds DB column `VarChar(20)` → Prisma 500 on long unit strings
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:15`
|
||||||
|
- **Failure scenario:** `unit: z.string().max(255)` vs `order_items.unit @db.VarChar(20)` (`schema.prisma:330`). A 21–255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-maps `status`-bearing errors; update has no try/catch). The sibling offers/issued-orders schemas correctly use `.max(20)`.
|
||||||
|
- **Repro:** `POST /api/admin/orders` (JSON) `items:[{ description:"Test", quantity:1, unit_price:100, unit:"abcdefghijklmnopqrstuvwxyz" }]` → HTTP 500 instead of 400. (The MUI form enforces `maxLength:20` client-side, so this is an API/non-browser vector.)
|
||||||
|
- **Pinning test:** `parseBody(OrderItemSchema, { unit: "x".repeat(21), ... })` should 400 at Zod — fails today. Fix: `.max(20)`.
|
||||||
|
|
||||||
|
### ~~M3. Offer PDF page footer ("Strana X z Y") never renders~~ — WITHDRAWN 2026-06-12 (false positive)
|
||||||
|
|
||||||
|
- **Status:** **Withdrawn** — user verified the footer DOES render in production.
|
||||||
|
- **Why the audit got it wrong:** the finding (and all three verification lenses) trusted the repo's own documentation — the `html-to-pdf.ts:68-74` comment and the CLAUDE.md line "Chromium has no CSS margin-box footers". That fact has been stale since **Chrome 131 (Nov 2024), which implemented CSS `@page` margin boxes**; the project runs Puppeteer 24.40 (bundled Chrome 146 locally) and prod renders with system Chromium 149, both far past 131, so the offer's `@bottom-center { counter(page) }` footer renders fine. No lens rendered an actual PDF — documentation poisoning, not a code bug.
|
||||||
|
- **Real follow-up (doc-only, Low):** correct the outdated claim in `html-to-pdf.ts` and CLAUDE.md so future work doesn't repeat this; the `footerTemplate` mechanism and the CSS margin-box mechanism are now BOTH valid, coexisting approaches.
|
||||||
|
|
||||||
|
### M4. `customer_order_number` Zod cap (255) exceeds DB column `VarChar(100)` → Prisma 500
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/orders.schema.ts:54`
|
||||||
|
- **Failure scenario:** `customer_order_number: z.string().max(255)` vs `orders.customer_order_number @db.VarChar(100)` (`schema.prisma:356`). A 101–255 char value passes Zod, overflows the column (P2000 under strict mode), and surfaces as 500 instead of 400 (no P2003/P2000 mapping; create catch only handles `status`-bearing errors).
|
||||||
|
- **Repro:** `POST /api/admin/orders` (JSON) `{ customer_order_number: "A".repeat(150), currency:"CZK", language:"cs", status:"prijata" }` → HTTP 500; no order created. Same on `PUT /api/admin/orders/:id`.
|
||||||
|
- **Pinning test:** `parseBody(CreateOrderSchema, { customer_order_number: "A".repeat(150), ... })` should 400 — fails today. Fix: `.max(100)` on both schemas.
|
||||||
|
|
||||||
|
### M5. Foreign-currency invoice PDF returns HTTP 500 (and fails to archive) when the CNB rate lookup throws
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/invoices-pdf.ts:411`
|
||||||
|
- **Failure scenario:** For a EUR invoice, `const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0` is unguarded. When CNB is unreachable and no cached entry exists for the issue date, `getRate` throws "Nepodařilo se získat aktuální kurzy z ČNB", which propagates to the route's catch and returns the 500 error page — blocking BOTH preview and NAS archival, even though the rate only feeds the cosmetic CZK-conversion footnote. Sibling consumers (`received-invoices` stats, `ai-tools`) wrap `toCzk` and degrade per-row; the PDF path has no fallback.
|
||||||
|
- **Repro:** EUR invoice with a back-dated/never-fetched issue date, CNB unreachable → `GET /api/admin/invoices-pdf/:id` (preview) or `?save=1` (archive) returns 500; the `save=1` archival is silently swallowed client-side, leaving no NAS copy.
|
||||||
|
- **Pinning test:** Mock `getRate` to throw, render a foreign-currency invoice PDF; assert a non-error response (PDF rendered with the conversion line omitted) — fails today (500).
|
||||||
|
|
||||||
|
### M6. `updateProject` sets FK fields with no existence check → FK violation 500
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/projects.service.ts:115`
|
||||||
|
- **Failure scenario:** `updateProject` writes `customer_id`/`responsible_user_id`/`quotation_id`/`order_id` straight into `prisma.projects.update` with only int coercion and no `findUnique`. The FKs are `onDelete: Restrict`, so a non-existent id raises P2003 → generic 500. `createProject` validates `customer_id`/`responsible_user_id` and returns clean Czech 400s; the update path is the inconsistent gap.
|
||||||
|
- **Repro:** `PUT /api/admin/projects/1` `{ "customer_id": 999999 }` → HTTP 500 "Interní chyba serveru" instead of "Zákazník nenalezen" 400. Same for the other three FK fields.
|
||||||
|
- **Pinning test:** `PUT /projects/:id` with a non-existent `customer_id`; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirror `createProject`'s existence checks.
|
||||||
|
|
||||||
|
### M7. Date-range filters/reports exclude the whole `date_to` day (`lte` against UTC-midnight) — inclusive end date silently drops same-day records
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/warehouse.ts:1038`
|
||||||
|
- **Failure scenario:** Receipts/issues filters and the project-consumption / movement-log reports build `created_at: { lte: new Date(date_to) }`. `new Date("2026-06-12")` parses as UTC midnight = 02:00 Prague; `created_at` is `@db.DateTime(0)` storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is `> 00:00Z` and excluded. The entire requested end day disappears from filtered lists and report totals.
|
||||||
|
- **Repro:** Confirm a receipt today at 09:00 Prague. `GET /admin/warehouse/receipts?date_from=2026-06-12&date_to=2026-06-12` returns zero rows. Reports for a period ending 2026-06-12 under-report the last day.
|
||||||
|
- **Pinning test:** Create a same-day receipt at 09:00 local, filter `date_to` = that date; assert the receipt is returned — fails today. Fix: half-open `lt utcMidnightOfLocalDay(date_to + 1 day)`.
|
||||||
|
|
||||||
|
### M8. POST/PUT receipts & issues 500 on nonexistent or deleted FK ids instead of a clean Czech 400
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/warehouse.ts:1117`
|
||||||
|
- **Failure scenario:** The receipts/issues create+update handlers write rows directly with no FK-existence pre-check and no try/catch; schemas only int-coerce ids. A deleted/nonexistent `supplier_id`/`location_id`/`item_id`/`project_id`/`batch_id` raises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) and `trips.ts` all pre-validate FK existence precisely to avoid this; warehouse omits the guard.
|
||||||
|
- **Repro:** `POST /receipts` `{ items:[{ item_id: 99999999, quantity:1, unit_price:10 }] }` → 500. `POST /issues` `{ project_id: 99999999, items:[{ item_id:<in-stock>, quantity:1 }] }` → 500. `POST /issues` with a valid item + nonexistent explicit `batch_id` → 500.
|
||||||
|
- **Pinning test:** `POST /receipts` with a nonexistent `item_id`; assert a 400 ("Položka nenalezena") — fails today (500).
|
||||||
|
|
||||||
|
### M9. `updateEntry` never re-checks the per-cell cap, so expanding an entry's range pushes a day past `MAX_RECORDS_PER_CELL` and silently hides existing records
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/services/plan.service.ts:597`
|
||||||
|
- **Failure scenario:** `createEntry`/`bulkCreateEntries`/`createOverride` all guard the per-cell cap of 3 via `assertEntryCapAvailable`, but `updateEntry` does not. Expanding an entry's `date_from`/`date_to` onto a day that already holds 3 active entries succeeds, producing 4. `resolveCell`/`resolveGrid` cap display at 3 (newest-first), so the oldest entry silently vanishes from the grid while remaining active in the DB.
|
||||||
|
- **Repro:** Create 3 active entries on 2099-07-01 for user 5; create a 4th on 2099-07-02; `PATCH /plan/entries/<4th>` `{ date_from:"2099-07-01", date_to:"2099-07-01" }` → 200; the grid for that day now shows only 3 of the 4 entries.
|
||||||
|
- **Pinning test:** Fill a day to the cap, then `PATCH` another entry to overlap that day; assert the update is rejected with the cap error — fails today.
|
||||||
|
|
||||||
|
### M10. `POST /users` does not strip `role_id` for non-admins — a `users.create` holder can grant any non-admin role
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/routes/admin/users.ts:63`
|
||||||
|
- **Failure scenario:** The POST handler forwards `role_id` straight to `createUser`; `createUser` only rejects the literal `admin` role. The PUT handler explicitly strips `role_id`/`is_active` for non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: a `users.create` holder can mint an account on any high-privilege non-admin role (e.g. one bundling `settings.roles` or `users.edit`) the caller itself lacks. Violates the documented "Role-management writes are admin-only and re-checked" rule.
|
||||||
|
- **Repro:** As account M with only `users.create`, `POST /api/admin/users` `{ ..., role_id: N }` where N is a non-admin role bundling `settings.roles`/`users.edit` → 201; the new account holds permissions M never had.
|
||||||
|
- **Pinning test:** As a non-admin `users.create` caller, POST a user with a privileged `role_id`; assert the created user's role is NOT the privileged one (role stripped, as on PUT) — fails today.
|
||||||
|
|
||||||
|
### M11. `route_from`/`route_to` Zod cap (255) exceeds DB column `VarChar(100)` — 500 or silent truncation
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/schemas/trips.schema.ts:17`
|
||||||
|
- **Failure scenario:** `route_from`/`route_to` cap at `.max(255)` (create lines 17–18, update 29–30) vs `trips.route_from/route_to @db.VarChar(100)` (`schema.prisma:668-669`). The route writes `String(body.route_from)` directly. A 101–255 char route passes Zod, then either 500s (P2000 strict mode, no global Prisma mapping) or silently truncates to 100 chars (data loss). The frontend TextFields have no `maxLength`, so the UI triggers it too.
|
||||||
|
- **Repro:** `POST /api/admin/trips` with a 150-char `route_from` → 500 (or truncated 201). Same on `PUT`.
|
||||||
|
- **Pinning test:** `parseBody(CreateTripSchema, { route_from: "x".repeat(101), ... })` should 400 — fails today. Fix: `.max(100)`.
|
||||||
|
|
||||||
|
### M12. Shared Puppeteer browser disconnecting mid-render fails concurrent PDF requests (no re-acquire/retry)
|
||||||
|
|
||||||
|
- **Severity:** Medium
|
||||||
|
- **File:** `src/utils/html-to-pdf.ts:92`
|
||||||
|
- **Failure scenario:** `getBrowser()` returns the cached module-level browser after a point-in-time `connected` check; `htmlToPdf` immediately does `b.newPage()` with no re-check. Two concurrent renders capture the same handle; if Chromium crashes (OOM/killed) after the guard, both reject. A's finally nulls the shared handle out from under B. Both return 500 even though an immediate retry would relaunch and succeed — there is no re-acquire/retry wrapper.
|
||||||
|
- **Repro:** Fire two simultaneous `GET /api/admin/orders-pdf/5` (always renders, no NAS short-circuit), then `pkill -9 chrome` during the render window → both requests 500; the next request succeeds.
|
||||||
|
- **Pinning test:** Stub the cached browser so `connected` is true at guard time and false at `newPage` time; assert `htmlToPdf` re-acquires and resolves (or both concurrent calls succeed on retry) — fails today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### L1. Client-supplied TOTP secret up to 255 chars overflows `VarChar(255)` after encryption, yielding a 500 instead of a 400
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/schemas/auth.schema.ts:30`
|
||||||
|
- **Failure scenario:** `TotpEnableSchema.secret` caps at `.max(255)` on the plaintext, but the stored value is the AES-256-GCM ciphertext (`base64(IV[12] + ciphertext + tag[16])`), which exceeds 255 chars for any plaintext over ~164 chars. `users.totp_secret` is `@db.VarChar(255)`. The pre-write `totp.validate` does not bound secret length, and the attacker controls both `secret` and `code`, so a long secret reaches the write → P2000 → 500 (strict mode) or silent ciphertext truncation that permanently locks the account out.
|
||||||
|
- **Repro:** Authenticated, 2FA-not-enabled user: generate a 250-char base32 secret + matching TOTP code, `POST /api/admin/totp/enable` with the correct password → 500 (or corrupted secret).
|
||||||
|
- **Pinning test:** Enable TOTP with a 250-char secret; assert a 400 (not 500) — fails today. Fix: `.max(64)` (real secrets are 16–32 chars).
|
||||||
|
|
||||||
|
### L2. Offer number sequence is never released on delete when created and finalized in different calendar years
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/services/offers.service.ts:522`
|
||||||
|
- **Failure scenario:** `generateOfferNumber` keys on `new Date().getFullYear()` (finalize year), assigning e.g. `2026/NA/001`. `deleteOffer` derives the release year from `existing.created_at.getFullYear()` (2025) and calls `releaseOfferNumber(2025, "2026/NA/001")`; `releaseSequence` reconstructs the highest as `2025/NA/<n>`, which never equals `2026/NA/001`, so the `deletedNumber === highestNumber` guard is false and the 2026 counter keeps a permanent gap. `invoices.service.ts` solves the same problem correctly by parsing the year out of the document number.
|
||||||
|
- **Repro:** Create a draft `2025-12-30`, finalize it `2026-01-02` → `2026/NA/001`. Delete it → next finalized 2026 offer is `2026/NA/002` (gap; no `2026/NA/001`).
|
||||||
|
- **Pinning test:** Create a draft in year Y, finalize+delete it in year Y+1; assert the Y+1 sequence counter was released — fails today. Fix: parse the year from the assigned number.
|
||||||
|
|
||||||
|
### L3. `customers` list paginates with non-unique sort columns and no `id` tiebreak → unstable pagination
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/routes/admin/customers.ts:54`
|
||||||
|
- **Failure scenario:** `orderBy: { [sortField]: order }` over allow-listed fields (`name`/`city`/`country`/`company_id`, all non-unique) with no `{ id }` tiebreak, used with offset pagination. Rows sharing a sort-key value that straddle a page boundary can reorder between the page-1 and page-2 queries, duplicating one and skipping another. Violates the documented determinism rule; siblings (`issued-orders.ts`, `trips.ts`) use compound `[..., { id }]` orderings. (The same defect exists at `received-invoices.ts:144`.)
|
||||||
|
- **Repro:** Seed customers with a shared `city` straddling a `limit=2` boundary; page through `?sort=city` and observe a customer appearing on two pages while another is skipped.
|
||||||
|
- **Pinning test:** Paginate a sort over duplicate-`city` customers; assert the concatenated pages contain every customer exactly once — fails today. Fix: `orderBy: [{ [sortField]: order }, { id: order }]`.
|
||||||
|
|
||||||
|
### L4. Invoice month navigation does not reset the page index → switching to a sparser month shows an empty table
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/Invoices.tsx:244`
|
||||||
|
- **Failure scenario:** `prevMonth`/`nextMonth` mutate `statsMonth`/`statsYear` but never `setPage(1)` (unlike the status/search handlers). The list query keys on `page` + month, and the server never clamps page to `total_pages`, so paging to page 3 of a busy month then switching to a 1-page month requests `?page=3&month=...` and gets an empty `data` array — empty table for a populated month, with the pager reading "3 of 1".
|
||||||
|
- **Repro:** Open a month with 60+ invoices, go to page 3, click the previous-month chevron to a month with a handful → empty "Zatím nejsou žádné faktury" table. API: `GET /api/admin/invoices?page=3&month=<sparse>&year=<y>` returns `data:[]` with non-zero `total`.
|
||||||
|
- **Pinning test (component):** With `page=3`, fire `nextMonth`; assert `page` resets to 1 — fails today.
|
||||||
|
|
||||||
|
### L5. Issued-orders sub-list keeps its page index when the parent Orders page changes month → empty list for sparser months
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/IssuedOrders.tsx:135`
|
||||||
|
- **Failure scenario:** `IssuedOrders` receives `month`/`year` as props and holds `page` in local state with no effect/key resetting it on prop change (the child is not keyed by month/year, so it doesn't remount). Paging to page 2 of a busy month then moving the parent to a sparse month requests page 2 of the new month → empty `data` → "Zatím žádné vydané objednávky." for a month that has orders on page 1.
|
||||||
|
- **Repro:** Objednávky → Vydané: navigate to a month with ≥26 issued orders, click page 2, then use the parent month chevron to a month with 1–25 orders → empty sub-list. API equivalent: `GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=<y>` → `data:[]` with non-zero `total`.
|
||||||
|
- **Pinning test (component):** With `page=2`, change the `month` prop; assert `page` resets to 1 (or is clamped to `total_pages`) — fails today.
|
||||||
|
|
||||||
|
### L6. Settings "System" tab save clobbers freshly-saved "Firma" numbering patterns with stale values
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/admin/pages/Settings.tsx:297`
|
||||||
|
- **Failure scenario:** Both tabs edit the same singleton `company_settings` row via `PUT /company-settings`. `sysForm` carries `offer_number_pattern`/`order_number_pattern`/`invoice_number_pattern` (populated once, gated by a never-reset `sysFormInitialized` flag). After editing numbering in the Firma tab (which invalidates `["company-settings"]`), `sysForm` stays stale; saving the System tab sends the whole stale form, and the backend applies every `!== undefined` field, silently reverting the Firma-tab change.
|
||||||
|
- **Repro:** Init the System tab; in the Firma tab change `offer_number_pattern` and save; return to System and save any unrelated field → `offer_number_pattern` reverts to its pre-edit value, with a success toast.
|
||||||
|
- **Pinning test:** PUT the offer pattern, then PUT a stale sysForm carrying the old pattern; assert the offer pattern is NOT reverted (or that System-tab saves omit the numbering fields) — fails today.
|
||||||
|
|
||||||
|
### L7. `start_km`/`end_km` accept decimals but DB columns are `Int` — non-integer odometer reading errors or truncates
|
||||||
|
|
||||||
|
- **Severity:** Low
|
||||||
|
- **File:** `src/schemas/trips.schema.ts:15`
|
||||||
|
- **Failure scenario:** `start_km`/`end_km` use `nonNegativeNumberFromForm` (only `Number.isFinite && >=0`, no integer refine), but the columns are `Int`. A decimal passes Zod, reaches the `Int` column → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated into `vehicles.actual_km`). Same mismatch for `vehicles.initial_km`/`actual_km`. The UI's `parseInt` is used only for the distance widget, not the submitted payload, so the browser is vulnerable too.
|
||||||
|
- **Repro:** `POST /api/admin/trips` `{ vehicle_id, trip_date:"2098-01-15", start_km:100.5, end_km:1200.7, route_from:"A", route_to:"B" }` → 500 (or truncated values).
|
||||||
|
- **Pinning test:** `parseBody(CreateTripSchema, { start_km: 100.5, ... })` should 400 — fails today. Fix: `nonNegativeIntFromForm` for the four km fields.
|
||||||
|
|
||||||
|
### L8. AI monthly budget control: severity-mismatched permission, date asymmetry, dead UI button, currency overflow, audit-log boundary (grouped low-severity cluster)
|
||||||
|
|
||||||
|
These five independently-confirmed low-severity findings share the "minor blast radius / narrow trigger" profile. Each is real and pinnable.
|
||||||
|
|
||||||
|
- **L8a. Company-wide AI monthly budget is mutable by any `ai.use` holder (not admin/settings).** `src/routes/admin/ai.ts:58`. `PUT /api/admin/ai/budget` is gated only by `requirePermission("ai.use")`, an ordinary grantable permission. Setting `budget_usd=0` makes `assertBudgetAvailable` return 402 for everyone (admins included) → company-wide AI DoS; setting `1000000` removes the cost cap. The parallel `company-settings` writes are gated behind `settings.company`/`settings.system`.
|
||||||
|
- **Repro:** As any `ai.use` holder, `PUT /api/admin/ai/budget {"budget_usd":0}` → all `ai/chat` and `extract-invoices` calls 402.
|
||||||
|
- **Pinning test:** Call `PUT /ai/budget` as a non-admin `ai.use`-only user; assert 403 — fails today. Fix: gate behind `settings.company`/admin.
|
||||||
|
|
||||||
|
- **L8b. Audit-log `date_from` filter boundary shifted ~2h vs `date_to` (UTC vs local parsing).** `src/routes/admin/audit-log.ts:46`. `from = new Date(date_from)` parses as UTC midnight (02:00 Prague), while `to = new Date(date_to + "T23:59:59")` parses as local — so events between 00:00 and 02:00 Prague on the from-date are silently excluded.
|
||||||
|
- **Repro:** `GET /api/admin/audit-log?date_from=2026-06-12&date_to=2026-06-12` omits a row logged at 01:30 Prague that day.
|
||||||
|
- **Pinning test:** Insert an audit row at 01:30 local, filter for that day; assert it's returned — fails today.
|
||||||
|
|
||||||
|
- **L8c. Draft invoice shows a non-functional "Zobrazit fakturu" PDF button that always errors.** `src/admin/pages/InvoiceDetail.tsx:1805`. The button render guard lacks the `status !== "draft"` check that `OfferDetail`/`IssuedOrderDetail` have; clicking it on a draft opens a blank tab, 404s `/file`, closes the tab, and toasts an error.
|
||||||
|
- **Repro:** Open a draft invoice, click "Zobrazit fakturu" → blank tab flash + "PDF soubor nenalezen" toast.
|
||||||
|
- **Pinning test:** Render `InvoiceDetail` for a draft; assert the PDF button is not rendered — fails today. Fix: add `&& invoice.invoice_number` (or non-draft) to the guard.
|
||||||
|
|
||||||
|
- **L8d. Odin "Měna" / received-invoice currency over 3 chars 500s the save (`VarChar(3)` vs schema `max(20)`).** `src/components/odin/InvoiceReviewCard.tsx:67` / `src/schemas/received-invoices.schema.ts:11`. A free-text currency like "Koruna" passes Zod (`max(20)`) but overflows `received_invoices.currency @db.VarChar(3)` (P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module's `description` (`max(8000)` vs `VarChar(500)`) and `invoice_number` (`max(255)` vs `VarChar(100)`) are also misaligned.
|
||||||
|
- **Repro:** Save an Odin invoice with `currency:"Koruna"` → 500 + orphaned NAS file. Equivalent: `POST /received-invoices` multipart with `currency:"Koruna"`.
|
||||||
|
- **Pinning test:** `parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... })` should 400 — fails today. Fix: `currency .max(3)`, `description .max(500)`, `invoice_number .max(100)`.
|
||||||
|
|
||||||
|
- **L8e. Warehouse supplier `ico`/`dic` Zod caps (255) exceed `VarChar(20)` columns.** `src/schemas/warehouse.schema.ts:34`. A >20-char `ico`/`dic` (bad paste) passes Zod and 500s at Prisma. Low realism (real IČO=8 digits) but a genuine 500-vs-400 defect; the customers schema (`company_id`/`vat_id`) shares it.
|
||||||
|
- **Repro:** `POST /api/admin/warehouse/suppliers` `{ name:"Test", ico:"123456789012345678901" }` → 500.
|
||||||
|
- **Pinning test:** `parseBody(CreateSupplierSchema, { ico:"x".repeat(21), ... })` should 400 — fails today. Fix: `.max(20)`.
|
||||||
|
|
||||||
|
> **Adjacent uncited Zod-cap mismatches confirmed during verification** (same bug class, fix alongside the above so the gap is closed once): invoice line-item `description` (`max(8000)` vs `VarChar(500)`) and `unit` (`max(255)` vs `VarChar(20)`) — `src/schemas/invoices.schema.ts:13,16`, **High** blast radius (whole invoice save rolls back); invoice bank/payment fields `payment_method`/`constant_symbol`/`bank_swift`/`bank_iban`/`bank_account` (caps 255 vs `VarChar(50)/VarChar(20)`) — `src/schemas/invoices.schema.ts:30-35`, **Medium**; warehouse `/items` list ignores the client `sort` param and sorts by non-unique `name` with no `id` tiebreak — `src/routes/admin/warehouse.ts:662`, **Low**; year-spanning leave request books all hours against the start year's balance only — `src/routes/admin/leave-requests.ts:195`, **Medium**; Dashboard "Přidat jízdu" quick action omits `["vehicles"]` invalidation — `src/admin/components/dashboard/DashQuickActions.tsx:184`, **Low**. These were verified `real` but fall outside the 29-item cited set; treat them as a fast-follow batch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Fix Order (dependencies considered)
|
||||||
|
|
||||||
|
**Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative).** These corrupt persisted state and worsen on retry; fix before anything cosmetic.
|
||||||
|
|
||||||
|
1. **C3** `confirmInventorySession` `return`→`throw` (one-line change; stops repeatable phantom inventory). Trivial, highest leverage.
|
||||||
|
2. **C2** `confirmIssue` cross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existing `confirmIssue` validation-first pattern; consider a `(issue_id, batch_id)` unique constraint as a backstop).
|
||||||
|
3. **C1** leave-balance restore on attendance delete — but **do C1 together with H6 and the year-spanning leave finding**, since all three live in the leave/attendance balance code and a single corrected helper (per-day, holiday-aware, per-year accumulation, with an inverse on delete) resolves them coherently. Fix the holiday-counting (H6) and per-year split first, then route delete through the same helper.
|
||||||
|
|
||||||
|
**Phase 1 — Security / privilege & session correctness.** 4. **H1** session-reuse misclassification (gate the reuse branch on `replaced_by_hash` alone; reorder vs the expiry check). Self-contained; high user impact. 5. **M10** `POST /users` role-strip for non-admins; **L8a** AI budget permission gate. Both are permission-boundary one-liners.
|
||||||
|
|
||||||
|
**Phase 2 — The Zod-cap-vs-DB-column batch (single coordinated sweep).** All of **H2, M2, M4, M11, L1, L8d, L8e** plus the adjacent invoice item/bank caps share one mechanism and one fix shape (`.max()` alignment + optionally a global P2000→400 mapping in `server.ts`). Do them in one pass with a shared "cap-must-fit-column" test helper so the class is closed, not whacked one at a time. Highest blast radius within the batch (whole-save rollback): invoice item caps, then H2.
|
||||||
|
|
||||||
|
> **Phase 2 follow-ups (2026-06-12, deliberately deferred):** (1) the optional
|
||||||
|
> global P2000→400 mapping in the `server.ts` error handler was NOT added —
|
||||||
|
> `server.ts` exports no app builder, so the mapping would be untestable
|
||||||
|
> without first extracting an exported `buildApp()`; do the refactor + backstop
|
||||||
|
> together. (2) Frontend `maxLength` attributes on the trips (odkud/kam) and
|
||||||
|
> invoice bank/payment form fields — pure UX polish now that Zod 400s
|
||||||
|
> over-length input with a Czech message; add when next touching those forms.
|
||||||
|
|
||||||
|
**Phase 3 — FK-existence & error-mapping consistency.** **M6** (projects update), **M8** (warehouse receipts/issues), **M5** (CNB graceful degradation). These align the lagging modules with the established offers/orders pre-validation pattern; a shared P2003→Czech-400 mapping (added in Phase 2 if you choose the global-handler route) reduces the per-site work.
|
||||||
|
|
||||||
|
**Phase 4 — Date/timezone boundaries.** **M1** (invoice date `isoDateString`), **M7** (warehouse `date_to` half-open), **H5** (Odin Czech-date), **L8b** (audit-log `from`). Group because they share the documented date-boundary helpers (`isoDateString`, `utcMidnightOfLocalDay`); fixing M1/H5 also removes a day-shift class.
|
||||||
|
|
||||||
|
**Phase 5 — Pagination determinism & frontend state.** **H4** (received-invoices pager — add the missing `usePaginatedQuery`/`<Pagination>`), **L3** (customers + received-invoices `id` tiebreak), **L4/L5** (page-reset on month change), **M9** (plan update cap), **H3** (issued-order transition flush edits + don't `markClean` stale state). H4 is the largest UI change; the rest are small, independent guards.
|
||||||
|
|
||||||
|
**Phase 6 — Cosmetic / low-blast cleanup (parallelizable, no dependencies).** ~~M3~~ (withdrawn; only the stale Chromium-footer comment in `html-to-pdf.ts`/CLAUDE.md remains as a doc fix), **M12** (Puppeteer re-acquire/retry), **L2** (offer number release year), **L6** (Settings System/Firma clobber), **L7** (km integer coercion), **L8c** (draft PDF button guard), warehouse `/items` sort param + tiebreak, and the Dashboard `["vehicles"]` invalidation.
|
||||||
|
|
||||||
|
Rationale for ordering: persisted-data corruption (Phase 0) cannot be allowed to accumulate; security boundaries (Phase 1) are cheap and high-impact; the cap batch (Phase 2) and FK/error mapping (Phase 3) are most efficient done as coordinated sweeps that share a fix shape and tests; date boundaries (Phase 4) share helpers; UI/pagination (Phase 5) depend on nothing earlier but benefit from the cleaner backend; everything in Phase 6 is independent and can be parallelized across contributors.
|
||||||
436
CLAUDE.md
Normal file
436
CLAUDE.md
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
# CLAUDE.md — boha-app-ts
|
||||||
|
|
||||||
|
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
|
||||||
|
Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
|
| Runtime | Node.js, TypeScript (strict, all tsconfigs) |
|
||||||
|
| HTTP Framework | Fastify 5 |
|
||||||
|
| ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL |
|
||||||
|
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||||
|
| Validation | Zod 4 |
|
||||||
|
| Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
|
||||||
|
| Testing | Vitest + Supertest against a real `app_test` DB |
|
||||||
|
| PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
|
||||||
|
| Email / Cron | nodemailer / node-cron |
|
||||||
|
| AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
|
||||||
|
|
||||||
|
(Exact versions live in `package.json` — don't trust any pinned here.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── server.ts # Fastify entry — plugins, routes, error handler
|
||||||
|
├── routes/admin/ # HTTP route handlers (one file per entity)
|
||||||
|
├── services/ # Business logic (no classes, exported functions, own Prisma)
|
||||||
|
├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
|
||||||
|
├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
|
||||||
|
├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
|
||||||
|
├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
|
||||||
|
├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
|
||||||
|
├── admin/ # React 19 + MUI v7 frontend
|
||||||
|
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||||
|
│ ├── theme.ts / GlobalStyles.tsx
|
||||||
|
│ ├── ui/ # MUI component kit — pages import from here
|
||||||
|
│ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
|
||||||
|
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||||
|
│ ├── pages/ # One file per page/feature
|
||||||
|
│ ├── lib/queries/ # React Query options + useApiMutation
|
||||||
|
│ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
|
||||||
|
│ └── utils/ # api.ts (apiFetch with token refresh), formatters
|
||||||
|
└── __tests__/ # Vitest suites (server-side only; real app_test DB)
|
||||||
|
|
||||||
|
prisma/ # schema.prisma (snake_case columns) + migrations/
|
||||||
|
dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
|
||||||
|
npm run build # server + client
|
||||||
|
npm test # vitest run — against app_test via .env.test
|
||||||
|
npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing)
|
||||||
|
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 0 errors
|
||||||
|
npm run format # prettier --write .
|
||||||
|
npx prisma generate # after every schema change (client is not committed)
|
||||||
|
npx prisma studio # DB browser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not start the dev server.** The user manages it separately.
|
||||||
|
|
||||||
|
**Before any migration, ask the user to stop their dev server and wait for
|
||||||
|
confirmation** (the running server holds DB connections).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
|
||||||
|
|
||||||
|
Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
|
||||||
|
hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
|
||||||
|
`ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
|
||||||
|
`REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files),
|
||||||
|
`NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees),
|
||||||
|
`MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`,
|
||||||
|
`APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it).
|
||||||
|
|
||||||
|
`.env` for dev, `.env.test` for tests (both gitignored — **never edit env
|
||||||
|
files; the user manages them**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture & Key Patterns
|
||||||
|
|
||||||
|
### Request flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Request → CORS → Cookie → Rate-limit → Security headers
|
||||||
|
→ requirePermission() / requireAnyPermission()
|
||||||
|
→ parseBody(Schema) / parseId(param)
|
||||||
|
→ Route handler → Service function → Prisma
|
||||||
|
→ success(reply, data) | error(reply, czechMessage, status)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response format
|
||||||
|
|
||||||
|
`{ success: true, data, message?, pagination? }` on success,
|
||||||
|
`{ success: false, error }` on failure. Always the `success()`/`error()`/
|
||||||
|
paginated helpers — never raw `reply.send()`.
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
Plain exported async functions; services own Prisma, routes stay thin.
|
||||||
|
Preferred error shape: return **token literals** (`"not_found"`,
|
||||||
|
`"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map
|
||||||
|
tokens to Czech messages + HTTP codes (the offers/issued-orders modules are
|
||||||
|
the reference). Legacy services return `{ error: czech, status }` — fine to
|
||||||
|
keep until touched; never the discriminated-union style. Respect soft-delete
|
||||||
|
(`is_deleted: false`) in reads where the model has it.
|
||||||
|
|
||||||
|
### Permissions
|
||||||
|
|
||||||
|
`requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
|
||||||
|
role bypasses checks (shortcut: `authData.roleName === "admin"` —
|
||||||
|
⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
|
||||||
|
GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
|
||||||
|
(bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
|
||||||
|
permission opens pages read-only; an _edit_ permission unlocks fields and
|
||||||
|
save buttons.
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
`logAudit()` on every create/update/delete (and security-relevant actions)
|
||||||
|
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
|
||||||
|
descriptions for unnumbered drafts. Audit failures are non-fatal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dates & Timezones (Critical — three rules)
|
||||||
|
|
||||||
|
1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
|
||||||
|
monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration
|
||||||
|
parity). All API date strings are local. Never assume UTC; never manually
|
||||||
|
offset. Do not remove the override.
|
||||||
|
2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date
|
||||||
|
part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of
|
||||||
|
the _previous_ day) silently shifts the queried window a day back — this was
|
||||||
|
a 14-site production bug class. Build `@db.Date` filter boundaries and
|
||||||
|
"today" writes as UTC-midnight instants of the LOCAL calendar day:
|
||||||
|
`new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from
|
||||||
|
`src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare
|
||||||
|
`new Date()` into a `@db.Date` column (stores yesterday between 00:00–02:00
|
||||||
|
Prague).
|
||||||
|
3. **Two deliberate write regimes — don't "fix" either:** the plan module
|
||||||
|
does date-only math in UTC; attendance/leave writes `@db.Date` at **local
|
||||||
|
noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from
|
||||||
|
`localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) —
|
||||||
|
**never** `new Date().toISOString().split("T")[0]` (UTC date, a day early
|
||||||
|
in the Prague evening).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Document Modules (offers · orders · issued orders · invoices)
|
||||||
|
|
||||||
|
**Business rules (user/accountant decisions — do not revert):**
|
||||||
|
|
||||||
|
- **VAT exists ONLY on invoices and received invoices.** Offers, order
|
||||||
|
confirmations and issued orders are NOT tax documents: net prices only, one
|
||||||
|
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
|
||||||
|
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
|
||||||
|
from an order prefill the company default VAT rate.
|
||||||
|
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
|
||||||
|
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
|
||||||
|
offers take customers. Issued orders share the `orders._` permission family
|
||||||
|
(deliberate).
|
||||||
|
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
|
||||||
|
design; invoices + issued orders share the red-accent (#de3a3a) family.
|
||||||
|
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
|
||||||
|
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
|
||||||
|
orders deliberately have NO scope-template picker, NO item templates, NO
|
||||||
|
duplicate action.
|
||||||
|
|
||||||
|
**Platform conventions:**
|
||||||
|
|
||||||
|
- **Deferred numbering:** drafts carry a NULL document number
|
||||||
|
(nullable-unique column); the sequence number is consumed in-transaction on
|
||||||
|
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
|
||||||
|
released-if-highest on delete. Never hardcode numbering; previews use the
|
||||||
|
collision-advancing helpers.
|
||||||
|
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
|
||||||
|
responses return `valid_transitions` and the UI renders transition buttons
|
||||||
|
from it. Field edits outside the editable states (offers: draft/active;
|
||||||
|
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
|
||||||
|
payloads still pass.
|
||||||
|
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
|
||||||
|
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
|
||||||
|
enriches `locked_by {user_id, username, full_name}` only when fresh and
|
||||||
|
held by another user; client side is the shared `useDocumentLock` hook +
|
||||||
|
`LockBanner`.
|
||||||
|
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
|
||||||
|
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
|
||||||
|
so the UI hides PDF buttons on drafts. Numbered-document archives write to
|
||||||
|
a deterministic path and **overwrite in place** — never uniquePath `_N`
|
||||||
|
suffixes. The issued-order PDF gets language from the document's `language`
|
||||||
|
column and carries a Puppeteer `footerTemplate` footer on every page
|
||||||
|
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating-
|
||||||
|
footer mechanisms coexist and BOTH work: Puppeteer `footerTemplate`
|
||||||
|
(invoices + issued orders — `htmlToPdf(html, { footerTemplate })`) and CSS
|
||||||
|
`@page` margin boxes (`@bottom-center` + `counter(page)`, offer PDF —
|
||||||
|
supported since Chrome 131, Nov 2024). Don't migrate one to the other.
|
||||||
|
- **Shared modules — extend, never fork local copies:**
|
||||||
|
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
|
||||||
|
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
|
||||||
|
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
|
||||||
|
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
|
||||||
|
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
|
||||||
|
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
|
||||||
|
diacritics in headers make Node throw 500s).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TOTP / 2FA
|
||||||
|
|
||||||
|
Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
|
||||||
|
hex formats both supported); encrypted backup codes with their own
|
||||||
|
`/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
|
||||||
|
enrollment. Login flow: password → (if enrolled) single-use 5-min
|
||||||
|
`loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
|
||||||
|
QR is generated locally with the `qrcode` package (never an external QR URL —
|
||||||
|
CSP blocks it and it would leak the secret).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
|
||||||
|
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
|
||||||
|
unless `DATABASE_URL` names a `*test*` database.
|
||||||
|
- **After any schema change, apply migrations to the test DB too** or the
|
||||||
|
suite fails: set `DATABASE_URL` to the app_test URL and run
|
||||||
|
`npx prisma migrate deploy`.
|
||||||
|
- To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
|
||||||
|
with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
|
||||||
|
`npx tsx prisma/seed.ts`.
|
||||||
|
- **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
|
||||||
|
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
|
||||||
|
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
|
||||||
|
sequence deadlocks. Tests are type-checked by `tsc -b`.
|
||||||
|
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
|
||||||
|
cleanup; FK-safe deletion order. New features get tests in
|
||||||
|
`src/__tests__/<feature>.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Conventions
|
||||||
|
|
||||||
|
- Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
|
||||||
|
toasts via `useAlert()`.
|
||||||
|
- **React Query for all fetching** (query options live in
|
||||||
|
`src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
|
||||||
|
effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
|
||||||
|
server message through). `apiFetch` refreshes tokens proactively.
|
||||||
|
- **Rules of Hooks:** every hook runs BEFORE any early return — the
|
||||||
|
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
|
||||||
|
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
|
||||||
|
— prefix matching covers sub-queries), and a mutation invalidates every
|
||||||
|
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
|
||||||
|
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
|
||||||
|
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
|
||||||
|
still must invalidate their domains.
|
||||||
|
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
|
||||||
|
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
|
||||||
|
DB. Don't duplicate label maps.
|
||||||
|
|
||||||
|
### Styling (MUI v7 — no custom .css, no Tailwind)
|
||||||
|
|
||||||
|
- Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
|
||||||
|
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/` —
|
||||||
|
reach for raw `@mui/material` only for one-off layout/infra.
|
||||||
|
- Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
|
||||||
|
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
|
||||||
|
one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
|
||||||
|
- Don't regress: status row tints are channel-alpha washes (never `.light`
|
||||||
|
fills — invisible text in dark mode); icon badges are solid `X.main` +
|
||||||
|
white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
|
||||||
|
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
|
||||||
|
`useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
|
||||||
|
fade; ConfirmDialogs stay open with `loading` during their request.
|
||||||
|
- When refactoring pages, **preserve ALL data logic verbatim** (hooks,
|
||||||
|
mutations, invalidate arrays, validation, permissions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Assistant — "Odin"
|
||||||
|
|
||||||
|
`ai.use`-gated Claude assistant at `/odin` (granted to admin). **Phase 2a:
|
||||||
|
read-only agentic assistant** — chat turns run an agentic loop
|
||||||
|
(`agenticChat` in `src/services/ai.service.ts`, max 6 round-trips, budget
|
||||||
|
re-checked between iterations) over the read-only tools in
|
||||||
|
`src/services/ai-tools.ts` (invoices, offers, orders, projects, customers,
|
||||||
|
warehouse, attendance).
|
||||||
|
|
||||||
|
**Security model (do not weaken):** Odin is the **user's delegate** — the
|
||||||
|
tool list is pre-filtered by the caller's permissions AND every handler
|
||||||
|
re-checks them (`ctxCan`); there are **NO write tools** (writes come in
|
||||||
|
Phase 2b as propose→confirm action cards, never autonomous). Tool handlers
|
||||||
|
call the same service functions the routes use. Assistant turns persist
|
||||||
|
their tool trace in `ai_chat_messages.content_json` (plain `content` stays
|
||||||
|
the display text); the UI shows consulted-tool chips. The system prompt
|
||||||
|
enforces plain-text replies (the chat renders pre-wrap text, no Markdown).
|
||||||
|
|
||||||
|
Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when
|
||||||
|
exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured
|
||||||
|
output) → review cards → existing `POST /received-invoices`.
|
||||||
|
**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is
|
||||||
|
back-calculated (`vatFromGross`). Phase-2 design notes live in
|
||||||
|
`docs/superpowers/specs/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database & Migrations
|
||||||
|
|
||||||
|
Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
|
||||||
|
soft-delete via `is_deleted` where present; `number_sequences` owns all
|
||||||
|
document numbering.
|
||||||
|
|
||||||
|
**Golden rules:**
|
||||||
|
|
||||||
|
- **NEVER `prisma db push`** — it bypasses migration history and causes drift.
|
||||||
|
- **Every DB change is a tracked migration** — schema, permission/seed rows,
|
||||||
|
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
|
||||||
|
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
|
||||||
|
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
|
||||||
|
`APP_ENV=production`) — prod baselines belong in migrations.
|
||||||
|
|
||||||
|
**Making a schema change (this shell is non-interactive — `prisma migrate
|
||||||
|
dev` will refuse to run; use this recipe):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
|
||||||
|
# 2. Generate the migration SQL into a new folder:
|
||||||
|
mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
|
> prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
|
||||||
|
# 3. Review the SQL, then apply + regenerate:
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
# 4. Apply to the test DB as well (suite breaks otherwise):
|
||||||
|
# DATABASE_URL=<app_test url> npx prisma migrate deploy
|
||||||
|
# 5. Commit schema.prisma AND the migration folder together.
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment, drift checks, hotfix-migration shipping and baselining:
|
||||||
|
see **`docs/release.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforced Conventions (single source — merged 2026-06 audits)
|
||||||
|
|
||||||
|
**Routes**
|
||||||
|
|
||||||
|
- `success()`/`error()`/paginated helpers only; `parseId` for numeric params
|
||||||
|
(`if (id === null) return;`); `parseBody` for every body; permission guards
|
||||||
|
on reads AND writes; `logAudit` with old/new values on every mutation.
|
||||||
|
- Role-management writes are admin-only and re-checked (no creating or
|
||||||
|
rewriting the `admin` role).
|
||||||
|
|
||||||
|
**Schemas (Zod 4)**
|
||||||
|
|
||||||
|
- `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
|
||||||
|
- **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
|
||||||
|
form-coerced fields: `numberFromForm`, `numberInRange`,
|
||||||
|
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
|
||||||
|
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
|
||||||
|
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
|
||||||
|
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
|
||||||
|
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
|
||||||
|
the single biggest historical bug class). FK ids → intIdFromForm;
|
||||||
|
quantities/prices → nonNegative/positive; bounded → numberInRange.
|
||||||
|
- The date/time helpers are deliberately **lenient** (strip trailing time
|
||||||
|
components) because edit forms re-submit `toJSON`-serialized values.
|
||||||
|
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
|
||||||
|
- Align `max()` caps with the DB column widths (an over-cap string 500s at
|
||||||
|
Prisma instead of 400ing at Zod). Update schemas derive via
|
||||||
|
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
|
||||||
|
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
|
||||||
|
still injects field defaults into partial payloads).
|
||||||
|
- User-facing messages in **Czech**; identifiers/tokens in English.
|
||||||
|
|
||||||
|
**Determinism & concurrency**
|
||||||
|
|
||||||
|
- Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
|
||||||
|
columns make single-key sorts non-deterministic).
|
||||||
|
- Read-modify-write of shared rows (stock, balances, sequences) runs inside
|
||||||
|
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
|
||||||
|
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
|
||||||
|
writes (header + items + sections) belong in ONE transaction.
|
||||||
|
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
|
||||||
|
the checker) and catch `P2002` → 409 as backstop.
|
||||||
|
|
||||||
|
**Errors**
|
||||||
|
|
||||||
|
- Never silently swallow — every catch logs (`console.*` in services; there
|
||||||
|
is no request-scoped logger there). The only allowed silent catch is an
|
||||||
|
_expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
|
||||||
|
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Gotchas
|
||||||
|
|
||||||
|
1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
|
||||||
|
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
|
||||||
|
2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
|
||||||
|
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
|
||||||
|
the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
|
||||||
|
pass unsanitized user data into Puppeteer templates; string-built HTML
|
||||||
|
(print views, PDF templates) must `escapeHtml` every interpolation.
|
||||||
|
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
|
||||||
|
with logged errors, and tests stub NAS reads.
|
||||||
|
4. **Prisma client**: regenerate after every schema change; it is not
|
||||||
|
committed. (On prod this is a mandatory deploy step — see docs/release.md.)
|
||||||
|
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
|
||||||
|
6. **Czech locale hardcoded** in messages/month names — intentional.
|
||||||
|
7. **Cross-login caches:** React Query keys are user-agnostic; the query
|
||||||
|
cache is cleared on login/logout in AuthContext — keep it that way.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
Process, deployment commands, hotfix-migration shipping and verification
|
||||||
|
checklist: **`docs/release.md`**. Run the full test suite before tagging; the
|
||||||
|
user picks version numbers. **Never push to production or restart services
|
||||||
|
without explicit confirmation.**
|
||||||
407
POSSIBLE_IMPROVEMENTS.md
Normal file
407
POSSIBLE_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
# Possible Improvements — UX & Consistency Review
|
||||||
|
|
||||||
|
_A prioritized UX review of boha-app, grounded in 104 verified, code-referenced findings._
|
||||||
|
|
||||||
|
> **How this was produced (2026-06-24):** a multi-agent audit fanned out 16 independent
|
||||||
|
> reviewers across 5 feature domains and 11 cross-cutting UX dimensions (155 raw findings),
|
||||||
|
> deduplicated them into 118 unique items, then **adversarially re-checked every finding
|
||||||
|
> against the actual code** — dropping 14 that didn't hold up or contradicted a deliberate
|
||||||
|
> documented decision (CLAUDE.md). What remains are 104 findings each confirmed in the
|
||||||
|
> source, with file references kept intact so they can be acted on directly. Read §1–§3 for
|
||||||
|
> the opinion and the plan; §4 is the full catalogue by theme; §5 is suggested sequencing.
|
||||||
|
>
|
||||||
|
> **Independently re-verified (2026-06-24):** a second adversarial pass (9 parallel
|
||||||
|
> verifiers, different model) re-checked all 104 findings against the source:
|
||||||
|
> **100 confirmed, 4 partial, 0 refuted.** The partials were factual nits (a couple of
|
||||||
|
> miscounts, one wrong fix target, one mis-cited import), corrected inline in this
|
||||||
|
> document. The pass also surfaced one new backend finding (trips create authz — see §4.9)
|
||||||
|
> and upgraded confidence on the AttendanceAdmin and warehouse-models findings.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
boha-app is a **mature, broad, and genuinely well-built** back-office system. The bones are excellent: there is a real component kit (`src/admin/ui/`), shared document modules, a single-source-of-truth status module (`documentStatus.ts`), a global React Query cache with disciplined invalidation conventions, timezone-safe date handling, edit-locking on documents, and an accessibility-aware theme. Most of the "hard" platform work is done and done well. This is not a rescue job — it is a **polish-and-converge job**.
|
||||||
|
|
||||||
|
That said, the owner asked the right question. The single dominant theme across these findings is **consistency debt: the app does the same thing several different ways, and the variants disagree in ways the daily user feels.** Almost every cluster below is a story of "module A got it right, module B is an older or forked copy that drifted." The codebase even documents this pattern against itself — `documentStatus.ts` exists _because_ per-page status maps drifted; `useUnsavedChangesGuard` exists _because_ the dirty-check was copy-pasted; CLAUDE.md explicitly says "extend, never fork." The findings show those rules are followed in the newest modules (offers, issued orders) and quietly violated in the older twins (invoices, orders, attendance-admin, projects, warehouse).
|
||||||
|
|
||||||
|
Why this matters: the user's mental model breaks at the seams. Marking an invoice paid from the **detail** page leaves a project's order-status label stale, but doing it from the **list** page refreshes it. The "create" button **navigates to a page** on one Orders tab and **opens a modal** on the other. A failed list fetch shows "no records yet" — which can convince a user a customer has zero invoices when the server actually errored. Validation errors appear **inline under the field** on the supplier modal but **as a 4-second toast** on its mirror-image customer modal. None of these is catastrophic alone; collectively they make a strong app feel "not quite finished" and erode trust in what's on screen.
|
||||||
|
|
||||||
|
**The five highest-leverage moves, in order:**
|
||||||
|
|
||||||
|
1. **Stop showing stale data after writes.** Define one canonical React Query invalidation set per document family and apply it to _both_ list and detail mutations. Today detail-page edits invalidate fewer domains than their list twins (`OrderDetail`, `InvoiceDetail`, `DashProfile`, leave-cancel). Small effort, removes a whole class of "why is this number wrong" confusion. _(Cluster: data-freshness)_
|
||||||
|
|
||||||
|
2. **Guard the irreversible actions that currently aren't.** Marking an invoice **"Zaplaceno"** is a single unconfirmed chip click and is _terminal_ — the one truly irreversible document transition with **no** confirm, while every reversible status change has one. Same inversion in the warehouse: **"Potvrdit"** (posts stock) has no confirm but its **undo** does. And the **Aktivní/Neaktivní** chips are secret one-click toggles that fail silently on error. _(Cluster: destructive actions)_
|
||||||
|
|
||||||
|
3. **Fix list-page failure and loading states.** No list page reads `isError`, and there is no global `QueryCache.onError`, so failed fetches fall through to the **empty state** — actively misleading. Add a global error toast plus a distinct inline error. _(Cluster: loading/empty/error)_
|
||||||
|
|
||||||
|
4. **Make the app usable on a phone.** The shared `Tabs` uses MUI's non-scrolling `variant="standard"`, so the 5 status-filter tabs on Offers/Invoices simply **clip off-screen and become unreachable** at ~360px — a one-prop fix that touches every status filter. Modals never go full-screen on phones. _(Cluster: mobile)_
|
||||||
|
|
||||||
|
5. **Converge the shared primitives the app already owns.** Route the forked dirty-guards, status maps, empty states, page headers, save buttons, and PDF openers back through their canonical implementations. This is the structural fix that prevents the other four from re-drifting. _(Cluster: forked modules)_
|
||||||
|
|
||||||
|
The rest of this report groups all findings by theme, gives a prioritized action table, and then lists every finding with its file references intact so they can be acted on directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Top Themes
|
||||||
|
|
||||||
|
### Theme A — Data-freshness drift (the trust killer)
|
||||||
|
|
||||||
|
React Query invalidation is the app's nervous system, and CLAUDE.md lays out clear rules ("invalidate every domain that embeds the data"). The newest modules obey; older and raw-`apiFetch` paths don't. **List pages and their own detail pages invalidate different domain sets** because list pages use manual `invalidate` arrays while detail pages use `useApiMutation` arrays, and the two drifted independently. The result is subtle: a value that is correct after editing it one way is stale after editing it the identical other way. Converge on **one named invalidation constant per family** and reuse it on both surfaces. The dashboard already solved the "many domains feed me" case with `refetchOnMount: "always"` — apply the same backstop to the audit log.
|
||||||
|
|
||||||
|
### Theme B — Destructive & risky actions are gated by consequence-inverted logic
|
||||||
|
|
||||||
|
The pattern across the app is sound — `ConfirmDialog` everywhere — but the gating is **inversely correlated with actual consequence** in the few places it matters most. The _irreversible_ transitions (invoice→paid, warehouse "Potvrdit" posts stock) have **no** guard, while their _reversible_ counterparts (cancel document) get a full danger confirm. Several state changes hide as **chip toggles with no affordance** and **no `onError`**, so a rejected toggle silently reverts with zero feedback. And the "wipe the entire audit log" case uses the same low-key modal as "trim 30-day rows." Converge on: irreversibility → stronger friction, never weaker; every mutating control announces itself and reports its errors.
|
||||||
|
|
||||||
|
### Theme C — Forms & save UX speak three dialects
|
||||||
|
|
||||||
|
Validation is **inline-under-field** in some forms and **transient toast** in others — and _mirrored screens disagree_ (supplier modal inline vs customer modal toast; trip editor inline vs admin-trip editor toast; received-invoice bulk-review inline vs its single-edit toast). Enter submits page forms but **never** submits a shared-`Modal` form. Save buttons **spin** in document pages but only **swap text** elsewhere, and dual-save warehouse forms can't tell you which action is running. Failed validation never **moves focus** to the bad field. Converge on: inline `<Field error>` for validation, toasts only for transport/server failures, `<form onSubmit>` in the shared Modal, one save affordance, and focus-the-first-error.
|
||||||
|
|
||||||
|
### Theme D — Unsaved-work protection is partial and fork-ridden
|
||||||
|
|
||||||
|
The shared `useUnsavedChangesGuard` is used by exactly two pages; `OrderDetail` and `InvoiceDetail` **re-implement it inline**, and `ProjectDetail`, `CompanySettings`, all three warehouse forms, and `AttendanceCreate` have **no guard at all** — a refresh discards a 15-line receipt silently. Worse, _every_ guard only catches `beforeunload`, so clicking the in-app **"Zpět"** or a sidebar link — the most common way to leave — discards edits with no prompt (this one needs a router upgrade). And `InvoiceDetail` has **no edit-lock** while its siblings do, so two users can clobber each other's invoice line items.
|
||||||
|
|
||||||
|
### Theme E — Loading, empty & error states have no single grammar
|
||||||
|
|
||||||
|
Three different loading behaviors for the _same_ "change the month" action (dim / nothing / flash). Empty states built two ways (`<EmptyState>` vs hand-rolled `Box+Typography`). 40 pages **blank the whole page to a centered spinner** (layout shift) instead of keeping chrome; there are **zero** real MUI `<Skeleton>`s despite identifiers literally named `showListSkeleton`. The dashboard hand-rolls its own spinner. And the big one: **failed fetches render as empty**, not as errors.
|
||||||
|
|
||||||
|
### Theme F — Navigation & information architecture has thin orientation
|
||||||
|
|
||||||
|
Every browser tab reads **"Admin"** (no per-page title), there are no breadcrumbs, the back control is built **four different ways**, and `Received-order` detail sends you back to the **wrong Orders tab**. Master data is scattered across four nav sections; "Zákazníci" lives at the misleading URL `/offers/customers`; `settings.templates` opens the gear then **bounces you to the dashboard**; and there are three different "access denied" experiences. None blocks work, but together they make deep pages feel un-anchored.
|
||||||
|
|
||||||
|
### Theme G — Mobile is a second-class citizen in specific, fixable spots
|
||||||
|
|
||||||
|
Status-filter tabs **clip and become unreachable** on phones. Modals never go full-screen. Long full-page editors keep **Save only at the top**, so phone users scroll all the way back up to save. Warehouse line-items lose the labeled card-per-row layout the document editor has — worst exactly where shop-floor mobile use is likely. `FilterBar` children mix fixed and growing flex bases, leaving dead space.
|
||||||
|
|
||||||
|
### Theme H — Microcopy & localization inconsistency
|
||||||
|
|
||||||
|
The goods-receipt module is named **four ways** (Příjmy / Nový příjem / Příjmové doklady / Příjemka) and "Příjmy" also means _financial income_. "Cancelled" is spelled two ways in the same status file. Create verbs split three ways. The busy label has five competing forms plus a `"Chyba pripojeni"` diacritics typo. Autocompletes leak English **"No options"** in an otherwise fully-Czech app. Several prices bypass cs-CZ formatting (period decimals, no currency).
|
||||||
|
|
||||||
|
### Theme I — Accessibility gaps in the hot paths
|
||||||
|
|
||||||
|
Clickable table rows aren't keyboard-operable (warehouse records literally can't be opened by keyboard). Search boxes are placeholder-only with no accessible name across ~13 pages. The desktop line-item grid is unlabeled while the mobile card is fully labeled. No skip-to-content link, so keyboard users tab the whole sidebar on every navigation.
|
||||||
|
|
||||||
|
### Theme J — Bundle & performance
|
||||||
|
|
||||||
|
MUI + Emotion sit in the **781 kB entry chunk**, so every patch release busts the cached UI vendor code and daily users re-download ~232 kB gzip of unchanged MUI. The Dashboard and its 7 subcomponents are bundled into the entry chunk and downloaded **on the Login screen**. No route-transition progress bar, no link prefetch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prioritized Recommendations
|
||||||
|
|
||||||
|
Effort: **S** = hours · **M** = a focused day or two · **L** = multi-day / structural.
|
||||||
|
|
||||||
|
### Quick wins — high impact, low effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 1 | Make status-filter `Tabs` scrollable so they stop clipping off-screen on phones | `src/admin/ui/Tabs.tsx:28-49` (one prop fixes all) | High | S |
|
||||||
|
| 2 | Add a confirm (or undo toast) + hover affordance to the **"mark invoice paid"** chip — the only unguarded irreversible transition | `Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680` | High | S |
|
||||||
|
| 3 | Converge detail-page invalidation sets onto the list-page (full) sets | `OrderDetail.tsx:162,168,177`; `InvoiceDetail.tsx:1054,1067,1073`; `DashProfile.tsx:225-226`; `LeaveRequests.tsx:90` | Medium | S |
|
||||||
|
| 4 | Gate the Invoices **list** PDF icon on `invoice_number` so drafts stop dead-ending in a 404 | `Invoices.tsx:616-626` | Medium | S |
|
||||||
|
| 5 | Point `Received-order` detail Zpět / redirects at `/orders?tab=prijate` | `OrderDetail.tsx:116-121,275-287,397-405` | Medium | S |
|
||||||
|
| 6 | Add a global `QueryCache.onError` toast so failed fetches stop masquerading as empty | `src/admin/lib/queryClient.ts` | High | S |
|
||||||
|
| 7 | Debounce search on Invoices / ReceivedInvoices / AuditLog (reuse `useDebounce`) | `Invoices.tsx:282`, `ReceivedInvoices.tsx:250`, `AuditLog.tsx:202` | Medium | S |
|
||||||
|
| 8 | Add global MUI `csCZ` `localeText` (kills English "No options" everywhere) | `src/admin/ui/MuiProvider.tsx:15`; pickers `CustomerPicker.tsx:49`, `SupplierPicker.tsx:49` | Medium | S |
|
||||||
|
| 9 | Add `onError` toast to Vehicles/Users active-toggle mutations (silent failure today) | `Vehicles.tsx:146-160`, `Users.tsx:162-171` | Medium | S |
|
||||||
|
| 10 | Add a per-page `document.title` (`<TitleSync>`) so tabs/bookmarks aren't all "Admin" | `index.html:22`, `AppShell`, `navData.tsx` | Medium | S |
|
||||||
|
| 11 | Use `PROJECT_STATUS`/`ORDER_STATUS` maps for project + linked-order chips (currently wrong colors / raw DB tokens) | `Projects.tsx:50`, `ProjectDetail.tsx:43,577`, `documentStatus.ts` | Medium | S |
|
||||||
|
| 12 | Fix `"Chyba pripojeni"` diacritics typo; unify cancelled-label spelling | `Dashboard.tsx:127`, `AuthContext.tsx:256,314`; `documentStatus.ts:49,58` | Low | S |
|
||||||
|
| 13 | Add a skip-to-content link + `id="main-content"` on `<main>` | `AppShell.tsx:130-145,210-223` | Medium | S |
|
||||||
|
| 14 | Lazy-load Dashboard so its 7 subcomponents leave the Login critical path | `AdminApp.tsx:14-15`, `Dashboard.tsx:16` | Medium | S |
|
||||||
|
| 15 | Relabel the two Orders-tab "Vytvořit objednávku" buttons (identical for two doc types) | `Orders.tsx:125,129` | Medium | S |
|
||||||
|
|
||||||
|
### High-impact — worth a focused effort
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 16 | Add unsaved-changes guards to the heavy editors that have none; de-fork `OrderDetail`/`InvoiceDetail` onto the shared hook | `ProjectDetail`, `CompanySettings`, `Warehouse*Form`, `AttendanceCreate`; `useUnsavedChangesGuard.ts` | High | M |
|
||||||
|
| 17 | Render an inline error (distinct from empty) when `isError`, with retry | `usePaginatedQuery.ts:47`, all list pages | High | M |
|
||||||
|
| 18 | Standardize validation on inline `<Field error>`; fix the mirrored screens that disagree | `WarehouseSuppliers` vs `OffersCustomers`; `Trips` vs `TripsAdmin`; `ReceivedInvoices` bulk vs single | Medium | M |
|
||||||
|
| 19 | Wrap shared `Modal` in `<form onSubmit>` so Enter submits every dialog | `src/admin/ui/Modal.tsx:84,92` | Medium | M |
|
||||||
|
| 20 | Route the three forked invoice PDF openers through `useDocumentPdf`/`useDocumentListPdf` | `Invoices.tsx:361`, `InvoiceDetail.tsx:1175`, `ReceivedInvoices.tsx:561` | Medium | M |
|
||||||
|
| 21 | Replace inline English/technical error strings with `apiErrorMessage(...)` across ~32 pages | `mutations.ts:30-36`; per-page catch blocks | Medium | M |
|
||||||
|
| 22 | Make `Modal` full-screen on phones; fix leave-modal hardcoded 2-col date grid | `Modal.tsx:67-75`, `Attendance.tsx:1195-1199` | Medium | S |
|
||||||
|
| 23 | Give warehouse line-items the labeled card-per-row mobile layout the document editor has | `WarehouseIssueForm.tsx:513-567` vs `DocumentItemsEditor.tsx:170-342` | Medium | M |
|
||||||
|
| 24 | Add keyboard semantics to `DataTable` clickable rows (role/tabindex/Enter-Space) | `DataTable.tsx:285-303,137-161` | Medium | M |
|
||||||
|
| 25 | Add a "Nová žádost" create action to "Moje žádosti" (lift the leave modal out of Attendance) | `LeaveRequests.tsx:241`, `Attendance.tsx:936,978` | Medium | M |
|
||||||
|
| 26 | Route hand-rolled headers through `PageHeader`/`headerActionsSx` (~20 pages) so mobile buttons stack | `PageHeader.tsx:19`; `Projects`, `Users`, `Vehicles`, `TripsAdmin`, `AttendanceAdmin`, `Settings`, `CompanySettings` | Medium | M |
|
||||||
|
| 27 | Confirm before warehouse **"Potvrdit"** / **"Uložit a potvrdit"** (posts stock irreversibly) | `WarehouseReceiptDetail.tsx:354-356`, `WarehouseReceiptForm.tsx:445` | Medium | S |
|
||||||
|
| 28 | Split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk | `vite.config.ts` | Medium | M |
|
||||||
|
| 29 | Give list-page search inputs an accessible name + `type="search"` (shared `SearchField`) | `Offers.tsx:787`, `Invoices.tsx:741`, +11 pages | Medium | S |
|
||||||
|
|
||||||
|
### Strategic — larger bets
|
||||||
|
|
||||||
|
| # | Change | Where | Impact | Effort |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------ | ------ |
|
||||||
|
| 30 | Add edit-locking (migration + route trio) to `InvoiceDetail` (and order notes) to stop multi-user clobbering | `invoices.ts`, `orders.ts`, schema; wire `useDocumentLock`/`LockBanner` | High | L |
|
||||||
|
| 31 | Migrate `InvoiceDetail` off its fork onto shared `DocumentItemsEditor`/`useDocumentPdf`/dirty-guard (add `showVat` flag) | `InvoiceDetail.tsx:241-541`, `DocumentItemsEditor.tsx` | Medium | L |
|
||||||
|
| 32 | Block dirty in-app navigation (requires `createBrowserRouter`/`RouterProvider` migration, then centralize in the guard) | `main.tsx`, `useUnsavedChangesGuard.ts` | Medium | L |
|
||||||
|
| 33 | Move `AttendanceAdmin` reads off raw `apiFetch` onto React Query (kills the `setTimeout(300)` refetch dance) | `useAttendanceAdmin.ts:823,871,1057` | Medium | L |
|
||||||
|
| 34 | Real skeleton/page-shell first paint instead of full-page spinner on 40 pages | `LoadingState.tsx`, list pages; `skeleton-boha` skill exists | Medium | L |
|
||||||
|
| 35 | Route-transition top progress bar + sidebar link prefetch | `AppShell.tsx:222`, `AdminApp.tsx` | Medium | M |
|
||||||
|
| 36 | Consolidate duplicated status/label maps into `documentStatus.ts` (warehouse/projects/leave) | `documentStatus.ts`; 6 warehouse files, `Projects`, `Leave*` | Medium | M |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Detailed Findings by Area
|
||||||
|
|
||||||
|
### 4.1 Data-freshness — stale data after mutations
|
||||||
|
|
||||||
|
- **Detail-page mutations invalidate fewer domains than their list twins.** `OrderDetail` status/notes/delete (`OrderDetail.tsx:162,168,177`) invalidate only `[orders,invoices]` while the `ReceivedOrders` list delete (`ReceivedOrders.tsx:175`) uses `[orders,offers,projects,invoices]`. `InvoiceDetail` save/status/delete (`InvoiceDetail.tsx:1054,1067,1073`) use `[invoices,orders]` while the `Invoices` list adds `projects` (`Invoices.tsx:328,352`). **Why it matters:** marking an invoice paid or changing an order status from the _detail_ page leaves `ProjectDetail`'s `order_status` label (`ProjectDetail.tsx:571`) and the source offer stale until staleTime/refocus. **Fix:** define one canonical set per family (e.g. `[offers,orders,issued-orders,projects,invoices]`) and apply to both surfaces; `OfferDetail`/`Offers` are the consistent reference to copy.
|
||||||
|
|
||||||
|
- **Self-profile edit refreshes fewer domains than an admin editing the same user.** `DashProfile` (`DashProfile.tsx:225-226`) invalidates only `[dashboard,users]`, but admin Users edit uses `USER_INVALIDATE=[users,trips,attendance,leave-requests,leave,projects]` (`Users.tsx:117-124`). **Why:** your display name is embedded in trips/attendance/leave/projects (none keyed under `[users]`), so renaming yourself leaves it stale there. **Fix:** export `USER_INVALIDATE` from a shared module and reuse it in `DashProfile`.
|
||||||
|
|
||||||
|
- **Cancelling a leave request omits the `[users]` balance invalidation that create/approve include.** Cancel (`LeaveRequests.tsx:90`) invalidates `[leave-requests,leave,attendance]`; create (`Attendance.tsx:305`) and approve/reject (`LeaveApproval.tsx:164,177`) add `users,dashboard`. **Why:** the genuinely-stale gap is `[users]` balance/history when an approved future request is cancelled (the `[dashboard]` part self-heals via `refetchOnMount`). **Fix:** hoist a shared `LEAVE_INVALIDATE` set across all three flows.
|
||||||
|
|
||||||
|
- **Audit Log is never invalidated by the mutations that write audit rows.** `logAudit()` runs on every mutation, but `[audit-log]` is invalidated only by plan flows (`usePlanWork.ts:167`) and the page's own cleanup (`AuditLog.tsx:191`). **Why:** recent actions don't appear within the 30s default staleTime. **Fix:** add `refetchOnMount:'always'` (and `staleTime:0`) to the AuditLog page's inline `useQuery` (`AuditLog.tsx:135`), mirroring `dashboardOptions` — don't sprinkle `[audit-log]` into dozens of arrays. _(Verification note: `auditLogOptions` in `auditLog.ts` is dead code — nothing imports it; either delete it or wire the page onto it and put the option there.)_
|
||||||
|
|
||||||
|
- **Odin invoice import invalidates only `[invoices]`, not `[suppliers]`/`[company-settings]`.** `OdinChat.tsx:362` vs manual `[invoices,suppliers,company-settings]` (`ReceivedInvoices.tsx:331,341`). **Why:** an Odin import that introduces a new vendor name won't appear in the `[suppliers]`-keyed autocomplete until staleTime expires. **Fix:** use the same set on both paths (they POST to the same endpoint).
|
||||||
|
|
||||||
|
- **Received-invoice supplier picker is disjoint from the warehouse/issued-order supplier registry.** Received invoices use a free-text `string[]` under `[suppliers]` → `/received-invoices/suppliers` (`common.ts:22-28`); issued orders/warehouse use the structured `sklad_suppliers` table (`issued-orders.ts:76`, `warehouse.ts:241`). **Why:** a vendor added in Warehouse→Suppliers never shows when entering a received invoice, and vice-versa — same conceptual entity, two disjoint stores. **Fix:** don't force an FK (keep one-off vendors as quick free-text), but **union** `sklad_suppliers` names into the received-invoice autocomplete source.
|
||||||
|
|
||||||
|
### 4.2 Destructive & risky actions — confirmation and undo
|
||||||
|
|
||||||
|
- **Marking an invoice "Zaplaceno" is a single unconfirmed chip click — the only irreversible transition with no guard.** In both invoice lists the chip instantly PUTs `status=paid` with no confirm and no undo (`Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680`); for issued invoices "paid" is terminal (detail goes read-only, row undeletable, `InvoiceDetail.tsx:1220-1314`). Every _detail_ page gates status changes behind a `ConfirmDialog`, but this one doesn't, and the chip only differs from a static one by a pointer cursor. **Fix:** route the chip click through a `ConfirmDialog` ("Označit fakturu … jako zaplacenou?") or an undo toast, and add a hover/tooltip affordance.
|
||||||
|
|
||||||
|
- **Aktivní/Neaktivní chips are hidden one-click toggles with no affordance, and fail silently.** On Vehicles, Users, Warehouse Locations and Suppliers the `StatusChip` is secretly an `onClick` toggle with no tooltip/label (`Vehicles.tsx:263`, `Users.tsx:288`). Deactivation fires on one click while the less-destructive delete gets a full `ConfirmDialog`. The Vehicles/Users toggle mutations declare only `onSuccess`, **no `onError`** and no try/catch (`Vehicles.tsx:146-160`, `Users.tsx:162-171`), so a rejected toggle gives zero feedback and the chip silently reverts. **Fix:** make activation an explicit labelled control (switch/menu action) with a tooltip; add a deactivation confirm for user/vehicle; add `onError` toasts. (`WarehouseLocations`/`WarehouseSuppliers` already toast via try/catch — copy them.)
|
||||||
|
|
||||||
|
- **`ConfirmDialog` in-flight `loading` is applied inconsistently — some dialogs allow duplicate submits.** `IssuedOrderDetail` passes `loading` to both status and delete dialogs (`:958`); `OrderDetail`/`InvoiceDetail` pass it to delete but not status (`OrderDetail.tsx:780-790`, `InvoiceDetail.tsx:2453-2463`); Vehicles delete (`:417-429`), `AttendanceAdmin` delete (raw `apiFetch`, no `isPending`, `useAttendanceAdmin.ts:1293-1320`) and CompanySettings bank delete omit it entirely → a second click fires a duplicate request. **Fix:** pass `loading={mutation.isPending}` to every `ConfirmDialog`; convert `AttendanceAdmin`'s raw delete to `useApiMutation`; standardize on `IssuedOrderDetail`'s keep-open "Zpracovávám…" convention.
|
||||||
|
|
||||||
|
- **Irreversible "Potvrdit" (posts stock) has no confirm, while its reversible "Zrušit doklad" undo does.** `WarehouseReceiptDetail.tsx:354-356` commits stock movements on one click; `:508-517` requires a danger confirm to reverse it. Same single-click commit in the form's "Uložit a potvrdit" (`WarehouseReceiptForm.tsx:445`). **Why:** the gating is inverted relative to consequence; a misclick commits stock. **Fix:** add a `ConfirmDialog` before "Potvrdit" (and ideally the form's commit).
|
||||||
|
|
||||||
|
- **Cancellation confirms show two near-identical "Zrušit" buttons.** When the confirmed action is itself a cancellation, both the dismiss and the confirm start with "Zrušit" (`WarehouseReceiptDetail.tsx:508-516`, `LeaveRequests.tsx:260-268`) — same leading word, opposite meanings (the buttons do differ visually: contained vs text). **Fix:** relabel the dismiss to "Zpět"/"Ne" via `cancelText` (`ConfirmDialog.tsx:31-33`).
|
||||||
|
|
||||||
|
- **Wiping the ENTIRE audit log uses the same low-key modal as trimming old rows.** "Vše" sits in the same dropdown as "30 dní"/"90 dní" and the only warning is a 0.6-opacity caption (`AuditLog.tsx:297-319,207-218`). The backend deliberately requires a special literal to wipe everything (`routes/admin/audit-log.ts:92-109`), but the UI auto-supplies it with no extra friction. **Fix:** when "Vše" is selected, switch to danger styling, show a prominent warning, ideally require typing a confirmation word.
|
||||||
|
|
||||||
|
- **Error toasts auto-dismiss after 4s identically to successes, with no history.** `AlertContext.tsx:53-70` defaults every severity to 4000ms; a long Czech validation message vanishes as fast as "Uloženo," with no toast history (`AlertContainer.tsx:20-27`). **Fix:** key the default off severity — error/warning longer or sticky-until-dismissed, success ~4s.
|
||||||
|
|
||||||
|
### 4.3 Error & message feedback fidelity
|
||||||
|
|
||||||
|
- **Raw English/technical error strings leak into Czech toasts on ~30 pages.** A helper `apiErrorMessage(err, fallback)` converts client-generated English ("Unauthorized", "Failed to fetch", "Invalid JSON response", "Request failed (NNN)") into a Czech fallback (`mutations.ts:30-36,69-84`), but only `OfferDetail`/`IssuedOrderDetail` use it. ~30 pages do `alert.error(e instanceof Error ? e.message : "Chyba připojení")` (`Vehicles.tsx:203`, `OrderDetail.tsx:191`), passing the raw English through on dropped connections / non-JSON 500s / 401s. **Fix:** replace the inline pattern with `apiErrorMessage(e, "<Czech fallback>")` across the ~32 files. **Do not** add a blanket `useApiMutation` default `onError` — pages call `mutateAsync` inside try/catch and also reset local state there, so a default handler would double-toast.
|
||||||
|
|
||||||
|
### 4.4 Unsaved-changes & data-loss protection
|
||||||
|
|
||||||
|
- **The dirty guard is absent on heavy editors and forked twice.** Shared `useUnsavedChangesGuard` is used only by `OfferDetail`/`IssuedOrderDetail`; `OrderDetail` (`:130-143`) and `InvoiceDetail` (`:942-958`) re-implement `isDirty`+`beforeunload` inline; `ProjectDetail`, `CompanySettings`, `WarehouseIssueForm/ReceiptForm/InventoryForm` and `AttendanceCreate` register **no guard at all** — a refresh discards everything typed. **Fix:** route the two forks through the shared hook and add it to the six unguarded editors.
|
||||||
|
|
||||||
|
- **Dirty guards block only tab-close, not in-app navigation.** Every guard registers only `beforeunload` (`useUnsavedChangesGuard.ts:25-33`), which doesn't fire on React-Router navigation — so the in-app "Zpět" or a sidebar link discards edits silently. **Fix (structural):** the app uses classic `<BrowserRouter>` (react-router 6.30.3) where `useBlocker`/`usePrompt` aren't available — this needs a migration to `createBrowserRouter`/`RouterProvider` (or a custom nav-intercept context), then centralize the blocker inside the shared hook.
|
||||||
|
|
||||||
|
- **Always-editable page forms offer no explicit Discard.** Modals have "Zrušit" and `WarehouseItemDetail` has an edit/cancel toggle that restores the snapshot (`:247-269`), but `ProjectDetail` (only Uložit+Smazat, `:343`) and `CompanySettings` (only Uložit, `:647`) give no way to abandon edits — you must reload (and they don't even warn). **Fix:** add a "Zrušit změny" button that restores the loaded snapshot from query cache.
|
||||||
|
|
||||||
|
- **Invoices have no edit-lock while offers and issued orders do (multi-user clobbering).** `OfferDetail`/`IssuedOrderDetail` acquire a server lock, heartbeat, render `LockBanner` and force read-only when another user holds it; `InvoiceDetail` imports none of it (`:22-88`), so two users can clobber each other's line items (last-save-wins). Same gap for received-order notes on `OrderDetail`. **Fix (structural):** the _client_ pieces exist (`useDocumentLock`+`LockBanner`) but the _server_ trio does **not** — needs a migration adding `locked_by`/`locked_at` to invoices (and orders), a lock/heartbeat/unlock route trio + `locked_by` enrichment in the detail endpoint (mirror `issued-orders.ts:138-245`), then wire the existing hook + banner.
|
||||||
|
|
||||||
|
### 4.5 Form validation & submission
|
||||||
|
|
||||||
|
- **Validation is inline in some forms, transient toasts in others — and mirrored screens disagree.** Inline `<Field error>` camp: Trips, Users, Vehicles, WarehouseSuppliers, OfferDetail. Toast camp: ProjectDetail, AttendanceCreate, CompanySettings, OffersCustomers, TripsAdmin, ReceivedInvoices-edit. The disagreements: supplier modal inline (`WarehouseSuppliers.tsx:266-270`) vs its mirror customer modal toast (`OffersCustomers.tsx:338-341`); regular trip editor inline `end_km>start_km` (`Trips.tsx:294-300`) vs admin trip editor toast (`TripsAdmin.tsx:485-490`); received-invoice bulk-review inline per-row (`:431-446`) vs single-edit toast (`:508-519`). **Fix:** standardize on inline `<Field error>`; reserve toasts for server/transport failures.
|
||||||
|
|
||||||
|
- **Enter submits page forms but never any shared-Modal form.** `Modal` renders children in `DialogContent` with an `onClick` button (default `type=button`) and no surrounding `<form>` (`Modal.tsx:84,92`), so Enter never submits a modal. `OfferDetail` even hand-rolled an `onKeyDown` Enter workaround (`:1059-1061`). **Fix:** wrap `DialogContent`+`DialogActions` in `<form onSubmit>`, make the primary action `type=submit`, mark secondary/in-content buttons `type=button`, and remove the workaround.
|
||||||
|
|
||||||
|
- **Failed validation doesn't move focus to the first invalid field nor announce to AT.** Handlers just `setErrors` and return (`Vehicles.tsx:192-197`); the `Field` error is a plain caption wired via `aria-describedby`/`aria-invalid`, not `aria-live`/`role=alert` (`Field.tsx:81-89`). In a long modal the error can be off-screen. **Fix:** after `setErrors`, focus+`scrollIntoView` the first errored field and render the inline error with `role=alert`; centralize in `Field.tsx`/`Modal.tsx`.
|
||||||
|
|
||||||
|
- **Required-asterisk marking is inconsistent with what's validated; DateField/TimeField can't show an error border.** `Field` paints the asterisk only when `required` is passed (`Field.tsx:74`), but it's applied unevenly: `WarehouseIssueForm` validates Projekt-required with no asterisk (`:474` vs `:328-329`); `ProjectDetail` validates Název-required with no asterisk (`:377` vs `:192`); `DateField`/`TimeField` take no `error` prop so a failed required date only reddens helper text, not the border. **Fix:** make `required` the single source of truth (drives asterisk + ideally validation); thread an `error` prop through `DateField`/`TimeField`.
|
||||||
|
|
||||||
|
- **Save-button feedback differs (spinner vs text swap); dual-save warehouse forms don't show which action is running.** Document pages render a `CircularProgress` and track `savingAction` so only the clicked button spins (`OfferDetail.tsx:764`); most other forms just swap to text (`ProjectDetail.tsx:346`). On `WarehouseIssueForm` both save buttons swap to identical "Ukládání..." and disable (`:455-465`) — you can't tell which is in flight. **Fix:** adopt one affordance app-wide (spinner+disabled with per-action targeting); spin only the clicked warehouse action.
|
||||||
|
|
||||||
|
- **Note typed in the create-project modal is saved to the legacy column and shown as non-editable.** The "Přidat projekt" modal's Poznámka writes `projects.notes` (`Projects.tsx:164`, `projects.service.ts:222`), but `ProjectDetail` renders that column read-only under "Starší poznámka (před zavedením systému)" (`:477,491`). So a note typed at creation is instantly mislabeled as legacy and uneditable, while the real editable `project_notes` system lives only on the detail page. **Fix:** drop the Poznámka field from the create modal, or route the create-time note through `createProjectNote`.
|
||||||
|
|
||||||
|
### 4.6 Loading, empty & error states
|
||||||
|
|
||||||
|
- **Failed list fetches silently render the empty state.** `usePaginatedQuery` exposes `isError`/`error` (`:47-48`) but no list page reads them, and `queryClient.ts` has no `QueryCache.onError`. So a 500/network drop falls through to the `DataTable` empty slot — Projects shows "Zatím nejsou žádné projekty" (`:480`). Detail pages handle errors loudly; `WarehouseReports.tsx:350` shows an inline `<Alert severity=error>`. **Fix:** add a `QueryCache.onError` toast and/or render an inline error distinct from empty when `isError`, with retry wired to `refetch`.
|
||||||
|
|
||||||
|
- **Empty states built two ways, and clickable-CTA vs text-only split.** Shared `<EmptyState>` is used by document/warehouse lists, but Projects/Vehicles/Users hand-roll `Box(textAlign,py:6)+Typography+Button` (`Projects.tsx:471/480`, `Vehicles.tsx:323`, `Users.tsx:350`), reinventing the `action` prop. Offers renders a clickable "Vytvořit první nabídku" (`:835`) while Invoices/ReceivedInvoices only name the button in description text (`Invoices.tsx:770`). **Fix:** migrate hand-rolled empties to `<EmptyState title description action>` and standardize the clickable-create-CTA convention.
|
||||||
|
|
||||||
|
- **Empty-state microcopy is inconsistent** (fragments vs sentences, trailing-period split, three filtered-empty phrasings, 2nd person). Fragments with no period (`AuditLog.tsx:368`, `WarehouseReports.tsx:223,364`) vs full sentences (`AttendanceHistory.tsx:653`). Filtered-empty worded three ways: "neodpovídají filtru" (`Offers.tsx:827`) / "neodpovídají filtru." (`Projects.tsx:473`) / "neodpovídají hledání." (`ReceivedInvoices.tsx:859`). Nothing-yet mixes "Zatím nejsou žádné X." with 2nd-person "Zatím nemáte žádné žádosti" (`LeaveRequests.tsx:253`). **Fix:** adopt one canonical filtered-empty string, one unfiltered-empty string, one trailing-period rule.
|
||||||
|
|
||||||
|
- **Changing a month/filter gives three different loading behaviors.** Document/warehouse lists dim the card via `isFetching` opacity (`Offers.tsx:815`). `TripsHistory`/`TripsAdmin` keep stale data with NO feedback (`:304-306`/`:810-812`). `AttendanceHistory` uses plain `useQuery` with no `placeholderData`, so a month change flips `isPending` and swaps the whole table for a centered spinner (`:523,646`). **Fix:** standardize on the `isFetching` card-dim; give Trips screens the dim and `AttendanceHistory` `keepPreviousData`.
|
||||||
|
|
||||||
|
- **No real skeletons; 40 list pages blank the whole page to a centered spinner (layout shift); "skeleton" identifiers are misnamed.** The only first-paint affordance is `LoadingState` (centered spinner, `LoadingState.tsx:10-22`); 40 pages early-return it before rendering chrome (`Projects.tsx:267`, `Offers.tsx:492`), so title/tabs/filter bar vanish then snap in. There are zero MUI `<Skeleton>`s despite identifiers like `showListSkeleton` (`ReceivedInvoices.tsx:357`). **Fix:** render `PageHeader`+`FilterBar` immediately and confine the spinner (or a real DataTable skeleton — the `skeleton-boha` skill exists) to the table area; at minimum rename the misleading identifiers.
|
||||||
|
|
||||||
|
- **Redundant sequential/stacked spinners.** First visit to a lazy section shows the Suspense fallback spinner (chunk download, `AdminApp.tsx:112`), then the page's own `isPending` shows a SECOND centered spinner. Separately `ReceivedInvoices` never early-returns: `renderKpi` shows `<LoadingState/>` (`:752`) AND the list shows one (`:846`) — two stacked spinners on cold load, where sibling `Invoices` shows one. **Fix:** render a single unified loading state per page (have lazy pages supply their own shell as the Suspense fallback).
|
||||||
|
|
||||||
|
- **Dashboard hand-rolls its loading spinner and mixes empty-state styles.** `Dashboard.tsx:313-316` and `DashSessions.tsx:161-164` inline `Box+CircularProgress` (different padding/size, no aria-label) vs the shared `<LoadingState/>`. `DashTodayPlan`/`DashSessions` hand-roll Typography empties while `DashActivityFeed`/`DashAttendanceToday` use `<EmptyState>`. On the most-visited page. **Fix:** use `<LoadingState/>` and standardize card empties on `<EmptyState>`.
|
||||||
|
|
||||||
|
- **Profile, 2FA and active sessions are gated behind the heavy dashboard aggregate query.** `DashProfile` and `DashSessions` render only inside `{!dashLoading && (...)}` (`Dashboard.tsx:505`), where `dashLoading` is the multi-domain aggregate (attendance+offers+invoices+orders+projects+leave). Neither block depends on that data — `DashProfile`'s 2FA comes from `totpStatusOptions`, `DashSessions` has its own query (`:74`). **Fix:** render `DashProfile`/`DashSessions` independently; gate only the data-dependent cards.
|
||||||
|
|
||||||
|
- **Autocomplete pickers show English "No options."** `MuiProvider` sets only the date-picker locale, no global `csCZ` `localeText` (`:15-22`); `CustomerPicker.tsx:49`/`SupplierPicker.tsx:49` omit `noOptionsText`, so empty search shows MUI's built-in English. `ItemPicker.tsx:61` correctly sets "Žádné položky." **Fix:** add MUI `csCZ` component `localeText` (fixes every Autocomplete/pagination at once) or at minimum `noOptionsText` on the two pickers.
|
||||||
|
|
||||||
|
### 4.7 List-page consistency
|
||||||
|
|
||||||
|
- **Invoices/ReceivedInvoices/AuditLog search fires a query per keystroke (no debounce).** Offers/IssuedOrders/ReceivedOrders/Projects/Warehouse use `useDebounce(value,300)`, but `Invoices.tsx:282`, `ReceivedInvoices.tsx:250` and `AuditLog.tsx:202` feed the raw value into the query key — laggier and hammering the API on the busiest financial screens. **Fix:** wrap in `useDebounce` (or a shared `SearchField`).
|
||||||
|
|
||||||
|
- **DataTable `onRowClick` rows aren't keyboard-operable; row-click only exists on warehouse lists.** Warehouse Items/Issues/Inventory/Receipts open via clicking the row (their _only_ entry), but Offers/Invoices/Orders/Projects/Users/Vehicles don't set `onRowClick`. Worse, the clickable `TableRow` has no `tabIndex`/`role`/`onKeyDown` (`DataTable.tsx:285-303,137-161`), so a keyboard user **cannot** open a warehouse record. `OdinSidebar` already has the correct pattern (`:126-140`). **Fix:** add `role="button"`, `tabIndex=0`, Enter/Space handling when `onRowClick` is set; then pick one row convention.
|
||||||
|
|
||||||
|
- **Column sorting absent on warehouse-document, audit-log and trips tables that look sortable.** `DataTable` renders sort headers only when columns carry `sortKey` and the page wires `onSort`. Offers/Invoices/Orders/Projects/WarehouseItems do; `WarehouseIssues.tsx:258`, `AuditLog.tsx:364`, `TripsAdmin.tsx:815` and WarehouseReceipts don't — you can't reorder warehouse docs by date/status nor the audit log by user, with no cue distinguishing a sortable header from a dead one. **Fix:** wire `useTableSort`+`sortKey`+`onSort` into the large paginated tables.
|
||||||
|
|
||||||
|
- **Sibling Order tabs disagree on the "all" status label.** `IssuedOrders` uses "Všechny" (`:60`), `ReceivedOrders` "Všechny stavy" (`:54`) under the same parent Orders tabs. **Fix:** unify the label (don't force the Tabs-vs-Select control change — IssuedOrders deliberately keeps a Select).
|
||||||
|
|
||||||
|
- **Filter baseline diverges: search/date/reset missing where needed.** WarehouseIssues/Receipts have search+status+date-range+reset; `WarehouseInventory.tsx:149` has only a status dropdown (no search despite `session_number`); `WarehouseReservations.tsx:354` has no text search or date filter; `AuditLog.tsx:321` (5 filters) and `TripsAdmin.tsx:734` (3 filters) have **no reset**. **Fix:** standardize a list-filter baseline; add the missing search/date/reset.
|
||||||
|
|
||||||
|
- **Offers/IssuedOrders filter by customer/supplier but the Invoices list cannot.** Offers has a customer Select (`:797-812`), IssuedOrders a supplier Select (`:400-415`), but the equally customer-centric Invoices list offers only free-text search (`:739-751`). **Fix:** add a customer Select (load via `offerCustomersOptions`, wire `customer_id` into list+totals; may need a small backend add).
|
||||||
|
|
||||||
|
- **Count subtitle missing on Projects/Users/Vehicles/Customers and the Přijaté tab; count copy splits `czechPlural` vs hand-rolled ternary.** Projects/Users/Vehicles show a bare title (`Projects.tsx:427`, `Users.tsx:338`, `Vehicles.tsx:311`), Customers a static description (`:492`), and Vydané shows a count line but Přijaté doesn't. The plural is hand-rolled five times (`WarehouseIssues.tsx:112-114`) where `czechPlural()` exists (`formatters.ts:69`). **Fix:** add a result-count subtitle via `PageHeader`; replace the hand-rolled ternaries with `czechPlural()`.
|
||||||
|
|
||||||
|
- **Many pages hand-roll the title bar instead of `PageHeader`, so multi-button headers wrap raggedly on mobile.** `PageHeader`'s `headerActionsSx` makes header buttons stack full-width on phones (`PageHeader.tsx:19-30`), but ~20 pages hand-roll `Box+Typography h4 + raw action Box` (Projects, Users, Vehicles, TripsAdmin, Attendance, AttendanceAdmin, Settings, CompanySettings). The multi-button ones (AttendanceAdmin Tisk/Vyplnit/Přidat `:179-198`; TripsAdmin Tisk/Vozidla `:699-731`) wrap into a ragged half-width grid on phones. **Fix:** route through `<PageHeader>` or wrap the action Box in `headerActionsSx`.
|
||||||
|
|
||||||
|
- **Three forked invoice PDF openers → inconsistent feedback and different error copy.** Shared `useDocumentListPdf`/`useDocumentPdf` pre-opens the tab, shows a per-row spinner, guards double-clicks, handles 401 and revokes the blob URL (`useDocumentPdf.ts:37-86`); Offers/IssuedOrders use it. But Invoices reimplements it inline (`:361`), ReceivedInvoices reimplements with **no** loading state (`:561`), and InvoiceDetail forks its own (`:1175`). Same failure yields three different sentences. **Fix:** route all three through the shared hook (make the error string a parameter — received-invoice `openFile` streams a stored upload, not a generated PDF).
|
||||||
|
|
||||||
|
- **Invoices LIST shows a PDF button on draft invoices, dead-ending in a 404.** Offers/IssuedOrders hide the PDF icon for drafts and InvoiceDetail's own header hides it on drafts (`:1890`), but the Invoices list renders it for every invoice gated only on permission (`:616-626`). **Fix:** gate on `inv.invoice_number` too, matching `Offers.tsx:660`/`IssuedOrders.tsx:303`.
|
||||||
|
|
||||||
|
- **Customers and Suppliers directories use opposite data/search/pagination models.** Built as mirrors, but `OffersCustomers` fetches the entire list once and filters client-side with no debounce/pagination (`:159,380`), while `WarehouseSuppliers` is server-paginated, debounced, page-sized with real Pagination (`:139,449`). **Fix:** align Customers on the Suppliers model (server pagination/search), preserving instant responsiveness via `keepPreviousData`/quick debounce.
|
||||||
|
|
||||||
|
### 4.8 Navigation & information architecture
|
||||||
|
|
||||||
|
- **Received-order detail returns you to the wrong Orders tab.** Received orders live under Orders→"Přijaté", but `OrderDetail`'s Zpět/post-delete/fetch-error redirects all go to `/orders` with no tab, and Orders defaults to "vydane" (`Orders.tsx:78-79`) — so you land on the issued-orders list (a different doc type). IssuedOrderDetail does it right with `/orders?tab=vydane`. **Fix:** point `OrderDetail`'s links (`:116,280,399`) at `/orders?tab=prijate`.
|
||||||
|
|
||||||
|
- **The "Zpět" back control is built four different ways.** No shared back primitive: document detail puts a labeled outlined Zpět on the LEFT (`OfferDetail.tsx:643`); warehouse detail puts it on the RIGHT inside PageHeader (`WarehouseItemDetail.tsx:372`); warehouse forms use an icon-only IconButton on the left (`WarehouseReceiptForm.tsx:423`); `AttendanceCreate` uses a text "← Zpět na správu" on the right (`:173`). All hardcode the list route. **Fix:** add an optional `back={{to,label}}`/`onBack` prop to `PageHeader` rendering one canonical left-aligned icon+label control.
|
||||||
|
|
||||||
|
- **No per-page browser title — every tab/bookmark/history entry reads "Admin."** `index.html:22` hardcodes `<title>Admin</title>` and nothing updates it (zero `document.title` refs). With multiple tabs open (common here), the tab strip/history/bookmarks all read "Admin." **Fix:** add a `<TitleSync>` setting `document.title` per route from `navData.tsx` labels (`+ " · BOHA"`).
|
||||||
|
|
||||||
|
- **No route breadcrumbs on deep detail/form pages.** Deep destinations (`/warehouse/items/:id`, `/warehouse/receipts/:id/edit`, `/orders/issued/:id`) give only one back link to a single hardcoded list, with no "Sklad › Položky › <item>" trail. Combined with the missing title, deep-page orientation is thin. **Fix:** add a lightweight section › list › record breadcrumb derived from route + `navData` metadata.
|
||||||
|
|
||||||
|
- **`settings.templates` gates the Nastavení nav item but Settings bounces such users to the dashboard.** The gear shows if the user holds any of five permissions including `settings.templates`, but `canAccessSettings` checks only roles/company/system/banking and `Navigate`-to-`/` otherwise (`Settings.tsx:199-200,421-423`). So a templates-only user sees the gear, clicks, and is teleported away. Meanwhile the offer-templates editor (`/offers/templates`, `OffersTemplates.tsx:137`) has **no** nav entry — only a "Šablony" button buried in the Offers header (`Offers.tsx:750`). **Fix:** drop `settings.templates` from the Nastavení gate (`navData.tsx:486-492`) and give the templates editor a discoverable home.
|
||||||
|
|
||||||
|
- **Three different "access denied" experiences.** ~40 pages render the polished `<Forbidden/>` (403 + explanation + "Zpět na Dashboard"); Settings silently `Navigate`-to-`/` (`:421-423`); Odin shows a bare unstyled line of text (`Odin.tsx:9-17`). **Fix:** standardize on `<Forbidden/>` everywhere.
|
||||||
|
|
||||||
|
- **"Zákazníci" master data sits under the misleading URL `/offers/customers`.** Customers are a top-level entity referenced by offers, orders and invoices, yet the route implies a sub-resource of offers — visible in code as the `matchExclude:['/offers/customers','/offers/templates']` hack the Nabídky nav item needs (`navData.tsx:250`). **Fix:** move customers to a top-level `/customers` (redirect from the old URL), removing the hack.
|
||||||
|
|
||||||
|
- **Master-data lists are scattered across four nav sections with no cross-links.** Zákazníci under Administrativa, Dodavatelé under Sklad, Vozidla under Kniha jízd, Uživatelé under Systém (`navData.tsx:226,297,448,470`). Salient consequence: issued orders (Administrativa) pull suppliers from `sklad_suppliers` (Sklad), so fixing a supplier means jumping to Sklad. **Fix:** at minimum add cross-links (e.g. "spravovat dodavatele" from the issued-order supplier picker → `/warehouse/suppliers`).
|
||||||
|
|
||||||
|
- **Sklad nav is a flat 10-item list mixing daily ops with one-time config.** Ten ungrouped items (`navData.tsx:314-463`) intermix `warehouse.operate` daily drivers with rarely-touched `warehouse.manage` config (Kategorie/Lokace/Dodavatelé). **Fix:** split into operations vs "nastavení skladu" subgroups (or move the three config screens behind one entry).
|
||||||
|
|
||||||
|
- **One "Vytvořit objednávku" button navigates to a page on one tab and opens a modal on the other.** On Objednávky: Vydané routes to `/orders/issued/new`, Přijaté opens a modal (`Orders.tsx:119-132`, `ReceivedOrders.tsx:606`). Across modules, offers/invoices/issued orders use routed full-page forms while received orders create in a modal. **Fix:** pick one pattern per tier or split into two distinct labelled actions.
|
||||||
|
|
||||||
|
- **No skip-to-content link — keyboard users tab the whole sidebar on every page.** A permanent 248px sidebar with many links renders before `<main>` (`AppShell.tsx:130-145,210-223`), with no skip link and no id on `<main>`. **Fix:** add a visually-hidden "Přeskočit na obsah" link (visible on focus) targeting `id="main-content"`.
|
||||||
|
|
||||||
|
### 4.9 Discoverability & workflow friction
|
||||||
|
|
||||||
|
- **"Moje žádosti" has no create action.** Its header has no actions and its empty state literally instructs filing on the Docházka page (`LeaveRequests.tsx:241,254`); the only way to open the leave form is a button buried on the punch screen (`Attendance.tsx:936,978`). **Fix:** add a "Nová žádost" button to `LeaveRequests` opening the same modal; lift the modal into a shared component.
|
||||||
|
|
||||||
|
- **Dashboard "Vaše dnešní zařazení" card shows the project as plain text — every other dash card links onward.** `DashTodayPlan` renders `projectLabel` as plain text (`:69`) while other cards link "Vše →"/"Detail →." **Fix:** wrap `projectLabel` in a `RouterLink` to `/projects/:id` (id is already in `TodayPlan`); optionally add a "Plán prací →" header button.
|
||||||
|
|
||||||
|
- **Issue detail shows the consumed reservation as a raw "ID: n" database identifier** (`WarehouseIssueDetail.tsx:204`), unlike the project reference beside it (a real link). **Fix:** at minimum render "R{id}" to match `ReservationPicker`'s own label (`:65`).
|
||||||
|
|
||||||
|
- **`ReservationPicker` is dead code — a manual Výdejka can't draw down an existing reservation.** A ready-made picker exists (`ReservationPicker.tsx:14`) but is imported nowhere; the issue form only sets `reservation_id` from router prefill (`WarehouseIssueForm.tsx:183`). **Fix:** render it in the issue-form line (gated on item_id+project_id, mirroring `BatchSelect`) — or delete the unused component.
|
||||||
|
|
||||||
|
- **Audit log surfaces only the one-line description — the stored old/new values are on the wire but never shown.** `audit_logs` stores `old_values`/`new_values` and the route returns full rows (`routes/admin/audit-log.ts:64-74`), but the page's row type omits them and rows aren't expandable (`AuditLog.tsx:97-105,229-275`). The most valuable part of an audit trail is invisible. **Fix:** add the fields to the type and open a detail modal rendering old→new.
|
||||||
|
|
||||||
|
- **Odin's empty state never hints it can query real system data.** The landing hero only says "Zeptejte se na cokoli, nebo přiložte fakturu…" (`OdinThread.tsx:82-149`), giving no hint Odin can query invoices/offers/orders/projects/warehouse via its read-only tools. **Fix:** add 3-4 clickable example-prompt chips that prefill the composer (`OdinChat.tsx:218-219`).
|
||||||
|
|
||||||
|
- **Keyboard shortcuts ship undiscoverable; `ShortcutsHelp` is a permanent no-op.** `ShortcutsHelp` is mounted but returns null (`:1-7`, `AppShell.tsx:226`), while Ctrl+Enter (note save), Enter (Odin send), Enter (plan category) ship with no help. **Fix:** build the "?"-opens-cheat-sheet overlay, or as a cheap first step add `title` hints (copy `OdinComposer`'s `title="Odeslat (Enter)"` onto `NoteCard`'s Ctrl+Enter).
|
||||||
|
|
||||||
|
- **Plan grid resets to the current week on every remount.** `usePlanWork` inits view/anchor to `new Date()` with no persistence (`:112-113`), so leaving and returning to `/plan` loses your scroll position. **Fix:** persist view+anchor (URL query or localStorage).
|
||||||
|
|
||||||
|
- **"Zpět na správu" / post-save links pass `?month=YYYY-MM` but AttendanceAdmin ignores it.** `AttendanceCreate` (`:141`) and `AttendanceLocation` (`:211`) link to `/attendance/admin?month=…`, but `useAttendanceAdmin` never reads the URL (hard-inits month to current). **Fix:** seed the initial month from `useSearchParams()` and keep it synced.
|
||||||
|
|
||||||
|
- **Receipt/Issue details never show a document-level total.** They compute per-line "Celkem" but the `DataTable` has no footer/sum (`WarehouseReceiptDetail.tsx:250`, `WarehouseIssueDetail.tsx:182`); forms show no running total. Every other money-bearing document leads with a prominent total. **Fix:** add a summary line (sum of qty×unit_price via `formatCurrency`) under the Položky table and a live running total in the forms.
|
||||||
|
|
||||||
|
- **Inventory form only creates a draft; Receipt/Issue forms offer "Uložit a potvrdit" in one action.** `WarehouseInventoryForm` offers only "Vytvořit inventuru" (`:204`); to post you must go to the detail and click "Potvrdit inventuru" (`:209`). **Fix:** add a "Vytvořit a potvrdit" button (the form already collects `actual_qty`), OR surface a hint explaining the deliberate two-step variance-review flow.
|
||||||
|
|
||||||
|
- **Trips admin can't enter a trip on behalf of a driver, though attendance admin can create for any employee.** `TripsAdmin` offers only Tisk + a Vozidla link (`:710`), no "add trip," and its edit modal has no driver picker. The `/trips` POST already accepts `body.user_id` (`trips.ts:341`). **Fix:** add an "Přidat jízdu" action with a driver selector, mirroring `AttendanceAdmin`.
|
||||||
|
|
||||||
|
- **⚠ Found during verification (backend, not UX): `POST /trips` accepts `body.user_id` without a `trips.manage` check.** Any user holding only `trips.record` can create a trip attributed to another user's id (`src/routes/admin/trips.ts:341`, `trips.schema.ts:13`) — reads are correctly scoped (`buildTripsWhere` limits non-managers to their own trips) but the create path is not. Minor impersonation-on-create authz gap. **Fix:** ignore `body.user_id` (force `authData.userId`) unless the caller holds `trips.manage`.
|
||||||
|
|
||||||
|
- **Personal Trips "Záznam" shows company-wide trips/stats for managers.** Unlike "Moje historie" (filters by `userId`), the personal Trips page calls `tripListOptions`/`tripStatsOptions` with no `userId` and renders a "Řidič" column (`Trips.tsx:166,359`) — surfacing all drivers' trips and company-wide totals, unlike the strictly-personal attendance punch screen. **Fix:** pass `userId` (matching `TripsHistory`), or relabel/drop the Řidič column. Keep list and stats on one scope.
|
||||||
|
|
||||||
|
### 4.10 Forked/duplicated shared modules & code drift
|
||||||
|
|
||||||
|
- **`InvoiceDetail` forks the shared document editor (and drops the item-description maxLength cap).** Offers/issued orders compose shared `DocumentItemsEditor`/`useDocumentPdf`/`useUnsavedChangesGuard`; `InvoiceDetail` reimplements all three (`:241-541,1175-1196,942-958`). Divergence: the invoice item description textarea has **no maxLength** while the shared editor caps it (`DocumentItemsEditor.tsx:390`) — an over-long description 500s at Prisma instead of capping client-side. **Fix:** add a `showVat`/`vatOptions` flag to `DocumentItemsEditor` (respecting "VAT only on invoices") and migrate `InvoiceDetail` onto it plus the shared hooks.
|
||||||
|
|
||||||
|
- **Status label/color maps duplicated across warehouse, projects and leave.** `documentStatus.ts` exists specifically to kill per-page maps, yet DRAFT/CONFIRMED/CANCELLED recur across 6 warehouse files (`WarehouseIssues.tsx:35-48`), `Projects.tsx:44-57`/`ProjectDetail.tsx:37-50` are byte-identical, and `LeaveRequests.tsx:25-55`/`LeaveApproval.tsx:36-66` are byte-identical. **Fix:** add `WAREHOUSE_DOC_STATUS`, `MOVEMENT_TYPE`, `RESERVATION_STATUS`, `PROJECT_STATUS`, `LEAVE_STATUS`/`LEAVE_TYPE` to `documentStatus.ts` and import them.
|
||||||
|
|
||||||
|
- **`AttendanceAdmin` fetches outside React Query (raw `apiFetch` in `useEffect` + manual refetch dance).** `useAttendanceAdmin` loads projects/users/records via raw `apiFetch` with its own `setData`/`setLoading` (`:823,871`), so it doesn't participate in `['attendance']` invalidations — each mutation must invalidate AND manually re-run `fetchData` with a `setTimeout(300)` (`:1057-1063`). The single biggest structural inconsistency and a latent stale-data risk. **Fix:** migrate reads to React Query options.
|
||||||
|
|
||||||
|
- **Orphaned standalone attendance-create page diverges from the admin modal (no project logs).** Adding a record exists twice: a standalone full-page form at `/attendance/create` AND `ShiftFormModal` (`AttendanceAdmin.tsx:194`). The modal supports per-shift project-time logs; the standalone page can't (`AttendanceCreate.tsx:63-147`), and nothing navigates to it. **Fix:** delete `AttendanceCreate.tsx` and its route — the modal is the live superset.
|
||||||
|
|
||||||
|
- **"Vytvořit objednávku" modal uses two different file pickers.** The Offers-list version uses the shared `FileUpload`; the Offer-detail version reimplements it with a raw `<input type=file>` + manual readout + custom remove button (`OfferDetail.tsx:1066-1112` vs `Offers.tsx:894-929`). Same dialog, two attachment UIs. **Fix:** replace the raw input with `FileUpload` (gains drag-drop/validation).
|
||||||
|
|
||||||
|
- **Warehouse entities use three different create/edit interaction models.** Items edit inline on the detail page via a toggle (`WarehouseItemDetail.tsx:112`); Receipts/Issues use a full-page Form + read-only Detail; Inventory has a Form for creation only; Reservations/Categories/Locations/Suppliers use a list-page Modal. Items (a flat record like Suppliers) using inline edit is the main outlier. **Fix:** document one convention (line-item records = Form+Detail; flat lookups = list modal) and reconcile the Items outlier — treat as a documented rule, not a forced risky migration.
|
||||||
|
|
||||||
|
- **Inconsistent "record not found": Item detail redirects away, siblings keep a stable URL.** `WarehouseItemDetail` toasts + `navigate('/warehouse/items')` (`:153-158`); `WarehouseIssueDetail`/`ReceiptDetail`/`InventoryDetail` render an in-place "nebyla nalezena" EmptyState with a Back button and keep the URL (`:122`/`:200`/`:99`). **Fix:** align Item detail to the in-place EmptyState pattern.
|
||||||
|
|
||||||
|
- **Settings save model is inconsistent.** The System tab has **two** "Uložit" buttons doing the same thing (`Settings.tsx:997-1007,1193-1199`); other cards have no save; 2FA saves instantly; on Firma, bank accounts/logo apply instantly while company info/numbering/currency need the bottom save (`CompanySettings.tsx:990-1013,1457-1461`). The user can't predict whether a change is live or pending. **Fix:** drop one duplicate System Save; visually distinguish auto-apply vs pending controls.
|
||||||
|
|
||||||
|
- **Attendance location page formats datetime with `new Date()` instead of the timezone-safe shared helper.** `AttendanceLocation` defines a local `formatDatetimeLocal` parsing with `new Date()` (`:176`); the rest of attendance uses the timezone-safe `formatDatetime` (`attendanceHelpers.ts:42`), so the same timestamp renders differently. **Fix:** build a regex-based timezone-safe formatter that keeps the year (the location detail legitimately wants the year, which `formatDatetime` omits).
|
||||||
|
|
||||||
|
- **Document editor uses two reorder gestures side by side.** Line items reorder via a drag handle (`DocumentItemsEditor.tsx:350-364`), but sections (`SectionsEditor.tsx:200-219`) and company custom fields (`CompanySettings.tsx:1049-1067`) use up/down arrows — on the same page. The drag-only items are also less keyboard-discoverable. **Fix:** add up/down arrow buttons alongside the line-item drag handle (matching SectionsEditor) — also fixes the keyboard-affordance gap.
|
||||||
|
|
||||||
|
- **Login re-implements the theme toggle.** The shell uses shared `<ThemeToggle/>` (animated crossfade, title + aria-label); Login builds its own IconButton with hand-duplicated SVGs, only a title, no aria-label, no animation (`Login.tsx:264-313` vs `ThemeToggle.tsx:36-57`). The first screen every user sees is less accessible. **Fix:** reuse the shared `<ThemeToggle/>`.
|
||||||
|
|
||||||
|
### 4.11 Visual & theming consistency
|
||||||
|
|
||||||
|
- **Projects status-chip colors diverge from the app-wide convention (and duplicate the label map).** `documentStatus.ts` establishes success=done/paid, error=cancelled, info=open. Projects breaks all three — ACTIVE=green, COMPLETED=**blue**, CANCELLED=**grey** (`Projects.tsx:50`, `ProjectDetail.tsx:43`). So a finished project shows blue while a finished order shows green. **Fix:** add a `PROJECT_STATUS` map (aktivni→info, dokonceny→success, zruseny→error) and consume via `statusLabel`/`statusColor`.
|
||||||
|
|
||||||
|
- **Linked order status on Project detail renders as a raw untranslated token.** `ProjectDetail` shows `STATUS_LABELS[project.order_status]` but `STATUS_LABELS` is the _project_ map while `order_status` is `prijata`/`v_realizaci`/… — zero key overlap (`:577`), so a completed order shows "(dokoncena)" lowercase plain text. **Fix:** render via `statusLabel(ORDER_STATUS, project.order_status)` (ideally a `StatusChip`).
|
||||||
|
|
||||||
|
- **Settings admin-role callout uses a solid `info.light`/`info.dark` fill (the forbidden .light-fill anti-pattern).** The "Administrátor má vždy plný přístup" box is hand-styled with `bgcolor:'info.light'`/`color:'info.dark'` (`Settings.tsx:1228-1229`) — the single solid palette-.light fill in the admin, violating the documented channel-alpha rule; in dark mode it's garish low-contrast blue-on-blue. **Fix:** use the kit `<Alert severity='info'>` (`Alert.tsx:8`), or a channel-alpha wash.
|
||||||
|
|
||||||
|
- **Dashboard "Vystavit fakturu" quick action reuses the destructive error/red palette.** `DashQuickActions` sets color `'danger'` → MUI error (`:296`,`:59`), the same red as every Delete button, so "Issue invoice" reads as destructive beside green/blue/orange create actions. _(Caveat: red is also the invoice PDF brand family (#de3a3a), and "Odchod" uses `danger` too — this may be deliberate branding; treat as optional.)_ **Fix:** if not intentional, change to a non-destructive color (info/primary); reserve error/red for deletes.
|
||||||
|
|
||||||
|
- **Plan `DayInRangeModal` shows raw ISO dates while the rest of the plan dialogs format cs-CZ.** It prints "2026-06-28 – 2026-07-02" (`PlanCellModal.tsx:466,474,495`) while ViewModal/DayPanel format "28. 6. 2026." **Fix:** wrap the dates in `formatDate()`.
|
||||||
|
|
||||||
|
- **Project delete confirm differs between list and detail.** The list ConfirmDialog always renders the "Smazat i soubory na disku" checkbox even for projects with no NAS folder, with terse copy, no name, no irreversibility warning (`Projects.tsx:508,516`); the detail gates the checkbox on `has_nas_folder` and uses richer copy + "Tato akce je nevratná." (`ProjectDetail.tsx:621,626`). **Fix:** gate the list checkbox on `has_nas_folder` and use the same copy (name + warning).
|
||||||
|
|
||||||
|
### 4.12 Microcopy & localization
|
||||||
|
|
||||||
|
- **Goods-receipt module named four ways; "Příjmy" also reads as financial income.** Nav "Příjmy" (`navData.tsx:348`), overview button "Nový příjem" (`Warehouse.tsx:203`), list title "Příjmové doklady" + "Nový doklad" (`WarehouseReceipts.tsx:170,178`), detail/audit "Příjemka" (`WarehouseReceiptDetail.tsx:204`, `entityTypeLabels.ts:29`). Issues mirror this (Výdeje/Výdejky/Výdejka). "Příjmy" is the standard Czech word for _income_, so the nav label is actively misleading. **Fix:** settle on "Příjemka"/"Výdejka" everywhere.
|
||||||
|
|
||||||
|
- **Saving/busy labels inconsistent; one "Chyba pripojeni" diacritics typo; Modal overrides caller `submitText` mid-save.** Competing forms: "Ukládám…" (Modal, AttendanceCreate, NoteCard, DashProfile), "Ukládání..." (most pages), "Zpracovávám…" vs "Zpracovávám...", plus "Vytváření..."/"Odesílám..."; ellipsis sometimes "…" sometimes three dots. The connection-error fallback is "Chyba pripojeni" (no diacritics) on the dashboard (`Dashboard.tsx:127`, `AuthContext.tsx:256,314`) vs "Chyba připojení" elsewhere. Modal hardcodes `loading?'Ukládám…':submitText` (`:96`), overriding "Vytvořit uživatele" mid-save. **Fix:** fix the typo (outlier vs ~90 sites); standardize the busy label + Unicode ellipsis; the Modal hardcoding is acceptable for save forms (lower priority).
|
||||||
|
|
||||||
|
- **"Cancelled" order status spelled two ways in `documentStatus.ts`.** Received orders use `stornovana`→"Stornována" (`:49`); issued orders use `cancelled`→"Stornovaná" (`:58`) — side-by-side under the same `/orders` page. These are display labels (safe to change). **Fix:** pick one Czech form for both.
|
||||||
|
|
||||||
|
- **Add-line button uses literal "+ Přidat položku" in document editors vs icon + "Přidat položku" in warehouse forms.** `DocumentItemsEditor.tsx:596` (literal plus, no startIcon) vs warehouse forms with `startIcon={PlusIcon}` (`WarehouseIssueForm.tsx:601` etc.). **Fix:** standardize the affordance and label.
|
||||||
|
|
||||||
|
- **Create/edit modal submit labels mix entity-specific, bare, and default wording.** "Vytvořit zákazníka"/"Uložit změny" (`OffersCustomers.tsx:543`), "Vytvořit uživatele" (`Users.tsx:369`), bare "Vytvořit"/"Uložit" (`OffersTemplates.tsx:361`), and Vehicles falls back to the Modal default "Uložit" (`Modal.tsx:36`). **Fix:** adopt one rule (create="Vytvořit", edit="Uložit změny") and set it as the Modal default.
|
||||||
|
|
||||||
|
- **Create-action verb differs across analogous list pages; both Orders tabs share the identical "Vytvořit objednávku" label.** Imperative "Přidat X" (master data) vs "Nový/Nová X" (documents) vs "Vytvořit objednávku" (Orders, identical on both tabs for two different doc types, `Orders.tsx:125,129`); the empty-state CTA verb mirrors and compounds this. **Fix:** disambiguate the two Orders tabs first ("Nová vydaná/přijatá objednávka"), then pick one verb convention and align the empty-state CTA.
|
||||||
|
|
||||||
|
- **Several numbers/prices bypass cs-CZ formatting; file-size unit casing disagrees (kB vs KB).** Offers item-template price uses `Number().toFixed(2)`→"1234.50" (period, no separator/currency, `OffersTemplates.tsx:287`); warehouse batch picker builds "…toFixed(2) Kč" (`WarehouseIssueForm.tsx:160`); vacation/sick hour balances use `.toFixed(1)`→"12.5" (`AttendanceBalances.tsx:307`); zeros render "0 Kč" vs "0,00 Kč." File-size helper duplicated with "kB" (`FileUpload.tsx:9`) vs "KB" (`OfferDetail.tsx:1072`). **Fix:** route money through `formatCurrency` and decimals through cs-CZ/Intl; extract one `formatFileSize()` with one unit casing.
|
||||||
|
|
||||||
|
- **Leave-request modal day/hour estimate ignores public holidays.** The modal previews "X pracovních dnů" using `calculateBusinessDays` (Mon–Fri only, `Attendance.tsx:498,1242`), but the rest of the system is holiday-aware (server `leave-requests.ts:99` excludes weekends AND holidays). A vacation spanning e.g. 1 May over-counts vs the stored `total_days`/`total_hours`. **Fix:** make the modal estimate holiday-aware (reuse the holiday helper).
|
||||||
|
|
||||||
|
### 4.13 Mobile / responsive
|
||||||
|
|
||||||
|
- **Status-filter tab bars clip on phones — shared `Tabs` has no scrollable variant.** `Tabs.tsx:28-49` uses MUI's default `variant="standard"` (overflow hidden, no scroll). At ~360px the 5 offer/invoice tabs and 4 project tabs overflow the centered Box and get clipped with no scroll affordance — effectively unreachable. **Fix:** set `variant="scrollable"`, `scrollButtons="auto"`, `allowScrollButtonsMobile` — one change fixes every status filter and the issued/received toggle.
|
||||||
|
|
||||||
|
- **Shared Modal never goes full-screen on phones; leave date grid is hardcoded two-column.** `Modal` renders a plain `fullWidth+maxWidth` Dialog with no responsive `fullScreen` (`:67-75`), so the ~9-field Trip form, the admin shift form and the leave form become a narrow scrolling box at 360px. The leave modal's Od/Do grid is hardcoded `minmax(0,1fr) minmax(0,1fr)` (`Attendance.tsx:1195-1199`) while every other date-pair grid is responsive `{xs:'1fr',sm:'1fr 1fr'}` (`ShiftFormModal.tsx:254`, `Trips.tsx:528-535`). **Fix:** add `fullScreen={useMediaQuery(down('sm'))}` to Modal; fix the leave grid.
|
||||||
|
|
||||||
|
- **Long full-page editors keep Save only in the top header (no sticky save); placement differs page-to-page.** Document detail editors place all Save/Create/Activate in the top `headerActionsSx` slot (`OfferDetail.tsx:688-828`), so after scrolling through header fields/picker/line-items/rich-text/notes a phone user must scroll back to the top to save. `AttendanceCreate` pins Zrušit/Uložit at the **bottom** (`:362-374`); modals pin submit in DialogActions — three placements. **Fix:** add a sticky action bar to long editors and standardize placement.
|
||||||
|
|
||||||
|
- **Warehouse issue/receipt line items lack the labeled card-per-row mobile layout the document editor has.** `DocumentItemsEditor` has a dedicated `isMobile` branch — a bordered card per row with "Položka N" index, labeled fields, per-row total, drag/remove (`:170-342`). `WarehouseIssueForm.tsx:513-567` just collapses a grid to `xs:'1fr'` with placeholder-only fields, no border/labels/separator — once a value is typed the placeholder vanishes and rows blur together. Worst exactly where shop-floor mobile use is likely. **Fix:** reuse the card-per-row pattern (ideally a shared mobile line-item card).
|
||||||
|
|
||||||
|
- **FilterBar children mix fixed-vs-grow flex bases, leaving dead space on mobile.** `AttendanceAdmin.tsx:202,205` use `flex:'0 0 180px'`/`'0 0 220px'` and `AttendanceHistory.tsx:516` uses `flex:'0 0 180px'` (fixed, never grow), so on a phone they sit narrow with dead space beside them, while `Offers.tsx:786` uses `flex:'1 1 320px'` which fills the row. **Fix:** adopt one convention (e.g. `flex:'1 1 <min>px'`) or have `FilterBar` stretch children full-width below `sm`.
|
||||||
|
|
||||||
|
### 4.14 Accessibility
|
||||||
|
|
||||||
|
- **Desktop document line-item grid inputs are unlabeled while the mobile card layout labels every field.** The mobile branch passes explicit labels (Popis, Množství, Jednotka, Jedn. cena, Sleva %); the desktop table renders the same TextFields with **no** label/aria-label, relying on visual `<th>` headers not programmatically associated with the inputs; the desktop "V ceně" Checkbox is unnamed (`DocumentItemsEditor.tsx:374-440,445-450`). A screen-reader user editing an offer on desktop hears a column of unlabeled "edit text" boxes. **Fix:** add an `aria-label` to each desktop cell control (e.g. `Množství, položka ${index+1}`).
|
||||||
|
|
||||||
|
- **List-page search boxes are placeholder-only inputs with no accessible name.** Raw TextFields with only a placeholder (not an accessible name) across ~13 pages (`Offers.tsx:787`, `Invoices.tsx:741`, `WarehouseItems.tsx:180`, `Projects.tsx:454`, `IssuedOrders.tsx:386`). **Fix:** add `aria-label="Hledat"` + `type="search"` — best via a shared `SearchField` (also adds the native clear button and mobile search keyboard).
|
||||||
|
|
||||||
|
- **(Cross-listed) DataTable clickable rows aren't keyboard-operable; no skip-to-content link.** See §4.7 (row semantics) and §4.8 (skip link).
|
||||||
|
|
||||||
|
### 4.15 Performance & bundle
|
||||||
|
|
||||||
|
- **MUI + Emotion live in the 781 kB entry chunk, so every deploy busts the cached UI vendor code.** `vite.config.ts` `manualChunks` only carves out react/react-dom/react-router (436 kB) and framer-motion (147 kB); the entire MUI library + Emotion fall into the entry chunk (781 kB raw / 232 kB gzip). With frequent same-day patch releases, daily users re-download ~232 kB gzip of unchanged MUI every deploy. **Fix:** split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk.
|
||||||
|
|
||||||
|
- **framer-motion (147 kB) is a static dependency of the eager AppShell, loading on first paint incl. Login.** `AppShell`/`PageEnter`/`ThemeToggle` statically import framer-motion (`PageHeader` deliberately does not); `AppShell` is eagerly imported by `AdminApp` (`:8`, `AppShell.tsx:101`). Login pays for it too. **Fix:** migrate to `LazyMotion` + the lightweight `m` component (`domAnimation`) — note Login and the Dash\* components import framer-motion directly, so lazy-gating only `PageEnter` won't remove it from the login critical path.
|
||||||
|
|
||||||
|
- **Dashboard + its 7 Dash\* subcomponents are bundled into the entry chunk, downloaded on the Login screen.** Dashboard and Login are statically imported in `AdminApp` (`:14-15`), so an unauthenticated user hitting `/login` downloads the entire authenticated dashboard's code before typing a password. **Fix:** lazy-load Dashboard via `lazyWithReload` like every other route; keep Login eager.
|
||||||
|
|
||||||
|
- **No route-transition progress indicator and no link prefetch — heavy pages blank to a spinner on click.** Clicking a sidebar item blanks main content to a centered spinner while the chunk downloads (PlanWork 44 kB, InvoiceDetail 35 kB, AttendanceLocation 155 kB with Leaflet), with no top progress bar and no prefetch (zero `prefetchQuery`/`onMouseEnter`/`preload` hits). A `ProgressBar` exists but is reused only as an attendance-fund meter. **Fix:** add an unobtrusive top `LinearProgress` during route transitions and prefetch the lazy chunk (+ primary query) on sidebar link hover/focus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Suggested Sequencing
|
||||||
|
|
||||||
|
**Sprint 1 — "Stop lying to the user" (mostly Quick Wins, ~2-3 days).**
|
||||||
|
Ship the data-freshness convergence (one invalidation set per family), the global `QueryCache.onError` so failed fetches stop showing as empty, the scrollable mobile tabs, the invoice-paid confirm, the silent-toggle `onError` toasts, the draft-PDF 404 gate, the wrong-Orders-tab redirect, the search debounces, the `csCZ` localeText, the `document.title`, the project/order status-color fixes, and the diacritics/label typos. These are individually small, collectively high-trust, and low-risk. Pair each with a quick Playwright check on the dev server (the user runs it).
|
||||||
|
|
||||||
|
**Sprint 2 — "Converge the primitives" (the structural anti-drift work, ~1 week).**
|
||||||
|
De-fork the dirty-guards and add them to the six unguarded editors; route the invoice PDF openers through `useDocumentPdf`; consolidate the status maps into `documentStatus.ts`; standardize validation on inline `<Field error>` (starting with the mirrored screens); add `<form onSubmit>` to Modal; migrate hand-rolled headers to `PageHeader`; replace inline English errors with `apiErrorMessage`. This is where you stop the Sprint-1 fixes from re-drifting, and it's the cleanest fit for the codebase's "extend, never fork" rule.
|
||||||
|
|
||||||
|
**Sprint 3 — "Mobile + a11y + warehouse parity" (~1 week).**
|
||||||
|
Full-screen modals, sticky save bars, warehouse card-per-row line items, keyboard-operable rows, skip link, search-field accessible names, warehouse document totals, the leave "Nová žádost" action, and the warehouse "Potvrdit" confirm.
|
||||||
|
|
||||||
|
**Strategic backlog (schedule deliberately, each is multi-day and one needs a migration).**
|
||||||
|
Invoice edit-locking (DB migration + route trio — coordinate the migration per CLAUDE.md: ask the user to stop the dev server, apply to `app_test` too). The router upgrade for in-app dirty-nav blocking. `AttendanceAdmin` onto React Query. `InvoiceDetail` onto the shared editor. Real skeletons. Bundle splitting + route prefetch. Treat the navigation/IA items (breadcrumbs, master-data nav grouping, `/customers` move) as a focused IA pass once the above settle.
|
||||||
|
|
||||||
|
A note on scope discipline: nearly every item here is "make B match the already-correct A," so the safest pattern is to **read the reference implementation first** (offers/issued orders for documents, `WarehouseSuppliers` for lists, `documentStatus.ts` for status) and converge onto it — rather than inventing new patterns. That keeps the diff small and the regression surface low, which matches the user's same-day-patch-release rhythm.
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# boha-app-ts
|
||||||
|
|
||||||
|
Internal business-management system for a Czech company (attendance, invoicing,
|
||||||
|
leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite
|
||||||
|
of the legacy PHP app. User-facing text is Czech by design.
|
||||||
|
|
||||||
|
> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA
|
||||||
|
- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an
|
||||||
|
SPA in `src/admin/`, served by the same Fastify process (Vite middleware in
|
||||||
|
dev, static files in prod)
|
||||||
|
- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the
|
||||||
|
admins-only "Odin" assistant)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js (LTS) and a MySQL database
|
||||||
|
- Copy `.env.example` → `.env` and fill in the **required** vars (`DATABASE_URL`,
|
||||||
|
`JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx prisma generate
|
||||||
|
npx prisma migrate dev # apply migrations to your dev DB
|
||||||
|
npx prisma db seed # optional: dev-only sample data (NEVER on prod)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:server # API (tsx watch) on http://127.0.0.1:3050
|
||||||
|
npm run dev:client # Vite dev server (manage separately)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # tsc server → dist/ + vite client → dist-client/
|
||||||
|
npm start # node dist/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quality gates
|
||||||
|
|
||||||
|
Before committing, all of these must pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`)
|
||||||
|
npm run build
|
||||||
|
npx vitest run
|
||||||
|
npm run lint # ESLint (incl. react-hooks) — see eslint.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database migrations
|
||||||
|
|
||||||
|
Every schema/data change is a tracked Prisma migration — **never** `prisma db push`
|
||||||
|
or raw SQL on production. Stop the dev server before running `prisma migrate dev`.
|
||||||
|
See CLAUDE.md → _Database Migrations_ for the full workflow and the production
|
||||||
|
deploy/hotfix process.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
393
REVIEW.md
Normal file
393
REVIEW.md
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
# Codebase Audit Checklist
|
||||||
|
|
||||||
|
**Date:** 2026-06-09 | **Total files:** 304 | Scope: full project (source + config). Excluded: docs/*.md, prisma/migrations/*.sql (generated SQL), package-lock.json, binaries.
|
||||||
|
|
||||||
|
## (root)
|
||||||
|
|
||||||
|
- [x] .env.example
|
||||||
|
- [x] .gitignore
|
||||||
|
- [x] .prettierignore
|
||||||
|
- [x] .prettierrc.json
|
||||||
|
- [x] boneyard.config.json
|
||||||
|
- [x] CLAUDE.md
|
||||||
|
- [x] eslint.config.mjs
|
||||||
|
- [x] index.html
|
||||||
|
- [x] package.json
|
||||||
|
- [x] prisma.config.ts
|
||||||
|
- [x] tsconfig.app.json
|
||||||
|
- [x] tsconfig.json
|
||||||
|
- [x] tsconfig.server.json
|
||||||
|
- [x] tsconfig.test.json
|
||||||
|
- [x] vite.config.ts
|
||||||
|
- [x] vitest.config.ts
|
||||||
|
|
||||||
|
## .claude
|
||||||
|
|
||||||
|
- [x] .claude/hooks/block-env.js
|
||||||
|
- [x] .claude/hooks/format-on-save.js
|
||||||
|
- [x] .claude/settings.json
|
||||||
|
- [x] .claude/settings.local.json
|
||||||
|
|
||||||
|
## prisma
|
||||||
|
|
||||||
|
- [x] prisma/seed.ts
|
||||||
|
- [x] prisma/schema.prisma
|
||||||
|
|
||||||
|
## scripts
|
||||||
|
|
||||||
|
- [x] scripts/check-roles.mjs
|
||||||
|
- [x] scripts/migrate-received-invoices-to-nas.ts
|
||||||
|
- [x] scripts/rotate-totp-key.ts
|
||||||
|
- [x] scripts/seed-test-users.mjs
|
||||||
|
|
||||||
|
## src/__tests__
|
||||||
|
|
||||||
|
- [x] src/__tests__/ai.test.ts
|
||||||
|
- [x] src/__tests__/auth.service.test.ts
|
||||||
|
- [x] src/__tests__/auth.test.ts
|
||||||
|
- [x] src/__tests__/bank-accounts.test.ts
|
||||||
|
- [x] src/__tests__/company-settings-numbering.test.ts
|
||||||
|
- [x] src/__tests__/customers.schema.test.ts
|
||||||
|
- [x] src/__tests__/drafts-aggregation.test.ts
|
||||||
|
- [x] src/__tests__/drafts-deferred-numbering.test.ts
|
||||||
|
- [x] src/__tests__/env.test.ts
|
||||||
|
- [x] src/__tests__/exchange-rates.test.ts
|
||||||
|
- [x] src/__tests__/helpers.ts
|
||||||
|
- [x] src/__tests__/invoices.service.test.ts
|
||||||
|
- [x] src/__tests__/issued-orders.test.ts
|
||||||
|
- [x] src/__tests__/manual-create.test.ts
|
||||||
|
- [x] src/__tests__/nas-document-manager.test.ts
|
||||||
|
- [x] src/__tests__/nas-file-manager.test.ts
|
||||||
|
- [x] src/__tests__/nas-project-folder.test.ts
|
||||||
|
- [x] src/__tests__/numbering.test.ts
|
||||||
|
- [x] src/__tests__/offer-invoice-totals.test.ts
|
||||||
|
- [x] src/__tests__/orders-list.test.ts
|
||||||
|
- [x] src/__tests__/order-totals.test.ts
|
||||||
|
- [x] src/__tests__/plan.test.ts
|
||||||
|
- [x] src/__tests__/planAuditDescription.test.ts
|
||||||
|
- [x] src/__tests__/planCategory.test.ts
|
||||||
|
- [x] src/__tests__/received-invoices-vat.test.ts
|
||||||
|
- [x] src/__tests__/setup.ts
|
||||||
|
- [x] src/__tests__/schema-nan.test.ts
|
||||||
|
- [x] src/__tests__/warehouse.test.ts
|
||||||
|
|
||||||
|
## src/admin (AdminApp.tsx)
|
||||||
|
|
||||||
|
- [x] src/admin/AdminApp.tsx
|
||||||
|
|
||||||
|
## src/admin (components)
|
||||||
|
|
||||||
|
- [x] src/admin/components/AlertContainer.tsx
|
||||||
|
- [x] src/admin/components/AttendanceShiftTable.tsx
|
||||||
|
- [x] src/admin/components/BulkAttendanceModal.tsx
|
||||||
|
- [x] src/admin/components/BulkPlanModal.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashKpiCards.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashProfile.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashQuickActions.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashSessions.tsx
|
||||||
|
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
|
||||||
|
- [x] src/admin/components/ErrorBoundary.tsx
|
||||||
|
- [x] src/admin/components/Forbidden.tsx
|
||||||
|
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinComposer.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinChat.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinMark.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinSidebar.tsx
|
||||||
|
- [x] src/admin/components/odin/OdinThread.tsx
|
||||||
|
- [x] src/admin/components/odin/types.ts
|
||||||
|
- [x] src/admin/components/OrderConfirmationModal.tsx
|
||||||
|
- [x] src/admin/components/PlanCategoriesModal.tsx
|
||||||
|
- [x] src/admin/components/PlanCellModal.tsx
|
||||||
|
- [x] src/admin/components/PlanGrid.tsx
|
||||||
|
- [x] src/admin/components/PlanRangeChips.tsx
|
||||||
|
- [x] src/admin/components/ProjectFileManager.tsx
|
||||||
|
- [x] src/admin/components/RichEditor.tsx
|
||||||
|
- [x] src/admin/components/ShiftFormModal.tsx
|
||||||
|
- [x] src/admin/components/ShortcutsHelp.tsx
|
||||||
|
- [x] src/admin/components/warehouse/ItemPicker.tsx
|
||||||
|
- [x] src/admin/components/warehouse/ReservationPicker.tsx
|
||||||
|
|
||||||
|
## src/admin (context)
|
||||||
|
|
||||||
|
- [x] src/admin/context/AlertContext.tsx
|
||||||
|
- [x] src/admin/context/AuthContext.tsx
|
||||||
|
|
||||||
|
## src/admin (GlobalStyles.tsx)
|
||||||
|
|
||||||
|
- [x] src/admin/GlobalStyles.tsx
|
||||||
|
|
||||||
|
## src/admin (hooks)
|
||||||
|
|
||||||
|
- [x] src/admin/hooks/useAttendanceAdmin.ts
|
||||||
|
- [x] src/admin/hooks/useDebounce.ts
|
||||||
|
- [x] src/admin/hooks/useModalLock.ts
|
||||||
|
- [x] src/admin/hooks/usePaginatedQuery.ts
|
||||||
|
- [x] src/admin/hooks/usePlanWork.ts
|
||||||
|
- [x] src/admin/hooks/useReducedMotion.ts
|
||||||
|
- [x] src/admin/hooks/useTableSort.ts
|
||||||
|
|
||||||
|
## src/admin (lib)
|
||||||
|
|
||||||
|
- [x] src/admin/lib/apiAdapter.ts
|
||||||
|
- [x] src/admin/lib/documentStatus.ts
|
||||||
|
- [x] src/admin/lib/entityTypeLabels.ts
|
||||||
|
- [x] src/admin/lib/queries/ai.ts
|
||||||
|
- [x] src/admin/lib/queries/attendance.ts
|
||||||
|
- [x] src/admin/lib/queries/auditLog.ts
|
||||||
|
- [x] src/admin/lib/queries/common.ts
|
||||||
|
- [x] src/admin/lib/queries/dashboard.ts
|
||||||
|
- [x] src/admin/lib/queries/invoices.ts
|
||||||
|
- [x] src/admin/lib/queries/issued-orders.ts
|
||||||
|
- [x] src/admin/lib/queries/leave.ts
|
||||||
|
- [x] src/admin/lib/queries/mutations.ts
|
||||||
|
- [x] src/admin/lib/queries/offers.ts
|
||||||
|
- [x] src/admin/lib/queries/orders.ts
|
||||||
|
- [x] src/admin/lib/queries/plan.ts
|
||||||
|
- [x] src/admin/lib/queries/projects.ts
|
||||||
|
- [x] src/admin/lib/queries/settings.ts
|
||||||
|
- [x] src/admin/lib/queries/trips.ts
|
||||||
|
- [x] src/admin/lib/queries/users.ts
|
||||||
|
- [x] src/admin/lib/queries/vehicles.ts
|
||||||
|
- [x] src/admin/lib/queries/warehouse.ts
|
||||||
|
- [x] src/admin/lib/queryClient.ts
|
||||||
|
|
||||||
|
## src/admin (pages)
|
||||||
|
|
||||||
|
- [x] src/admin/pages/Attendance.tsx
|
||||||
|
- [x] src/admin/pages/AttendanceAdmin.tsx
|
||||||
|
- [x] src/admin/pages/AttendanceBalances.tsx
|
||||||
|
- [x] src/admin/pages/AttendanceCreate.tsx
|
||||||
|
- [x] src/admin/pages/AttendanceHistory.tsx
|
||||||
|
- [x] src/admin/pages/AttendanceLocation.tsx
|
||||||
|
- [x] src/admin/pages/AuditLog.tsx
|
||||||
|
- [x] src/admin/pages/CompanySettings.tsx
|
||||||
|
- [x] src/admin/pages/Dashboard.tsx
|
||||||
|
- [x] src/admin/pages/InvoiceDetail.tsx
|
||||||
|
- [x] src/admin/pages/Invoices.tsx
|
||||||
|
- [x] src/admin/pages/IssuedOrderDetail.tsx
|
||||||
|
- [x] src/admin/pages/IssuedOrders.tsx
|
||||||
|
- [x] src/admin/pages/LeaveApproval.tsx
|
||||||
|
- [x] src/admin/pages/LeaveRequests.tsx
|
||||||
|
- [x] src/admin/pages/Login.tsx
|
||||||
|
- [x] src/admin/pages/NotFound.tsx
|
||||||
|
- [x] src/admin/pages/Odin.tsx
|
||||||
|
- [x] src/admin/pages/OfferDetail.tsx
|
||||||
|
- [x] src/admin/pages/Offers.tsx
|
||||||
|
- [x] src/admin/pages/OffersCustomers.tsx
|
||||||
|
- [x] src/admin/pages/OffersTemplates.tsx
|
||||||
|
- [x] src/admin/pages/OrderDetail.tsx
|
||||||
|
- [x] src/admin/pages/Orders.tsx
|
||||||
|
- [x] src/admin/pages/PlanWork.tsx
|
||||||
|
- [x] src/admin/pages/ProjectDetail.tsx
|
||||||
|
- [x] src/admin/pages/Projects.tsx
|
||||||
|
- [x] src/admin/pages/ReceivedInvoices.tsx
|
||||||
|
- [x] src/admin/pages/ReceivedOrders.tsx
|
||||||
|
- [x] src/admin/pages/Settings.tsx
|
||||||
|
- [x] src/admin/pages/Trips.tsx
|
||||||
|
- [x] src/admin/pages/TripsAdmin.tsx
|
||||||
|
- [x] src/admin/pages/TripsHistory.tsx
|
||||||
|
- [x] src/admin/pages/UiKit.tsx
|
||||||
|
- [x] src/admin/pages/Users.tsx
|
||||||
|
- [x] src/admin/pages/Vehicles.tsx
|
||||||
|
- [x] src/admin/pages/Warehouse.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseCategories.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseInventory.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseInventoryDetail.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseInventoryForm.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseIssueDetail.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseIssueForm.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseIssues.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseItemDetail.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseItems.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseLocations.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseReceiptDetail.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseReceiptForm.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseReceipts.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseReports.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseReservations.tsx
|
||||||
|
- [x] src/admin/pages/WarehouseSuppliers.tsx
|
||||||
|
|
||||||
|
## src/admin (theme.test.ts)
|
||||||
|
|
||||||
|
- [x] src/admin/theme.test.ts
|
||||||
|
|
||||||
|
## src/admin (theme.ts)
|
||||||
|
|
||||||
|
- [x] src/admin/theme.ts
|
||||||
|
|
||||||
|
## src/admin (ui)
|
||||||
|
|
||||||
|
- [x] src/admin/ui/Alert.tsx
|
||||||
|
- [x] src/admin/ui/AppShell.tsx
|
||||||
|
- [x] src/admin/ui/Button.tsx
|
||||||
|
- [x] src/admin/ui/Card.tsx
|
||||||
|
- [x] src/admin/ui/ConfirmDialog.tsx
|
||||||
|
- [x] src/admin/ui/CustomerPicker.tsx
|
||||||
|
- [x] src/admin/ui/DataTable.tsx
|
||||||
|
- [x] src/admin/ui/DateField.tsx
|
||||||
|
- [x] src/admin/ui/EmptyState.tsx
|
||||||
|
- [x] src/admin/ui/Field.tsx
|
||||||
|
- [x] src/admin/ui/FileUpload.tsx
|
||||||
|
- [x] src/admin/ui/FilterBar.tsx
|
||||||
|
- [x] src/admin/ui/Checkbox.tsx
|
||||||
|
- [x] src/admin/ui/index.ts
|
||||||
|
- [x] src/admin/ui/LoadingState.tsx
|
||||||
|
- [x] src/admin/ui/Modal.tsx
|
||||||
|
- [x] src/admin/ui/MonthField.tsx
|
||||||
|
- [x] src/admin/ui/MuiProvider.tsx
|
||||||
|
- [x] src/admin/ui/navData.tsx
|
||||||
|
- [x] src/admin/ui/PageEnter.tsx
|
||||||
|
- [x] src/admin/ui/PageHeader.tsx
|
||||||
|
- [x] src/admin/ui/Pagination.tsx
|
||||||
|
- [x] src/admin/ui/ProgressBar.tsx
|
||||||
|
- [x] src/admin/ui/RichText.tsx
|
||||||
|
- [x] src/admin/ui/Select.tsx
|
||||||
|
- [x] src/admin/ui/SidebarNav.tsx
|
||||||
|
- [x] src/admin/ui/StatCard.tsx
|
||||||
|
- [x] src/admin/ui/StatusChip.tsx
|
||||||
|
- [x] src/admin/ui/Tabs.tsx
|
||||||
|
- [x] src/admin/ui/TextField.tsx
|
||||||
|
- [x] src/admin/ui/ThemeToggle.tsx
|
||||||
|
- [x] src/admin/ui/TimeField.tsx
|
||||||
|
- [x] src/admin/ui/useDialogScrollLock.ts
|
||||||
|
|
||||||
|
## src/admin (utils)
|
||||||
|
|
||||||
|
- [x] src/admin/utils/api.ts
|
||||||
|
- [x] src/admin/utils/attendanceHelpers.ts
|
||||||
|
- [x] src/admin/utils/dashboardHelpers.ts
|
||||||
|
- [x] src/admin/utils/formatters.ts
|
||||||
|
|
||||||
|
## src/App.tsx
|
||||||
|
|
||||||
|
- [x] src/App.tsx
|
||||||
|
|
||||||
|
## src/config
|
||||||
|
|
||||||
|
- [x] src/config/database.ts
|
||||||
|
- [x] src/config/env.ts
|
||||||
|
|
||||||
|
## src/context
|
||||||
|
|
||||||
|
- [x] src/context/ThemeContext.tsx
|
||||||
|
|
||||||
|
## src/main.tsx
|
||||||
|
|
||||||
|
- [x] src/main.tsx
|
||||||
|
|
||||||
|
## src/middleware
|
||||||
|
|
||||||
|
- [x] src/middleware/auth.ts
|
||||||
|
- [x] src/middleware/security.ts
|
||||||
|
|
||||||
|
## src/routes
|
||||||
|
|
||||||
|
- [x] src/routes/admin/ai.ts
|
||||||
|
- [x] src/routes/admin/attendance.ts
|
||||||
|
- [x] src/routes/admin/audit-log.ts
|
||||||
|
- [x] src/routes/admin/auth.ts
|
||||||
|
- [x] src/routes/admin/bank-accounts.ts
|
||||||
|
- [x] src/routes/admin/company-settings.ts
|
||||||
|
- [x] src/routes/admin/customers.ts
|
||||||
|
- [x] src/routes/admin/dashboard.ts
|
||||||
|
- [x] src/routes/admin/invoices.ts
|
||||||
|
- [x] src/routes/admin/invoices-pdf.ts
|
||||||
|
- [x] src/routes/admin/issued-orders.ts
|
||||||
|
- [x] src/routes/admin/issued-orders-pdf.ts
|
||||||
|
- [x] src/routes/admin/leave-requests.ts
|
||||||
|
- [x] src/routes/admin/offers-pdf.ts
|
||||||
|
- [x] src/routes/admin/orders.ts
|
||||||
|
- [x] src/routes/admin/orders-pdf.ts
|
||||||
|
- [x] src/routes/admin/plan.ts
|
||||||
|
- [x] src/routes/admin/profile.ts
|
||||||
|
- [x] src/routes/admin/project-files.ts
|
||||||
|
- [x] src/routes/admin/projects.ts
|
||||||
|
- [x] src/routes/admin/quotations.ts
|
||||||
|
- [x] src/routes/admin/received-invoices.ts
|
||||||
|
- [x] src/routes/admin/roles.ts
|
||||||
|
- [x] src/routes/admin/scope-templates.ts
|
||||||
|
- [x] src/routes/admin/sessions.ts
|
||||||
|
- [x] src/routes/admin/totp.ts
|
||||||
|
- [x] src/routes/admin/trips.ts
|
||||||
|
- [x] src/routes/admin/users.ts
|
||||||
|
- [x] src/routes/admin/vehicles.ts
|
||||||
|
- [x] src/routes/admin/warehouse.ts
|
||||||
|
|
||||||
|
## src/server.ts
|
||||||
|
|
||||||
|
- [x] src/server.ts
|
||||||
|
|
||||||
|
## src/services
|
||||||
|
|
||||||
|
- [x] src/services/ai.service.ts
|
||||||
|
- [x] src/services/attendance.service.ts
|
||||||
|
- [x] src/services/audit.ts
|
||||||
|
- [x] src/services/auth.ts
|
||||||
|
- [x] src/services/exchange-rates.ts
|
||||||
|
- [x] src/services/invoice-alerts.ts
|
||||||
|
- [x] src/services/invoices.service.ts
|
||||||
|
- [x] src/services/issued-orders.service.ts
|
||||||
|
- [x] src/services/leave-notification.ts
|
||||||
|
- [x] src/services/mailer.ts
|
||||||
|
- [x] src/services/nas-file-manager.ts
|
||||||
|
- [x] src/services/nas-financials-manager.ts
|
||||||
|
- [x] src/services/nas-offers-manager.ts
|
||||||
|
- [x] src/services/numbering.service.ts
|
||||||
|
- [x] src/services/offers.service.ts
|
||||||
|
- [x] src/services/orders.service.ts
|
||||||
|
- [x] src/services/plan.service.ts
|
||||||
|
- [x] src/services/planCategory.service.ts
|
||||||
|
- [x] src/services/projects.service.ts
|
||||||
|
- [x] src/services/system-settings.ts
|
||||||
|
- [x] src/services/users.service.ts
|
||||||
|
- [x] src/services/warehouse.service.ts
|
||||||
|
|
||||||
|
## src/schemas
|
||||||
|
|
||||||
|
- [x] src/schemas/ai.schema.ts
|
||||||
|
- [x] src/schemas/attendance.schema.ts
|
||||||
|
- [x] src/schemas/auth.schema.ts
|
||||||
|
- [x] src/schemas/bank-accounts.schema.ts
|
||||||
|
- [x] src/schemas/common.ts
|
||||||
|
- [x] src/schemas/customers.schema.ts
|
||||||
|
- [x] src/schemas/invoices.schema.ts
|
||||||
|
- [x] src/schemas/issued-orders.schema.ts
|
||||||
|
- [x] src/schemas/leave-requests.schema.ts
|
||||||
|
- [x] src/schemas/offers.schema.ts
|
||||||
|
- [x] src/schemas/orders.schema.ts
|
||||||
|
- [x] src/schemas/plan.schema.ts
|
||||||
|
- [x] src/schemas/planCategory.schema.ts
|
||||||
|
- [x] src/schemas/profile.schema.ts
|
||||||
|
- [x] src/schemas/projects.schema.ts
|
||||||
|
- [x] src/schemas/received-invoices.schema.ts
|
||||||
|
- [x] src/schemas/roles.schema.ts
|
||||||
|
- [x] src/schemas/scope-templates.schema.ts
|
||||||
|
- [x] src/schemas/settings.schema.ts
|
||||||
|
- [x] src/schemas/trips.schema.ts
|
||||||
|
- [x] src/schemas/users.schema.ts
|
||||||
|
- [x] src/schemas/vehicles.schema.ts
|
||||||
|
- [x] src/schemas/warehouse.schema.ts
|
||||||
|
|
||||||
|
## src/types
|
||||||
|
|
||||||
|
- [x] src/types/fastify.d.ts
|
||||||
|
- [x] src/types/index.ts
|
||||||
|
|
||||||
|
## src/utils
|
||||||
|
|
||||||
|
- [x] src/utils/czech-holidays.ts
|
||||||
|
- [x] src/utils/date.ts
|
||||||
|
- [x] src/utils/encryption.ts
|
||||||
|
- [x] src/utils/html-to-pdf.ts
|
||||||
|
- [x] src/utils/pagination.ts
|
||||||
|
- [x] src/utils/planAuditDescription.ts
|
||||||
|
- [x] src/utils/response.ts
|
||||||
|
- [x] src/utils/totp.ts
|
||||||
|
|
||||||
|
## src/vite-env.d.ts
|
||||||
|
|
||||||
|
- [x] src/vite-env.d.ts
|
||||||
|
|
||||||
|
|
||||||
1580
REVIEW_FINDINGS.md
Normal file
1580
REVIEW_FINDINGS.md
Normal file
File diff suppressed because it is too large
Load Diff
37
boneyard.config.json
Normal file
37
boneyard.config.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"breakpoints": [375, 768, 1280],
|
||||||
|
"out": "./src/admin/bones",
|
||||||
|
"color": "#e0e0e0",
|
||||||
|
"animate": "shimmer",
|
||||||
|
"shimmerColor": "#f0f0f0",
|
||||||
|
"speed": "1.2s",
|
||||||
|
"shimmerAngle": 110,
|
||||||
|
"wait": 3000,
|
||||||
|
"routes": [
|
||||||
|
"/",
|
||||||
|
"/users",
|
||||||
|
"/attendance",
|
||||||
|
"/attendance/history",
|
||||||
|
"/attendance/admin",
|
||||||
|
"/attendance/balances",
|
||||||
|
"/attendance/requests",
|
||||||
|
"/attendance/approval",
|
||||||
|
"/attendance/create",
|
||||||
|
"/trips",
|
||||||
|
"/trips/history",
|
||||||
|
"/trips/admin",
|
||||||
|
"/vehicles",
|
||||||
|
"/offers",
|
||||||
|
"/offers/new",
|
||||||
|
"/offers/customers",
|
||||||
|
"/offers/templates",
|
||||||
|
"/orders",
|
||||||
|
"/orders/1",
|
||||||
|
"/projects",
|
||||||
|
"/projects/80",
|
||||||
|
"/invoices",
|
||||||
|
"/invoices/new",
|
||||||
|
"/settings",
|
||||||
|
"/audit-log"
|
||||||
|
]
|
||||||
|
}
|
||||||
90
docs/codebase-audit-2026-06-06.md
Normal file
90
docs/codebase-audit-2026-06-06.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Codebase Consistency & Best-Practices Audit — 2026-06-06 (overnight)
|
||||||
|
|
||||||
|
**Branch:** `chore/codebase-consistency-audit` (off `master` @ v1.9.0). Nothing here is pushed or deployed; for user review in the morning.
|
||||||
|
|
||||||
|
**Mandate (from user):** scan the whole codebase, find inconsistencies, unify manners/conventions, apply best practices (use context7 for library docs). Edit CLAUDE.md to codify rules. Work only in the project dir. Use many agents.
|
||||||
|
|
||||||
|
**Operating rules I'm following (self-imposed for safe autonomous work):**
|
||||||
|
|
||||||
|
- Isolated branch only. Never touch `master`, never push, never deploy, never touch prod/DB.
|
||||||
|
- Keep `tsc` (server+client) at 0 and `vitest` green after every change set.
|
||||||
|
- Prefer documenting risky/large refactors over auto-applying them. Auto-apply only safe, verified, mechanical unifications.
|
||||||
|
- Don't `git add -A`; stage explicit paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 CRITICAL FINDINGS (need user action)
|
||||||
|
|
||||||
|
### C1 — v1.9.0 production regression: plan edit-modal delete button missing
|
||||||
|
|
||||||
|
- **Cause:** `FormModal`'s `footerLeft` / `hideCloseButton` props + modal animations lived only in the user's _uncommitted_ working tree. Committed `PlanCellModal.tsx:225` (and the modal system) depend on `footerLeft`. Releases build from committed code (WIP stashed), so v1.9.0 shipped a `FormModal` without the footer-left slot → the **delete button in the plan entry/override edit modal does not render in production**. Also caused a committed-code `tsc` error (TS2322).
|
||||||
|
- **Impact:** plan entry/override deletion via the edit modal is broken in prod v1.9.0. Category management is NOT affected (it uses `hideFooter` + in-body delete). Minor cosmetic: modal entry animation, `hideCloseButton`.
|
||||||
|
- **Fix:** committed the modal enhancements (`86e4cbf` on this branch). **Needs a patch release (v1.9.1) to fix prod.**
|
||||||
|
- **Status:** fixed on branch; prod patch pending user.
|
||||||
|
|
||||||
|
### C2 — test suite was red on master/v1.9.0 (2 failing auth tests)
|
||||||
|
|
||||||
|
- **Cause:** the JWT log-noise fix (`88c9b7c`) stopped logging expected invalid/expired tokens, but `auth.service.test.ts` still asserted it logged. The wrong auth test file (`auth.test.ts`) was run when that change landed, so 2 failing tests shipped on master.
|
||||||
|
- **Fix:** updated the tests to assert null + no-log for `JsonWebTokenError` cases (`55a2c50`). Suite back to **134/134**.
|
||||||
|
- **Status:** fixed on branch. (No runtime impact; CI/test hygiene + would also be cleared by the v1.9.1 patch.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings log
|
||||||
|
|
||||||
|
(Discovery sweep results appended below as agents report. Severity: critical/high/medium/low. Fix class: safe-auto / needs-judgment / manual.)
|
||||||
|
|
||||||
|
## Changes applied on this branch (all verified: server+client tsc 0, vitest 134, build 0)
|
||||||
|
|
||||||
|
Correctness / bugs:
|
||||||
|
|
||||||
|
- `86e4cbf` **C1** modal `footerLeft`/`hideCloseButton` enhancements committed (fixes v1.9.0 regression + tsc error).
|
||||||
|
- `55a2c50` **C2** fix 2 failing `verifyAccessToken` tests (red on master).
|
||||||
|
- `379725a` **H1/H4** `isAdminLike` reads `roleName` not `role` (admins were self-scoped on /entries,/overrides); dropped 4 stale `as any` category casts.
|
||||||
|
- `c5f986a` **H5** corrected + unified audit `entity_type` label map (was showing raw English for invoice/order/quotation/trip/vehicle + all warehouse\_\*).
|
||||||
|
- `1f05ef6` date: `todayLocalStr()` replaces 11 UTC-`today` defaults (off-by-one in post-midnight Prague).
|
||||||
|
- `744baec` security: audit-log session termination (single + all-others); new `session` EntityType.
|
||||||
|
|
||||||
|
Consistency / cleanup:
|
||||||
|
|
||||||
|
- `9ae49c0` parseId (trips/sessions), broad `["offers"]` invalidation, Czech messages (attendance/orders/offers/project-files), Zod 4 `z.strictObject`.
|
||||||
|
- `522afef` undefined `--danger-color`→`--danger`, dedup `formatDate` + `require2FAOptions`, removed dead `systemSettingsOptions`, dropped double `plan.css` import.
|
||||||
|
- `9c582e1` **H2/H3** log the 30 silent NAS filesystem catches (kept 3 ENOENT-expected as deliberate no-logs).
|
||||||
|
|
||||||
|
Docs:
|
||||||
|
|
||||||
|
- `94a9fb8` CLAUDE.md: codified "Conventions (enforced)" section; corrected stale known-issues #4 (NAS) and #5 (sanitization is in place, not a gap).
|
||||||
|
- `58bf034`/`0559664` this report + full 84-finding catalog (`codebase-audit-findings-2026-06-06.md`).
|
||||||
|
|
||||||
|
## Remaining recommendations (NOT auto-applied — for your review)
|
||||||
|
|
||||||
|
Deliberately left for review (too broad/behavior-risky to apply unattended, or low value):
|
||||||
|
|
||||||
|
- **`reply.send` → helpers** (medium): ~1/3 of route files use raw `reply.send({success:true,...})` instead of `success()/paginated()`. Mechanical but touches many files + response-shape sensitive — convert per-file when touched. (List in findings catalog, dimension `routes`.)
|
||||||
|
- **Zod coercion helpers** (medium): the number/nullable-number/boolean-from-form coercions are copy-pasted ~150×; extract to `src/schemas/common.ts` (with a `NaN` guard — current coercion silently yields `NaN`). Big mechanical sweep — do with review.
|
||||||
|
- **console.\* → request logger** (medium): services log via `console.*` (bypasses pino/log-level). Architectural; needs a service-logging strategy.
|
||||||
|
- **`markOverdueInvoices`** (medium): swallows the DB-update failure and returns void, so the route treats a failed update as success — surface the error. Also it compares a `@db.Date` due_date against full `new Date()` (flips "overdue" on the due day) — compare date-only.
|
||||||
|
- **Dead exports** (low): `previewPattern()` (numbering.service), `usersQuery`/`planKeys.users()` (lib/queries/plan) — unused API surface; remove or wire up.
|
||||||
|
- **`ShiftFormModal`** (low): inline styles use `--danger-color`/`--success-color`/etc. (undefined; rely on fallbacks) — switch to the `--danger`/`--success` design tokens.
|
||||||
|
- **`.env.example` drift** (low): out of sync with vars read in `config/env.ts` — regenerate.
|
||||||
|
- **`@/*` path alias** (low): declared in tsconfig but not wired into Vite/vitest — either wire it up or remove.
|
||||||
|
- **warehouse soft-delete test** (low): a test named "soft delete" asserts a hard delete its own comment flags as buggy — clarify intent.
|
||||||
|
- **TOTP verify** doesn't increment failed-login counter (relies on rate limit); **HSTS** lacks `preload` — security hardening, low.
|
||||||
|
|
||||||
|
Full detail + file:line for every item: `docs/codebase-audit-findings-2026-06-06.md`.
|
||||||
|
|
||||||
|
## Progress tracker
|
||||||
|
|
||||||
|
- [x] Branch + clean baseline (tsc server 0 / client 0; vitest 134).
|
||||||
|
- [x] C1 modal regression + C2 red tests found & fixed (134/134).
|
||||||
|
- [x] Discovery sweep (16 parallel dimensions, 84 findings).
|
||||||
|
- [x] Synthesis + prioritization (high + safe-auto).
|
||||||
|
- [x] Safe unifications applied (12 commits, each verified green).
|
||||||
|
- [x] CLAUDE.md updated with codified conventions.
|
||||||
|
- [x] Remaining items documented as a review roadmap.
|
||||||
|
- [x] Final verification.
|
||||||
|
|
||||||
|
## ⚠️ Action for you in the morning
|
||||||
|
|
||||||
|
- **v1.9.1 patch:** C1 (plan edit-modal delete button) + C2 (red tests) affect production v1.9.0. Merge this branch (or cherry-pick `86e4cbf`,`55a2c50`,`379725a`,`c5f986a`) and cut a patch.
|
||||||
|
- Review this branch (`chore/codebase-consistency-audit`, 12 commits) and merge what you like. Nothing here is pushed or deployed.
|
||||||
544
docs/codebase-audit-findings-2026-06-06.md
Normal file
544
docs/codebase-audit-findings-2026-06-06.md
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
# Codebase Audit — Full Findings Catalog (2026-06-06)
|
||||||
|
|
||||||
|
Generated from the 16-dimension discovery sweep. 84 findings (high 5, medium 31, low 48).
|
||||||
|
|
||||||
|
## Dimension summaries
|
||||||
|
|
||||||
|
**svc-errors** — test2
|
||||||
|
|
||||||
|
**routes** — Route handlers in src/routes/admin/* are largely consistent and follow the documented patterns well: nearly every route uses a requirePermission/requireAnyPermission/requireAuth guard, validates bodies via parseBody(ZodSchema, ...) with the uniform `if ("error" in parsed) return error(reply, parsed.error, 400)` shape, uses parseId for numeric route params in most files, and logs audit entries on create/update/delete with old/new values. The warehouse, customers, invoices, projects, roles, and auth/totp files are exemplary. The real issues are: (1) a genuine authorization/visibility bug in plan.ts where the admin check reads a non-existent field; (2) a recurring style violation where roughly a third of files send raw `reply.send({ success: true, ... })` instead of the success()/paginated() helpers that CLAUDE.md mandates; (3) two security-relevant session-termination mutations with no audit logging; and (4) inconsistent ID parsing (raw parseInt vs the parseId helper) in trips.ts and sessions.ts. HTTP status codes are generally correct (201 on create, 404 not-found, 409 conflict, 403 permission), with one cluster of not-found/validation responses in project-files.ts falling back to the default 400.
|
||||||
|
|
||||||
|
**schemas** — The 21 Zod schema files in src/schemas/* are consistently organized (one file per entity, Create/Update pairs, z.infer type exports, Czech user-facing messages — all intentional and good). Every route body is validated via the shared parseBody helper (src/schemas/common.ts) — coverage is complete; the handful of raw `request.body` reads (attendance.ts, orders.ts, projects.ts, project-files.ts) either re-dispatch to parseBody with branch-specific schemas or do narrow manual checks, so there is no unvalidated-body gap. The dominant real issue is massive duplication of three coercion idioms — a number-from-form `z.union([z.number(),z.string()]).transform(v=>Number(v))`, a nullable variant with `z.null()`, and a boolean-from-form `z.preprocess(v=>v===true||v===1||v==="1", z.boolean())` — copy-pasted ~150+ times across files instead of living as shared helpers in common.ts. Secondary issues: the number coercion is unsafe (no NaN guard in most places, so "abc" silently becomes NaN), email validation is applied inconsistently (only users/profile, not customers/warehouse/settings), error-message language is mixed (Czech vs English "Must be a valid number"), no schemas use strict object mode so unknown keys are silently stripped, and the two inline route schemas use Zod-3-deprecated `.strict()`/`.passthrough()` rather than Zod 4's `z.strictObject()`/`z.looseObject()`. Severity is mostly low/medium: these are maintainability and robustness gaps, not security holes (the app is on Zod 4.3.6, so the modern helpers are available).
|
||||||
|
|
||||||
|
**logging** — Overall the codebase handles errors deliberately: routes use Fastify's pino logger (request.log.*/app.log.*), the global error handler logs unhandled exceptions, audit failures are logged and treated as non-fatal, and a couple of catch blocks (auth.ts verifyAccessToken, attendance/leave duplicate-record handling) are intentionally selective with good comments. The two real systemic issues are: (1) the NAS file/financials managers (src/services/nas-file-manager.ts, src/services/nas-financials-manager.ts) contain ~25 empty/silent catch blocks that swallow genuine filesystem operation failures (delete/mkdir/write/rename) with zero logging, returning only a generic Czech string — directly violating the CLAUDE.md "never silently swallow errors" rule and making NAS issues undebuggable; and (2) all service-layer error logging uses console.* instead of the configured pino logger, so those messages bypass structured logging and the production log-level config. A secondary correctness smell: markOverdueInvoices swallows DB-update failures and returns void, so its route caller treats a failed update as success. Frontend error handling is consistent (alert.error with Czech fallback); a few best-effort .catch(()=>{}) swallows on background calls are defensible but undocumented.
|
||||||
|
|
||||||
|
**prisma** — Solid Prisma usage overall. Real issues in findings.
|
||||||
|
|
||||||
|
**security** — Auth and crypto fundamentals are solid: refresh tokens are SHA-256-hashed with rotation + reuse detection (replaced_at/replaced_by_hash + FOR UPDATE), TOTP has counter-based replay protection, password comparison is timing-safe (dummy bcrypt hash on user-not-found/locked/inactive), TOTP secrets are AES-256-GCM encrypted, account lockout + per-route rate limits exist, bcrypt cost is 12, and JWT verification pins HS256. Contrary to CLAUDE.md "known issue #5", HTML sanitization for invoice notes, quotation scope, and order notes is NOT missing: DOMPurify (jsdom) is applied at all three server-side PDF routes plus a regex-based cleanQuillHtml second pass, and both frontend render sites (InvoiceDetail, OrderDetail) sanitize before dangerouslySetInnerHTML. NAS file access has strong path-traversal defenses (rejects "..", null bytes, confines to base, rejects symlink components, MIME/extension allowlist). The real gaps are narrower: the TOTP-code verification step does not increment failed-login counters (relies only on a 5/min rate limit), HSTS lacks preload, and the server-side invoice/offer PDF endpoints serve attacker-influenced (but sanitized) HTML as same-origin text/html. No SQL injection found — all $queryRaw uses are parameterized tagged templates.
|
||||||
|
|
||||||
|
**dates** — Date handling splits into two well-reasoned, internally-consistent regimes plus a set of genuine off-by-one bugs at the boundary between them. (1) The plan module (src/services/plan.service.ts, src/admin/pages/PlanWork.tsx, usePlanWork.ts, PlanGrid.tsx) deliberately does ALL date-only arithmetic in UTC (setUTCDate/getUTCDate, toISOString().slice(0,10), proper ISO-8601 week calc). Because every column it touches is `@db.Date` (stored at UTC-midnight by Prisma), this is correct and stable — explicitly documented at plan.service.ts:63-67 and 342-352. Do not "fix" it. (2) The attendance/leave server code constructs `@db.Date` writes at LOCAL noon (new Date(y,m,d,12,0,0)), which is the robust convention. The real problems: a repeated frontend pattern `new Date().toISOString().split("T")[0]` for "today" defaults computes the UTC date, giving yesterday in the late-evening Prague window; the invoices/received-invoices/trips edit forms round-trip API date strings through `new Date(x).toISOString().split(...)` instead of the existing string-slice helper `normalizeDateStr`, risking a shift; trips' raw API datetime string is fed into a native <input type=date> (blank on mobile); markOverdueInvoices compares a `@db.Date` due_date against full `new Date()` so invoices flip to "overdue" on their due day; and getCzechDate uses a non-ISO local week formula that disagrees with PlanWork's ISO week. A correct local-date string helper (utils/date.ts localDateStr, and frontend normalizeDateStr) already exists but isn't used in the offending spots.
|
||||||
|
|
||||||
|
**ts-safety** — Both tsconfigs enable `strict: true` and there are zero `@ts-ignore`/`@ts-nocheck` directives, so the baseline is good. The real risk is concentrated, not diffuse: 77 `any` occurrences cluster in the work-plan stack (plan.service.ts, routes/admin/plan.ts, hooks/usePlanWork.ts) and a few PDF/HTML builders. The single most important finding is a latent authorization-scoping bug masked by an `any`-typed parameter in plan.ts (`isAdminLike` reads `authData.role`, but `AuthData` only has `roleName`), which makes admins always scope to their own plan rows. Systemically, ~47 of 107 exported service functions have no explicit return type, which forces `(result as any).status` casts in route handlers (projects.ts, quotations.ts) because the inferred error union isn't uniform. There is also a genuinely duplicated/contradictory `PaginationMeta` shape defined three times with diverging fields. Non-null assertions are mostly the defensible `request.authData!` post-auth pattern and are not a concern.
|
||||||
|
|
||||||
|
**rq-data** — The frontend data layer is built on a clean, idiomatic foundation: every read is a `queryOptions()` factory in `src/admin/lib/queries/*` (TanStack Query v5 best practice), data flows through a thin `apiAdapter.ts` (jsonQuery/paginatedJsonQuery) that unwraps the `{success,data}` envelope and throws on error, and a purpose-built `useApiMutation` hook (lib/queries/mutations.ts) wraps `useMutation` + apiFetch + broad invalidation exactly as the docs recommend. The factory pattern is consistently applied for queries and `useApiMutation` is adopted in 34 files. The real weakness is on the WRITE side: a large minority of mutations bypass `useApiMutation` and hand-roll `apiFetch` + `await response.json()` + manual `invalidateQueries` + `try/catch alert("Chyba připojení")` (14 files, ~27 sites), duplicating the exact logic the hook exists to centralize. Within that hand-rolled code the broad-domain invalidation convention from CLAUDE.md is violated in OfferDetail.tsx (uses narrow `["offers","list"]` while Offers.tsx uses broad `["offers"]`). useAttendanceAdmin.ts runs an entirely parallel data system (manual fetchData + setTimeout(300) instead of query factories). Query-key conventions themselves are sound; minor smells are a duplicated `require2FAOptions` factory and an unused deprecated alias. None of these are correctness bugs — they are consistency/maintainability gaps. Best-practice basis: TanStack Query v5 docs (/tanstack/query) confirm prefix-matching invalidation (validating the broad-key convention) and the useMutation→onSuccess→invalidateQueries pattern that useApiMutation implements.
|
||||||
|
|
||||||
|
**react** — The frontend is mostly modern and disciplined: data flows through TanStack Query via lib/queries/*, lazy-loading is consistent for every page in AdminApp.tsx, and there are two solid reusable modal primitives (FormModal, ConfirmModal) used in 34 files. The real issues are concentrated and concrete: (1) one large hook (useAttendanceAdmin.ts, ~830 lines) opts out of TanStack Query and hand-rolls fetching with useEffect/useState, complete with silent empty catches and no race-condition guard — the single biggest consistency/best-practice gap; (2) two editable, mutable lists use the array index as React key (OrderConfirmationModal items, OfferDetail scope sections), which the React docs flag as a correctness bug because rows are deleted/reordered while holding controlled-input state; (3) modal usage is inconsistent — alongside FormModal/ConfirmModal, ~7 components hand-roll the admin-modal-overlay markup with diverging accessibility (DashProfile/DashQuickActions omit role="dialog"/aria-modal entirely; none of the hand-rolled ones wire Esc-to-close that FormModal provides). Several "sync server data into form state via useEffect" effects exist (InvoiceDetail, OfferDetail, CompanySettings, Settings); these use a one-shot ref/flag guard and are an accepted-but-not-ideal pattern per the React "You Might Not Need an Effect" guidance, so they are reported as low severity. Per React docs I pulled: avoid adjusting/resetting state on prop change in an Effect (prefer key-reset or compute during render), and data-fetching effects need an ignore/abort cleanup to avoid stale overwrites.
|
||||||
|
|
||||||
|
**css** — The admin CSS is a single global stylesheet assembled from 19 per-feature files, all imported in AdminApp.tsx (so every selector shares one global namespace — the file split is purely organizational, not scoped). Theming is well-architected and consistent: a central variables.css drives a token system via [data-theme="dark"]/[data-theme="light"] (no prefers-color-scheme mixing), and the large majority of color usage correctly references CSS variables. Genuine issues are: (1) one undefined variable (--danger-color) used in base.css; (2) an inconsistent class-naming scheme — most files use either admin-* or a feature prefix (plan-*, dash-*, fm-*, attendance-*, offers-*), but warehouse uses admin-warehouse-*, invoices mixes admin-badge-*/invoice-*/received-*, and a block of leave badges in components.css drops the admin- prefix entirely; (3) heavy duplication of the badge color recipe (background: color-mix(... 15%) + matching text color) across leave/order/project/invoice/attendance badges, with two different recipes (--success-soft vs color-mix --success 15%) used for the same semantic color; and (4) several defined-but-unused tokens plus an empty responsive.css stub and a double-import of plan.css. Hardcoded hex outside variables.css is mostly legitimate (#fff text on accent backgrounds, feature-scoped local theme token blocks in plan.css/layout.css), not a real problem.
|
||||||
|
|
||||||
|
**naming** — Naming in this codebase is broadly consistent and mostly intentional. Backend `src/` is overwhelmingly kebab-case for files (94 kebab vs 5 camelCase), service functions follow a clean `list*` (collections) / `get*` (single) / `create|update|delete*` (mutations) scheme with no fetch/load mixing, route verbs map cleanly to fastify methods, and Czech appears only in user-facing string literals (a documented, intentional choice — not an identifier problem). The frontend leans on its own consistent conventions: `handle*` event handlers (148 vs 8 `on*`, where the `on*` cases are local DOM listeners — idiomatic), `is*/has*` for boolean props/fields, and `*Options` for TanStack query factories (55 of them). The real, genuine deviations are a small cluster of files added during the "plan" feature work that broke the surrounding kebab-case convention (`planCategory.schema.ts`, `planCategory.service.ts`, `planAuditDescription.ts` and their tests), and the `plan.ts` query module that uses `gridQuery`/`usersQuery` instead of the established `*Options` suffix. The `.service.ts` suffix is applied to 10 of 20 service files, but this tracks a defensible entity-CRUD-vs-infra split rather than randomness. These are low-to-medium consistency gaps, not correctness issues; most fixes are renames with cross-file import ripples, so few qualify as safe-auto.
|
||||||
|
|
||||||
|
**deadcode** — The codebase is generally well-factored (date helpers in src/utils/date.ts and frontend formatters are correctly centralized and reused). The real dead-code/duplication problems are concentrated in two areas: (1) the three PDF route files (invoices-pdf.ts, orders-pdf.ts, offers-pdf.ts) each redefine the same set of HTML/number helpers (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml) plus an identical JSDOM+DOMPurify bootstrap — and the copies have already diverged (one uses a regular space as a thousands separator where the others use a non-breaking space); and (2) a handful of genuinely unused exports and one orphan component file. escapeHtml alone is independently defined six times across server and frontend. There are no leftover debug console.logs in the conventional sense — service-layer console.* calls are the established (consistent) logging pattern here since services have no Fastify logger, and the one documented console.warn in plan.service.ts is intentional. No commented-out code blocks of note were found.
|
||||||
|
|
||||||
|
**i18n** — Localization is overwhelmingly correct and intentional: all React UI labels, placeholders, titles, button text, alert/toast messages, and the large majority of backend error strings are Czech with correct gender agreement (nenalezen/nenalezena/nenalezeno), while code identifiers, enum values, internal sentinels (e.g. EMAIL_EXISTS), env-validation throws, console.error logs, and dev-only React invariant throws ("useAuth must be used within...") are English — all intentional and not flagged. The genuine gaps are narrow: (1) a handful of English user-facing API error strings that leak to the client ("Missing attendance_id"/"Missing id" in attendance.ts, and "Unauthorized"/"Request failed (n)"/"Invalid JSON response" thrown in the frontend query layer that ProjectFileManager renders verbatim); (2) the parseBody helper surfaces raw Zod messages, so any validator lacking a custom Czech message leaks Zod's default English (enums, .max() length caps, TOTP token .min(1)); and (3) inconsistent Czech phrasing for "not found" — the compact "nenalezen*" form (92 uses) vs the verbose "nebyl/nebyla nalezen*" form (16 uses), sometimes for the identical entity ("Projekt nenalezen" vs "Projekt nebyl nalezen"). No mixed tone, no English UI chrome.
|
||||||
|
|
||||||
|
**tests part2b** — remaining two low findings
|
||||||
|
|
||||||
|
**config-structure** — The build/config layout is coherent: a project-references tsconfig.json splits server (CJS, ES2022, excludes src/admin) from app (ES2020, bundler resolution), with matching vite/vitest configs and env validation in src/config/env.ts. The main structural problem is the absence of a shared constants/types module spanning server and src/admin: domain label maps (entity-type, action, leave-type) and the canonical EntityType value list are duplicated and have DRIFTED. Most seriously, the frontend audit-log label/filter maps use PHP-legacy entity-type keys (offers_quotation, invoices_invoice, projects_project, trips, vehicles) that no longer match what the TS server writes (quotation, invoice, project, trip, vehicle) — a functional bug caused by the duplication. Secondary issues: a configured-but-unused @/* path alias (also absent from vite resolve), a package.json db:push script contradicting the CLAUDE.md golden rule, and .env.example drift vs config/env.ts. The one piece of genuine cross-boundary sharing (czech-holidays imported by src/admin from src/utils) is inconsistent with how everything else is duplicated and is structurally fragile.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 1. [HIGH/needs-judgment] Frontend audit-type label/filter maps use legacy keys that don't match server-written entity_type values
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** src/admin/pages/AuditLog.tsx:47-66; src/admin/utils/dashboardHelpers.ts:21-40; src/types/index.ts:121-149; src/services/audit.ts:30,43; src/routes/admin/audit-log.ts:34; src/admin/lib/queries/auditLog.ts:18
|
||||||
|
- **problem:** The server's canonical EntityType union (src/types/index.ts) and every logAudit() call write values like 'invoice', 'customer', 'quotation', 'order', 'project', 'trip', 'vehicle' (e.g. invoices.ts:134 entityType:'invoice', vehicles.ts:68 'vehicle', quotations.ts:80 'quotation'). But the frontend ENTITY_TYPE_LABELS maps (duplicated identically in AuditLog.tsx and dashboardHelpers.ts) key on PHP-legacy values: offers_quotation, offers_customer, orders_order, invoices_invoice, projects_project, trips, vehicles. Two consequences: (1) the audit log UI falls back to showing raw entity_type strings for most entities because the lookup misses; (2) ENTITY_OPTIONS (derived from this map) feeds the entity-type filter dropdown, which sends e.g. entity_type=invoices_invoice to audit-log.ts:34 where it is exact-matched (where.entity_type = String(...)), so filtering by Faktura/Objednavka/Projekt/Jizda/Vozidlo returns zero rows.
|
||||||
|
- **fix:** Make EntityType a single runtime source of truth (an `as const` array exported from a module importable by both server and src/admin) and derive both the TS union and the label maps from it, or at minimum fix the frontend keys to match the server values (invoice, customer, quotation, order, project, trip, vehicle) and de-duplicate the two copies. Add a small test asserting every EntityType value has a label.
|
||||||
|
|
||||||
|
### 2. [HIGH/needs-judgment] NAS file manager swallows real filesystem failures with empty catch blocks and zero logging
|
||||||
|
- **dimension:** logging
|
||||||
|
- **where:** src/services/nas-file-manager.ts:118; src/services/nas-file-manager.ts:139; src/services/nas-file-manager.ts:153; src/services/nas-file-manager.ts:176; src/services/nas-file-manager.ts:426; src/services/nas-file-manager.ts:531; src/services/nas-file-manager.ts:679
|
||||||
|
- **problem:** nas-file-manager.ts has ~25 catch blocks and not a single logging call (Grep for console./.log./logger returns no matches). While many are legitimate existence/control-flow checks where the error is expected (e.g. lines 471-473, 525-527, 666-668), several swallow genuine operation failures: deleteProjectFolder (118), createProjectFolder (139), delete (426) returning 'Nepodařilo se smazat...', createFolder mkdir (531) returning 'Nepodařilo se vytvořit složku', and countItems (679). The underlying OS error (EACCES, ENOSPC, network share down, etc.) is discarded, so when a NAS operation fails in production there is nothing in the logs to diagnose it. This directly violates the CLAUDE.md rule: 'Never silently swallow errors. Even if a failure is non-fatal, log it.'
|
||||||
|
- **fix:** In the catch blocks that represent actual operation failures (delete, mkdir, rename move, write, copy), capture the error and log it via the Fastify logger before returning the generic Czech message. Since these are service methods without request context, either accept an optional logger/request param or have the calling route log the returned error with the original cause. At minimum, distinguish 'expected ENOENT existence check' catches (which can stay silent) from 'operation should have succeeded' catches (which must log).
|
||||||
|
|
||||||
|
### 3. [HIGH/needs-judgment] NAS financials manager swallows write/read/delete/mkdir errors without logging
|
||||||
|
- **dimension:** logging
|
||||||
|
- **where:** src/services/nas-financials-manager.ts:135; src/services/nas-financials-manager.ts:151; src/services/nas-financials-manager.ts:163; src/services/nas-financials-manager.ts:184
|
||||||
|
- **problem:** saveReceivedInvoice (135) catches a writeFileSync failure and returns the error string to the caller but never logs it server-side. readReceivedInvoice (151-153), deleteReceivedInvoice (163-165) and ensureDir (184-186) use fully empty 'catch { return null/false }' blocks that discard the filesystem error entirely. A failed received-invoice save/read/delete on the network share leaves no server-side trace, again violating the 'never silently swallow' rule.
|
||||||
|
- **fix:** Log the caught error (with the resolved path) before returning null/false/error-string. As with nas-file-manager, thread the Fastify logger in or log the returned error at the route layer. The empty 'catch {}' forms at 151/163/184 are the worst offenders — they should at least preserve the error variable and log it.
|
||||||
|
|
||||||
|
### 4. [HIGH/needs-judgment] plan.ts admin check reads non-existent authData.role field — admins cannot see other users' plan rows
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/plan.ts:43-45; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/services/plan.service.ts:780; src/services/plan.service.ts:799; src/types/index.ts:51
|
||||||
|
- **problem:** isAdminLike() does `return authData?.role === 'admin'`, but the AuthData interface (src/types/index.ts:51) has `roleName`, not `role`. Every other consumer in the codebase uses `roleName` (middleware/auth.ts:47,69; users.ts:109; services/auth.ts). So `authData.role` is always undefined and isAdminLike() always returns false. It is passed as the `isAdmin` argument to listEntries()/listOverrides() (plan.ts:120,142), where plan.service.ts:780/799 do `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Net effect: GET /plan/entries and GET /plan/overrides silently ignore the user_id query param for everyone, including admins, so an admin can never read another employee's raw plan/override rows. (Security direction is safe — it over-restricts rather than leaking — but the feature is broken for admins.)
|
||||||
|
- **fix:** Change isAdminLike to use the documented role/permission model: either `authData?.roleName === 'admin'` to match middleware/auth.ts, or better, check the relevant permission (e.g. authData.permissions.includes('attendance.manage')) consistent with how trips.ts:22 and attendance.ts:208 distinguish admin vs self. Also drop the `any` type on the parameter and type it as AuthData.
|
||||||
|
|
||||||
|
### 5. [HIGH/needs-judgment] `any`-typed param hides admin-scoping bug: isAdminLike reads nonexistent `authData.role`
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/routes/admin/plan.ts:43; src/routes/admin/plan.ts:44; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/types/index.ts:44; src/services/plan.service.ts:780; src/services/plan.service.ts:799
|
||||||
|
- **problem:** `isAdminLike(authData: any)` returns `authData?.role === "admin"`. The `AuthData` type (types/index.ts:44) has NO `role` field — the admin role is on `roleName` (used correctly everywhere else: auth.ts:47/69, users.ts:109). Because the parameter is typed `any`, the compiler cannot flag that `.role` is always `undefined`, so `isAdminLike` always returns `false`. It is passed as the `isAdmin` arg to `listEntries`/`listOverrides`, where `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Result: an admin querying another user's plan is silently scoped to their own rows; the `user_id` query param is ignored for everyone. The `any` is the direct cause the type system did not catch the `role` vs `roleName` typo.
|
||||||
|
- **fix:** Type the parameter as `AuthData | null | undefined` (or reuse the existing `roleName === "admin"` convention) and change the comparison to `authData?.roleName === "admin"`. This both fixes the bug and lets the compiler verify the field exists. Add/confirm a list-scoping test for the cross-user admin case.
|
||||||
|
|
||||||
|
### 6. [MEDIUM/needs-judgment] Domain label maps duplicated verbatim across frontend files (and diverging)
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** src/admin/utils/dashboardHelpers.ts:1-47; src/admin/pages/AuditLog.tsx:17-66; src/admin/utils/attendanceHelpers.ts:80-97; src/admin/components/ShiftFormModal.tsx:338-340
|
||||||
|
- **problem:** ENTITY_TYPE_LABELS and ACTION_LABELS exist as near-identical copies in dashboardHelpers.ts and AuditLog.tsx, and have already diverged: ACTION_LABELS.create is 'Vytvoreni' in AuditLog but 'Vytvoril' in dashboardHelpers, and AuditLog's copy has extra entries (view, activate, deactivate, password_change, ...). Separately, leave-type labels (vacation->'Dovolena', sick->'Nemoc', unpaid->'Neplacene volno') are repeated in dashboardHelpers.ts:2-4, attendanceHelpers.ts:83-85, and inline <option> values in ShiftFormModal.tsx. There is no shared constants module, so these will keep drifting.
|
||||||
|
- **fix:** Extract one constants module (e.g. src/admin/constants/labels.ts) holding ENTITY_TYPE_LABELS, ACTION_LABELS, LEAVE_TYPE_LABELS and import it everywhere; ideally co-locate with the shared EntityType list from the audit-labels finding.
|
||||||
|
|
||||||
|
### 7. [MEDIUM/safe-auto] Undefined CSS variable --danger-color used in base.css
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/base.css:268
|
||||||
|
- **problem:** .admin-error-stack sets color: var(--danger-color), but no file defines --danger-color (variables.css defines --danger, --error, --accent-color, etc., but never --danger-color). The property has no fallback, so the error-stack text color resolves to the inherited/initial color instead of the intended danger red. This is a copy-paste/naming bug, not an intentional choice.
|
||||||
|
- **fix:** Change to color: var(--danger) (the intended token). Optionally add a --danger-color alias in variables.css if other call sites expect it, but a grep shows base.css:268 is the only consumer.
|
||||||
|
|
||||||
|
### 8. [MEDIUM/needs-judgment] Duplicated badge color recipe across 5 feature areas
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/components.css:142; src/admin/components.css:160; src/admin/components.css:178; src/admin/invoices.css:5; src/admin/attendance.css:333
|
||||||
|
- **problem:** The same status-badge formula — background: color-mix(in srgb, var(--X) 15%, transparent); color: var(--X) — is hand-repeated for every semantic color across leave badges (badge-pending/approved/rejected, components.css:142-152), order badges (admin-badge-order-*, :160-174), project badges (admin-badge-project-*, :178-188), invoice badges (admin-badge-invoice-*, invoices.css:5-17), and attendance leave badges (badge-vacation/sick/holiday, attendance.css:333-345). E.g. info-15% is duplicated 4x and danger-15% 4x. Worse, the recipe is inconsistent with the generic badges right above it: admin-badge-success/info/danger (components.css:106-128) use the --*-soft tokens instead of color-mix 15%, so two different visual recipes exist for the same semantic 'success/danger badge'.
|
||||||
|
- **fix:** Introduce semantic helper classes (e.g. .admin-badge-tone-success/info/warning/danger/muted) defined once, and have the leave/order/project/invoice/attendance status classes compose them, or apply the tone class in TSX. Pick one recipe (either --*-soft or color-mix 15%) so the same status reads identically everywhere. This removes ~20 near-identical rules.
|
||||||
|
|
||||||
|
### 9. [MEDIUM/needs-judgment] "Today" form defaults use UTC date (new Date().toISOString().split("T")[0]) → off-by-one in late evening
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/admin/pages/InvoiceDetail.tsx:329; src/admin/pages/InvoiceDetail.tsx:331; src/admin/pages/Attendance.tsx:143; src/admin/pages/Attendance.tsx:144; src/admin/pages/Attendance.tsx:337; src/admin/pages/Attendance.tsx:338; src/admin/pages/AttendanceCreate.tsx:49; src/admin/pages/Trips.tsx:56; src/admin/pages/Trips.tsx:120; src/admin/components/dashboard/DashQuickActions.tsx:77
|
||||||
|
- **problem:** These default-date initializers compute today's date with new Date().toISOString().split("T")[0], which returns the UTC date. The whole app is intentionally Europe/Prague local time (config/env.ts), and Prague is always ahead of UTC. So between ~22:00–24:00 (summer) / ~23:00–24:00 (winter) local, UTC is still the previous day and the form pre-fills yesterday's date for trip_date, shift_date, leave date_from/date_to, invoice issue/tax dates. A user creating an attendance/trip/leave record late in the evening silently gets the wrong day.
|
||||||
|
- **fix:** Replace with a local-date string built from local getters. A correct helper already exists server-side (utils/date.ts localDateStr); add/import an equivalent frontend helper, e.g. `const d=new Date(); const today=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}``, and use it for every "today" default. Note InvoiceDetail.tsx:330 due_date = new Date(Date.now()+14*86400000).toISOString()... has the same issue and should derive from the local issue_date instead.
|
||||||
|
|
||||||
|
### 10. [MEDIUM/needs-judgment] Edit forms round-trip API date strings through new Date().toISOString() instead of string slicing
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/admin/pages/InvoiceDetail.tsx:481; src/admin/pages/InvoiceDetail.tsx:484; src/admin/pages/InvoiceDetail.tsx:487; src/admin/pages/InvoiceDetail.tsx:658; src/admin/pages/ReceivedInvoices.tsx:373-377
|
||||||
|
- **problem:** When populating a date <input>/AdminDatePicker from an API value, these do new Date(inv.issue_date).toISOString().split("T")[0]. The API value is already a local-time string (Date.toJSON override drops the Z), so new Date() parses it as local, then toISOString() converts back to UTC. For @db.Date values (read back as UTC-midnight → local +1/+2h) this currently lands on the right day in Prague, but it is fragile: it depends on the offset always being positive and the time-of-day being near midnight. The codebase already has the correct, timezone-safe primitive — normalizeDateStr (src/admin/utils/attendanceHelpers.ts:309), which regex-slices the YYYY-MM-DD prefix off the string without ever constructing a Date.
|
||||||
|
- **fix:** Use normalizeDateStr(inv.issue_date) / a string slice for date-only fields instead of routing through new Date().toISOString(). For computedDueDate (InvoiceDetail.tsx:654-659) the date-only UTC math is self-consistent, but switching to plain string/day arithmetic via the same helper removes the latent risk.
|
||||||
|
|
||||||
|
### 11. [MEDIUM/needs-judgment] Raw API datetime string assigned to a native <input type=date> value (mobile blanks the field)
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/admin/pages/Trips.tsx:140; src/admin/components/AdminDatePicker.tsx:92-96
|
||||||
|
- **problem:** openEditModal sets form.trip_date = trip.trip_date, but trip_date is a @db.Date serialized by the toJSON override as a full datetime string like "2026-06-05T02:00:00" (routes/admin/trips.ts returns the Prisma object directly). On desktop AdminDatePicker uses date-fns parse(val,"yyyy-MM-dd",...) which tolerates the trailing time, so it works. But on touch devices (AdminDatePicker.tsx:127-140) it renders <input type="date" value="2026-06-05T02:00:00">, which is not a valid date-input value — browsers show it blank, so the user appears to have no date and may submit an empty/changed value.
|
||||||
|
- **fix:** Normalize before seeding the form: trip_date: normalizeDateStr(trip.trip_date). More robustly, have AdminDatePicker's NativeInput coerce its value to the date prefix for mode="date" so any caller passing a datetime string is handled.
|
||||||
|
|
||||||
|
### 12. [MEDIUM/needs-judgment] markOverdueInvoices flips invoices to "overdue" on their due date (date-only column vs full now())
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/services/invoices.service.ts:78-85
|
||||||
|
- **problem:** due_date is a @db.Date column (stored at UTC-midnight, e.g. 2026-06-06T00:00:00Z for "due 2026-06-06"). The query `due_date: { lt: new Date() }` compares it against the current full timestamp. Any time after local midnight on the due day, now() (e.g. 2026-06-06T06:00Z) is already greater than the stored UTC-midnight, so an invoice due *today* is immediately marked overdue. An invoice is normally not overdue until the day after its due date.
|
||||||
|
- **fix:** Compare against local start-of-today, not now(): build today = new Date(y,m,d,0,0,0) from local getters and use `due_date: { lt: today }` (and the reverse branch `gte: today`). This makes the boundary inclusive of the due day. Confirm the intended business rule with the user (due-day inclusive vs exclusive) before changing.
|
||||||
|
|
||||||
|
### 13. [MEDIUM/needs-judgment] Three PDF route files duplicate the same helper set (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml, DOMPurify bootstrap)
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/routes/admin/invoices-pdf.ts:14; src/routes/admin/invoices-pdf.ts:19; src/routes/admin/invoices-pdf.ts:26; src/routes/admin/invoices-pdf.ts:35; src/routes/admin/invoices-pdf.ts:44; src/routes/admin/orders-pdf.ts:12; src/routes/admin/orders-pdf.ts:34; src/routes/admin/orders-pdf.ts:41; src/routes/admin/orders-pdf.ts:50; src/routes/admin/orders-pdf.ts:59; src/routes/admin/offers-pdf.ts:11; src/routes/admin/offers-pdf.ts:14; src/routes/admin/offers-pdf.ts:22; src/routes/admin/offers-pdf.ts:31; src/routes/admin/offers-pdf.ts:51; src/routes/admin/offers-pdf.ts:61
|
||||||
|
- **problem:** All three PDF generation routes independently define near-identical formatDate, formatNum, escapeHtml, and cleanQuillHtml functions, plus the same `const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window);` bootstrap. The duplication has already caused a real divergence: orders-pdf.ts (formatNum, line 45) uses a regular ASCII space ' ' for the thousands separator, while invoices-pdf.ts (line 30) and offers-pdf.ts (line 26) use a non-breaking space ' '. This is exactly the failure mode duplication creates — the copies drift and produce inconsistent output (orders invoices can line-wrap mid-number where the others won't). cleanQuillHtml/escapeHtml are security-sensitive sanitizers, so divergence here is more than cosmetic.
|
||||||
|
- **fix:** Extract the shared PDF helpers into a single module (e.g. src/utils/pdf-format.ts or extend src/utils/html-to-pdf.ts): escapeHtml, formatNum(value, decimals), formatCurrency, formatDate, cleanQuillHtml, and a shared DOMPurify instance. Import them in all three *-pdf.ts files. Pick one canonical thousands separator (the non-breaking space is correct for print) so output is consistent across invoice/order/offer PDFs.
|
||||||
|
|
||||||
|
### 14. [MEDIUM/safe-auto] English user-facing API error strings in attendance route
|
||||||
|
- **dimension:** i18n
|
||||||
|
- **where:** src/routes/admin/attendance.ts:188; src/routes/admin/attendance.ts:196
|
||||||
|
- **problem:** Two error() responses send raw English to the client: error(reply, "Missing attendance_id", 400) and error(reply, "Missing id", 400). Every other validation/not-found message in this file and across all routes is Czech (e.g. "Záznam nenalezen", "Nedostatečná oprávnění"). error() sends the string straight into the API envelope, so a user hitting these endpoints without the param sees an English message.
|
||||||
|
- **fix:** Replace with Czech equivalents, e.g. "Chybí ID docházky" and "Chybí ID" (or reuse the existing parseId/"Neplatné ID" pattern). Mechanical, no behavior change.
|
||||||
|
|
||||||
|
### 15. [MEDIUM/needs-judgment] Frontend query layer throws English errors that render in the UI
|
||||||
|
- **dimension:** i18n
|
||||||
|
- **where:** src/admin/lib/queries/projects.ts:96; src/admin/lib/queries/projects.ts:102; src/admin/lib/apiAdapter.ts:16; src/admin/lib/apiAdapter.ts:23; src/admin/lib/apiAdapter.ts:27; src/admin/lib/apiAdapter.ts:52; src/admin/lib/apiAdapter.ts:64; src/admin/lib/apiAdapter.ts:68; src/admin/lib/queries/mutations.ts:51; src/admin/lib/queries/mutations.ts:56; src/admin/components/ProjectFileManager.tsx:230
|
||||||
|
- **problem:** The TanStack Query helpers throw new Error with English literals: "Unauthorized", "Invalid JSON response", and `Request failed (${status})`. ProjectFileManager.tsx:230-232 renders filesError.message verbatim (only falling back to the Czech "Nepodařilo se načíst soubory" when message is empty), so on a 401 or non-JSON/unknown-status response the user sees English ("Unauthorized", "Request failed (500)"). ErrorBoundary.tsx:30 likewise renders error.message under a Czech heading. The server-supplied result.error is Czech, but these client-side fallbacks are not. Note projects.ts:94 already does this right with the Czech "Chyba připojení", making the English siblings inconsistent.
|
||||||
|
- **fix:** Czech-ify the user-visible fallbacks: "Unauthorized" -> e.g. "Přihlášení vypršelo" / "Nejste přihlášeni", "Request failed (n)" -> "Požadavek selhal (n)", "Invalid JSON response" -> "Neplatná odpověď serveru". These are user-facing message strings, not error codes, so translating them is safe; just confirm no code branches on the literal message text (none observed).
|
||||||
|
|
||||||
|
### 16. [MEDIUM/needs-judgment] Service-layer logging uses console.* instead of the configured Fastify/pino logger
|
||||||
|
- **dimension:** logging
|
||||||
|
- **where:** src/services/audit.ts:57; src/services/mailer.ts:34; src/services/invoices.service.ts:88; src/services/exchange-rates.ts:47; src/services/leave-notification.ts:120; src/services/invoice-alerts.ts:220; src/services/invoice-alerts.ts:224; src/services/plan.service.ts:168; src/utils/totp.ts:27; src/services/auth.ts:363
|
||||||
|
- **problem:** server.ts:40-41 configures a pino logger (level 'warn' in production, 'info' in dev) and routes correctly use request.log.*/app.log.*. But every service/util logs via console.error/warn/log. These messages bypass pino's structured JSON output, log-level filtering (in production console.log/info still print regardless of the 'warn' level), and any future log transport/aggregation. CLAUDE.md's error-handling guidance explicitly shows app.log.error(e, 'context') as the pattern. invoice-alerts.ts:224 and plan.service.ts:168 in particular emit console.log/console.warn that will print in production despite the configured warn level.
|
||||||
|
- **fix:** Standardize service logging on the Fastify logger. Since services are plain functions without app context, the cleanest fix is to pass the request/logger into service calls that can log, or export a shared logger instance (e.g. the pino instance from the app) that services import. The CLAUDE.md known-issue list already flags 'Mixed error patterns'; this is the logging analogue. If a full refactor is out of scope, at least demote informational console.log calls (invoice-alerts.ts:224) so they don't spam production stdout.
|
||||||
|
|
||||||
|
### 17. [MEDIUM/needs-judgment] markOverdueInvoices swallows DB failure and returns void, so the route treats failure as success
|
||||||
|
- **dimension:** logging
|
||||||
|
- **where:** src/services/invoices.service.ts:76; src/services/invoices.service.ts:87; src/routes/admin/invoices.ts:37
|
||||||
|
- **problem:** markOverdueInvoices() wraps two updateMany calls in try/catch, logs to console.error on failure, and returns nothing. It is invoked from an onRequest hook (invoices.ts:33-39) on every GET (throttled hourly). Because the error is fully swallowed and not surfaced, a persistent DB failure to flip overdue statuses is invisible to the request path and only visible via console (which, per the previous finding, isn't even in the structured log). The throttled best-effort design is reasonable, but the failure should be logged through the Fastify logger that the hook has access to (request.log).
|
||||||
|
- **fix:** Either keep the best-effort behavior but pass request.log into markOverdueInvoices (or log at the hook: wrap the await in try/catch and request.log.error(err, 'markOverdueInvoices failed')), so failures land in the structured production log instead of bare console.error. Do not change it to throw — that would break unrelated GET requests.
|
||||||
|
|
||||||
|
### 18. [MEDIUM/needs-judgment] camelCase 'plan*' files break the kebab-case convention of the backend src/ tree
|
||||||
|
- **dimension:** naming
|
||||||
|
- **where:** src/schemas/planCategory.schema.ts; src/services/planCategory.service.ts; src/utils/planAuditDescription.ts; src/utils/planAuditDescription.test.ts; src/services/planCategory.test.ts
|
||||||
|
- **problem:** The backend src/ tree (excluding src/admin) is kebab-case for 94 of 99 .ts files. Sibling files in the same directories establish the convention clearly: src/schemas uses bank-accounts.schema.ts, received-invoices.schema.ts, scope-templates.schema.ts; src/services uses exchange-rates.ts, nas-file-manager.ts, invoice-alerts.ts; src/utils uses czech-holidays.ts, html-to-pdf.ts. The only 5 camelCase backend files are this 'plan' feature cluster (planCategory.schema.ts, planCategory.service.ts, planAuditDescription.ts plus two test files). Note the same feature's other files DO follow the convention (plan.schema.ts, plan.service.ts), so this is internal inconsistency within one feature, not a sub-convention.
|
||||||
|
- **fix:** Rename to kebab-case to match the directory convention: plan-category.schema.ts, plan-category.service.ts, plan-audit-description.ts (and matching .test.ts files), updating their import paths. Mechanical but touches importers, so verify references after renaming.
|
||||||
|
|
||||||
|
### 19. [MEDIUM/needs-judgment] createInvoice header and items not atomic
|
||||||
|
- **dimension:** prisma
|
||||||
|
- **where:** src/services/invoices.service.ts:345-388
|
||||||
|
- **problem:** invoices.create then a separate invoice_items.createMany with no transaction; a failure leaves a header without items plus a consumed sequence. updateInvoice 469-485, createOrder, createOffer all wrap header and children in a transaction.
|
||||||
|
- **fix:** Wrap both writes in one transaction; move generateInvoiceNumber inside it.
|
||||||
|
|
||||||
|
### 20. [MEDIUM/needs-judgment] N1 in getBelowMinimumItems per-item aggregate in a loop
|
||||||
|
- **dimension:** prisma
|
||||||
|
- **where:** src/services/warehouse.service.ts:232-261; src/services/warehouse.service.ts:199-205
|
||||||
|
- **problem:** getItemTotalStock with item.id called inside the loop; each runs its own sklad_batches aggregate, N1 round trips scaling with the catalog.
|
||||||
|
- **fix:** One sklad_batches groupBy on item_id with is_consumed false summing quantity; build an id to total map; filter in memory.
|
||||||
|
|
||||||
|
### 21. [MEDIUM/manual] useAttendanceAdmin hook bypasses TanStack Query and hand-rolls fetching (with silent catches + race condition)
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/hooks/useAttendanceAdmin.ts:818; src/admin/hooks/useAttendanceAdmin.ts:837; src/admin/hooks/useAttendanceAdmin.ts:866; src/admin/hooks/useAttendanceAdmin.ts:915
|
||||||
|
- **problem:** The whole AttendanceAdmin data layer loads projects, users, attendance records and leave balances with manual apiFetch inside useEffect + useState, instead of the TanStack Query pattern the rest of the app standardizes on (lib/queries/*). Consequences: (a) no caching/dedupe/refetch story consistent with other pages; (b) the load effects swallow errors with empty `catch { /* silent */ }` blocks (lines 827-829, 856-858), which also violates the project's 'never silently swallow errors' rule; (c) fetchData (line 866) re-runs on every month/filterUserId change with no abort or ignore flag, so a slow earlier response can overwrite a newer one — exactly the race the React docs warn about for effect-based fetching. The kept eslint-disable on the deps (line 909) is a symptom of fighting the linter rather than modeling this as a query.
|
||||||
|
- **fix:** Migrate these loads to useQuery options in src/admin/lib/queries/attendance.ts (projectListOptions, attendanceUsersOptions, attendance records keyed by [month,filterUserId], balances keyed by year). That removes all four effects, the manual loading state, the silent catches, and the race condition for free. If a full migration is out of scope now, at minimum add an `ignore`/AbortController guard in fetchData and log the caught errors via the alert/log path instead of empty catches.
|
||||||
|
|
||||||
|
### 22. [MEDIUM/needs-judgment] Array index used as React key on editable, mutable lists
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/components/OrderConfirmationModal.tsx:247; src/admin/pages/OfferDetail.tsx:1500
|
||||||
|
- **problem:** Both lists render rows with controlled inputs and support remove/add (and OfferDetail also reorders) yet key by index. OrderConfirmationModal: items.map((item,i)=><tr key={i}>) with updateItem(i,...) and removeItem via prev.filter((_,i)=>i!==index) (line 88). OfferDetail: sections.map((section,idx)=><div key={idx}>) with delete via prev.filter((_,i)=>i!==idx) (line 1594) and swap-reorder (lines 1540/1567). Per React docs, indices as keys on a list whose order/length changes cause React to reuse the wrong DOM node and component state — here that means after deleting a row the input values/focus shift to the wrong row. This is a genuine correctness bug, not a style nit.
|
||||||
|
- **fix:** Key by a stable id. ConfirmationItem/ScopeSection should carry a stable client id (e.g. crypto.randomUUID() assigned when the row is created, or the server item id when present) and use key={item.id}. Read-only, non-reordering tables (Warehouse recentMovements, WarehouseReports rows) can keep index keys but ideally use the row's domain id.
|
||||||
|
|
||||||
|
### 23. [MEDIUM/needs-judgment] Inconsistent modal implementation: FormModal/ConfirmModal vs hand-rolled overlays with diverging accessibility
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/components/FormModal.tsx:88; src/admin/components/dashboard/DashProfile.tsx:266; src/admin/components/dashboard/DashProfile.tsx:405; src/admin/components/dashboard/DashProfile.tsx:660; src/admin/components/dashboard/DashQuickActions.tsx:321; src/admin/components/BulkAttendanceModal.tsx:47; src/admin/components/ShiftFormModal.tsx:252; src/admin/components/OrderConfirmationModal.tsx:108; src/admin/pages/WarehouseLocations.tsx:339; src/admin/pages/Attendance.tsx:936
|
||||||
|
- **problem:** There are two good reusable modal primitives (FormModal handles body-scroll lock, Esc-to-close, role=dialog/aria-modal/aria-labelledby, focus-grouped footer; ConfirmModal similar), but at least 7 components reimplement the admin-modal-overlay + backdrop + motion markup by hand with inconsistent behavior. DashProfile's three modals (lines 266/405/660) and DashQuickActions (line 321) omit role="dialog", aria-modal, and aria-labelledby entirely; none of the hand-rolled overlays wire Esc-to-close (only FormModal/ConfirmModal do). BulkAttendanceModal/ShiftFormModal/OrderConfirmationModal include the ARIA roles but still lack Esc handling. The result is duplicated markup and an accessibility/UX behavior that varies modal-to-modal.
|
||||||
|
- **fix:** For the smaller content modals (DashProfile edit-profile, 2FA setup/disable, DashQuickActions trip modal) switch to FormModal — they fit its API and would inherit Esc/lock/ARIA. For the large complex forms (ShiftFormModal, BulkAttendanceModal, OrderConfirmationModal) where FormModal's per-child stagger animation is awkward, factor the overlay+backdrop+Esc+ARIA shell into a shared ModalShell component and have these consume it, so a11y/Esc behavior is uniform. At minimum, add role="dialog"/aria-modal/aria-labelledby to the DashProfile/DashQuickActions overlays.
|
||||||
|
|
||||||
|
### 24. [MEDIUM/safe-auto] Raw reply.send({ success: true, ... }) used instead of success()/paginated() helpers
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/attendance.ts:32; src/routes/admin/attendance.ts:108; src/routes/admin/attendance.ts:118; src/routes/admin/attendance.ts:128; src/routes/admin/attendance.ts:142; src/routes/admin/attendance.ts:165; src/routes/admin/attendance.ts:179; src/routes/admin/attendance.ts:190; src/routes/admin/attendance.ts:203; src/routes/admin/attendance.ts:235; src/routes/admin/trips.ts:73; src/routes/admin/leave-requests.ts:57; src/routes/admin/invoices.ts:62; src/routes/admin/projects.ts:41; src/routes/admin/received-invoices.ts:80; src/routes/admin/customers.ts:100
|
||||||
|
- **problem:** CLAUDE.md explicitly states: 'Use the success() and error() helpers in routes — never write raw reply.send().' Many handlers hand-roll the envelope. The paginated-list cases (attendance.ts:235, trips.ts:73, leave-requests.ts:57, invoices.ts:62, projects.ts:41, received-invoices.ts:80, customers.ts:100) duplicate exactly what paginated(reply, data, meta) produces; the single-object cases (attendance.ts:32,108,118,...) duplicate success(reply, data). This is purely a consistency/maintainability gap (the shapes happen to match today), but it bypasses the central helper, so any future change to the response envelope or status handling won't propagate.
|
||||||
|
- **fix:** Replace the raw `return reply.send({ success: true, data, pagination })` calls with `return paginated(reply, data, buildPaginationMeta(total, page, limit))`, and `return reply.send({ success: true, data })` with `return success(reply, data)`. attendance.ts and customers.ts already import these helpers; trips/leave-requests/invoices/projects/received-invoices import paginated where needed.
|
||||||
|
|
||||||
|
### 25. [MEDIUM/needs-judgment] Session-termination mutations have no audit logging
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/sessions.ts:80-99; src/routes/admin/sessions.ts:102-127
|
||||||
|
- **problem:** DELETE /sessions/:id (revoke a specific refresh token) and DELETE /sessions?action=all (revoke all other sessions) mutate security-relevant state — they invalidate refresh tokens — but call no logAudit(). CLAUDE.md and the database conventions say data that is created/updated/deleted should be audit-logged, and these are exactly the kind of security events (forced logout / session revocation) that belong in the forensic trail. Every other mutating route in the module logs an audit entry; these two are the notable omissions among security-sensitive endpoints.
|
||||||
|
- **fix:** Add logAudit() calls after the successful update/updateMany in both handlers (e.g. action 'delete' or 'logout', entityType 'session' or 'refresh_token', entityId the session id for the single case), mirroring the audit pattern used in totp.ts and auth.ts for login/logout events.
|
||||||
|
|
||||||
|
### 26. [MEDIUM/needs-judgment] Two competing mutation patterns coexist; ~27 raw apiFetch mutations bypass useApiMutation
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/lib/queries/mutations.ts:88; src/admin/pages/WarehouseReservations.tsx:106; src/admin/pages/WarehouseReservations.tsx:136; src/admin/pages/Invoices.tsx:208; src/admin/pages/Invoices.tsx:234; src/admin/pages/Offers.tsx:222; src/admin/pages/OfferDetail.tsx:734; src/admin/pages/OfferDetail.tsx:757; src/admin/pages/OfferDetail.tsx:782; src/admin/hooks/useAttendanceAdmin.ts:1039
|
||||||
|
- **problem:** The project ships a dedicated useApiMutation hook (mutations.ts) that wraps useMutation + apiFetch + envelope unwrapping + broad invalidation, and it is adopted in 34 files. But 14 files (~27 call sites) still hand-roll the same flow: `const response = await apiFetch(url,{method:...}); const result = await response.json(); if(result.success){ queryClient.invalidateQueries(...); alert.success(...) } else { alert.error(...) } } catch { alert.error('Chyba připojení') }`. This duplicates exactly what useApiMutation centralizes (error throwing as ApiError, success/error branching, invalidation), and the two paths diverge in behavior: useApiMutation throws typed ApiError with .status so callers can branch on HTTP code, whereas the raw path collapses all failures into a generic Czech 'Chyba připojení' string and never exposes status. This is the dominant inconsistency in the data layer. TanStack v5 docs document the useMutation→onSuccess→invalidateQueries flow that useApiMutation implements; the raw path is the non-idiomatic outlier.
|
||||||
|
- **fix:** Standardize new and touched mutations on useApiMutation (pass `invalidate: ["warehouse"]` etc. instead of manual invalidateQueries, and use mutate/mutateAsync's onSuccess/onError for UI side-effects). Migrate the raw-apiFetch mutation sites incrementally, starting with the simplest CRUD pages (WarehouseReservations, Invoices delete/toggleStatus). Document useApiMutation as the canonical mutation entry point in CLAUDE.md's Frontend Conventions so the pattern stops regrowing.
|
||||||
|
|
||||||
|
### 27. [MEDIUM/safe-auto] Broad-domain invalidation convention violated: OfferDetail uses narrow ["offers","list"]
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/pages/OfferDetail.tsx:681; src/admin/pages/OfferDetail.tsx:693; src/admin/pages/OfferDetail.tsx:739; src/admin/pages/OfferDetail.tsx:765; src/admin/pages/OfferDetail.tsx:792; src/admin/pages/Offers.tsx:227
|
||||||
|
- **problem:** CLAUDE.md mandates invalidating the broad domain key (`["offers"]` over `["offers","list"]`) because React Query prefix-matches. OfferDetail.tsx invalidates the narrow key `["offers","list"]` in five handlers (save edit/create, create order, invalidate, delete), while the sibling Offers.tsx (line 227) and CompanySettings.tsx (line 437) correctly invalidate broad `["offers"]`. Consequence: the narrow key does NOT prefix-match the offer-detail cache entry `["offers", id]` (offerDetailOptions, offers.ts:144) nor `["offers","next-number"]`, so after editing an offer the detail query for OTHER offers / next-number stays stale until refetched by other means. The TanStack v5 docs' prefix-match example (`invalidateQueries({queryKey:['projects']})`) is the exact rationale CLAUDE.md cites — OfferDetail is the lone violator.
|
||||||
|
- **fix:** Change the five `["offers","list"]` invalidations in OfferDetail.tsx to broad `["offers"]` to match the convention and the rest of the offers code. (The delete handler additionally removeQueries(["offers",id]) which is fine to keep.) Mechanical and behavior-safe: broadening only marks more inactive queries stale; active ones already refetch.
|
||||||
|
|
||||||
|
### 28. [MEDIUM/manual] useAttendanceAdmin runs a parallel data system instead of query factories / useApiMutation
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/hooks/useAttendanceAdmin.ts:1049; src/admin/hooks/useAttendanceAdmin.ts:1050; src/admin/hooks/useAttendanceAdmin.ts:1121; src/admin/hooks/useAttendanceAdmin.ts:1256; src/admin/hooks/useAttendanceAdmin.ts:1286
|
||||||
|
- **problem:** This 1200+ line hook does its own data orchestration: each mutation does `queryClient.invalidateQueries(["attendance"])` AND `await fetchData(false)` AND `await new Promise(r=>setTimeout(r,300))` to wait for the manual refetch (e.g. lines 1049-1052, 1121-1123). It runs a hand-rolled `fetchData` alongside React Query rather than relying on the attendance.ts queryOptions factories and letting invalidation drive refetch. The fixed 300ms sleep is a race-condition smell (it guesses how long the refetch takes) and the double invalidate+fetchData duplicates work. This is the single largest divergence from the otherwise-consistent data layer.
|
||||||
|
- **fix:** Treat as a larger refactor (out of scope for safe-auto): migrate the hook's reads to the attendance.ts queryOptions factories and its writes to useApiMutation so invalidation alone drives refetch, removing fetchData and the setTimeout(300). At minimum, drop the redundant manual fetchData+sleep where invalidateQueries already covers it. Flag for a dedicated cleanup ticket.
|
||||||
|
|
||||||
|
### 29. [MEDIUM/needs-judgment] Three coercion idioms duplicated ~150+ times instead of shared helpers
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/common.ts (only has parseBody); src/schemas/invoices.schema.ts:5-33; src/schemas/orders.schema.ts:6-27; src/schemas/warehouse.schema.ts:85-219; src/schemas/attendance.schema.ts:19-145; src/schemas/settings.schema.ts:15-62; src/schemas/trips.schema.ts:4-37; src/schemas/vehicles.schema.ts:8-39; src/schemas/bank-accounts.schema.ts:14-34; src/schemas/received-invoices.schema.ts:5-60
|
||||||
|
- **problem:** The same three sub-schemas are copy-pasted across nearly every file: (1) number-from-form `z.union([z.number(), z.string()]).transform((v) => Number(v))`, (2) its nullable form `z.union([z.number(), z.string(), z.null()]).transform((v) => (v === null ? null : Number(v)))`, and (3) boolean-from-form `z.preprocess((v) => v === true || v === 1 || v === '1', z.boolean())`. There are 150+ occurrences. common.ts only exports parseBody — no shared id/number/bool/date primitives exist, even though plan.schema.ts (intFromForm/isoDate) and planCategory.schema.ts (hexColor) already prove the team knows how to factor these out locally. This is the single biggest source of drift: any fix to coercion behavior must be applied in dozens of places.
|
||||||
|
- **fix:** Add shared exports to src/schemas/common.ts, e.g. `numFromForm`, `nullableNumFromForm`, `boolFromForm`, `idFromForm`, `isoDate`, and import them everywhere. Zod 4 (in use here, v4.3.6) provides `z.coerce.number()` and `z.stringbool()` which replace idioms (1) and (3) outright; per Zod 4 docs `z.stringbool()` throws on unrecognized strings (safer than the silent preprocess). Consolidating also lets you fix the NaN bug (next finding) in one spot.
|
||||||
|
|
||||||
|
### 30. [MEDIUM/needs-judgment] Number coercion silently produces NaN (no validation guard in most files)
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/trips.schema.ts:4,12-13; src/schemas/vehicles.schema.ts:8-17,29-34; src/schemas/invoices.schema.ts:26-29,48-52,89-92; src/schemas/settings.schema.ts:15-62; src/schemas/attendance.schema.ts:20-37,90-129; src/schemas/received-invoices.schema.ts:5-6,35-60; src/schemas/bank-accounts.schema.ts:14-18,31-34
|
||||||
|
- **problem:** Most `.transform((v) => Number(v))` coercions have no follow-up refine, so a non-numeric string parses to `NaN` and passes validation. e.g. CreateTripSchema vehicle_id/start_km/end_km (trips.schema.ts:4,12,13) accept garbage as NaN; settings break_threshold_hours, clock_rounding_minutes, max_login_attempts etc. all coerce to NaN silently. Notably this is applied INCONSISTENTLY: orders.schema.ts and offers.schema.ts DO add `.refine((v) => !Number.isNaN(v), ...)` after every numeric transform, and invoices.schema.ts:8-12 throws inside the transform for quantity — so the same project has three different behaviors for the identical concern. NaN then flows into Prisma/business logic.
|
||||||
|
- **fix:** Standardize on one safe numeric primitive (ideally the shared helper from the previous finding) that rejects NaN — e.g. `z.coerce.number()` which natively errors on non-numeric input, or `.refine((v) => !Number.isNaN(v))`. Apply it uniformly so trips/vehicles/settings/attendance match the stricter orders/offers behavior.
|
||||||
|
|
||||||
|
### 31. [MEDIUM/needs-judgment] Email validation applied inconsistently across schemas
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/users.schema.ts:5,21; src/schemas/profile.schema.ts:4; src/schemas/customers.schema.ts (no email field, but); src/schemas/warehouse.schema.ts:30,42; src/schemas/settings.schema.ts:38-41
|
||||||
|
- **problem:** Email fields are validated as proper emails only in users.schema.ts and profile.schema.ts via `z.string().email(...)`. Other schemas that accept email-typed data use plain `z.string().nullish()` with no format check: warehouse supplier `email` (warehouse.schema.ts:30,42), and company-settings `invoice_alert_email`, `leave_notify_email`, `smtp_from` (settings.schema.ts:38-40). The notify/alert emails in settings are used to actually send mail, so an invalid value is a silent operational failure.
|
||||||
|
- **fix:** Apply email-format validation to the email-typed fields in warehouse and settings schemas (e.g. `z.string().email(...).nullish()`, allowing empty/null). Note: Zod 4 prefers the top-level `z.email()` over the now-deprecated `z.string().email()`; consider migrating the existing users/profile usages too for consistency.
|
||||||
|
|
||||||
|
### 32. [MEDIUM/needs-judgment] Services return error with no status, forcing routes to hardcode or cast the HTTP code
|
||||||
|
- **dimension:** svc-errors
|
||||||
|
- **where:** src/services/attendance.service.ts:376; src/routes/admin/attendance.ts:50; src/routes/admin/projects.ts:73; src/routes/admin/quotations.ts:286
|
||||||
|
- **problem:** Many service functions return an error object with NO status field, while the documented convention is error plus status. Routes compensate inconsistently: attendance hardcodes the status at the call site (400 at attendance.ts:50, 404 at :70 for the same shape), and projects/quotations cast result.status with as-any. The same conceptual error yields 400 from one route and 404 from another by call-site choice, not service intent.
|
||||||
|
- **fix:** Make every error path return error plus status. Then collapse route call sites to the uniform check already used by plan.ts, removing the as-any casts.
|
||||||
|
|
||||||
|
### 33. [MEDIUM/needs-judgment] String-sentinel error values decoded by routes instead of self-describing error plus status
|
||||||
|
- **dimension:** svc-errors
|
||||||
|
- **where:** src/services/invoices.service.ts:393; src/services/offers.service.ts:231; src/services/projects.service.ts:149; src/routes/admin/invoices.ts:165
|
||||||
|
- **problem:** updateInvoice, deleteProject and updateOffer return machine-codes as the error string (not_found, has_order, invalidated) without a status. The Czech message and HTTP status are reconstructed in the route via if/else chains ending in a fall-through 500 Neznama chyba. This splits one entity error vocabulary across two files and is easy to desync.
|
||||||
|
- **fix:** Return the final Czech message plus status directly, as updateOrder/createOffer already do. The route then needs only the uniform one-line check and the 500 fall-throughs disappear.
|
||||||
|
|
||||||
|
### 34. [MEDIUM/needs-judgment] Duplicated and contradictory `PaginationMeta` / pagination shape (3 definitions)
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/types/index.ts:85; src/admin/lib/apiAdapter.ts:33; src/admin/hooks/usePaginatedQuery.ts:8
|
||||||
|
- **problem:** The pagination response shape is declared three times with diverging fields. The server type `PaginationMeta` (types/index.ts:85) has `page, limit, per_page, total, total_pages`. The frontend `PaginationMeta` (apiAdapter.ts:33) drops `limit` entirely (`total, page, per_page, total_pages`). A third inline `PaginatedResult.pagination` (usePaginatedQuery.ts:8-16) re-declares the same fields again. These are independent definitions that can drift; the missing `limit` on the frontend copy means a consumer expecting the server contract is silently incompatible.
|
||||||
|
- **fix:** Pick one canonical `PaginationMeta` (the server one in types/index.ts is the contract) and have the frontend import/derive from it, or at minimum collapse apiAdapter.ts and usePaginatedQuery.ts onto a single shared interface so the `limit` discrepancy is resolved and can't drift.
|
||||||
|
|
||||||
|
### 35. [MEDIUM/needs-judgment] Missing return types on ~47 of 107 exported service functions force `as any` casts in routes
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/services/projects.service.ts:95; src/routes/admin/projects.ts:73; src/routes/admin/projects.ts:106; src/routes/admin/quotations.ts:286; src/services/users.service.ts:59; src/services/offers.service.ts:161; src/services/invoices.service.ts:339
|
||||||
|
- **problem:** Roughly 47 of 107 exported `async function`s in src/services have no explicit return type and rely on inference. For functions like `updateProject` (projects.service.ts:95) the inferred type is a union of `null | { error, status } | <prismaRow>`; after `"error" in result` narrowing the row branch still has no `status`, so route handlers reach for `(result as any).status ?? 400` (projects.ts:73, projects.ts:106, quotations.ts:286) to read a field the compiler can't guarantee. The `any` cast erases all type checking on the result at exactly the error-handling boundary. The standard `{ data } | { error, status }` service contract documented in CLAUDE.md is not enforced because the return type is left implicit.
|
||||||
|
- **fix:** Annotate service functions with an explicit discriminated return type (e.g. a shared `ServiceResult<T> = { data: T } | { error: string; status: number }`, or `Result<T>` like plan.service.ts:387 already defines). With a uniform `status` on the error branch, the `(result as any).status` casts in routes can be deleted and become type-safe. This is the systemic root cause of the route-level casts.
|
||||||
|
|
||||||
|
### 36. [MEDIUM/needs-judgment] Heavy `any` in usePlanWork mutations plus mutation-object monkey-patching (`(createEntry as any)._rolled`)
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/admin/hooks/usePlanWork.ts:235; src/admin/hooks/usePlanWork.ts:241; src/admin/hooks/usePlanWork.ts:261; src/admin/hooks/usePlanWork.ts:273; src/admin/hooks/usePlanWork.ts:322; src/admin/hooks/usePlanWork.ts:355; src/admin/hooks/usePlanWork.ts:388; src/admin/hooks/usePlanWork.ts:430; src/admin/hooks/usePlanWork.ts:457; src/admin/hooks/usePlanWork.ts:478
|
||||||
|
- **problem:** The plan-work optimistic-update hook is the single densest `any` site in the frontend (20 occurrences). Mutation `mutationFn`/`onSuccess` bodies and vars are typed `any` (e.g. `(body: any)`, `(data: any, body: any)`), and rollback state is stored by mutating the mutation object: `(createEntry as any)._rolled = rolled` (and 5 more `_rolled` writes). `qc.getQueryData<GridData>(currentGridKey as any)` also casts the typed query key away. This defeats type safety on the optimistic-update path where cell-patching correctness matters most, and the `_rolled` side-channel is invisible to the type system, so a typo in the property name would silently break rollback.
|
||||||
|
- **fix:** Type the mutation bodies with the request DTOs (the plan entry/override create/update shapes already exist in schemas/ and lib/queries/plan.ts) and `onSuccess` data with the API response type. Replace the `(mutation as any)._rolled` side-channel with React Query's `onMutate`/context return value (the supported, fully-typed rollback mechanism), which removes the casts.
|
||||||
|
|
||||||
|
### 37. [LOW/safe-auto] Configured @/* path alias is unused and not wired into the Vite/vitest build
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** tsconfig.app.json:19-22; tsconfig.server.json:16-19; vite.config.ts:4-32; vitest.config.ts:5-14
|
||||||
|
- **problem:** Both tsconfig.app.json and tsconfig.server.json declare baseUrl:'.' with paths {'@/*':['src/*']}, but a search for imports from '@/' across src returns zero matches — the alias is dead config. Worse, if someone did adopt it, vite.config.ts and vitest.config.ts define no matching resolve.alias, so TypeScript would resolve it but the Vite client build and Vitest would fail at runtime. The codebase consistently uses relative imports instead.
|
||||||
|
- **fix:** Either remove the baseUrl/paths block from both tsconfigs to avoid implying an alias convention the bundler does not support, or commit to it by adding the matching resolve.alias in vite.config.ts/vitest.config.ts and migrating imports. Removal is the lower-risk option given current usage.
|
||||||
|
|
||||||
|
### 38. [LOW/needs-judgment] package.json db:push / db:pull scripts contradict the documented migration golden rule
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** package.json:16-17; CLAUDE.md
|
||||||
|
- **problem:** CLAUDE.md states the golden rule 'NEVER use prisma db push' (it bypasses migration history and causes drift), yet package.json exposes "db:push": "prisma db push" (and "db:pull": "prisma db pull") as first-class npm scripts, which invites exactly the prohibited workflow. The documented schema-change path is prisma migrate dev, which has no script alias.
|
||||||
|
- **fix:** Remove the db:push (and likely db:pull) scripts, or rename/guard them so they can't be run casually; add a db:migrate script wrapping `prisma migrate dev` to match the documented workflow.
|
||||||
|
|
||||||
|
### 39. [LOW/needs-judgment] Cross-boundary import of a server util by the client is inconsistent and structurally fragile
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** src/admin/utils/attendanceHelpers.ts:1; src/admin/hooks/useAttendanceAdmin.ts:4; src/utils/czech-holidays.ts:1-8; tsconfig.server.json:21-30
|
||||||
|
- **problem:** src/admin code imports getHolidays/isHoliday from the server-side util src/utils/czech-holidays via '../../utils/czech-holidays'. This is the ONLY place server logic is genuinely shared with the client; every other shared concern (formatters, label maps, EntityType) is duplicated instead, so the sharing strategy is inconsistent. It also reaches across the build boundary: tsconfig.server.json compiles src/utils into the server bundle while Vite separately bundles the same file into the client. It works today only because czech-holidays has no Node-only dependencies (it imports just ./date). If czech-holidays ever pulls in a Node API (fs, crypto, prisma), the client build breaks silently.
|
||||||
|
- **fix:** Decide on one strategy: either introduce a dedicated src/shared/ (or src/common/) module for genuinely isomorphic helpers like czech-holidays and date formatters, referenced by both builds with an explicit boundary, or duplicate intentionally. Avoid ad-hoc src/admin -> src/utils relative imports that silently couple the two bundles.
|
||||||
|
|
||||||
|
### 40. [LOW/safe-auto] .env.example has drifted from the variables actually read in config/env.ts
|
||||||
|
- **dimension:** config-structure
|
||||||
|
- **where:** .env.example:1-32; src/config/env.ts:55-110
|
||||||
|
- **problem:** config/env.ts reads several env vars absent from .env.example: TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD, LOGIN_TOKEN_EXPIRY_MINUTES, NAS_FINANCIALS_PATH, NAS_OFFERS_PATH, SMTP_FROM_NAME, INVOICE_ALERT_EMAIL, RATE_LIMIT_MAX/WINDOW/LOGIN/TOTP/REFRESH, and TRUST_PROXY. All have defaults so nothing breaks, but the example file no longer documents the full configuration surface, making prod tuning (rate limits, trust proxy) undiscoverable.
|
||||||
|
- **fix:** Add the missing optional variables (with their default values as comments) to .env.example so it stays a complete reference for config/env.ts.
|
||||||
|
|
||||||
|
### 41. [LOW/manual] Inconsistent badge/class naming scheme (admin- prefix dropped)
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/components.css:142; src/admin/components.css:146; src/admin/components.css:150; src/admin/components.css:154; src/admin/attendance.css:333; src/admin/invoices.css:24; src/admin/invoices.css:70
|
||||||
|
- **problem:** Class naming is inconsistent within the single global namespace. Most badges use admin-badge-* (admin-badge-order-prijata, admin-badge-invoice-paid), but the leave-status badges in components.css:142-154 are bare badge-pending/badge-approved/badge-rejected/badge-cancelled, and attendance.css reuses bare badge-vacation/sick/holiday/unpaid. invoices.css mixes three schemes in one file: admin-badge-invoice-* (:5), invoice-month-* (:24), and received-upload-* (:70). Because all CSS is global, these bare badge-* names risk collision and make the convention ambiguous (admin-* for shared vs feature-prefix for page-local).
|
||||||
|
- **fix:** Document and enforce one rule: shared/reusable components use admin-*, page-local elements use a feature prefix (already done for plan-*, dash-*, fm-*). Rename bare badge-* to admin-badge-* (or feature-prefix them) — coordinate the matching className strings in the TSX. Not safe-auto because it requires synchronized TSX edits.
|
||||||
|
|
||||||
|
### 42. [LOW/needs-judgment] Defined-but-unused CSS variables (dead tokens)
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/variables.css:27; src/admin/variables.css:34; src/admin/variables.css:14; src/admin/variables.css:15; src/admin/variables.css:26
|
||||||
|
- **problem:** Several tokens are declared but never referenced anywhere in *.css: --gradient-subtle (variables.css:27), --transition-slow (:34), --space-10 (:14) and --space-12 (:15). Additionally --gradient (:26) is used only once (attendance.css:220) and is just a duplicate literal of --accent-color (#d63031), so it is a redundant alias rather than a real gradient token. These are dead/redundant and add noise to the token system.
|
||||||
|
- **fix:** Remove the unused tokens, or wire them up if intended. For --gradient, either point attendance.css:220 at var(--accent-color) and delete --gradient, or rename it to something meaningful. Verify against TSX inline styles before deleting (grep showed no CSS consumers).
|
||||||
|
|
||||||
|
### 43. [LOW/needs-judgment] Empty responsive.css stub imported into the bundle
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/responsive.css:1; src/admin/AdminApp.tsx:22
|
||||||
|
- **problem:** responsive.css contains only a 6-line comment ('reserved for responsive rules that span multiple components') and no rules, yet is imported at AdminApp.tsx:22. All responsive media queries actually live in their per-feature files (forms.css, layout.css, components.css, etc.), so the file is dead. It is harmless but misleading — a reader may expect cross-cutting breakpoints to live here.
|
||||||
|
- **fix:** Either delete the file and its import, or actually consolidate the repeated breakpoints here. Note there is no shared breakpoint token: 480/640/768/1024 recur across ~15 files as raw px (e.g. forms.css:293-312, components.css, layout.css). If kept, this file is the natural place to at least document the canonical breakpoint set.
|
||||||
|
|
||||||
|
### 44. [LOW/safe-auto] plan.css imported twice
|
||||||
|
- **dimension:** css
|
||||||
|
- **where:** src/admin/AdminApp.tsx:26; src/admin/pages/PlanWork.tsx:18
|
||||||
|
- **problem:** plan.css is imported globally in AdminApp.tsx:26 (alongside all other stylesheets) and again in PlanWork.tsx:18. Since AdminApp already loads it globally, the second import is redundant. The bundler de-dupes so there is no runtime double-application, but it is inconsistent — no other page re-imports its own CSS (e.g. WarehousePage does not re-import warehouse.css), so this is an outlier that suggests uncertainty about the loading model.
|
||||||
|
- **fix:** Remove the import at PlanWork.tsx:18 to match the convention that all admin CSS is loaded once in AdminApp.tsx. Low risk since the global import already covers it.
|
||||||
|
|
||||||
|
### 45. [LOW/needs-judgment] getCzechDate week number uses a non-ISO local formula that disagrees with the plan module's ISO week
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/admin/utils/dashboardHelpers.ts:75-78; src/admin/pages/PlanWork.tsx:61-69
|
||||||
|
- **problem:** The dashboard header (getCzechDate) computes "Týden N" with a homegrown formula: Math.ceil(((now - Jan1)/86400000 + Jan1.getDay() + 1)/7) using local getters. This is not ISO-8601 (ISO weeks start Monday; week 1 is the week containing the first Thursday) and is also locale-dependent via getDay(). PlanWork.tsx:61-69 implements a correct ISO-8601 week (Thursday-anchored, UTC). For dates near year boundaries the two will display different week numbers for the same day, an internal inconsistency users can notice.
|
||||||
|
- **fix:** Extract PlanWork's isoWeekNumber into a shared util and reuse it in getCzechDate so the week number is computed one consistent (ISO) way across the UI.
|
||||||
|
|
||||||
|
### 46. [LOW/needs-judgment] Two divergent conventions for writing @db.Date columns (UTC-midnight vs local-noon)
|
||||||
|
- **dimension:** dates
|
||||||
|
- **where:** src/routes/admin/trips.ts:234; src/routes/admin/trips.ts:294; src/services/invoices.service.ts:362-364; src/services/attendance.service.ts:1176; src/services/attendance.service.ts:1227-1234
|
||||||
|
- **problem:** Date-only (@db.Date) columns are written two different ways. Trips and invoices do new Date(String(body.x)) where body.x is "YYYY-MM-DD" → UTC-midnight. Attendance constructs new Date(year,month,day,12,0,0) → local noon. Both currently store the correct date in Prague only because the local offset is always positive and 12:00 local is comfortably inside the day in UTC. The local-noon form is the robust one (immune to offset sign and DST edges); the UTC-midnight form is the fragile one. Mixing them is an inconsistency that invites a future off-by-one if anyone copies the wrong pattern or the deployment TZ ever changes.
|
||||||
|
- **fix:** Standardize on one convention for @db.Date writes — prefer the local-noon construction (or a single shared helper like dateOnly("YYYY-MM-DD")) and use it in trips/invoices too. Behavior is unchanged for Prague today, so this is a consistency/robustness cleanup, not an urgent fix.
|
||||||
|
|
||||||
|
### 47. [LOW/needs-judgment] escapeHtml is defined six times across the codebase
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/routes/admin/invoices-pdf.ts:35; src/routes/admin/orders-pdf.ts:50; src/routes/admin/offers-pdf.ts:51; src/services/invoice-alerts.ts:18; src/services/leave-notification.ts:21; src/admin/hooks/useAttendanceAdmin.ts:341
|
||||||
|
- **problem:** There are six separate definitions of an escapeHtml(str) function. Five escape &,<,>," identically; the sixth (useAttendanceAdmin.ts:341) additionally escapes the single quote ('). They are used for HTML email bodies, PDF templates, and print HTML — all contexts where consistent escaping matters. The subtle difference (one escapes the apostrophe, the others don't) is the kind of inconsistency that hides XSS/output bugs.
|
||||||
|
- **fix:** Move escapeHtml into a single shared util (server-side src/utils, plus one for the frontend, or share via a common module). Standardize on the stricter variant that also escapes the single quote. Replace the six local copies with imports.
|
||||||
|
|
||||||
|
### 48. [LOW/safe-auto] formatDate duplicated between two frontend util files
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/admin/utils/formatters.ts:20; src/admin/utils/attendanceHelpers.ts:24
|
||||||
|
- **problem:** formatters.ts exports `formatDate(dateStr)` => `d.toLocaleDateString('cs-CZ')` with a '—' fallback, and attendanceHelpers.ts exports an identical `formatDate` (same body, same '—' fallback). Both are imported across the app, so two names resolve to the same logic in different modules — a maintenance hazard if one is changed.
|
||||||
|
- **fix:** Keep the canonical formatDate in formatters.ts and have attendanceHelpers re-export or import it instead of redefining. Update attendanceHelpers consumers to the shared one.
|
||||||
|
|
||||||
|
### 49. [LOW/safe-auto] Dead export: previewPattern() in numbering.service.ts
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/services/numbering.service.ts:343
|
||||||
|
- **problem:** `previewPattern(pattern, prefix, code)` is exported but has zero references anywhere in src (verified by grep across .ts/.tsx). It is the only public function in the numbering service with no callers; all sibling preview/generate functions are used.
|
||||||
|
- **fix:** Remove previewPattern, or wire it into the settings UI/route that previews a numbering pattern if that was its intent. As-is it is dead code.
|
||||||
|
|
||||||
|
### 50. [LOW/safe-auto] Dead export: usersQuery + planKeys.users() in plan query module
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/admin/lib/queries/plan.ts:111; src/admin/lib/queries/plan.ts:95
|
||||||
|
- **problem:** `usersQuery()` (plan.ts:111) is exported but never imported or used by any component/page. Its query key `planKeys.users()` (plan.ts:95) is referenced only by usersQuery itself. The `/api/admin/plan/users` endpoint string appears only inside this dead query (the PlanWork grid gets its users from the grid payload instead). So the frontend query, its key entry, and the `users: () => ['plan','users']` key are an orphaned cluster. (The backend route src/routes/admin/plan.ts:63 still exists and was not assessed as dead from the server side.)
|
||||||
|
- **fix:** Remove usersQuery and the planKeys.users key entry. Separately confirm whether the backend GET /plan/users route still has any consumer; if not, it can be removed too.
|
||||||
|
|
||||||
|
### 51. [LOW/needs-judgment] Dead export: getMonthlyWorkFund() in czech-holidays.ts
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/utils/czech-holidays.ts:95
|
||||||
|
- **problem:** `getMonthlyWorkFund(...)` is exported but has zero references across src (verified by grep). Sibling exports getHolidays/isHoliday/getBusinessDaysInMonth are all used; this one is not.
|
||||||
|
- **fix:** Remove getMonthlyWorkFund, or use it where monthly work-fund hours are currently computed inline (e.g. attendance.service.ts computes fund hours as `getBusinessDaysInMonth(...) * 8` in several places — that inline math is what this helper appears intended to replace).
|
||||||
|
|
||||||
|
### 52. [LOW/needs-judgment] Unused file: ReservationPicker.tsx
|
||||||
|
- **dimension:** deadcode
|
||||||
|
- **where:** src/admin/components/warehouse/ReservationPicker.tsx:11
|
||||||
|
- **problem:** The whole file (54 lines, default export ReservationPicker) has no importers anywhere in the project — grep for 'ReservationPicker' returns only self-references inside the file. The sibling warehouse pickers (ItemPicker, BatchPicker, SupplierSelect, LocationSelect) are all imported and used; this one is an orphan.
|
||||||
|
- **fix:** Delete src/admin/components/warehouse/ReservationPicker.tsx, or wire it into the warehouse issue/reservation flow if it was intended for an unfinished feature.
|
||||||
|
|
||||||
|
### 53. [LOW/safe-auto] Inconsistent Czech phrasing for "not found" (nenalezen vs nebyl nalezen)
|
||||||
|
- **dimension:** i18n
|
||||||
|
- **where:** src/routes/admin/project-files.ts:61; src/routes/admin/project-files.ts:73; src/routes/admin/project-files.ts:92; src/routes/admin/project-files.ts:112; src/routes/admin/project-files.ts:165; src/routes/admin/project-files.ts:228; src/routes/admin/project-files.ts:270; src/services/warehouse.service.ts:967; src/services/warehouse.service.ts:1021; src/routes/admin/projects.ts:56
|
||||||
|
- **problem:** Two phrasings express the same concept. The dominant, compact form "nenalezen/nenalezena/nenalezeno" appears ~92 times across routes/services; the verbose form "nebyl/nebyla nalezen*" appears ~16 times (all of project-files.ts plus warehouse.service.ts reservations/inventory). The clash is sometimes on the identical entity: projects.ts:56 "Projekt nenalezen" vs project-files.ts:61 "Projekt nebyl nalezen"; warehouse routes "Kategorie nenalezena"/"Dodavatel nenalezen" vs warehouse.service.ts "Rezervace nebyla nalezena"/"Inventarizace nebyla nalezena". Same product, two wordings for the same 404.
|
||||||
|
- **fix:** Standardize on the dominant compact form ("Projekt nenalezen", "Soubor nenalezen", "Složka nenalezena", "Rezervace nenalezena", "Inventarizace nenalezena"). Pure string normalization, no logic change. Low priority polish.
|
||||||
|
|
||||||
|
### 54. [LOW/needs-judgment] parseBody leaks default English Zod messages for validators without a custom message
|
||||||
|
- **dimension:** i18n
|
||||||
|
- **where:** src/schemas/common.ts:12; src/schemas/received-invoices.schema.ts:27; src/schemas/attendance.schema.ts:25; src/schemas/leave-requests.schema.ts:4; src/schemas/offers.schema.ts:92; src/schemas/orders.schema.ts:93; src/schemas/attendance.schema.ts:8; src/schemas/plan.schema.ts:42; src/schemas/auth.schema.ts:26
|
||||||
|
- **problem:** parseBody (common.ts:12) joins each Zod issue's .message and returns it directly as the user-facing API error. Fields with explicit Czech messages (e.g. .min(1, "Název je povinný")) are fine, but validators relying on Zod defaults emit English to the user: z.enum([...]) without a message -> "Invalid enum value..."; .max(500) on note/address -> "String must contain at most 500 character(s)"; TOTP secret/code .min(1) (auth.schema.ts:26-33) -> "String must contain at least 1 character(s)". Most are hard for a normal user to trigger (enum values come from dropdowns), but the length caps on free-text note/address fields are reachable.
|
||||||
|
- **fix:** Add Czech messages to the user-reachable validators (the free-text .max() caps and any enum a user could submit), or centralize a Czech errorMap on the Zod instance so defaults are Czech globally. Lower urgency for purely internal enums/tokens. Treat as needs-judgment because deciding which validators are user-reachable requires per-field review.
|
||||||
|
|
||||||
|
### 55. [LOW/safe-auto] Best-effort frontend background calls swallow errors silently with .catch(() => {})
|
||||||
|
- **dimension:** logging
|
||||||
|
- **where:** src/admin/pages/Attendance.tsx:235; src/admin/pages/Attendance.tsx:238; src/admin/pages/InvoiceDetail.tsx:778
|
||||||
|
- **problem:** Three fire-and-forget calls (reverse-geocode address update, and post-save PDF pre-generation) use '.catch(() => {})' with no logging. These are genuinely non-critical side effects, so failing silently is a defensible product choice, but the empty handler gives no breadcrumb when the address never updates or the PDF isn't pre-generated. Unlike the rest of the frontend, which consistently surfaces errors via alert.error with a Czech fallback, these are invisible.
|
||||||
|
- **fix:** Add a console.warn (or a debug-level log) in the catch so the swallow is intentional-and-traceable rather than fully silent, e.g. '.catch((e) => console.warn("address update failed", e))'. No user-facing alert is warranted for these background ops.
|
||||||
|
|
||||||
|
### 56. [LOW/needs-judgment] plan.ts query factories use '*Query' suffix instead of the established '*Options' convention
|
||||||
|
- **dimension:** naming
|
||||||
|
- **where:** src/admin/lib/queries/plan.ts:98; src/admin/lib/queries/plan.ts:111
|
||||||
|
- **problem:** Across src/admin/lib/queries, 55 query factories use the '*Options' suffix (userListOptions, orderListOptions, attendanceBalancesOptions, planCategoriesOptions, etc.). plan.ts itself uses planCategoriesOptions correctly, but then defines gridQuery (line 98) and usersQuery (line 111) with a '*Query' suffix instead. These are the only two '*Query'-suffixed factories in the whole queries directory, so the names read as a different kind of thing than they are (they are queryOptions() factories identical in shape to the *Options ones).
|
||||||
|
- **fix:** Rename gridQuery -> planGridOptions and usersQuery -> planUsersOptions for parity with the surrounding 55 *Options factories. Both are used inside the same module/feature, so the blast radius is small.
|
||||||
|
|
||||||
|
### 57. [LOW/needs-judgment] '.service.ts' suffix applied to only half of service files, with two borderline cases
|
||||||
|
- **dimension:** naming
|
||||||
|
- **where:** src/services/exchange-rates.ts; src/services/system-settings.ts; src/services/numbering.service.ts
|
||||||
|
- **problem:** Of 20 files in src/services, exactly 10 carry the '.service.ts' suffix and 10 do not. The split largely tracks a sensible distinction (entity CRUD services like invoices.service.ts / users.service.ts are suffixed; infra/integration helpers like mailer.ts, audit.ts, nas-*-manager.ts, leave-notification.ts are not), so this is not pure randomness. However the boundary is fuzzy: exchange-rates.ts (exports getRate/toCzk) and system-settings.ts (exports getSystemSettings) are read/query services that resemble the suffixed group but lack the suffix, while numbering.service.ts is suffixed despite being a low-level sequence helper. A reader cannot rely on the suffix to predict a file's role.
|
||||||
|
- **fix:** Document the intended rule in CLAUDE.md (e.g. '*.service.ts = entity business-logic services; bare name = infra/integration helpers') and rename the borderline files to match it, OR drop the suffix entirely since the directory name 'services/' already conveys the role. Either way pick one rule; do not leave it implicit.
|
||||||
|
|
||||||
|
### 58. [LOW/needs-judgment] queryKey helper naming: only 'plan' defines a 'planKeys' key-factory object; other domains inline keys
|
||||||
|
- **dimension:** naming
|
||||||
|
- **where:** src/admin/lib/queries/plan.ts:87
|
||||||
|
- **problem:** plan.ts introduces a dedicated planKeys object (all/grid/entries/overrides/users) to centralize query keys, which is a good pattern. But it is the only domain in src/admin/lib/queries that does so; every other module (users.ts, orders.ts, invoices.ts, trips.ts, etc.) inlines its queryKey arrays directly inside each *Options factory. The result is two coexisting key-management styles with no documented reason, so a contributor cannot tell which pattern to follow when adding a query.
|
||||||
|
- **fix:** Decide on one approach: either adopt the planKeys-style key-factory object per domain (more robust against typos and easier invalidation), or treat planKeys as a deliberate exception and note it. Low priority — purely a consistency/discoverability issue, no behavior impact.
|
||||||
|
|
||||||
|
### 59. [LOW/needs-judgment] getInvoiceStats walks the dataset four times with sequential per-invoice currency conversion
|
||||||
|
- **dimension:** prisma
|
||||||
|
- **where:** src/services/invoices.service.ts:247-256; src/services/invoices.service.ts:284-297
|
||||||
|
- **problem:** sumCzk awaits toCzk per invoice in a loop, called three times plus a fourth VAT loop; serializes many awaits. Cached so it is await overhead not network.
|
||||||
|
- **fix:** Pre-fetch the rate map once and convert synchronously, or resolve conversions together. Low priority.
|
||||||
|
|
||||||
|
### 60. [LOW/needs-judgment] getBalances runs a sequential leave_balances query per user
|
||||||
|
- **dimension:** prisma
|
||||||
|
- **where:** src/services/attendance.service.ts:490-504; src/services/attendance.service.ts:944-946
|
||||||
|
- **problem:** Loops users running leave_balances findFirst each; getPrintData already uses one findMany for the year then a map, so this is inconsistent.
|
||||||
|
- **fix:** One findMany filtered by user_id in the list and year; index by user_id.
|
||||||
|
|
||||||
|
### 61. [LOW/needs-judgment] enrich and body handlers typed as any, dropping Prisma types
|
||||||
|
- **dimension:** prisma
|
||||||
|
- **where:** src/services/orders.service.ts:41; src/services/offers.service.ts:49; src/services/invoices.service.ts:339; src/services/invoices.service.ts:391; src/services/projects.service.ts:95
|
||||||
|
- **problem:** enrichOrder and enrichQuotation and several handlers take any, losing the types Prisma generates for includes and mapped rows; code otherwise strict TS, deriving a getPayload type at attendance.service.ts 23-28.
|
||||||
|
- **fix:** Type enrich inputs with the Prisma getPayload helper; tighten bodies to existing input interfaces.
|
||||||
|
|
||||||
|
### 62. [LOW/needs-judgment] Server data synced into form state via useEffect instead of derived/key-reset (accepted-but-not-ideal)
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/pages/InvoiceDetail.tsx:371; src/admin/pages/InvoiceDetail.tsx:454; src/admin/pages/OfferDetail.tsx:383; src/admin/pages/OfferDetail.tsx:428; src/admin/pages/CompanySettings.tsx:256; src/admin/pages/Settings.tsx:213
|
||||||
|
- **problem:** Multiple pages copy query data into local form state inside an effect, guarded by a one-shot ref/flag (formInitializedRef / dataReady / sysFormInitialized) or a derived-default merge (InvoiceDetail:371, OfferDetail:383 adjust currency/vat from companySettings on prop change). The React 'You Might Not Need an Effect' guidance explicitly lists 'adjusting/resetting state on prop change in an Effect' as an anti-pattern (causes an extra render with stale data) and recommends either computing during render or resetting state with a `key`. The flag-guarded population effects are the commonly-accepted compromise for editable forms seeded from server data, so this is low severity, but it is the same family of effect the docs steer away from.
|
||||||
|
- **fix:** Where feasible, prefer resetting form state by giving the editor a key (e.g. key={id} on the detail component) so a fresh mount re-seeds state without an effect, and compute the companySettings-derived defaults (currency/vat) during render rather than patching state in an effect. Treat as cleanup, not a blocker — the current guards prevent the worst (re-clobbering user edits).
|
||||||
|
|
||||||
|
### 63. [LOW/manual] 2FA/profile state prop-drilled through DashProfile (18 props)
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/components/dashboard/DashProfile.tsx:10; src/admin/components/dashboard/DashProfile.tsx:40
|
||||||
|
- **problem:** DashProfile receives 18 props, ~14 of which are a single concern: the 2FA enrollment/disable flow state and its setters (totpEnabled, totpLoading, totpSubmitting, totpSecret, totpQrUri, totpCode/setTotpCode, backupCodes/setBackupCodes, show2FASetup/setShow2FASetup, show2FADisable/setShow2FADisable, disableCode/setDisableCode, plus onStart/onConfirm/onDisable callbacks). This is all owned by the Dashboard parent and threaded down, making the component signature brittle and the 2FA logic split across two files.
|
||||||
|
- **fix:** Encapsulate the 2FA flow in a dedicated hook (e.g. use2FA()) colocated with DashProfile, or move the 2FA modals fully into DashProfile and have it own that state. The parent then passes only totpEnabled (or nothing). This is a maintainability refinement, not a bug.
|
||||||
|
|
||||||
|
### 64. [LOW/safe-auto] Minor: derived-state effect in ItemPicker resets activeIndex via useEffect
|
||||||
|
- **dimension:** react
|
||||||
|
- **where:** src/admin/components/warehouse/ItemPicker.tsx:48
|
||||||
|
- **problem:** The effect at line 48 watches [items, activeIndex] to reset the highlighted index when the result list shrinks. This is derived state maintained through an effect (the docs' 'you might not need an effect' territory) and introduces an extra render cycle; it also lists activeIndex in deps while setting it, which is benign here only because of the guard. Low impact — the component otherwise correctly uses refs for keyboard nav.
|
||||||
|
- **fix:** Clamp activeIndex during render (e.g. const safeActive = activeIndex >= items.length ? -1 : activeIndex) or reset it in the same handler that changes the search/items rather than reacting in an effect. Optional cleanup.
|
||||||
|
|
||||||
|
### 65. [LOW/safe-auto] Inconsistent numeric ID parsing — raw parseInt instead of the parseId helper
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/trips.ts:192-193; src/routes/admin/trips.ts:264-265; src/routes/admin/trips.ts:338-339; src/routes/admin/sessions.ts:84-85
|
||||||
|
- **problem:** response.ts exports parseId(raw, reply) specifically to centralize param-id parsing (it rejects NaN and id<=0 with a uniform 400 'Neplatné ID'). Most files use it, but trips.ts and sessions.ts hand-roll `parseInt(...,10)` + `isNaN` checks. Besides the duplication, the hand-rolled versions accept id <= 0 (e.g. 0 or negative) because they only check isNaN, whereas parseId also rejects id <= 0 — a subtle behavioral divergence. Error messages also differ ('Neplatné ID vozidla', 'Neplatné ID relace' vs the helper's 'Neplatné ID').
|
||||||
|
- **fix:** Replace the parseInt/isNaN blocks with `const id = parseId(request.params.id, reply); if (id === null) return;` (trips.ts uses request.params.id / vehicleId; keep a tailored message only if the distinct wording is desired). This unifies the <=0 rejection and the 400 response shape.
|
||||||
|
|
||||||
|
### 66. [LOW/needs-judgment] Several not-found / precondition errors in project-files.ts fall back to default 400 instead of an explicit status
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/project-files.ts:68; src/routes/admin/project-files.ts:69; src/routes/admin/project-files.ts:88; src/routes/admin/project-files.ts:114; src/routes/admin/project-files.ts:124; src/routes/admin/project-files.ts:138; src/routes/admin/project-files.ts:179; src/routes/admin/project-files.ts:202; src/routes/admin/project-files.ts:229; src/routes/admin/project-files.ts:241; src/routes/admin/project-files.ts:279
|
||||||
|
- **problem:** Within the same file, downloadFile-not-found correctly returns error(reply, 'Soubor nebyl nalezen', 404) (line 73) and listFiles folder-not-found returns 404 (line 92), but the parallel 'Projekt nemá číslo projektu' (88,114,229), required-path messages (68,279), and fm-returned-error passthroughs (138,202,241) all omit the status and silently use the helper's default 400. Most are genuinely validation/precondition (400 is fine), but 'Projekt nemá číslo projektu' is a state precondition inconsistently a 400 here vs config-missing cases returning 500, and the omitted statuses make intent unclear. Consistency/clarity issue, not a correctness bug.
|
||||||
|
- **fix:** Pass an explicit status to each error() call so the intended HTTP semantics are visible and uniform (400 for client validation, 404/409/422 for state preconditions as appropriate). At minimum make the 'Projekt nemá číslo projektu' and required-path messages explicit rather than relying on the default.
|
||||||
|
|
||||||
|
### 67. [LOW/safe-auto] Redundant non-null assertions on parseBody result (body.data!) in plan.ts
|
||||||
|
- **dimension:** routes
|
||||||
|
- **where:** src/routes/admin/plan.ts:170; src/routes/admin/plan.ts:194; src/routes/admin/plan.ts:238-242; src/routes/admin/plan.ts:267; src/routes/admin/plan.ts:319; src/routes/admin/plan.ts:361
|
||||||
|
- **problem:** plan.ts writes `const body = parseBody(...); if ('error' in body) return ...; ... body.data!`. parseBody returns `{ data: T } | { error: string }` (schemas/common.ts:3-6), so after the `'error' in body` guard TypeScript already narrows body to `{ data: T }` and `.data` is non-null. Every other route file uses the cleaner `const parsed = parseBody(...); if ('error' in parsed) return ...; const body = parsed.data;` form without the `!`. The `!` is harmless but inconsistent and signals (falsely) that data might be nullable.
|
||||||
|
- **fix:** Adopt the prevailing pattern: bind `const parsed = parseBody(...)`, guard, then `const body = parsed.data` without the non-null assertion, matching customers.ts / warehouse.ts / attendance.ts.
|
||||||
|
|
||||||
|
### 68. [LOW/needs-judgment] usePlanWork re-implements the gridQuery factory inline with untyped apiFetch parsing
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/hooks/usePlanWork.ts:87; src/admin/lib/queries/plan.ts:98
|
||||||
|
- **problem:** plan.ts exports a `gridQuery(dateFrom,dateTo,view)` queryOptions factory (line 98) that uses jsonQuery<GridData> for type-safe envelope handling. usePlanWork.ts:87 ignores it and inlines its own useQuery with `apiFetch(...).then(r=>r.json().then((b:any)=>b.data as GridData))` — duplicating the URL construction, skipping jsonQuery's `!response.ok || !result.success` error check (so a backend `{success:false}` silently yields undefined data instead of throwing), and using `b:any`. The planKeys factory is reused for the key, but the queryFn diverges. This is the only place a query bypasses the apiAdapter.
|
||||||
|
- **fix:** Replace the inline useQuery body with the existing gridQuery factory: `useQuery({ ...gridQuery(isoDate(range.from), isoDate(range.to), view), placeholderData: keepPreviousData })`. Restores jsonQuery's error handling and removes the `any`. Low risk but should be eyeballed since the hook also reads this query's cache key elsewhere (key is unchanged, so cache stays compatible).
|
||||||
|
|
||||||
|
### 69. [LOW/safe-auto] Duplicate require2FAOptions factory (dead copy in dashboard.ts) and unused deprecated systemSettingsOptions alias
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/lib/queries/dashboard.ts:12; src/admin/lib/queries/settings.ts:82; src/admin/lib/queries/settings.ts:80
|
||||||
|
- **problem:** `require2FAOptions` is defined identically in two query files — dashboard.ts:12 and settings.ts:82 — both with the same key `["settings","2fa"]` and same URL. Both consumers (Dashboard.tsx:10, Settings.tsx:14) import the settings.ts copy, so the dashboard.ts copy is dead code. Because keys match it is not a cache-collision bug, but two sources of truth for one query is a maintenance hazard (edit one, miss the other). Separately, settings.ts:80 exports a `/** @deprecated */ systemSettingsOptions = systemInfoOptions` alias that has no importers (only systemInfoOptions is used).
|
||||||
|
- **fix:** Delete the duplicate require2FAOptions from dashboard.ts:12 (keep the settings.ts canonical one). Delete the unused deprecated systemSettingsOptions alias at settings.ts:80. Both are confirmed unreferenced by grep, so removal is mechanical and behavior-free.
|
||||||
|
|
||||||
|
### 70. [LOW/needs-judgment] Inconsistent filter-key serialization across list query factories
|
||||||
|
- **dimension:** rq-data
|
||||||
|
- **where:** src/admin/lib/queries/invoices.ts:129; src/admin/lib/queries/invoices.ts:157; src/admin/lib/queries/trips.ts:40; src/admin/lib/queries/warehouse.ts:194; src/admin/lib/queries/projects.ts:45
|
||||||
|
- **problem:** List factories are inconsistent in how the filters object becomes part of the query key. Most spread the raw filters object directly (invoices.ts:129 `["invoices","list",filters]`; warehouse, projects, offers, audit-log do the same), but trips.ts:40 and invoices received (invoices.ts:157) instead spell out an explicit object literal of the same fields. Functionally both work (React Query hashes the key deterministically and ignores object key order), but the inconsistency is gratuitous: the explicit form silently drops any new filter field that isn't manually listed, whereas the spread form auto-includes it. Not a bug today, just a divergence and a latent footgun for the spelled-out ones.
|
||||||
|
- **fix:** Pick one convention (spreading the filters object is the simpler, more future-proof choice) and apply it uniformly. Low priority — cosmetic consistency, no behavior change. Verify each endpoint's filters object contains only key-relevant fields before spreading.
|
||||||
|
|
||||||
|
### 71. [LOW/needs-judgment] Wrong TOTP code does not increment failed-login attempts (relies on rate limit only)
|
||||||
|
- **dimension:** security
|
||||||
|
- **where:** src/routes/admin/auth.ts:160-163; src/services/system-settings.ts
|
||||||
|
- **problem:** In the /login/totp handler, an invalid TOTP code returns 'Neplatný TOTP kód' (line 161-163) without touching failed_login_attempts or locked_until. The password stage (auth.ts service) and the backup-code endpoint (totp.ts:315-335) both implement lockout, but the primary TOTP step does not. The only protection against TOTP brute force after a valid login_token is the per-minute rate limit (config.rateLimit.loginTotp, default 5/min). The login_token lives 5 minutes and a fresh one can be re-minted by re-submitting the password, so a determined attacker who has the password gets a sustained ~5 guesses/minute against a 6-digit (1M) space without ever tripping account lockout.
|
||||||
|
- **fix:** On invalid TOTP code in /login/totp, increment failed_login_attempts inside a transaction and set locked_until when it reaches max_login_attempts, mirroring the backup-verify logic. This makes the second factor enforce the same lockout policy as the first.
|
||||||
|
|
||||||
|
### 72. [LOW/needs-judgment] Server-side PDF endpoints return attacker-influenceable HTML as same-origin text/html
|
||||||
|
- **dimension:** security
|
||||||
|
- **where:** src/routes/admin/invoices-pdf.ts:1076; src/routes/admin/offers-pdf.ts:764; src/routes/admin/invoices-pdf.ts:521; src/routes/admin/offers-pdf.ts:357
|
||||||
|
- **problem:** In non-save mode, the invoices-pdf and offers-pdf routes render the full document HTML and send it with reply.type('text/html').send(html) directly to the browser (not application/pdf). The notes/scope content is user-controlled rich text. It IS sanitized (DOMPurify.sanitize wrapped by cleanQuillHtml), so this is defense-in-depth rather than an open hole, but serving same-origin HTML built from user data is fragile: any future regression in the sanitizer, or any unescaped interpolation added to these large templates, becomes stored XSS on the app origin. The production CSP (security.ts:24-36) does mitigate inline script, but these PDF responses are served by the same Fastify app and inherit that origin.
|
||||||
|
- **fix:** Prefer returning application/pdf (render via htmlToPdf) to the client instead of raw HTML, as the orders-pdf route already does (orders-pdf.ts:889-892). If an HTML preview must be served, consider a Content-Disposition or a sandboxed delivery path. At minimum, keep the DOMPurify + cleanQuillHtml layering and avoid adding any non-escapeHtml interpolation of DB/user fields into these templates.
|
||||||
|
|
||||||
|
### 73. [LOW/needs-judgment] Scope/notes rich-text is sanitized only at render time, never on write
|
||||||
|
- **dimension:** security
|
||||||
|
- **where:** src/routes/admin/invoices-pdf.ts:521; src/routes/admin/orders-pdf.ts:409; src/admin/pages/InvoiceDetail.tsx:1206; src/admin/pages/OrderDetail.tsx:687
|
||||||
|
- **problem:** Invoice notes and quotation/order scope HTML are stored raw in the DB and sanitized at every read/render site (3 PDF routes + 2 frontend render sites). This is consistent and currently complete, but it means correctness depends on every present and future consumer remembering to sanitize. Any new feature that renders these fields (e.g. an email body, a new export, an admin preview) without DOMPurify reintroduces stored XSS. Sanitizing on write would make the stored value safe-by-default for all consumers.
|
||||||
|
- **fix:** Consider sanitizing rich-text scope/notes once on the write path (in the quotations/orders/invoices create+update services or Zod transforms) so stored data is already clean, treating render-time sanitization as defense-in-depth. Not urgent given current coverage is complete; flagging so the invariant is documented and enforced centrally.
|
||||||
|
|
||||||
|
### 74. [LOW/needs-judgment] HSTS header omits preload and is gated entirely on APP_ENV=production
|
||||||
|
- **dimension:** security
|
||||||
|
- **where:** src/middleware/security.ts:18-22; src/middleware/security.ts:23-36
|
||||||
|
- **problem:** Strict-Transport-Security is set to 'max-age=31536000; includeSubDomains' without 'preload', and both HSTS and the full CSP are only emitted when config.isProduction (APP_ENV==='production'). The max-age/includeSubDomains is good, but without preload the first-visit TLS-strip window remains. More notably, all CSP protection is absent in any non-production deployment (e.g. a staging box on real DNS), so the same app code runs without script-src/frame-ancestors restrictions there.
|
||||||
|
- **fix:** Add 'preload' to the HSTS value if the domain is (or will be) submitted to the preload list. Consider emitting at least a baseline CSP and the security headers in non-local non-production environments too, so staging/preprod is not left unprotected.
|
||||||
|
|
||||||
|
### 75. [LOW/needs-judgment] TOTP backup-code comparison does not stop at first match (minor timing/clarity)
|
||||||
|
- **dimension:** security
|
||||||
|
- **where:** src/routes/admin/totp.ts:307-312
|
||||||
|
- **problem:** The backup-code verify loop runs bcrypt.compare against every stored hash and records matchIndex but never breaks, so it always performs all 8 comparisons. This is intentional-looking constant-time behavior, but the loop also lets a later match overwrite an earlier one and does an unnecessary 8x bcrypt work per attempt. With bcryptCost=12, 8 comparisons per submission under a 5/min rate limit is a small but real CPU amplification, and the non-break pattern is easy to misread. The CLAUDE.md error-handling rule is not violated here; this is a robustness nit.
|
||||||
|
- **fix:** Either document that the full-scan is deliberate constant-time, or break on first match. Given backup codes are unique random values, breaking on match is safe and reduces bcrypt load; combine with the existing lockout to keep brute-force resistance.
|
||||||
|
|
||||||
|
### 76. [LOW/safe-auto] Error-message language mixed Czech/English within schemas
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/orders.schema.ts:9,16,26,37,45,71,81,106; src/schemas/offers.schema.ts:9,16,26,37,56,87; src/schemas/invoices.schema.ts:10,19 (thrown Error strings)
|
||||||
|
- **problem:** Project convention is Czech for user-facing messages. Most schemas follow this, but orders.schema.ts and offers.schema.ts mix English `{ message: 'Must be a valid number' }` (orders:9,16,26,37,71,81,106; offers:9,16,26,37,56,87) alongside Czech `'Musí být platné číslo'` (orders:55,60; offers:47) in the SAME file for the same validation. parseBody surfaces issue.message directly to the client (common.ts:12), so end users can see the English strings. invoices.schema.ts:10,19 also throw English `'Invalid quantity'`/`'Invalid unit_price'`.
|
||||||
|
- **fix:** Translate the English validation messages to Czech to match the rest of the codebase (e.g. 'Musí být platné číslo'). If a shared numeric helper is introduced (finding 1), it can carry one canonical Czech message and eliminate the divergence.
|
||||||
|
|
||||||
|
### 77. [LOW/safe-auto] Inline route schemas use Zod-3-deprecated .strict()/.passthrough()
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/routes/admin/audit-log.ts:15-20; .strict(); src/routes/admin/orders-pdf.ts:15-30; .passthrough()
|
||||||
|
- **problem:** The only two object-mode modifiers in the codebase use the Zod 3 forms `.strict()` (audit-log.ts:20) and `.passthrough()` (orders-pdf.ts:30). Per the Zod 4 changelog (the project is on zod ^4.3.6) these are deprecated in favor of the top-level `z.strictObject({...})` and `z.looseObject({...})`. They still work but are flagged for removal and are inconsistent with a Zod-4 codebase.
|
||||||
|
- **fix:** Migrate to `z.strictObject({ days: ..., confirm: ... })` and `z.looseObject({ items: ... })` respectively. Mechanical, no behavior change.
|
||||||
|
|
||||||
|
### 78. [LOW/needs-judgment] No schemas use strict object mode — unknown keys silently stripped everywhere
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/common.ts:8 (schema.parse, default strip); all src/schemas/*.schema.ts object definitions
|
||||||
|
- **problem:** Every body schema is a default `z.object(...)`, which strips unknown keys silently. Only audit-log.ts uses `.strict()`. For mutation endpoints this means a client typo (e.g. `is_activ` instead of `is_active`) is silently dropped rather than rejected, masking client bugs. This is a deliberate-tradeoff call (lenient APIs are sometimes intentional), so flagged as low, but the near-total absence of strictness combined with the broad Create/Update surface is worth a conscious decision rather than an accident.
|
||||||
|
- **fix:** Decide on a policy: either keep lenient (document it) or adopt `z.strictObject` for Create/Update bodies to catch client mistakes early. If adopting strict, do it via the shared pattern so it is uniform; watch for fields the frontend sends but the schema omits (audit before flipping).
|
||||||
|
|
||||||
|
### 79. [LOW/needs-judgment] Duplicated ScopeSection/Item sub-schemas across offers/orders/scope-templates
|
||||||
|
- **dimension:** schemas
|
||||||
|
- **where:** src/schemas/offers.schema.ts:3-39 (QuotationItemSchema, ScopeSectionSchema); src/schemas/orders.schema.ts:3-39 (OrderItemSchema, OrderSectionSchema); src/schemas/scope-templates.schema.ts:3-11 (ScopeSectionSchema); src/schemas/invoices.schema.ts:3-34 (InvoiceItemSchema)
|
||||||
|
- **problem:** QuotationItemSchema (offers:3-28) and OrderItemSchema (orders:3-28) are byte-for-byte identical. OrderSectionSchema (orders:30-39), ScopeSectionSchema (offers:30-39) and ScopeSectionSchema (scope-templates:3-11) are the same shape (title/title_cz/content/position) duplicated three times. These line/quote/section structures are the same domain concept reimplemented per file, so a change to e.g. position validation must be made in 3-4 places.
|
||||||
|
- **fix:** Extract a shared `LineItemSchema` and `ScopeSectionSchema` (e.g. in common.ts or a schemas/shared.ts) and import them. Note invoices.schema.ts:6-12 deliberately diverges (throws on quantity<=0), so keep that one specialized or make the shared schema configurable.
|
||||||
|
|
||||||
|
### 80. [LOW/needs-judgment] buildApp and token helpers duplicated instead of reusing helpers.ts
|
||||||
|
- **dimension:** tests part2b
|
||||||
|
- **where:** src/__tests__/helpers.ts:7; src/__tests__/warehouse.test.ts:26; src/__tests__/plan.test.ts:649
|
||||||
|
- **problem:** helpers.ts exports buildApp for auth and totp routes only. warehouse.test.ts and plan.test.ts each redefine their own buildApp, generateToken, and auth header helpers with slightly different setup, since warehouse adds securityHeaders and helpers does not. The token-minting and app-bootstrap logic now lives in three places and will drift, and a token minted wrong in one file is not caught by the others.
|
||||||
|
- **fix:** Extract a shared buildApp that takes routes and options plus one token and auth-header helper into helpers.ts and import everywhere, then re-run the full suite since it changes test bootstrap.
|
||||||
|
|
||||||
|
### 81. [LOW/safe-auto] Test named soft delete actually asserts a hard delete that its own comment flags as buggy
|
||||||
|
- **dimension:** tests part2b
|
||||||
|
- **where:** src/__tests__/warehouse.test.ts:630; src/__tests__/warehouse.test.ts:637
|
||||||
|
- **problem:** The test titled deactivates an item soft delete asserts a 404 after delete. Its own comment at lines 636 to 638 notes that the route hard-deletes the row and calls this inconsistent with the supplier soft-delete pattern, so the misleading name hides the discrepancy from future readers.
|
||||||
|
- **fix:** Rename the test to hard-deletes an item, and if soft-delete is the intended contract convert it to a todo or expected-fail test documenting the desired behaviour. Confirm the decision with the team.
|
||||||
|
|
||||||
|
### 82. [LOW/needs-judgment] `Record<string, any>` / untyped record builders in attendance PDF/HTML construction
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/admin/hooks/useAttendanceAdmin.ts:350; src/admin/hooks/useAttendanceAdmin.ts:382; src/admin/hooks/useAttendanceAdmin.ts:397; src/admin/hooks/useAttendanceAdmin.ts:598; src/services/offers.service.ts:49; src/services/orders.service.ts:41
|
||||||
|
- **problem:** Several HTML/PDF/total-calculation builders take `Record<string, any>` and iterate with `(log: any)`/`(i: any)` (useAttendanceAdmin buildUserSectionHtml/recordsByDate; offers `enrichQuotation`, orders `enrichOrder`). These produce HTML that is interpolated into PDF/print output, so an `any`-typed field that is unexpectedly an object or undefined can render `[object Object]` or `NaN` totals with no compile-time signal. Lower severity than the auth bug because output is internal documents, but it is the broadest untyped surface after the plan stack.
|
||||||
|
- **fix:** Define narrow input interfaces for the attendance record and quotation/order item shapes (the Prisma row types or the existing schemas can be reused) and replace the `Record<string, any>` parameters. At minimum type the `.map`/`.reduce` callbacks (`i`, `log`) with the item type to catch field-name and numeric-coercion mistakes.
|
||||||
|
|
||||||
|
### 83. [LOW/safe-auto] `category: input.category as any` casts bypass the Prisma category column type
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/services/plan.service.ts:451; src/services/plan.service.ts:518; src/services/plan.service.ts:639; src/services/plan.service.ts:710
|
||||||
|
- **problem:** Four `create`/`update` calls cast `input.category` (a plain `string`) to `any` to satisfy the Prisma `category` column type. Validity is enforced at runtime via `assertActiveCategory` (a DB lookup), so it's not unsafe in practice, but the `as any` discards the compiler's check and would also silence a future schema type change for this column.
|
||||||
|
- **fix:** If the Prisma column is an enum, cast to that specific enum type (e.g. `Prisma.$Enums.<Name>`) instead of `any`; if it is a `String` column, the cast is unnecessary and can be removed. Either way avoid `as any` so the field stays type-checked.
|
||||||
|
|
||||||
|
### 84. [LOW/manual] `request.authData!` non-null assertions: acceptable post-auth pattern (no action)
|
||||||
|
- **dimension:** ts-safety
|
||||||
|
- **where:** src/routes/admin/attendance.ts:30; src/middleware/auth.ts:44; src/routes/admin/plan.ts:119; src/routes/admin/warehouse.ts:1066; src/routes/admin/quotations.ts:139
|
||||||
|
- **problem:** Non-null assertions are widespread but almost entirely `request.authData!` inside handlers guarded by `requirePermission`/`requireAuth`, plus `result.error!`/`result.status!` after an `"error" in result` narrowing. These are correct given the middleware contract; the Fastify `authData` is declared optional on the request type but is always populated after the auth preHandler. Flagging only to note it was reviewed and is intentional, not a defect.
|
||||||
|
- **fix:** No change required. If you want to eliminate the `authData!` assertions, declare an `AuthenticatedRequest` type (or a module augmentation making `authData` required) for routes mounted behind the auth hook, so the `!` becomes unnecessary. Optional, cosmetic.
|
||||||
|
|
||||||
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
180
docs/cross-module-consistency-audit-2026-06-09.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# Cross-Module Consistency Report: Offers, Invoices, Orders
|
||||||
|
|
||||||
|
> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
|
||||||
|
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
|
||||||
|
> Examination only — no code was changed. File:line refs are at the time of the audit.
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.
|
||||||
|
|
||||||
|
## 2. Recommended Canonical Patterns
|
||||||
|
|
||||||
|
| Dimension | Standardize on | Why |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| List KPI cards | **Invoices** (`Invoices.tsx:420-505`) | Only module with a 4-card metrics grid; gives business context Orders/Offers lack |
|
||||||
|
| List load/skeleton | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784) | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank |
|
||||||
|
| List row state tinting | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx` | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
|
||||||
|
| List status filter | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders) | Tabs show all options inline; Select scales — choose by option count |
|
||||||
|
| Search-aware empty state | **Offers** (`Offers.tsx:840-855`) | Distinguishes search-empty (no CTA) from list-empty (with CTA) |
|
||||||
|
| Detail read-only mode | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`) | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced |
|
||||||
|
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`) | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct) |
|
||||||
|
| Save-button loading | **Invoice/Order** (CircularProgress + text) | Clearer visual signal than Offers' text-only swap |
|
||||||
|
| Detail guard placement | **OfferDetail** (all guards top-level, `:1087-1091`) | Flattest; no `if (!dataReady)` buried inside render branches |
|
||||||
|
| Customer/supplier picker | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`) | Keyboard-accessible, searchable, Material-standard |
|
||||||
|
| Boolean fields | **MUI `CheckboxField` kit** (as in OrdersReceived modal) | Themed, accessible; replaces raw `<input type=checkbox>` |
|
||||||
|
| Notes fields | **RichEditor** (Offers/Orders already) | Uniform editing; future-proof for formatting |
|
||||||
|
| `issued_by` | **Read-only, auto-filled** (Invoices, `:1881-1885`) | Immutable provenance tied to the issuing user |
|
||||||
|
| Currency options | **Company settings** (Offers/Invoices) | Admin-configurable without code change |
|
||||||
|
| Line-item labels/widths | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier |
|
||||||
|
| Amount column header | **"Celkem"** (all but ReceivedInvoices' "Částka") | Single label for the same concept |
|
||||||
|
|
||||||
|
## 3. Inconsistencies by Dimension
|
||||||
|
|
||||||
|
### A. Tables & Lists
|
||||||
|
|
||||||
|
**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.
|
||||||
|
|
||||||
|
**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.
|
||||||
|
|
||||||
|
**MED — Orders missing list features that Invoices/Offers have.**
|
||||||
|
|
||||||
|
- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
|
||||||
|
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
|
||||||
|
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.
|
||||||
|
|
||||||
|
**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.
|
||||||
|
|
||||||
|
**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.
|
||||||
|
|
||||||
|
**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.
|
||||||
|
|
||||||
|
**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.
|
||||||
|
|
||||||
|
_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._
|
||||||
|
|
||||||
|
### B. Detail Buttons & Layout
|
||||||
|
|
||||||
|
**HIGH — Delete-button safety bugs + Orders feature gap.**
|
||||||
|
|
||||||
|
- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
|
||||||
|
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
|
||||||
|
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
|
||||||
|
Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.
|
||||||
|
|
||||||
|
**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.
|
||||||
|
|
||||||
|
**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.
|
||||||
|
|
||||||
|
**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.
|
||||||
|
|
||||||
|
**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.
|
||||||
|
|
||||||
|
**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.
|
||||||
|
|
||||||
|
**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.
|
||||||
|
|
||||||
|
_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._
|
||||||
|
|
||||||
|
### C. Forms & Fields
|
||||||
|
|
||||||
|
**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.
|
||||||
|
|
||||||
|
**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.
|
||||||
|
|
||||||
|
**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.
|
||||||
|
|
||||||
|
**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.
|
||||||
|
|
||||||
|
**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.
|
||||||
|
|
||||||
|
**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.
|
||||||
|
|
||||||
|
_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._
|
||||||
|
|
||||||
|
### D. Line-Item Editors
|
||||||
|
|
||||||
|
Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).
|
||||||
|
|
||||||
|
**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
|
||||||
|
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem` → `align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
|
||||||
|
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
|
||||||
|
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
|
||||||
|
**LOW — Remove-button cell width.** Offers `3rem` → `2.5rem`. Effort: **S**.
|
||||||
|
|
||||||
|
_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._
|
||||||
|
|
||||||
|
## 4. Suggested Fix Order
|
||||||
|
|
||||||
|
**Batch 1 — Mechanical quick wins (low judgment, low risk).**
|
||||||
|
|
||||||
|
1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem`→`5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem`→`2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right`→`center`; "%DPH"→"DPH").
|
||||||
|
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
|
||||||
|
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
|
||||||
|
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.
|
||||||
|
|
||||||
|
**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.
|
||||||
|
|
||||||
|
**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.
|
||||||
|
|
||||||
|
**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).
|
||||||
|
|
||||||
|
## 5. Already Consistent — Do Not Touch
|
||||||
|
|
||||||
|
- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
|
||||||
|
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
|
||||||
|
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
|
||||||
|
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
|
||||||
|
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.
|
||||||
|
|
||||||
|
**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Part 2 — Status/State Models + Draft Persistence (follow-up audit)
|
||||||
|
|
||||||
|
> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.
|
||||||
|
|
||||||
|
## State-model comparison
|
||||||
|
|
||||||
|
| Document | Storage | Key language | #States | Transitions | Auto-transitions | Edit-lock | Color consistency |
|
||||||
|
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
|
||||||
|
| Offer (quotations) | `String(30)` def `active` | mixed (`active`/`ordered`/`invalidated`) | 3 | none (manual; `invalidateOffer`) | none | locked only when `invalidated` | **no label/color map** (tabs only) |
|
||||||
|
| Customer Order (orders) | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4 | `VALID_TRANSITIONS` | → project status sync | items lock in `dokoncena`/`stornovana` | consistent list↔detail |
|
||||||
|
| Issued Order (issued_orders) | **enum** def `draft` | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5 | `VALID_TRANSITIONS` | none | full lock outside `draft`/`sent` | consistent |
|
||||||
|
| Issued Invoice (invoices) | `String(30)` def `issued` | English (`issued`/`overdue`/`paid`) | 3 | `VALID_TRANSITIONS` | `markOverdueInvoices()` + auto paid_date | full lock in `paid` | **DRIFTED**: `issued`=warning in list, =info in detail |
|
||||||
|
| Received Invoice (received_invoices) | **enum** def `unpaid` | English (`unpaid`/`paid`) | 2 | implicit (no revert) | auto paid_date, month/year | locks in `paid` | consistent (list only) |
|
||||||
|
|
||||||
|
## Intentional (KEEP) vs Accidental (FIX)
|
||||||
|
|
||||||
|
**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.
|
||||||
|
|
||||||
|
**Accidental — fix:**
|
||||||
|
|
||||||
|
- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
|
||||||
|
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
|
||||||
|
- Divergent semantic color for the same status (`issued` = warning vs info).
|
||||||
|
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
|
||||||
|
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
|
||||||
|
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
|
||||||
|
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
|
||||||
|
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
|
||||||
|
- Arbitrary draft-banner visibility heuristics.
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata`→`received`, `v_realizaci`→`in_progress`, `dokoncena`→`completed`, `stornovana`→`cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
|
||||||
|
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
|
||||||
|
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
|
||||||
|
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
|
||||||
|
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**
|
||||||
|
|
||||||
|
## Effort + risk
|
||||||
|
|
||||||
|
- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
|
||||||
|
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
|
||||||
|
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
# Deployment Guide — boha-app-ts (Ubuntu Server)
|
|
||||||
|
|
||||||
Migration from PHP boha-app to TypeScript boha-app-ts on the same Ubuntu server.
|
|
||||||
Both apps share the same MySQL database. The PHP app stays running during migration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Ubuntu server with the PHP boha-app already running
|
|
||||||
- nginx with SSL (Let's Encrypt) already configured
|
|
||||||
- MySQL database already running with production data
|
|
||||||
- NAS storage mounted (e.g., `/mnt/nas/02_PROJEKTY`)
|
|
||||||
- SSH access to the server
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Prepare on Dev Machine (Windows)
|
|
||||||
|
|
||||||
### 1.1 Create Prisma migration baseline
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd D:\cortex\boha-app-ts
|
|
||||||
mkdir -p prisma/migrations/0_init
|
|
||||||
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
|
|
||||||
npx prisma migrate resolve --applied 0_init
|
|
||||||
git add prisma/migrations/
|
|
||||||
git commit -m "chore: create Prisma migration baseline"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 Build the application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates:
|
|
||||||
- `dist/` — compiled server (Node.js)
|
|
||||||
- `dist-client/` — compiled frontend (static files)
|
|
||||||
|
|
||||||
### 1.3 Generate new production secrets
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openssl rand -hex 32
|
|
||||||
# Save this as JWT_SECRET
|
|
||||||
|
|
||||||
openssl rand -hex 32
|
|
||||||
# Save this as TOTP_ENCRYPTION_KEY (only if you want a new key)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 Test the production build locally (optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
APP_ENV=production JWT_SECRET=<your-dev-key> TOTP_ENCRYPTION_KEY=<your-dev-key> DATABASE_URL=<your-dev-db> node dist/server.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify it starts and responds at `http://localhost:3001/api/health`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Prepare Ubuntu Server
|
|
||||||
|
|
||||||
### 2.1 Install Node.js 22
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
|
||||||
sudo apt-get install -y nodejs
|
|
||||||
node -v
|
|
||||||
npm -v
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Install PM2
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo npm install -g pm2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 Create application directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /var/www/boha-app-ts
|
|
||||||
sudo chown $USER:$USER /var/www/boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Transfer Files to Server
|
|
||||||
|
|
||||||
### 3.1 Copy built files
|
|
||||||
|
|
||||||
From your Windows machine (Git Bash or PowerShell with SCP):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scp -r dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use rsync if available:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -avz --exclude node_modules --exclude .env dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Install production dependencies on server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh user@server
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
npm install --production
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Configure Environment
|
|
||||||
|
|
||||||
### 4.1 Create production .env
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
cp .env.example .env
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Fill in:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Database — same as the PHP app
|
|
||||||
DATABASE_URL=mysql://user:password@localhost:3306/your_db_name
|
|
||||||
|
|
||||||
# Server
|
|
||||||
PORT=3001
|
|
||||||
HOST=127.0.0.1
|
|
||||||
APP_ENV=production
|
|
||||||
|
|
||||||
# Auth — use the NEW secrets generated in step 1.3
|
|
||||||
JWT_SECRET=<paste-new-jwt-secret>
|
|
||||||
ACCESS_TOKEN_EXPIRY=900
|
|
||||||
REFRESH_TOKEN_SESSION_EXPIRY=3600
|
|
||||||
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
|
|
||||||
|
|
||||||
# TOTP — use SAME key as PHP app (unless you want to re-encrypt)
|
|
||||||
TOTP_ENCRYPTION_KEY=<same-key-as-php-app>
|
|
||||||
|
|
||||||
# NAS — Linux mount point
|
|
||||||
NAS_PATH=/mnt/nas/02_PROJEKTY
|
|
||||||
MAX_UPLOAD_SIZE=52428800
|
|
||||||
|
|
||||||
# Email
|
|
||||||
CONTACT_EMAIL_TO=manager@boha-automation.cz
|
|
||||||
CONTACT_EMAIL_FROM=web@boha-automation.cz
|
|
||||||
SMTP_FROM=noreply@boha-automation.cz
|
|
||||||
|
|
||||||
# CORS — your production domain(s)
|
|
||||||
CORS_ORIGINS=https://app.boha-automation.cz,https://www.boha-automation.cz
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important decisions:**
|
|
||||||
|
|
||||||
| Setting | Recommendation |
|
|
||||||
|---------|---------------|
|
|
||||||
| `JWT_SECRET` | **New key.** All PHP sessions will be invalid — users re-login. This is expected. |
|
|
||||||
| `TOTP_ENCRYPTION_KEY` | **Same key as PHP app.** Avoids re-encrypting all TOTP secrets. |
|
|
||||||
| `DATABASE_URL` | **Same database as PHP app.** Both apps share it. |
|
|
||||||
| `NAS_PATH` | **Linux mount point** instead of Windows drive letter. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Database Setup
|
|
||||||
|
|
||||||
### 5.1 Mark Prisma baseline as applied
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
npx prisma migrate resolve --applied 0_init
|
|
||||||
```
|
|
||||||
|
|
||||||
This tells Prisma the database already has all tables. No SQL is executed.
|
|
||||||
|
|
||||||
### 5.2 Verify database connection
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db pull --print | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
Should show your existing tables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: TOTP Key Rotation (only if using new encryption key)
|
|
||||||
|
|
||||||
**Skip this section if you're using the same `TOTP_ENCRYPTION_KEY` as the PHP app.**
|
|
||||||
|
|
||||||
If you generated a new encryption key:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
|
|
||||||
# Dry run — verify all secrets can be decrypted and re-encrypted
|
|
||||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key> --dry-run
|
|
||||||
|
|
||||||
# If all [OK], run for real
|
|
||||||
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key>
|
|
||||||
```
|
|
||||||
|
|
||||||
After this, the PHP app's TOTP verification will break (secrets are now encrypted with the new key). Only do this when you're ready to cut over.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Start Application with PM2
|
|
||||||
|
|
||||||
### 7.1 Create PM2 config
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
cat > ecosystem.config.js << 'EOF'
|
|
||||||
module.exports = {
|
|
||||||
apps: [{
|
|
||||||
name: 'boha-app-ts',
|
|
||||||
script: 'dist/server.js',
|
|
||||||
cwd: '/var/www/boha-app-ts',
|
|
||||||
instances: 1,
|
|
||||||
env: {
|
|
||||||
NODE_ENV: 'production',
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 Start the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 start ecosystem.config.js
|
|
||||||
pm2 save
|
|
||||||
pm2 startup
|
|
||||||
# Follow the printed command to enable auto-start on boot
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.3 Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 status
|
|
||||||
pm2 logs boha-app-ts --lines 20
|
|
||||||
curl http://localhost:3001/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: `{"status":"ok","timestamp":"..."}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Nginx Configuration
|
|
||||||
|
|
||||||
### 8.1 Create nginx config
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo nano /etc/nginx/sites-available/boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
Paste:
|
|
||||||
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name app.boha-automation.cz;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/app.boha-automation.cz/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/app.boha-automation.cz/privkey.pem;
|
|
||||||
|
|
||||||
client_max_body_size 55M;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3001;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $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_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name app.boha-automation.cz;
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust `server_name` and SSL paths to match your setup.
|
|
||||||
|
|
||||||
### 8.2 Enable and reload
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ln -s /etc/nginx/sites-available/boha-app-ts /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl reload nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Testing
|
|
||||||
|
|
||||||
### 9.1 Verify in browser
|
|
||||||
|
|
||||||
Open `https://app.boha-automation.cz` and test:
|
|
||||||
|
|
||||||
- [ ] Login page loads
|
|
||||||
- [ ] Login works (users will need to re-login due to new JWT_SECRET)
|
|
||||||
- [ ] TOTP verification works (if using same encryption key)
|
|
||||||
- [ ] Dashboard loads with data
|
|
||||||
- [ ] Offers — list, create, edit, PDF export
|
|
||||||
- [ ] Orders — list, create from offer, status transitions
|
|
||||||
- [ ] Invoices — list, create, PDF with QR code
|
|
||||||
- [ ] Projects — list, create, file manager (upload/download)
|
|
||||||
- [ ] Attendance — clock in/out, admin view, print
|
|
||||||
- [ ] Trips — list, history
|
|
||||||
- [ ] Settings — company settings, users, roles
|
|
||||||
|
|
||||||
### 9.2 Check logs for errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pm2 logs boha-app-ts --lines 50
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Cutover Strategy
|
|
||||||
|
|
||||||
Both apps run simultaneously on the same database. Recommended approach:
|
|
||||||
|
|
||||||
1. **Week 1:** Run both apps. Use the TS app for daily work. Fall back to PHP if issues arise.
|
|
||||||
2. **Week 2:** If stable, redirect the main domain to the TS app.
|
|
||||||
3. **Week 3:** Stop the PHP app.
|
|
||||||
|
|
||||||
To redirect the PHP domain to the TS app, update the nginx config for the PHP domain to proxy to port 3001 instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Updates
|
|
||||||
|
|
||||||
When you push code changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On dev machine
|
|
||||||
npm run build
|
|
||||||
git push
|
|
||||||
|
|
||||||
# On server
|
|
||||||
cd /var/www/boha-app-ts
|
|
||||||
git pull
|
|
||||||
npm install --production
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate deploy # runs any new migrations
|
|
||||||
pm2 restart boha-app-ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Problem | Solution |
|
|
||||||
|---------|----------|
|
|
||||||
| `EADDRINUSE: port 3001` | `pm2 stop boha-app-ts` then `pm2 start` |
|
|
||||||
| Prisma connection error | Check `DATABASE_URL` in `.env` |
|
|
||||||
| TOTP verification fails | Verify `TOTP_ENCRYPTION_KEY` matches the key used to encrypt secrets |
|
|
||||||
| NAS files not accessible | Check mount: `ls /mnt/nas/02_PROJEKTY`, verify permissions |
|
|
||||||
| 502 Bad Gateway | App not running: `pm2 status`, check logs: `pm2 logs` |
|
|
||||||
| CSS/JS not loading | Verify `dist-client/` was copied, check `APP_ENV=production` |
|
|
||||||
| CORS errors | Check `CORS_ORIGINS` in `.env` matches your domain exactly |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
| Command | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `pm2 start ecosystem.config.js` | Start the app |
|
|
||||||
| `pm2 restart boha-app-ts` | Restart after update |
|
|
||||||
| `pm2 stop boha-app-ts` | Stop the app |
|
|
||||||
| `pm2 logs boha-app-ts` | View logs |
|
|
||||||
| `pm2 monit` | Live monitoring |
|
|
||||||
| `npx prisma migrate deploy` | Apply database migrations |
|
|
||||||
| `npx prisma studio` | Database GUI (dev only) |
|
|
||||||
107
docs/release.md
Normal file
107
docs/release.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# Release & deployment runbook — boha-app-ts
|
||||||
|
|
||||||
|
Referenced from CLAUDE.md. **Never deploy to production or restart services
|
||||||
|
without explicit user confirmation.**
|
||||||
|
|
||||||
|
## Standard release
|
||||||
|
|
||||||
|
1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`,
|
||||||
|
`npm run lint` (0 errors).
|
||||||
|
2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number).
|
||||||
|
3. `npm run build`
|
||||||
|
4. Commit and tag: `git tag -a vX.Y.Z`
|
||||||
|
5. Push to Gitea: `git push origin master && git push origin vX.Y.Z`
|
||||||
|
6. Create tarball:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
|
||||||
|
without it, `prisma generate`/`migrate deploy` on prod have no datasource.
|
||||||
|
|
||||||
|
7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
ssh boha_admin@192.168.50.100
|
||||||
|
cd /var/www/app-ts
|
||||||
|
rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
|
||||||
|
tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
|
||||||
|
npm install --omit=dev
|
||||||
|
npx prisma generate # MANDATORY — see below
|
||||||
|
npx prisma migrate deploy
|
||||||
|
pm2 restart app-ts --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
**`npx prisma generate` is mandatory.** `npm install` skips client
|
||||||
|
regeneration when dependencies didn't change, leaving a stale client that
|
||||||
|
still selects dropped/renamed columns → P2022 500s in prod (this caused
|
||||||
|
the v2.4.0 incident: every issued-orders query failed until generate ran).
|
||||||
|
|
||||||
|
8. Verify: `pm2 list` (status online, correct version, restart counter not
|
||||||
|
climbing), `npx prisma migrate status` ("up to date"),
|
||||||
|
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200,
|
||||||
|
and grep logs for errors from the CURRENT pid only (the out log keeps old
|
||||||
|
errors from prior pids).
|
||||||
|
9. Clean up tarballs locally and in `/tmp/` on the server.
|
||||||
|
|
||||||
|
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
|
||||||
|
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
|
||||||
|
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
|
||||||
|
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
|
||||||
|
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
|
||||||
|
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
|
||||||
|
`client_max_body_size`, rate limits, etc.). When you do change it, always
|
||||||
|
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
|
||||||
|
nginx` (graceful re-read, keeps active connections; a bad config on reload is
|
||||||
|
rejected and the old one keeps running).
|
||||||
|
|
||||||
|
Risky releases (framework jumps, FK/constraint-altering migrations) get a
|
||||||
|
read-only pre-flight first: `prisma migrate status` on prod, verify FK
|
||||||
|
constraint names the migration DROPs, check no data violates new
|
||||||
|
UNIQUE/RESTRICT constraints — and confirm with the user before the
|
||||||
|
irreversible steps. The prod DB is backed up by a cron job (no manual
|
||||||
|
mysqldump needed).
|
||||||
|
|
||||||
|
## Hotfixing a migration added after the tarball was built
|
||||||
|
|
||||||
|
A migration folder created after the release tarball shipped is not on prod,
|
||||||
|
so `prisma migrate deploy` reports "No pending migrations". Ship just the
|
||||||
|
migration folder (still tracked in git — this is NOT raw SQL on prod):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local
|
||||||
|
cd prisma/migrations
|
||||||
|
tar -czf /tmp/<name>_migration.tar.gz <timestamp>_<name>/
|
||||||
|
scp /tmp/<name>_migration.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
|
||||||
|
# On prod
|
||||||
|
ssh boha_admin@192.168.50.100
|
||||||
|
cd /var/www/app-ts/prisma/migrations
|
||||||
|
tar -xzf /tmp/<name>_migration.tar.gz
|
||||||
|
cd /var/www/app-ts
|
||||||
|
npx prisma migrate deploy
|
||||||
|
pm2 restart app-ts --update-env
|
||||||
|
```
|
||||||
|
|
||||||
|
## Drift check / preview before deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another
|
||||||
|
DB need its URL in a config/env override.) If drift includes DROPs,
|
||||||
|
investigate before touching production.
|
||||||
|
|
||||||
|
## Baselining a database that has no migrations
|
||||||
|
|
||||||
|
If a database was synced without migration history (no `_prisma_migrations`
|
||||||
|
table): create the initial migration locally, copy `prisma/migrations` to the
|
||||||
|
server, then mark it applied without running SQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate resolve --applied <migration_name>
|
||||||
|
```
|
||||||
3874
docs/superpowers/plans/2026-05-29-warehouse-module.md
Normal file
3874
docs/superpowers/plans/2026-05-29-warehouse-module.md
Normal file
File diff suppressed because it is too large
Load Diff
124
docs/superpowers/plans/2026-06-03-deferred-high-issues.md
Normal file
124
docs/superpowers/plans/2026-06-03-deferred-high-issues.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Deferred HIGH issues — performance & UX sprint
|
||||||
|
|
||||||
|
**Created:** 2026-06-03
|
||||||
|
**Source audit:** Parallel 5-agent production risk audit (2026-06-03)
|
||||||
|
**Context:** All 8 CRITICAL and 3 of 8 HIGH issues were fixed in the same session.
|
||||||
|
The 5 items below were intentionally deferred — they are not data-integrity or
|
||||||
|
auth risks, but they are real production pain that should be addressed in a
|
||||||
|
dedicated performance / UX sprint before the user base notices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #9 — N+1 queries in warehouse list/detail/report
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- `src/services/warehouse.service.ts:184-199` (getBelowMinimumItems)
|
||||||
|
- `src/services/warehouse.service.ts:629-646` (somewhere in items list)
|
||||||
|
- `src/services/warehouse.service.ts:681-683`
|
||||||
|
- `src/services/warehouse.service.ts:2182-2203` (report)
|
||||||
|
|
||||||
|
**Pattern:** `items.map(async (i) => { const stock = await getStock(i.id); const available = await getAvailable(i.id); const value = await getValue(i.id); })` — fires N+1 round-trips per item. For a list of 50 items, that's 150 sequential queries.
|
||||||
|
|
||||||
|
**Production impact:** Slow page loads (1-3s on 50 items, scales linearly). Users already complain about the items page; this is the root cause for the warehouse report being the slowest page in the app.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Replace the `for` loop with a single grouped query: `prisma.sklad_batches.groupBy({ by: ['item_id'], _sum: { quantity: true }, where: { item_id: { in: itemIds } } })` for stocks, similar aggregates for reservations.
|
||||||
|
- Map the grouped results back to items in JS. One query instead of N.
|
||||||
|
- For the report view, do the same — group all batches and reservations by item_id in a single round-trip, then join with items in memory.
|
||||||
|
- Estimated effort: M (4-6 hours including testing).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #10 — Missing FK indexes on hot warehouse tables
|
||||||
|
|
||||||
|
**File:** `prisma/schema.prisma` (no `@@index` directives on the new warehouse tables)
|
||||||
|
|
||||||
|
**Missing indexes (verify against `prisma/schema.prisma` and add to a new migration):**
|
||||||
|
|
||||||
|
- `sklad_receipt_lines.item_id` — every `confirmReceipt` reads/writes this
|
||||||
|
- `sklad_issue_lines.batch_id` — every `confirmIssue` reads/writes this
|
||||||
|
- `sklad_issue_lines.item_id`
|
||||||
|
- `sklad_reservations.project_id` — reservation list per project
|
||||||
|
- `sklad_reservations.item_id`
|
||||||
|
- `sklad_item_locations.item_id`
|
||||||
|
- `sklad_item_locations.location_id`
|
||||||
|
- `sklad_batches.receipt_line_id` (if nullable FK)
|
||||||
|
- `sklad_inventory_lines.item_id`
|
||||||
|
- `sklad_inventory_lines.session_id`
|
||||||
|
|
||||||
|
**Production impact:** MySQL currently does table scans for these joins. With even 10k batch rows, the receipts/issues/reservations pages will start to crawl. On production data (likely already 50k+ batches), this is a real performance cliff.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Add `@@index([item_id])`, `@@index([batch_id])` etc. to each model in `prisma/schema.prisma`.
|
||||||
|
- Run `npx prisma migrate dev --name warehouse_fk_indexes` (after asking user to stop dev server per CLAUDE.md).
|
||||||
|
- Run `npx prisma generate`.
|
||||||
|
- Verify with `EXPLAIN` on the slow queries after deploy.
|
||||||
|
- Estimated effort: S (1-2 hours).
|
||||||
|
|
||||||
|
**CLAUDE.md note:** Before running `prisma migrate dev`, the user must stop the dev server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #13 — Pagination does not auto-adjust after delete
|
||||||
|
|
||||||
|
**Files:** All list pages (Invoices, Offers, Projects, Orders, WarehouseXxx)
|
||||||
|
|
||||||
|
**Pattern:** User is on page 5 (e.g. 10 items per page, 47 total, pages 1-5). They delete the last item on page 5. Total becomes 46, but the page param is still 5. The list endpoint returns 0 results, the user sees an empty table, and the "next" button is disabled but there's no automatic jump back to page 4.
|
||||||
|
|
||||||
|
**Production impact:** Confusing UX. Users have to manually click "previous" or reset filters. Doesn't cause data loss but is a small papercut that erodes trust.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- In each list page's `useQuery` error/empty handler, detect `pagination.total === 0 && page > 1` and call `setPage(page - 1)`.
|
||||||
|
- Or more cleanly: have the API include `pagination.total_pages` and the frontend clamp `page` to `min(page, total_pages)` after each refetch.
|
||||||
|
- Estimated effort: S (2-3 hours; touches ~10-12 pages).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #14 — Offer lock lifecycle has multiple silent failures
|
||||||
|
|
||||||
|
**File:** `src/admin/pages/OfferDetail.tsx:472-516`
|
||||||
|
|
||||||
|
**Pattern:** Lock acquire, heartbeat (interval), and unlock all use `.catch(() => {})`. If any of them silently fails, the user thinks they have the lock but actually don't — or worse, the lock is held by a tab that's already closed because the unload handler also swallowed the error.
|
||||||
|
|
||||||
|
**Production impact:** Two users editing the same offer can both see "You have the lock" and clobber each other's changes. Data loss in a real, low-probability but high-impact scenario.
|
||||||
|
|
||||||
|
**Fix sketch:**
|
||||||
|
|
||||||
|
- Replace `.catch(() => {})` with `.catch((err) => console.error("Offer lock error:", err))` at minimum.
|
||||||
|
- Better: surface lock acquisition failures via a toast (`alert.error("Nepodařilo se získat zámek, stránka bude pouze pro čtení.")`).
|
||||||
|
- For the unlock on unmount: best-effort `navigator.sendBeacon` or a synchronous fallback so the lock is released even if the page is closing.
|
||||||
|
- Estimated effort: S (1-2 hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## #11 (audit item, not a real bug) — TOTP replay counter rewind
|
||||||
|
|
||||||
|
**File investigated:** `src/utils/totp.ts:5-30`, `src/routes/admin/auth.ts:165-184`
|
||||||
|
|
||||||
|
**Status:** False positive. The audit suggested `verifyResult.counter` could be lower than the actual step used. After reading the implementation:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const delta = totp.validate({ token: code, window: 1 });
|
||||||
|
// ...
|
||||||
|
const counterDelta = Math.min(delta, 0); // -1 or 0, never +1
|
||||||
|
const counter = currentCounter + counterDelta;
|
||||||
|
```
|
||||||
|
|
||||||
|
`Math.min(delta, 0)` clamps to ≤ 0, so `counter` is always the **current** step or **one step in the past** — never a future step. The `counter <= lastCounter` check in the route is correct and complete. No fix needed.
|
||||||
|
|
||||||
|
If anyone revisits this in the future, this analysis is the basis for closing the ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested order for the next sprint
|
||||||
|
|
||||||
|
1. **#10 (indexes)** — cheap, high impact, addresses a growing data cliff. Do first.
|
||||||
|
2. **#9 (N+1)** — bigger effort but the same root cause family. Tackle after indexes.
|
||||||
|
3. **#14 (offer lock)** — small but prevents data loss; fits in a single PR.
|
||||||
|
4. **#13 (pagination)** — UX polish; do last, batch with other UX cleanup.
|
||||||
|
|
||||||
|
Estimated total: 1-2 days of focused work.
|
||||||
3597
docs/superpowers/plans/2026-06-05-plan-praci.md
Normal file
3597
docs/superpowers/plans/2026-06-05-plan-praci.md
Normal file
File diff suppressed because it is too large
Load Diff
1154
docs/superpowers/plans/2026-06-06-manual-project-order-creation.md
Normal file
1154
docs/superpowers/plans/2026-06-06-manual-project-order-creation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,680 @@
|
|||||||
|
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
|
||||||
|
|
||||||
|
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
|
||||||
|
|
||||||
|
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
|
||||||
|
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
|
||||||
|
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
|
||||||
|
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
|
||||||
|
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
|
||||||
|
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
|
||||||
|
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
|
||||||
|
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
|
||||||
|
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
|
||||||
|
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
|
||||||
|
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Install dependencies and reconcile React types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `package.json` (via npm)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install MUI + Emotion + X pickers**
|
||||||
|
|
||||||
|
Run (date-fns ^4 is already a dependency):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
|
||||||
|
|
||||||
|
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @types/react@^18.3 @types/react-dom@^18.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: devDependencies now show `@types/react@^18.3.x`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the project still type-checks and builds**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json
|
||||||
|
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/theme.ts`
|
||||||
|
- Test: `src/admin/theme.test.ts`
|
||||||
|
|
||||||
|
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/admin/theme.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { theme } from "./theme";
|
||||||
|
|
||||||
|
describe("MUI theme", () => {
|
||||||
|
it("uses the brand fonts", () => {
|
||||||
|
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
|
||||||
|
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the brand red per color scheme", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
|
||||||
|
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the refreshed light canvas + paper", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
|
||||||
|
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps semantic colors per scheme", () => {
|
||||||
|
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
|
||||||
|
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the base radius of 10", () => {
|
||||||
|
expect(theme.shape.borderRadius).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
|
||||||
|
|
||||||
|
Create `src/admin/theme.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createTheme } from "@mui/material/styles";
|
||||||
|
|
||||||
|
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
|
||||||
|
const FONT_HEADING = "'Urbanist', sans-serif";
|
||||||
|
const FONT_MONO = "'DM Mono', Menlo, monospace";
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
cssVariables: {
|
||||||
|
// Read the SAME attribute ThemeContext already writes (`data-theme`).
|
||||||
|
// The "%s" placeholder is required; value must start with "." or "[".
|
||||||
|
colorSchemeSelector: "[data-theme='%s']",
|
||||||
|
},
|
||||||
|
colorSchemes: {
|
||||||
|
light: {
|
||||||
|
palette: {
|
||||||
|
mode: "light",
|
||||||
|
primary: { main: "#c73030" },
|
||||||
|
success: { main: "#15803d" },
|
||||||
|
warning: { main: "#b45309" },
|
||||||
|
error: { main: "#b91c1c" },
|
||||||
|
info: { main: "#1d4ed8" },
|
||||||
|
background: { default: "#f4f3f1", paper: "#ffffff" },
|
||||||
|
text: { primary: "#1a1a1a", secondary: "#555555" },
|
||||||
|
divider: "rgba(0,0,0,0.1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
palette: {
|
||||||
|
mode: "dark",
|
||||||
|
primary: { main: "#d63031" },
|
||||||
|
success: { main: "#22c55e" },
|
||||||
|
warning: { main: "#f59e0b" },
|
||||||
|
error: { main: "#ef4444" },
|
||||||
|
info: { main: "#3b82f6" },
|
||||||
|
background: { default: "#0f0f0f", paper: "#1a1a1a" },
|
||||||
|
text: { primary: "#ffffff", secondary: "#a0a0a0" },
|
||||||
|
divider: "rgba(255,255,255,0.08)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shape: { borderRadius: 10 },
|
||||||
|
typography: {
|
||||||
|
fontFamily: FONT_BODY,
|
||||||
|
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
|
||||||
|
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
|
||||||
|
button: { textTransform: "none", fontWeight: 600 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-exported for tasks/components that need the mono stack.
|
||||||
|
export const fonts = {
|
||||||
|
body: FONT_BODY,
|
||||||
|
heading: FONT_HEADING,
|
||||||
|
mono: FONT_MONO,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||||
|
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/theme.ts`
|
||||||
|
- Test: `src/admin/theme.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a failing assertion for the overrides**
|
||||||
|
|
||||||
|
Append to `src/admin/theme.test.ts` (inside the `describe`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("makes buttons pill-shaped and cards 16px by default", () => {
|
||||||
|
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
|
||||||
|
expect(btn?.borderRadius).toBe(999);
|
||||||
|
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
|
||||||
|
expect(card?.borderRadius).toBe(16);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `components` to the `createTheme` call**
|
||||||
|
|
||||||
|
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: { disableElevation: true },
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 999, paddingInline: "0.95rem" },
|
||||||
|
containedPrimary: {
|
||||||
|
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
|
||||||
|
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 16,
|
||||||
|
boxShadow:
|
||||||
|
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (6 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/theme.ts src/admin/theme.test.ts
|
||||||
|
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: MUI provider + color-scheme sync
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
|
||||||
|
- Create: `src/admin/ui/MuiProvider.tsx`
|
||||||
|
- Modify: `src/admin/AdminApp.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the sync component**
|
||||||
|
|
||||||
|
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useColorScheme } from "@mui/material/styles";
|
||||||
|
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
|
||||||
|
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
|
||||||
|
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
|
||||||
|
*/
|
||||||
|
export default function MuiColorSchemeSync() {
|
||||||
|
const { theme } = useAppTheme();
|
||||||
|
const { mode, setMode } = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== theme) setMode(theme as "light" | "dark");
|
||||||
|
}, [theme, mode, setMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the provider wrapper**
|
||||||
|
|
||||||
|
Create `src/admin/ui/MuiProvider.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
|
import { theme } from "../theme";
|
||||||
|
import MuiColorSchemeSync from "./MuiColorSchemeSync";
|
||||||
|
|
||||||
|
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme} defaultMode="dark">
|
||||||
|
<MuiColorSchemeSync />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
|
||||||
|
|
||||||
|
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then wrap the existing returned tree. Change:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
return (
|
||||||
|
<MuiProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
and change the matching closing tag at the end of the JSX:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
</AuthProvider>
|
||||||
|
</MuiProvider>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the app builds and renders with no console errors**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
|
||||||
|
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: First UI-kit wrappers + barrel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the wrappers**
|
||||||
|
|
||||||
|
Create `src/admin/ui/Button.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiButton, { type ButtonProps } from "@mui/material/Button";
|
||||||
|
|
||||||
|
/** App Button: defaults to the brand primary contained style. */
|
||||||
|
export default function Button(props: ButtonProps) {
|
||||||
|
return <MuiButton variant="contained" color="primary" {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/admin/ui/Card.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
|
||||||
|
export default function Card({ children, ...props }: CardProps) {
|
||||||
|
return (
|
||||||
|
<MuiCard {...props}>
|
||||||
|
<CardContent>{children}</CardContent>
|
||||||
|
</MuiCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `src/admin/ui/TextField.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||||
|
|
||||||
|
/** App TextField: small + outlined + full width by default. */
|
||||||
|
export default function TextField(props: TextFieldProps) {
|
||||||
|
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the barrel**
|
||||||
|
|
||||||
|
Create `src/admin/ui/index.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export { default as MuiProvider } from "./MuiProvider";
|
||||||
|
export { default as Button } from "./Button";
|
||||||
|
export { default as Card } from "./Card";
|
||||||
|
export { default as TextField } from "./TextField";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify type-check**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
|
||||||
|
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Dev-only `/ui-kit` showcase route
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/pages/UiKit.tsx`
|
||||||
|
- Modify: `src/admin/AdminApp.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the showcase page**
|
||||||
|
|
||||||
|
Create `src/admin/pages/UiKit.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Stack from "@mui/material/Stack";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import { Button, Card, TextField } from "../ui";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
export default function UiKit() {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
|
||||||
|
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
|
||||||
|
<Typography variant="h4">UI Kit</Typography>
|
||||||
|
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
|
||||||
|
Theme: {theme}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack spacing={3} sx={{ maxWidth: 520 }}>
|
||||||
|
<Card>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Faktury
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
12 vystavených tento měsíc
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1} mb={2}>
|
||||||
|
<Chip label="Zaplaceno" color="success" size="small" />
|
||||||
|
<Chip label="Po splatnosti" color="error" size="small" />
|
||||||
|
</Stack>
|
||||||
|
<TextField
|
||||||
|
label="Hledat fakturu"
|
||||||
|
placeholder="Číslo nebo klient…"
|
||||||
|
/>
|
||||||
|
<Box mt={2}>
|
||||||
|
<Button>Nová faktura</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register the route, DEV-only**
|
||||||
|
|
||||||
|
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const UiKit = lazy(() => import("./pages/UiKit"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Manual + Playwright verification**
|
||||||
|
|
||||||
|
The user runs the dev server. Once it's up, verify at `/ui-kit`:
|
||||||
|
|
||||||
|
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
|
||||||
|
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
|
||||||
|
- No new console errors.
|
||||||
|
|
||||||
|
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
|
||||||
|
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Token-level palette refresh (app-wide)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/variables.css`
|
||||||
|
|
||||||
|
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the light canvas token**
|
||||||
|
|
||||||
|
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg-primary: #f5f4f2;
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--bg-primary: #f4f3f1;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:client
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual check**
|
||||||
|
|
||||||
|
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/variables.css
|
||||||
|
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Phase-0 release verification
|
||||||
|
|
||||||
|
**Files:** none (verification + tag)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full type-check, unit tests, and build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npx vitest run src/admin/theme.test.ts
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Backend test suite still green (server untouched)**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
|
||||||
|
|
||||||
|
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
|
||||||
|
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
|
||||||
|
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
|
||||||
|
- Light canvas is `#f4f3f1`.
|
||||||
|
- Production build excludes the `/ui-kit` route.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Tag/release per the project release process**
|
||||||
|
|
||||||
|
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 2–3), provider + `ScopedCssBaseline` + `ThemeContext`↔`setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
|
||||||
|
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
|
||||||
|
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# MUI Migration — Phase 1: AppShell — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
|
||||||
|
|
||||||
|
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
|
||||||
|
|
||||||
|
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||||
|
|
||||||
|
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
|
||||||
|
|
||||||
|
- Auth: `loading` → spinner; `!isAuthenticated` → `<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"` → `/`).
|
||||||
|
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
|
||||||
|
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
|
||||||
|
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
|
||||||
|
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
|
||||||
|
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
|
||||||
|
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
|
||||||
|
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
|
||||||
|
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
|
||||||
|
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
|
||||||
|
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
|
||||||
|
|
||||||
|
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extract nav data into `navData.tsx`
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { type ReactNode } from "react";
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
end?: boolean;
|
||||||
|
permission?: string | string[];
|
||||||
|
matchPrefix?: string;
|
||||||
|
matchAlso?: string[];
|
||||||
|
matchExclude?: string[];
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuSection {
|
||||||
|
label: string;
|
||||||
|
items: MenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const menuSections: MenuSection[] = [
|
||||||
|
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
|
||||||
|
export function isItemActive(item: MenuItem, pathname: string): boolean {
|
||||||
|
if (item.matchPrefix) {
|
||||||
|
let active = pathname.startsWith(item.matchPrefix);
|
||||||
|
if (active && item.matchExclude) {
|
||||||
|
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
|
||||||
|
}
|
||||||
|
if (!active && item.matchAlso) {
|
||||||
|
active = item.matchAlso.some((p) => pathname.startsWith(p));
|
||||||
|
}
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (item.end) return pathname === item.path;
|
||||||
|
return pathname.startsWith(item.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Permission check: true if no permission, or any listed permission is held. */
|
||||||
|
export function hasItemPermission(
|
||||||
|
item: MenuItem,
|
||||||
|
hasPermission: (p: string) => boolean,
|
||||||
|
): boolean {
|
||||||
|
if (!item.permission) return true;
|
||||||
|
if (Array.isArray(item.permission)) {
|
||||||
|
return item.permission.some((p) => hasPermission(p));
|
||||||
|
}
|
||||||
|
return hasPermission(item.permission);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `SidebarNav.tsx` (MUI drawer content)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
|
||||||
|
|
||||||
|
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
|
||||||
|
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
|
||||||
|
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
|
||||||
|
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
|
||||||
|
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
|
||||||
|
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
|
||||||
|
```tsx
|
||||||
|
<ListItemButton
|
||||||
|
component={NavLink}
|
||||||
|
to={item.path}
|
||||||
|
end={item.end}
|
||||||
|
onClick={onNavigate}
|
||||||
|
selected={isItemActive(item, location.pathname)}
|
||||||
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
mb: 0.25,
|
||||||
|
py: 0.6,
|
||||||
|
color: "text.secondary",
|
||||||
|
"& svg": { width: 18, height: 18 },
|
||||||
|
"&.Mui-selected, &.Mui-selected:hover": {
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
color: "primary.main",
|
||||||
|
fontWeight: 700,
|
||||||
|
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
|
||||||
|
{item.icon}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={item.label}
|
||||||
|
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
```
|
||||||
|
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
|
||||||
|
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/AppShell.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||||
|
import { setLogoutAlert } from "../utils/api";
|
||||||
|
import SidebarNav from "./SidebarNav";
|
||||||
|
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 248;
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useAppTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
setLogoutAlert();
|
||||||
|
setTimeout(() => logout(), 400);
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={
|
||||||
|
loggingOut
|
||||||
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||||
|
: { scale: 1, opacity: 1, filter: "none" }
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: loggingOut ? 0.4 : 0.25,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
}}
|
||||||
|
style={{ minHeight: "100vh" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Desktop permanent drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
border: 0,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav onLogout={handleLogout} />
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Mobile temporary drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: "block", md: "none" },
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Main column */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
minHeight: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Otevřít menu"
|
||||||
|
sx={{ display: { md: "none" } }}
|
||||||
|
>
|
||||||
|
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
|
||||||
|
</IconButton>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
>
|
||||||
|
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<ShortcutsHelp />
|
||||||
|
</motion.div>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Wire `AppShell`, delete legacy chrome
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 2–4), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
|
||||||
|
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
|
||||||
|
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
|
||||||
|
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
|
||||||
|
|
||||||
|
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
|
||||||
|
|
||||||
|
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
|
||||||
|
|
||||||
|
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage` → `<Forbidden/>`; empty state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility | Action |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
|
||||||
|
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
|
||||||
|
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
|
||||||
|
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
|
||||||
|
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
|
||||||
|
| `src/admin/ui/index.ts` | Export the new components | Modify |
|
||||||
|
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
|
||||||
|
|
||||||
|
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import MuiButton from "@mui/material/Button";
|
||||||
|
|
||||||
|
export interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
loading?: boolean;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
submitText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
maxWidth?: "xs" | "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
loading = false,
|
||||||
|
submitDisabled = false,
|
||||||
|
submitText = "Uložit",
|
||||||
|
cancelText = "Zrušit",
|
||||||
|
maxWidth = "sm",
|
||||||
|
}: ModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={loading ? undefined : onClose}
|
||||||
|
fullWidth
|
||||||
|
maxWidth={maxWidth}
|
||||||
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
|
||||||
|
{title}
|
||||||
|
{subtitle && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>{children}</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||||
|
{cancelText}
|
||||||
|
</MuiButton>
|
||||||
|
<MuiButton
|
||||||
|
onClick={onSubmit}
|
||||||
|
variant="contained"
|
||||||
|
disabled={loading || submitDisabled}
|
||||||
|
>
|
||||||
|
{loading ? "Ukládám…" : submitText}
|
||||||
|
</MuiButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import MuiButton from "@mui/material/Button";
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
confirmVariant?: "primary" | "danger";
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText = "Potvrdit",
|
||||||
|
cancelText = "Zrušit",
|
||||||
|
confirmVariant = "primary",
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={loading ? undefined : onClose}
|
||||||
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{message}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
|
||||||
|
{cancelText}
|
||||||
|
</MuiButton>
|
||||||
|
<MuiButton
|
||||||
|
onClick={onConfirm}
|
||||||
|
variant="contained"
|
||||||
|
color={confirmVariant === "danger" ? "error" : "primary"}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Pracuji…" : confirmText}
|
||||||
|
</MuiButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `DataTable` (dense MUI Table)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Table from "@mui/material/Table";
|
||||||
|
import TableBody from "@mui/material/TableBody";
|
||||||
|
import TableCell from "@mui/material/TableCell";
|
||||||
|
import TableContainer from "@mui/material/TableContainer";
|
||||||
|
import TableHead from "@mui/material/TableHead";
|
||||||
|
import TableRow from "@mui/material/TableRow";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
export interface DataColumn<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
align?: "left" | "right";
|
||||||
|
mono?: boolean; // DM Mono numeric cell
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<T> {
|
||||||
|
columns: DataColumn<T>[];
|
||||||
|
rows: T[];
|
||||||
|
rowKey: (row: T) => string | number;
|
||||||
|
rowInactive?: (row: T) => boolean;
|
||||||
|
empty?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
rowInactive,
|
||||||
|
empty,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
if (rows.length === 0 && empty) return <>{empty}</>;
|
||||||
|
return (
|
||||||
|
<TableContainer sx={{ borderRadius: 2 }}>
|
||||||
|
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<TableCell
|
||||||
|
key={c.key}
|
||||||
|
align={c.align}
|
||||||
|
sx={{
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: ".05em",
|
||||||
|
fontSize: ".64rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={rowKey(row)}
|
||||||
|
hover
|
||||||
|
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
|
||||||
|
>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<TableCell
|
||||||
|
key={c.key}
|
||||||
|
align={c.align}
|
||||||
|
sx={{
|
||||||
|
fontSize: ".8rem",
|
||||||
|
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.render(row)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
|
||||||
|
|
||||||
|
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
|
||||||
|
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Field controls (`Field` + `SwitchField`)
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Switch from "@mui/material/Switch";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
mb: 0.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
|
||||||
|
*
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<Typography variant="caption" color="error">
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
) : hint ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
|
||||||
|
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
|
||||||
|
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Migrate `Vehicles.tsx` onto the kit
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
|
||||||
|
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
|
||||||
|
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
|
||||||
|
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
|
||||||
|
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
|
||||||
|
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
|
||||||
|
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
|
||||||
|
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
|
||||||
|
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Phase-2 verification + final review
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
|
||||||
|
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
|
||||||
|
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (for the controller)
|
||||||
|
|
||||||
|
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 1–3), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
|
||||||
|
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
|
||||||
|
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
|
||||||
|
- **Deferred:** all live visual checks (no dev server in implementer scope).
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
|
||||||
|
|
||||||
|
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Component(s) | API |
|
||||||
|
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
|
||||||
|
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
|
||||||
|
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
|
||||||
|
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
|
||||||
|
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
|
||||||
|
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
|
||||||
|
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
|
||||||
|
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
|
||||||
|
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
|
||||||
|
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
|
||||||
|
| `src/admin/ui/index.ts` | extend | export all new components |
|
||||||
|
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
|
||||||
|
|
||||||
|
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
|
||||||
|
|
||||||
|
**Files:** Create the four files; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Select.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
|
||||||
|
value: string | number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options?: SelectOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Dropdown styled like the kit TextField; composes inside <Field>. */
|
||||||
|
export default function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options
|
||||||
|
? options.map((o) => (
|
||||||
|
<MenuItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</MenuItem>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
|
</MuiTextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `StatusChip.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Chip, { type ChipProps } from "@mui/material/Chip";
|
||||||
|
|
||||||
|
type Props = Omit<ChipProps, "color"> & {
|
||||||
|
color?: "default" | "success" | "error" | "warning" | "info";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Semantic status chip. Pass onClick to make it a toggle. */
|
||||||
|
export default function StatusChip({
|
||||||
|
color = "default",
|
||||||
|
size = "small",
|
||||||
|
onClick,
|
||||||
|
sx,
|
||||||
|
...props
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
onClick={onClick}
|
||||||
|
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `Checkbox.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import MuiCheckbox from "@mui/material/Checkbox";
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<MuiCheckbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: `Alert.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiAlert from "@mui/material/Alert";
|
||||||
|
|
||||||
|
export default function Alert({
|
||||||
|
severity = "info",
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
severity?: "success" | "error" | "warning" | "info";
|
||||||
|
children: ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||||
|
{children}
|
||||||
|
</MuiAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
|
||||||
|
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `Tabs` + `TabPanel`, `Pagination`
|
||||||
|
|
||||||
|
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `Tabs.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import MuiTabs from "@mui/material/Tabs";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
|
||||||
|
export interface TabDef {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
tabs: TabDef[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MuiTabs
|
||||||
|
value={value}
|
||||||
|
onChange={(_, v) => onChange(v)}
|
||||||
|
sx={{
|
||||||
|
borderBottom: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
minHeight: 44,
|
||||||
|
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<Tab key={t.value} value={t.value} label={t.label} />
|
||||||
|
))}
|
||||||
|
</MuiTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabPanel({
|
||||||
|
value,
|
||||||
|
current,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
current: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (value !== current) return null;
|
||||||
|
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `Pagination.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import MuiPagination from "@mui/material/Pagination";
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
||||||
|
<MuiPagination
|
||||||
|
page={page}
|
||||||
|
count={pageCount}
|
||||||
|
onChange={(_, p) => onChange(p)}
|
||||||
|
color="primary"
|
||||||
|
shape="rounded"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Sortable `DataTable`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/ui/DataTable.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Extend the types:
|
||||||
|
- `DataColumn<T>`: add `sortKey?: string;`
|
||||||
|
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
|
||||||
|
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||||
|
// ...header cell:
|
||||||
|
{
|
||||||
|
c.sortKey && onSort ? (
|
||||||
|
<TableSortLabel
|
||||||
|
active={sortBy === c.sortKey}
|
||||||
|
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
|
||||||
|
onClick={() => onSort(c.sortKey!)}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</TableSortLabel>
|
||||||
|
) : (
|
||||||
|
c.header
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `DateField` + LocalizationProvider
|
||||||
|
|
||||||
|
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
|
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
|
||||||
|
import { cs } from "date-fns/locale";
|
||||||
|
// inside MuiProvider, wrap {children} (and the sync) :
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||||
|
<MuiColorSchemeSync />
|
||||||
|
{children}
|
||||||
|
</LocalizationProvider>;
|
||||||
|
```
|
||||||
|
|
||||||
|
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
import { parseISO, format, isValid } from "date-fns";
|
||||||
|
|
||||||
|
export default function DateField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
...props
|
||||||
|
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
|
||||||
|
const dateValue = value ? parseISO(value) : null;
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={dateValue && isValid(dateValue) ? dateValue : null}
|
||||||
|
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
|
||||||
|
format="dd.MM.yyyy"
|
||||||
|
slotProps={{ textField: { size: "small", fullWidth: true } }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3:** export `DateField` from `index.ts`.
|
||||||
|
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Extend `/ui-kit` showcase + final review
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/UiKit.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
|
||||||
|
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
|
||||||
|
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
|
||||||
|
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
|
||||||
|
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
|
||||||
|
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Migrate `src/admin/pages/Users.tsx` onto the MUI kit, mirroring the already-migrated `Vehicles.tsx`, preserving ALL data logic. Presentation only changes.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button`, `Card`, `DataTable`, `Modal`, `ConfirmDialog`, `Field`, `TextField`, `SwitchField`, **`Select`** (new), **`StatusChip`** (new). MUI `Avatar`/`IconButton` used directly (as Vehicles uses `IconButton`).
|
||||||
|
|
||||||
|
**Reference:** `src/admin/pages/Vehicles.tsx` is the canonical migrated page — copy its structure (Box + motion.div header, `Button startIcon`, `Card` + `DataTable`, `Modal` with `Field`/`TextField`, `ConfirmDialog`). The Users data layer already exists; do not change it.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (dev server) is the user's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior to preserve VERBATIM (from the scout)
|
||||||
|
|
||||||
|
- **Three mutations + invalidation:** `USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"]`. `saveUser` (PUT if `editingUser` else POST), `deleteUser` (DELETE), `toggleUser` (PUT `{id,is_active}`). Keep all `onSuccess` messages.
|
||||||
|
- **Self-edit AuthContext sync (critical):** in `saveUser.onSuccess`, when `editingUser && currentUser && Number(editingUser.id)===Number(currentUser.id)`, call `updateUser({ username, email, fullName: \`${first_name} ${last_name}\`.trim() })`. Then `setShowModal(false); setEditingUser(null);`then`await new Promise(r=>setTimeout(r,300))` **(intentional delay — keep it)** before the success toast.
|
||||||
|
- **Auth guard:** `const { user: currentUser, updateUser, hasPermission } = useAuth();` → `if (!hasPermission("users.view")) return <Forbidden/>;`
|
||||||
|
- **Self-protection:** the delete action is **hidden** and the status toggle is **disabled** when `user.id === currentUser?.id`. These reference the live `currentUser` — keep them in the column render closures.
|
||||||
|
- **Password:** required only on create (`!editingUser`); empty string on edit means "keep existing".
|
||||||
|
- **Role dropdown:** options from `roleListOptions()` query; default to `roles[0]?.id` on create. `role_id` is `number|string` in form state.
|
||||||
|
- **`FormData` shape:** `{ username, email, password, first_name, last_name, role_id, is_active }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Migrate `Users.tsx`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Users.tsx` (read it first; it's the source of truth for the logic above).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `src/admin/pages/Users.tsx` fully and `src/admin/pages/Vehicles.tsx` (the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn`. From `@mui/material`: `Box, Typography, Avatar, IconButton`. Keep `motion` (framer-motion), `useQuery`, `roleListOptions`, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. **Remove** old imports: `FormModal`, `ConfirmModal`, `FormField`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Page shell** — mirror Vehicles: outer `Box`; `motion.div` header (`Typography variant="h4"` "Správa uživatelů" + `Button startIcon={PlusIcon}` "Přidat uživatele"); `motion.div` + `Card` wrapping the `DataTable`. Loading state keeps the `admin-spinner` markup.
|
||||||
|
|
||||||
|
- [ ] **Step 4: DataTable columns** (`DataColumn<User>[]`, defined inside the component so they close over `currentUser`/handlers):
|
||||||
|
- **Uživatel:** composite render — MUI `Avatar` (initial of `full_name`/`username`, `bgcolor:"primary.main"`) + name (`Typography`) + `@username` (`Typography variant="caption" color="text.secondary"`).
|
||||||
|
- **Email:** `render: (u) => u.email`.
|
||||||
|
- **Role:** `StatusChip` colored by role — map `"admin"` → `color="warning"`, else `"default"`; label = `roleDisplay`/role name.
|
||||||
|
- **Stav:** clickable `StatusChip` (`color={u.is_active?"success":"default"}`, `onClick` → `toggleActive(u)`), but **non-clickable / no onClick when `u.id===currentUser?.id`** (self-protection).
|
||||||
|
- **Akce:** `IconButton` edit (always); `IconButton` delete (`color="error"`) **only when `u.id !== currentUser?.id`**. Use the same edit/delete SVGs as Vehicles.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add/Edit `Modal`** — `Field` + kit `TextField` for username/email/first_name/last_name; password `TextField type="password"` (label notes optional on edit); **`Select`** (inside a `Field` label="Role") for `role_id` with `options` from the roles query (`value: String(r.id), label: r.display_name||r.name`) — note `Select` is string-based, so store/compare `role_id` as string or `String(...)` at the boundary; `SwitchField` for `is_active`. Add a local `errors` state + validation in `handleSubmit` (required: username, email, first_name, last_name; password required when `!editingUser`) mirroring Vehicles, before `saveUser.mutateAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Delete `ConfirmDialog`** — drop-in replacement for `ConfirmModal` (title "Smazat uživatele", message with the user's name, `confirmVariant="danger"`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify** `npx tsc -b --noEmit` clean; `npm run build` OK; `npx vitest run` (148). Drop all `admin-*` class names.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (`feat(mui): migrate Users (Uživatelé) page onto MUI kit`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 9: MANUAL (USER, dev server) — deferred:** list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Review + finish
|
||||||
|
|
||||||
|
- [ ] Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
|
||||||
|
- [ ] finishing-a-development-branch after the user's visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- The role `Select` is string-based (per the Phase 3 review fix); convert `role_id` at the boundary (`String(r.id)` for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match).
|
||||||
|
- Self-protection guards and the self-edit `updateUser` sync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor.
|
||||||
|
- No `users.css` exists; Users uses shared classes — nothing to delete, it just stops using them.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# MUI Migration — Phase 5: Projects (Projekty) — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
|
||||||
|
|
||||||
|
**Goal:** Migrate `src/admin/pages/Projects.tsx` onto the MUI kit. It's a **paginated, server-sorted list** with a create modal (customer/user `Select`s, `DateField`s, multiline notes) and a delete confirm that has an extra "delete files from disk" checkbox. Mirror `Vehicles.tsx`/`Users.tsx`; preserve ALL data logic.
|
||||||
|
|
||||||
|
**Prereq kit tweak (Task 1):** `ConfirmDialog` needs to accept optional extra content below the message (for the delete-files checkbox), since `message` is a string.
|
||||||
|
|
||||||
|
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button, Card, DataTable` (now sortable), `Pagination`, `Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box/Typography/IconButton` direct.
|
||||||
|
|
||||||
|
**Reference:** `Vehicles.tsx` + `Users.tsx` (migrated). The Projects data layer is authoritative — keep it.
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check is the user's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavior to preserve VERBATIM (from the scout)
|
||||||
|
|
||||||
|
- **List state:** `const { sort, order, handleSort, activeSort } = useTableSort("project_number")`; `search`/`page` state; **reset `setPage(1)` when search changes**. `usePaginatedQuery<Project>(projectListOptions({ search, sort, order, page }))` → `{ items, pagination, isPending, isFetching }`.
|
||||||
|
- **isFetching dimming:** the list Card gets `sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}`.
|
||||||
|
- **Create-only** (edit navigates to the `/projects/:id` detail route — NOT a modal). `createForm = { name, customer_id, responsible_user_id, status: "aktivni", start_date, end_date, notes }`, `createErrors`, `creating`. Dependent queries enabled only while the modal is open: `offerCustomersOptions()`, `userListOptions("projects.view")` (filter to `is_active`), `projectNextNumberOptions()`. `createMutation`: POST `/projects`, `invalidate: ["projects","orders","offers","invoices","attendance"]`, onSuccess close + "Projekt byl vytvořen". **Validation: only `name` required ("Zadejte název projektu").**
|
||||||
|
- **Delete:** `deleteTarget`/`deleteFiles` state. `deleteMutation`: DELETE `/projects/:id`, `invalidate: ["projects","warehouse","orders","offers","invoices","attendance"]`, called as `mutateAsync({ id, delete_files: deleteFiles })`, onSuccess toast `data?.message || "Projekt byl smazán"` + reset.
|
||||||
|
- **Permissions:** `projects.view` → `<Forbidden/>`; `projects.create` hides the add button; `projects.delete` hides the per-row delete; delete also hidden when `p.order_id` (order-linked projects can't be manually deleted).
|
||||||
|
- **Status constants:** `STATUS_LABELS = { aktivni:"Aktivní", dokonceny:"Dokončený", zruseny:"Zrušený" }`. Map to `StatusChip` colors: `aktivni`→`success`, `dokonceny`→`info`, `zruseny`→`default`.
|
||||||
|
- **Next number:** `nextNumberQuery.data?.number ?? nextNumberQuery.data?.next_number ?? "…"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend `ConfirmDialog` with optional extra content
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/ui/ConfirmDialog.tsx`.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `children?: ReactNode` to `ConfirmDialogProps` (import `type { ReactNode }`). Render it in `DialogContent` BELOW the message (live — not frozen, so the checkbox stays interactive):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>{shown.message}</DialogContentText>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2:** Verify `tsc -b` clean; `build:client` OK; existing callers (Vozidla, Users) unaffected (no `children` → unchanged). **Commit** (`feat(mui): ConfirmDialog optional children (extra content below message)`; + trailer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Migrate `Projects.tsx`
|
||||||
|
|
||||||
|
**Files:** Modify `src/admin/pages/Projects.tsx` (read it first — authoritative for the logic above).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `src/admin/pages/Projects.tsx` fully + `Users.tsx`/`Vehicles.tsx`. Keep ALL hooks/queries/mutations/handlers/permission-guards/state. Change only presentation.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box, Typography, IconButton` direct. Keep `motion`, `useNavigate`, the query options, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. Remove `FormModal`/`ConfirmModal`/`FormField`/`AdminDatePicker`/legacy `Pagination` imports.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Page shell** — Box + motion.div header (`Typography variant="h4"` "Projekty" + `Button startIcon={PlusIcon}` "Nový projekt", hidden without `projects.create`). A search `TextField` (no `Field` wrapper) that sets search + `setPage(1)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: List** — `Card` (with the `isFetching` opacity sx) wrapping a sortable `DataTable<Project>`:
|
||||||
|
- Wire sort: `sortBy={sort} sortDir={order} onSort={handleSort}`. Give sortable columns a `sortKey` (e.g. `project_number`, `name` — match the server's accepted sort keys from the original `SortIcon` usage).
|
||||||
|
- Columns: project number (`mono`, `sortKey`), name (`sortKey`), customer, responsible user, status (`StatusChip` per the color map), linked order number (if `order_id`), start/end dates (`mono`, via the existing date formatter), actions — a detail link/`IconButton` that `navigate(\`/projects/${p.id}\`)`for edit, and a delete`IconButton color="error"`shown only when`hasPermission("projects.delete") && !p.order_id`.
|
||||||
|
- Below the table: `<Pagination page={page} pageCount={pagination?.totalPages ?? 1} onChange={setPage} />` (use the actual page-count field from `pagination` — check the shape).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Create `Modal`** (`isOpen={showCreate}`, `loading={creating}`, title "Nový projekt", subtitle/hint with the next number): `Field`+`TextField` for name (required, error from `createErrors`); `Select` (in a `Field`) for customer (`offerCustomersOptions`) and responsible user (active users) — options `{ value: String(id), label }`, convert at the boundary; `Select` for status (the STATUS_LABELS options); two `DateField`s for start/end (string in/out — matches `createForm.start_date`); `TextField multiline minRows={3}` for notes. Validation: only name required.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Delete `ConfirmDialog`** — title "Smazat projekt", message with the project name, `confirmVariant="danger"`, and as **children** a `CheckboxField` "Smazat i soubory z disku" bound to `deleteFiles`. `onConfirm` calls the existing delete handler (`mutateAsync({ id, delete_files: deleteFiles })`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify** `tsc -b` clean; `npm run build` OK; `npx vitest run` (148). Drop `admin-*` classes. (Projects has no own CSS file; it uses shared classes.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit** (`feat(mui): migrate Projects (Projekty) page onto MUI kit`; + trailer).
|
||||||
|
|
||||||
|
- [ ] **Step 9: MANUAL (USER) — deferred:** list renders + sorts (click headers) + paginates + dims while fetching; create modal (customer/user/status selects, date pickers, notes; name-required validation; next number shown) saves; delete confirm with the files checkbox works; order-linked projects show no delete; permission gating; dark+light.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Review + finish
|
||||||
|
|
||||||
|
- [ ] Spec-compliance review (server sort/pagination wiring, the create `invalidate` arrays, name-only validation, delete `{id, delete_files}` body + the checkbox, permission/order-link gating, status color map, the Select string boundaries) → code-quality review.
|
||||||
|
- [ ] finishing-a-development-branch after the user's visual check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes
|
||||||
|
|
||||||
|
- This is the first page to exercise the **sortable DataTable** + **Pagination** + **DateField** + **Select** together — confirm the sort keys match what the server accepts and the pagination page-count field name is correct (read the query options / a working paginated page).
|
||||||
|
- `Select` is string-based; convert customer/user/status ids at the form boundary (`String(...)` for options/value; send what the server expects).
|
||||||
|
- The delete-files checkbox rides in `ConfirmDialog` `children` (Task 1) — it's live (not frozen), so it toggles while open; it resets via the caller on close.
|
||||||
|
- Edit is a route navigation (`/projects/:id`), NOT a modal — don't add an edit modal.
|
||||||
1406
docs/superpowers/plans/2026-06-06-plan-categories-management.md
Normal file
1406
docs/superpowers/plans/2026-06-06-plan-categories-management.md
Normal file
File diff suppressed because it is too large
Load Diff
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
# Bulk Plan Creation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work).
|
||||||
|
|
||||||
|
**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5).
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
|
||||||
|
- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`.
|
||||||
|
- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`).
|
||||||
|
- `src/routes/admin/plan.ts` — add `POST /entries/bulk`.
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
|
||||||
|
- `src/admin/components/BulkPlanModal.tsx` — new presentational modal.
|
||||||
|
- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation.
|
||||||
|
- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- `src/__tests__/plan.test.ts` — `bulkCreateEntries` service tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — bulk endpoint + service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/plan.schema.ts`
|
||||||
|
- Modify: `src/services/plan.service.ts`
|
||||||
|
- Modify: `src/routes/admin/plan.ts`
|
||||||
|
- Test: `src/__tests__/plan.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing service tests**
|
||||||
|
|
||||||
|
Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bulkCreateEntries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("plan.service.bulkCreateEntries", () => {
|
||||||
|
it("creates one continuous range per employee when weekends are included", async () => {
|
||||||
|
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||||
|
// range 06-01..06-03 covering all 3 days.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-wknd`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(1);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
expect(res.data.users).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||||
|
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||||
|
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-05",
|
||||||
|
include_weekends: false,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-week`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips days already at the cap and splits the range around them", async () => {
|
||||||
|
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||||
|
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||||
|
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2096-07-02"),
|
||||||
|
date_to: new Date("2096-07-02"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}cap${i}`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-07-01",
|
||||||
|
date_to: "2096-07-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-cap`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
expect(res.data.skipped_days).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates across multiple employees", async () => {
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId, noPermUserId],
|
||||||
|
date_from: "2096-08-03", // Friday
|
||||||
|
date_to: "2096-08-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-multi`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.users).toBe(2);
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||||
|
const empty = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in empty && empty.status).toBe(400);
|
||||||
|
|
||||||
|
const past = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2000-01-01",
|
||||||
|
date_to: "2000-01-02",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in past && past.status).toBe(403);
|
||||||
|
|
||||||
|
const badRange = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-10",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badRange && badRange.status).toBe(400);
|
||||||
|
|
||||||
|
const badCat = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "__nope__",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badCat && badCat.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||||
|
Expected: FAIL — `bulkCreateEntries` is not exported / not a function.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`**
|
||||||
|
|
||||||
|
Add at the end of `src/services/plan.service.ts` (after `listOverrides`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bulk entry creation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assign a project/category to many employees over a date range. For each
|
||||||
|
* employee, walk the range, keep the days that pass the weekday filter
|
||||||
|
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
|
||||||
|
* group contiguous kept days into runs, and create one range entry per run by
|
||||||
|
* reusing createEntry (so cap / past-date / category validation stays DRY).
|
||||||
|
*
|
||||||
|
* Days that pass the weekday filter but are already at the cap are counted in
|
||||||
|
* skipped_days (and split the range). Weekend days, when excluded, are out of
|
||||||
|
* scope and are NOT counted as skipped.
|
||||||
|
*/
|
||||||
|
export async function bulkCreateEntries(
|
||||||
|
input: {
|
||||||
|
user_ids: number[];
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id?: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
},
|
||||||
|
actorUserId: number,
|
||||||
|
): Promise<
|
||||||
|
Result<{
|
||||||
|
created_entries: number;
|
||||||
|
created_days: number;
|
||||||
|
skipped_days: number;
|
||||||
|
users: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (input.user_ids.length === 0) {
|
||||||
|
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
|
||||||
|
}
|
||||||
|
if (input.date_to < input.date_from) {
|
||||||
|
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||||
|
}
|
||||||
|
const lockFrom = assertNotPastDate(input.date_from, false);
|
||||||
|
if (lockFrom) return lockFrom;
|
||||||
|
const lockTo = assertNotPastDate(input.date_to, false);
|
||||||
|
if (lockTo) return lockTo;
|
||||||
|
const catErr = await assertActiveCategory(input.category);
|
||||||
|
if (catErr) return catErr;
|
||||||
|
|
||||||
|
const from = toDateOnly(input.date_from);
|
||||||
|
const to = toDateOnly(input.date_to);
|
||||||
|
|
||||||
|
// The day list for the whole range (UTC midnights), built once.
|
||||||
|
const days: Date[] = [];
|
||||||
|
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
days.push(new Date(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdEntries = 0;
|
||||||
|
let createdDays = 0;
|
||||||
|
let skippedDays = 0;
|
||||||
|
|
||||||
|
for (const userId of input.user_ids) {
|
||||||
|
// Per-day cap counts from this user's existing active entries.
|
||||||
|
const existing = await prisma.work_plan_entries.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
is_deleted: false,
|
||||||
|
date_from: { lte: to },
|
||||||
|
date_to: { gte: from },
|
||||||
|
},
|
||||||
|
select: { date_from: true, date_to: true },
|
||||||
|
});
|
||||||
|
const countFor = (day: Date): number =>
|
||||||
|
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
|
||||||
|
|
||||||
|
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
|
||||||
|
const eligible: boolean[] = [];
|
||||||
|
for (const day of days) {
|
||||||
|
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
if (isWeekend && !input.include_weekends) {
|
||||||
|
eligible.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
|
||||||
|
skippedDays++;
|
||||||
|
eligible.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eligible.push(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group contiguous eligible days into runs; create one range per run.
|
||||||
|
let runStart = -1;
|
||||||
|
for (let i = 0; i <= days.length; i++) {
|
||||||
|
const ok = i < days.length && eligible[i];
|
||||||
|
if (ok && runStart === -1) {
|
||||||
|
runStart = i;
|
||||||
|
} else if (!ok && runStart !== -1) {
|
||||||
|
const segFrom = days[runStart].toISOString().slice(0, 10);
|
||||||
|
const segTo = days[i - 1].toISOString().slice(0, 10);
|
||||||
|
const runDays = i - runStart;
|
||||||
|
const res = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
date_from: segFrom,
|
||||||
|
date_to: segTo,
|
||||||
|
project_id: input.project_id ?? null,
|
||||||
|
category: input.category,
|
||||||
|
note: input.note,
|
||||||
|
},
|
||||||
|
actorUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if ("data" in res) {
|
||||||
|
createdEntries++;
|
||||||
|
createdDays += runDays;
|
||||||
|
} else {
|
||||||
|
// Rare race (e.g. the cap filled concurrently). Treat as skipped.
|
||||||
|
skippedDays += runDays;
|
||||||
|
}
|
||||||
|
runStart = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
created_entries: createdEntries,
|
||||||
|
created_days: createdDays,
|
||||||
|
skipped_days: skippedDays,
|
||||||
|
users: input.user_ids.length,
|
||||||
|
},
|
||||||
|
oldData: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the service tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||||
|
Expected: PASS (including the 5 new bulk tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`**
|
||||||
|
|
||||||
|
Append to `src/schemas/plan.schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const BulkPlanEntrySchema = z
|
||||||
|
.object({
|
||||||
|
user_ids: z
|
||||||
|
.array(intFromForm)
|
||||||
|
.min(1, "Vyberte alespoň jednoho zaměstnance"),
|
||||||
|
date_from: isoDate,
|
||||||
|
date_to: isoDate,
|
||||||
|
include_weekends: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((v) => v === true || v === "true" || v === "1")
|
||||||
|
.default(false),
|
||||||
|
project_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.nullish(),
|
||||||
|
category: planCategoryEnum.default("work"),
|
||||||
|
note: z.string().max(500, "Maximálně 500 znaků").default(""),
|
||||||
|
})
|
||||||
|
.refine((v) => v.date_to >= v.date_from, {
|
||||||
|
message: "Datum do musí být stejné nebo po datumu od",
|
||||||
|
path: ["date_to"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the `POST /entries/bulk` route**
|
||||||
|
|
||||||
|
In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// --- POST /plan/entries/bulk ---
|
||||||
|
app.post(
|
||||||
|
"/entries/bulk",
|
||||||
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = parseBody(BulkPlanEntrySchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await bulkCreateEntries(
|
||||||
|
body.data!,
|
||||||
|
request.authData!.userId,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "create",
|
||||||
|
entityType: "work_plan_entry",
|
||||||
|
description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`,
|
||||||
|
});
|
||||||
|
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add an HTTP route test**
|
||||||
|
|
||||||
|
Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-01",
|
||||||
|
date_to: "2095-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-forbidden`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||||
|
date_to: "2095-06-05",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-http`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.created_days).toBe(1);
|
||||||
|
expect(body.data.users).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts
|
||||||
|
git commit -m "feat(plan): bulk entry creation endpoint + service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — bulk modal + wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/BulkPlanModal.tsx`
|
||||||
|
- Modify: `src/admin/hooks/usePlanWork.ts`
|
||||||
|
- Modify: `src/admin/pages/PlanWork.tsx`
|
||||||
|
|
||||||
|
Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `BulkPlanModal.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Field,
|
||||||
|
DateField,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
CheckboxField,
|
||||||
|
} from "../ui";
|
||||||
|
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
|
||||||
|
import type { Project } from "../lib/queries/projects";
|
||||||
|
|
||||||
|
export interface BulkPlanForm {
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
is_range: boolean;
|
||||||
|
user_ids: string[];
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id: string;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
form: BulkPlanForm;
|
||||||
|
setForm: (form: BulkPlanForm) => void;
|
||||||
|
users: PlanUser[];
|
||||||
|
projects: Project[];
|
||||||
|
categories: PlanCategory[];
|
||||||
|
onSubmit: () => void;
|
||||||
|
submitting: boolean;
|
||||||
|
toggleUser: (userId: number) => void;
|
||||||
|
toggleAllUsers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkPlanModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
users,
|
||||||
|
projects,
|
||||||
|
categories,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
toggleUser,
|
||||||
|
toggleAllUsers,
|
||||||
|
}: Props) {
|
||||||
|
const activeCategories = categories.filter((c) => c.is_active);
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Hromadné přiřazení"
|
||||||
|
maxWidth="lg"
|
||||||
|
loading={submitting}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitText="Vytvořit"
|
||||||
|
submitDisabled={form.user_ids.length === 0}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 0.25, mb: 2 }}
|
||||||
|
>
|
||||||
|
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už
|
||||||
|
mají 3 záznamy, se přeskočí.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Field label="Datum od">
|
||||||
|
<DateField
|
||||||
|
value={form.date_from}
|
||||||
|
onChange={(val) => setForm({ ...form, date_from: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Rozsah dnů">
|
||||||
|
<CheckboxField
|
||||||
|
label="Více dní"
|
||||||
|
checked={form.is_range}
|
||||||
|
onChange={(checked) => setForm({ ...form, is_range: checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{form.is_range && (
|
||||||
|
<Field label="Datum do">
|
||||||
|
<DateField
|
||||||
|
value={form.date_to}
|
||||||
|
onChange={(val) => setForm({ ...form, date_to: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Víkendy">
|
||||||
|
<CheckboxField
|
||||||
|
label="Zahrnout víkendy"
|
||||||
|
checked={form.include_weekends}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setForm({ ...form, include_weekends: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Zaměstnanci
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllUsers}
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
p: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "primary.main",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.user_ids.length === users.length
|
||||||
|
? "Odznačit vše"
|
||||||
|
: "Vybrat vše"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 0.25,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((u) => (
|
||||||
|
<CheckboxField
|
||||||
|
key={u.id}
|
||||||
|
label={u.full_name}
|
||||||
|
checked={form.user_ids.includes(String(u.id))}
|
||||||
|
onChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Projekt">
|
||||||
|
<Select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— bez projektu —" },
|
||||||
|
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kategorie">
|
||||||
|
<Select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(val) => setForm({ ...form, category: val })}
|
||||||
|
options={activeCategories.map((c) => ({
|
||||||
|
value: c.key,
|
||||||
|
label: c.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Text poznámky (volitelný)">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
|
inputProps={{ maxLength: 500 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`**
|
||||||
|
|
||||||
|
After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bulkCreate = useMutation({
|
||||||
|
mutationFn: (body: any) =>
|
||||||
|
apiCall("/api/admin/plan/entries/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
// No optimistic patch — bulk can touch many cells; just invalidate and
|
||||||
|
// let the grid refetch the authoritative state.
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`**
|
||||||
|
|
||||||
|
(a) Add imports near the other component imports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.).
|
||||||
|
|
||||||
|
(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
const [bulkSubmitting, setBulkSubmitting] = useState(false);
|
||||||
|
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
|
||||||
|
const today = todayIsoLocal();
|
||||||
|
return {
|
||||||
|
date_from: today,
|
||||||
|
date_to: today,
|
||||||
|
is_range: false,
|
||||||
|
user_ids: [],
|
||||||
|
include_weekends: false,
|
||||||
|
project_id: "",
|
||||||
|
category: "work",
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const planUsers = grid?.users ?? [];
|
||||||
|
|
||||||
|
const toggleBulkUser = useCallback((userId: number) => {
|
||||||
|
setBulkForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.includes(String(userId))
|
||||||
|
? prev.user_ids.filter((u) => u !== String(userId))
|
||||||
|
: [...prev.user_ids, String(userId)],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAllBulkUsers = useCallback(() => {
|
||||||
|
const allIds = (grid?.users ?? []).map((u) => String(u.id));
|
||||||
|
setBulkForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
|
||||||
|
}));
|
||||||
|
}, [grid]);
|
||||||
|
|
||||||
|
const submitBulk = useCallback(async () => {
|
||||||
|
if (bulkForm.user_ids.length === 0) return;
|
||||||
|
setBulkSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data: any = await bulkCreate.mutateAsync({
|
||||||
|
user_ids: bulkForm.user_ids.map(Number),
|
||||||
|
date_from: bulkForm.date_from,
|
||||||
|
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
|
||||||
|
include_weekends: bulkForm.include_weekends,
|
||||||
|
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
|
||||||
|
category: bulkForm.category,
|
||||||
|
note: bulkForm.note,
|
||||||
|
});
|
||||||
|
const skipped =
|
||||||
|
data?.skipped_days > 0
|
||||||
|
? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).`
|
||||||
|
: "";
|
||||||
|
alert.success(
|
||||||
|
`Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`,
|
||||||
|
);
|
||||||
|
setBulkOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
|
} finally {
|
||||||
|
setBulkSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [bulkForm, bulkCreate, alert]);
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar `<Box>`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setBulkOpen(true)}
|
||||||
|
>
|
||||||
|
Hromadné přiřazení
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(e) Render the modal alongside `<PlanCategoriesModal ... />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BulkPlanModal
|
||||||
|
isOpen={bulkOpen}
|
||||||
|
onClose={() => setBulkOpen(false)}
|
||||||
|
form={bulkForm}
|
||||||
|
setForm={setBulkForm}
|
||||||
|
users={planUsers}
|
||||||
|
projects={projects}
|
||||||
|
categories={categories}
|
||||||
|
onSubmit={submitBulk}
|
||||||
|
submitting={bulkSubmitting}
|
||||||
|
toggleUser={toggleBulkUser}
|
||||||
|
toggleAllUsers={toggleAllBulkUsers}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate + Chrome check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only Mon–Fri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx
|
||||||
|
git commit -m "feat(plan): bulk assignment modal on /plan-work"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Release v2.0.6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version** to `"version": "2.0.6"` in `package.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full gate**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npx vitest run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: tsc exit 0, vitest all pass, build success.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Merge to master + commit + tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git merge --ff-only feat/bulk-plan-creation
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore(release): v2.0.6 — bulk plan creation"
|
||||||
|
git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation"
|
||||||
|
```
|
||||||
|
|
||||||
|
(If implementation happened directly on master, skip the checkout/merge.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
git push origin v2.0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts
|
||||||
|
scp app-ts-2.0.6.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
|
||||||
|
```
|
||||||
|
|
||||||
|
No migration ships in this release → `prisma migrate deploy` reports "No pending migrations".
|
||||||
|
|
||||||
|
- [ ] **Step 6: Health check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.6 online.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those.
|
||||||
|
- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped.
|
||||||
|
- **No DB migration.** Don't run any prisma migrate command.
|
||||||
|
- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check.
|
||||||
|
- **Do not deploy to production without explicit confirmation** (Task 3, Steps 5–6).
|
||||||
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
File diff suppressed because it is too large
Load Diff
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
# Odin Multi-Conversation Chat — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
|
||||||
|
|
||||||
|
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
|
||||||
|
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
|
||||||
|
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
|
||||||
|
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
|
||||||
|
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
|
||||||
|
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
|
||||||
|
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
|
||||||
|
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
|
||||||
|
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
|
||||||
|
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
|
||||||
|
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
|
||||||
|
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
|
||||||
|
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
|
||||||
|
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
|
||||||
|
|
||||||
|
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate` → `npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data model + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma`
|
||||||
|
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ai_conversations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||||
|
messages ai_chat_messages[]
|
||||||
|
|
||||||
|
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_chat_messages {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
conversation_id Int
|
||||||
|
role String @db.VarChar(20)
|
||||||
|
content String @db.Text
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_conversations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||||
|
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||||
|
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||||
|
FROM `ai_chat_messages`
|
||||||
|
GROUP BY `user_id`;
|
||||||
|
|
||||||
|
UPDATE `ai_chat_messages` m
|
||||||
|
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||||
|
SET m.`conversation_id` = c.`id`;
|
||||||
|
|
||||||
|
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
DROP INDEX `idx_ai_chat_user`,
|
||||||
|
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||||
|
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||||
|
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||||
|
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Controller applies the migration** (not the implementer):
|
||||||
|
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
|
||||||
|
- `npx prisma generate` — expect "Generated Prisma Client".
|
||||||
|
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
|
||||||
|
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — conversation service, schemas, routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/ai.service.ts`
|
||||||
|
- Modify: `src/schemas/ai.schema.ts`
|
||||||
|
- Modify: `src/routes/admin/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── Conversations (server-side, per user) ─────────────────────────────────
|
||||||
|
export interface StoredChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
|
||||||
|
const MESSAGE_LIMIT = 200; // cap a single thread read
|
||||||
|
|
||||||
|
export async function listConversations(
|
||||||
|
userId: number,
|
||||||
|
): Promise<ConversationSummary[]> {
|
||||||
|
return prisma.ai_conversations.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
orderBy: { id: "asc" }, // stable tab order
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(
|
||||||
|
userId: number,
|
||||||
|
title?: string,
|
||||||
|
): Promise<ConversationSummary> {
|
||||||
|
return prisma.ai_conversations.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ownership-checked fetch; null when not found / not owned. */
|
||||||
|
async function ownConversation(userId: number, convId: number) {
|
||||||
|
return prisma.ai_conversations.findFirst({
|
||||||
|
where: { id: convId, user_id: userId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const rows = await prisma.ai_chat_messages.findMany({
|
||||||
|
where: { conversation_id: convId },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: MESSAGE_LIMIT,
|
||||||
|
select: { role: true, content: true, created_at: true },
|
||||||
|
});
|
||||||
|
return { data: rows.reverse() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
const conv = await ownConversation(userId, convId);
|
||||||
|
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await prisma.ai_chat_messages.createMany({
|
||||||
|
data: messages.map((m) => ({
|
||||||
|
user_id: userId,
|
||||||
|
conversation_id: convId,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Auto-title from the first user message (only while still the default), and
|
||||||
|
// always bump updated_at.
|
||||||
|
const firstUser = messages.find((m) => m.role === "user");
|
||||||
|
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
|
||||||
|
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
|
||||||
|
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
|
||||||
|
if (t) data.title = t;
|
||||||
|
}
|
||||||
|
await prisma.ai_conversations.update({ where: { id: convId }, data });
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const updated = await prisma.ai_conversations.update({
|
||||||
|
where: { id: convId },
|
||||||
|
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
return { data: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AiCreateConversationSchema = z.object({
|
||||||
|
title: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AiRenameConversationSchema = z.object({
|
||||||
|
title: z.string().min(1, "Název je povinný").max(255),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// GET /conversations — this user's conversations (stable order)
|
||||||
|
app.get(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const conversations = await listConversations(request.authData!.userId);
|
||||||
|
return success(reply, { conversations });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations — create
|
||||||
|
app.post(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = parseBody(AiCreateConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const conversation = await createConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
return success(reply, { conversation });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /conversations/:id/messages
|
||||||
|
app.get(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await getConversationMessages(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { messages: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations/:id/messages — append turns
|
||||||
|
app.post(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiHistoryAppendSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await appendConversationMessages(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.messages,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /conversations/:id — rename
|
||||||
|
app.patch(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiRenameConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await renameConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { conversation: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /conversations/:id
|
||||||
|
app.delete(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await deleteConversation(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/__tests__/ai.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
computeCostUsd,
|
||||||
|
recordUsage,
|
||||||
|
getMonthSpendUsd,
|
||||||
|
getBudgetUsd,
|
||||||
|
assertBudgetAvailable,
|
||||||
|
isConfigured,
|
||||||
|
listConversations,
|
||||||
|
createConversation,
|
||||||
|
getConversationMessages,
|
||||||
|
appendConversationMessages,
|
||||||
|
renameConversation,
|
||||||
|
deleteConversation,
|
||||||
|
} from "../services/ai.service";
|
||||||
|
|
||||||
|
const KIND = "test_ai";
|
||||||
|
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||||
|
const CONV_UID_B = 999999992;
|
||||||
|
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { title: { contains: HIST_MARKER } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe("ai.service conversations", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
expect(conv.title).toBe("Nová konverzace");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||||
|
{ role: "assistant", content: "Nevím, ale…" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||||
|
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||||
|
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
|
||||||
|
"Kolik je hodin v Praze?",
|
||||||
|
"Nevím, ale…",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite a renamed title on later appends", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list[0].title).toBe("Moje téma");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates conversations per user (404 across owners)", async () => {
|
||||||
|
const a = await createConversation(CONV_UID_A);
|
||||||
|
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||||
|
const read = await getConversationMessages(CONV_UID_B, a.id);
|
||||||
|
expect("error" in read && read.status).toBe(404);
|
||||||
|
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
|
||||||
|
expect("error" in ren && ren.status).toBe(404);
|
||||||
|
const del = await deleteConversation(CONV_UID_B, a.id);
|
||||||
|
expect("error" in del && del.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascade-deletes messages with the conversation", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "x" },
|
||||||
|
]);
|
||||||
|
await deleteConversation(CONV_UID_A, conv.id);
|
||||||
|
const remaining = await prisma.ai_chat_messages.count({
|
||||||
|
where: { conversation_id: conv.id },
|
||||||
|
});
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("GET /conversations requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} t` },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const id = created.json().data.conversation.id as number;
|
||||||
|
|
||||||
|
const app2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||||
|
});
|
||||||
|
expect(app2.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const msgs = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||||
|
).toContain("ahoj");
|
||||||
|
|
||||||
|
const renamed = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} renamed` },
|
||||||
|
});
|
||||||
|
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(del.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET messages of a non-existent conversation → 404", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations/999999000/messages",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/__tests__/ai.test.ts
|
||||||
|
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend queries + types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiConversationsOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversations"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ conversations: Conversation[] }>(
|
||||||
|
"/api/admin/ai/conversations",
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiConversationMessagesOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversation", id, "messages"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ messages: StoredChatMessage[] }>(
|
||||||
|
`/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
),
|
||||||
|
// Re-read the DB on each mount/selection (no stale-cache resurrection).
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation query options"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend presentational components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinTabs.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinThread.tsx`
|
||||||
|
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinComposer.tsx`
|
||||||
|
|
||||||
|
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ChatTurn {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface ReviewInvoice {
|
||||||
|
uid: string;
|
||||||
|
supplier_name: string;
|
||||||
|
invoice_number: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
vat_rate: string;
|
||||||
|
issue_date: string;
|
||||||
|
due_date: string;
|
||||||
|
description: string;
|
||||||
|
file_name: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
export type EditableField =
|
||||||
|
| "supplier_name"
|
||||||
|
| "invoice_number"
|
||||||
|
| "amount"
|
||||||
|
| "currency"
|
||||||
|
| "vat_rate"
|
||||||
|
| "issue_date"
|
||||||
|
| "due_date"
|
||||||
|
| "description";
|
||||||
|
export interface StagedFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinTabsProps {
|
||||||
|
conversations: { id: number; title: string }[];
|
||||||
|
activeId: number | null;
|
||||||
|
busy: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onRename: (id: number, title: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `OdinThread.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinThreadProps {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
busy: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
threadRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface InvoiceReviewCardProps {
|
||||||
|
inv: ReviewInvoice;
|
||||||
|
busy: boolean;
|
||||||
|
onChange: (uid: string, field: EditableField, value: string) => void;
|
||||||
|
onSave: (inv: ReviewInvoice) => void;
|
||||||
|
onDiscard: (uid: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)`→`onChange(...)`, `saveInvoice`→`onSave`, the discard filter→`onDiscard(inv.uid)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `OdinComposer.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinComposerProps {
|
||||||
|
input: string;
|
||||||
|
attachments: StagedFile[];
|
||||||
|
busy: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onInput: (v: string) => void;
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin
|
||||||
|
git commit -m "feat(odin): tabs, thread, review-card, composer components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: OdinChat orchestrator + page + delete DashAssistant
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinChat.tsx`
|
||||||
|
- Modify: `src/admin/pages/Odin.tsx`
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
|
||||||
|
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
|
||||||
|
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
|
||||||
|
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
|
||||||
|
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
|
||||||
|
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
|
||||||
|
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
|
||||||
|
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
|
||||||
|
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
|
||||||
|
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
|
||||||
|
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
|
||||||
|
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
|
||||||
|
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
|
||||||
|
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
|
||||||
|
|
||||||
|
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52–333) — copy them, applying only the adaptations above.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import OdinChat from "../components/odin/OdinChat";
|
||||||
|
// …
|
||||||
|
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||||
|
<OdinChat />
|
||||||
|
</Box>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm src/admin/components/dashboard/DashAssistant.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification (controller)
|
||||||
|
|
||||||
|
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
|
||||||
|
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
|
||||||
|
- Adversarial review pass (workflow) before merge/release.
|
||||||
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
2445
docs/superpowers/plans/2026-06-09-issued-orders.md
Normal file
File diff suppressed because it is too large
Load Diff
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
188
docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings
|
||||||
|
|
||||||
|
Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass:
|
||||||
|
the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency
|
||||||
|
finding. The two project-level HIGH _structural refactors_ (service-layer extraction for
|
||||||
|
6 route files; PDF template dedup) are explicitly **out of scope** — they are
|
||||||
|
multi-thousand-line refactors that deserve their own pass.
|
||||||
|
|
||||||
|
Execution: subagent-driven development — one implementer per task (sequential, shared
|
||||||
|
test DB forbids parallel test runs), spec-compliance review then code-quality review
|
||||||
|
after each. No commits until the user asks. Final full gates:
|
||||||
|
`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task H — Dependencies (project-level CRITICAL) — done inline by controller
|
||||||
|
|
||||||
|
- Remove `concurrently` from devDependencies (unused; carries both critical advisories
|
||||||
|
GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1).
|
||||||
|
- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate
|
||||||
|
advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade).
|
||||||
|
- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom`
|
||||||
|
to devDependencies.
|
||||||
|
- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external
|
||||||
|
QR service).
|
||||||
|
- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck.
|
||||||
|
|
||||||
|
## Task A — Attendance create/edit broken since 519edce (top-5 #1)
|
||||||
|
|
||||||
|
Findings (both verified HIGH):
|
||||||
|
|
||||||
|
- `src/admin/pages/AttendanceCreate.tsx:80-99` — handleSubmit posts the raw form whose
|
||||||
|
shape the API does not accept: `arrival_date`/`break_start_date`/`break_start_time`/
|
||||||
|
`break_end_*`/`departure_date` are not in CreateAttendanceSchema (silently stripped —
|
||||||
|
breaks and overnight dates never save); times are never combined with dates (unlike
|
||||||
|
useAttendanceAdmin's combineDatetime), so a validated "HH:MM" would reach
|
||||||
|
createAttendance which does `new Date('08:00')` → Invalid Date → 500; and since commit
|
||||||
|
519edce the always-sent empty-string time fields (`arrival_time: ""`) fail timeString
|
||||||
|
validation, so EVERY submit from this page — including leave records — returns 400.
|
||||||
|
- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) —
|
||||||
|
create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for
|
||||||
|
arrival/departure/break, but since 519edce CreateAttendanceSchema/UpdateAttendanceSchema
|
||||||
|
validate them with `timeString` (HH:MM only) — every create/edit containing a time is
|
||||||
|
rejected 400 "Čas musí být ve formátu HH:MM" (and the service itself expects full
|
||||||
|
datetimes via `new Date(...)`); additionally `break_start`/`break_end` are absent from
|
||||||
|
CreateAttendanceSchema, so breaks are silently stripped on create.
|
||||||
|
|
||||||
|
Direction: first `git show 519edce` to understand the intent; the schema contradicts
|
||||||
|
both client and service, so the fix is to make the schemas validate the combined
|
||||||
|
local-datetime wire format the client sends and the service consumes (lenient helper in
|
||||||
|
`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create
|
||||||
|
schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty
|
||||||
|
optionals. TDD: route-level regression tests (work record with times+breaks, overnight,
|
||||||
|
leave record without times, update) in `src/__tests__/`.
|
||||||
|
|
||||||
|
## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2)
|
||||||
|
|
||||||
|
Findings (all verified HIGH):
|
||||||
|
|
||||||
|
- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA
|
||||||
|
always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server
|
||||||
|
ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400.
|
||||||
|
The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never
|
||||||
|
called anywhere in the frontend — lockout for any user who lost their authenticator.
|
||||||
|
- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `<img>` from api.qrserver.com
|
||||||
|
is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA
|
||||||
|
enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a
|
||||||
|
third-party URL.) Fix: generate the QR locally with the `qrcode` package
|
||||||
|
(`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed.
|
||||||
|
- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions()
|
||||||
|
which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the
|
||||||
|
per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and
|
||||||
|
wrong button shown.
|
||||||
|
|
||||||
|
Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code
|
||||||
|
goes to backup-verify and completes the login-token flow. If the server endpoint doesn't
|
||||||
|
fit the loginToken flow, extend it minimally (single-use code consumption + logAudit
|
||||||
|
preserved). Server test for backup-verify login path if server is touched.
|
||||||
|
|
||||||
|
## Task C — Invoice/issued-order edit integrity (top-5 #3)
|
||||||
|
|
||||||
|
Findings (all verified HIGH):
|
||||||
|
|
||||||
|
- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's
|
||||||
|
persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`)
|
||||||
|
though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice
|
||||||
|
silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21%
|
||||||
|
(same falsy-zero at line 689).
|
||||||
|
- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is
|
||||||
|
silently lost: payload includes billing_text but UpdateInvoiceSchema
|
||||||
|
(src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it
|
||||||
|
(updateInvoice supports it via strFields). Add the field to the schema + server test.
|
||||||
|
- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks:
|
||||||
|
`quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate)
|
||||||
|
|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and
|
||||||
|
silently persisted on next save.
|
||||||
|
- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the
|
||||||
|
new status into form.status, so StatusChip, the `editable` flag and the delete-button
|
||||||
|
gate use the stale status after every transition.
|
||||||
|
|
||||||
|
Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x);
|
||||||
|
Number.isFinite(n) ? n : fallback`).
|
||||||
|
|
||||||
|
## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a)
|
||||||
|
|
||||||
|
Findings (all verified HIGH):
|
||||||
|
|
||||||
|
- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated
|
||||||
|
endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit
|
||||||
|
25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no
|
||||||
|
pagination controls.
|
||||||
|
- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page →
|
||||||
|
25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows.
|
||||||
|
- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page;
|
||||||
|
header labels stats "current month" though the query has no month filter.
|
||||||
|
- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but
|
||||||
|
UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT
|
||||||
|
handler never applies it — vehicle change silently dropped. Add to schema
|
||||||
|
(nullableIntIdFromForm) + apply in route + audit + server test.
|
||||||
|
- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the
|
||||||
|
month view and its stat cards.
|
||||||
|
|
||||||
|
Direction for totals: prefer a server-side aggregate (the repo already has per-currency
|
||||||
|
totals endpoints for invoices/orders as the pattern) over fetching all pages client-side;
|
||||||
|
lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces
|
||||||
|
used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must
|
||||||
|
cover the WHOLE month, not one page).
|
||||||
|
|
||||||
|
## Task E — orders.service.ts: PDF blob in every response (top-5 #4b)
|
||||||
|
|
||||||
|
Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683`
|
||||||
|
— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit
|
||||||
|
and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob
|
||||||
|
for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the
|
||||||
|
blob to check status/number. A dedicated /:id/attachment endpoint already exists.
|
||||||
|
Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if
|
||||||
|
the FE needs it — check FE usage), keep the attachment endpoint working, extend
|
||||||
|
`src/__tests__/orders-list.test.ts` to assert attachment_data is absent.
|
||||||
|
|
||||||
|
## Task F — Warehouse: unit enum 400s + dead attachment download
|
||||||
|
|
||||||
|
Findings (both verified HIGH):
|
||||||
|
|
||||||
|
- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the
|
||||||
|
server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value
|
||||||
|
400s create/update. Make it a Select over the enum values (single source: mirror the
|
||||||
|
server enum list).
|
||||||
|
- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets
|
||||||
|
GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST
|
||||||
|
upload + DELETE are registered in warehouse.ts), and a plain <a href> sends no
|
||||||
|
Authorization header anyway. Add the GET download route (requirePermission, correct
|
||||||
|
content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch →
|
||||||
|
object URL, like other binary downloads in the repo). Server test for the new route.
|
||||||
|
|
||||||
|
## Task G — Small fixes
|
||||||
|
|
||||||
|
- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes
|
||||||
|
effect: prints {decision:'block'} JSON to stdout then exits 1; Claude Code only parses
|
||||||
|
stdout JSON on exit 0 and only blocks on exit 2 (reason on stderr). Fix: exit 2 with the
|
||||||
|
reason on stderr.
|
||||||
|
- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits
|
||||||
|
start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating
|
||||||
|
a project without filling BOTH dates always 400s. Send null/omit when empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the
|
||||||
|
2 known-mitigated quill lows remain
|
||||||
|
- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten
|
||||||
|
AttendanceCreate payload; 6 regression tests
|
||||||
|
- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via
|
||||||
|
qrcode lib, per-user totp status; 6 tests
|
||||||
|
- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts),
|
||||||
|
billing_text in UpdateInvoiceSchema, status mirror; 2 tests
|
||||||
|
- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats
|
||||||
|
(buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute,
|
||||||
|
print rebuilt (sync window.open, escaped string template); 7 tests
|
||||||
|
- [x] E — orders attachment_data excluded from all non-binary reads (incl.
|
||||||
|
numbering/invoices/orders-pdf callers); 4 tests
|
||||||
|
- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob
|
||||||
|
download; RFC 5987 content-disposition helper (also fixes received-invoices +
|
||||||
|
orders Czech-filename 500); 6 tests
|
||||||
|
- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates
|
||||||
|
→ null
|
||||||
|
- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors ·
|
||||||
|
build OK. Whole-diff 3-lens review: see final report.
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
# Per-document custom-field print selection — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
|
||||||
|
|
||||||
|
**Architecture:** A new nullable `selected_custom_fields` column (JSON-array-in-VARCHAR) on `quotations`, `issued_orders`, `invoices`. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to `number[]`. The PDF `buildAddressLines()` company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real `app_test` DB), React 19 + MUI v7 + React Query.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for this plan
|
||||||
|
|
||||||
|
- This shell is non-interactive — `prisma migrate dev` is forbidden. Use the `migrate diff` recipe (Task 1).
|
||||||
|
- **Before the migration, ask the user to stop their dev server and wait for confirmation** (it holds DB connections). Do not run the migration until they confirm.
|
||||||
|
- Server tests run against `app_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
|
||||||
|
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must stay at 0 errors.
|
||||||
|
- Selection semantics in `buildAddressLines`: param **omitted/`undefined`** ⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an **array** (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `prisma/schema.prisma` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
|
||||||
|
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
|
||||||
|
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `parseSelectedCustomFields`.
|
||||||
|
- **Create** `src/__tests__/selected-custom-fields.test.ts` — util + integration coverage.
|
||||||
|
- **Modify** `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts` — new optional array field.
|
||||||
|
- **Modify** `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts` — persist on create/update, decode in detail.
|
||||||
|
- **Modify** `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts` — filter company custom lines.
|
||||||
|
- **Modify** `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts` — add `selected_custom_fields: number[]` to detail interfaces.
|
||||||
|
- **Create** `src/admin/components/document/CustomFieldsPrintPicker.tsx` — shared picker.
|
||||||
|
- **Modify** `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` — render picker, wire into save payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema column + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma` (models `quotations`, `issued_orders`, `invoices`)
|
||||||
|
- Create: `prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ask the user to stop their dev server**
|
||||||
|
|
||||||
|
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later `migrate deploy` step.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the column to each model in `prisma/schema.prisma`**
|
||||||
|
|
||||||
|
Add this line to the `quotations` model (near other scalar columns, e.g. after `language`):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the identical line to the `issued_orders` model and to the `invoices` model.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration SQL**
|
||||||
|
|
||||||
|
Run (Git Bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cortex/boha-app-ts
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
|
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a `migration.sql` containing three `ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL` statements (one per table). Open it and confirm it touches ONLY `quotations`, `issued_orders`, `invoices` and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "All migrations have been applied" and a regenerated client.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Apply to the test DB**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the same migration applied to `app_test`. (If the env parsing is awkward on this shell, temporarily set `DATABASE_URL` to the app_test URL from `.env.test` and run `npx prisma migrate deploy`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS (the new Prisma field is now known to the client).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations
|
||||||
|
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend encode/decode util (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils/custom-fields.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/__tests__/selected-custom-fields.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to confirm failure**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: FAIL — `encodeSelectedCustomFields is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the helpers**
|
||||||
|
|
||||||
|
Append to `src/utils/custom-fields.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-document selection of which COMPANY custom fields print on a PDF.
|
||||||
|
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
|
||||||
|
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
|
||||||
|
*/
|
||||||
|
export function encodeSelectedCustomFields(indices: unknown): string | null {
|
||||||
|
const clean = normalizeIndices(indices);
|
||||||
|
return clean.length > 0 ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
|
||||||
|
export function parseSelectedCustomFields(raw: unknown): number[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (Array.isArray(raw)) return normalizeIndices(raw);
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return [];
|
||||||
|
try {
|
||||||
|
return normalizeIndices(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON in a selection column degrades to "none" (expected
|
||||||
|
// condition — a hand-edited/legacy row should never 500 a PDF render).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndices(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const v of input) {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to confirm it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (all util cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Zod schemas
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/offers.schema.ts`
|
||||||
|
- Modify: `src/schemas/issued-orders.schema.ts`
|
||||||
|
- Modify: `src/schemas/invoices.schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers schema**
|
||||||
|
|
||||||
|
In `src/schemas/offers.schema.ts`, inside `CreateQuotationSchema`, add after the `sections` line (line 50):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateQuotationSchema` already derives from this via `.partial().omit({ quotation_number: true })` — no change needed there.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Issued-orders schema**
|
||||||
|
|
||||||
|
In `src/schemas/issued-orders.schema.ts`, inside `CreateIssuedOrderSchema`, add after the `sections` field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateIssuedOrderSchema` derives via `.partial()` — no change.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Invoices schema**
|
||||||
|
|
||||||
|
In `src/schemas/invoices.schema.ts`, add the same line to BOTH `CreateInvoiceSchema` (after its `sections` field) and `UpdateInvoiceSchema` (after its `sections` field — invoices define the two schemas separately, so it must be added in both):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
|
||||||
|
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Services — persist on write, decode in detail (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/offers.service.ts`
|
||||||
|
- Modify: `src/services/issued-orders.service.ts`
|
||||||
|
- Modify: `src/services/invoices.service.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to all three services**
|
||||||
|
|
||||||
|
At the top of each of the three service files, add (or extend the existing import from `../utils/custom-fields`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers — persist on create**
|
||||||
|
|
||||||
|
In `createOffer` (`src/services/offers.service.ts`), inside `tx.quotations.create({ data: { … } })`, add after `scope_description`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Offers — persist on update**
|
||||||
|
|
||||||
|
In `updateOffer`, inside the `const data = { … }` object (after `scope_description`, before `modified_at`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
(`undefined` = "key absent in payload, leave column untouched" — matches the sibling header fields.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Offers — decode in detail**
|
||||||
|
|
||||||
|
In `getOffer`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Issued orders — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/issued-orders.service.ts`:
|
||||||
|
|
||||||
|
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Invoices — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/invoices.service.ts`:
|
||||||
|
|
||||||
|
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
|
||||||
|
```ts
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Extend the test with an offers round-trip (real DB)**
|
||||||
|
|
||||||
|
Append to `src/__tests__/selected-custom-fields.test.ts`. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> Before running: confirm the prisma import path (`../config/prisma` vs `../config/db` — grep an existing test). Adjust the import to match.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the `customer_id`-less draft path above avoids them.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Typecheck + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PDF rendering — filter company custom lines (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/routes/admin/offers-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/issued-orders-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/invoices-pdf.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers PDF — add the filter param to `buildAddressLines`**
|
||||||
|
|
||||||
|
In `src/routes/admin/offers-pdf.ts`, change the signature (line 28-32):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildAddressLines(
|
||||||
|
entity: Record<string, unknown> | null,
|
||||||
|
isSupplier: boolean,
|
||||||
|
t: (key: string) => string,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
|
): AddressResult {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
|
const cfName = (cf.name || "").trim();
|
||||||
|
const cfValue = (cf.value || "").trim();
|
||||||
|
const showLabel = cf.showLabel !== false;
|
||||||
|
if (cfValue) {
|
||||||
|
fieldMap[`custom_${i}`] =
|
||||||
|
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers PDF — pass the document's selection into the COMPANY call**
|
||||||
|
|
||||||
|
The offer's sender/company block is `supp` (`isSupplier: true` on `settings`). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (`cust`) unchanged so customer custom fields still show in full:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings as unknown as Record<string, unknown>,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing to adjust here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/issued-orders-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
|
||||||
|
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
|
||||||
|
```ts
|
||||||
|
const buyer = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `buildSupplierLines(...)` untouched (supplier fields keep showing in full).
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm the render function's `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before assuming.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard.
|
||||||
|
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(invoice as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `cust = buildAddressLines(customer, false, t)` untouched.
|
||||||
|
- Note the separate `settings.custom_fields` read lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — **do not** filter that; leave it as-is.
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is present.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a focused render assertion (extend the test file)**
|
||||||
|
|
||||||
|
If the three PDF modules export their HTML render function (e.g. offers exports `renderOfferHtml`), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock `html-to-pdf` per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
|
||||||
|
|
||||||
|
it("offer PDF prints only selected company custom fields", () => {
|
||||||
|
const settings = {
|
||||||
|
name: "Naše Firma s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Tel.", value: "123", showLabel: true },
|
||||||
|
{ name: "Web", value: "example.cz", showLabel: true },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const quotation = {
|
||||||
|
customers: null,
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
};
|
||||||
|
const html = renderOfferHtml(quotation as never, settings as never);
|
||||||
|
expect(html).toContain("Tel.: 123");
|
||||||
|
expect(html).not.toContain("example.cz");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — detail query types + shared picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- Create: `src/admin/components/document/CustomFieldsPrintPicker.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the field to the three detail interfaces**
|
||||||
|
|
||||||
|
- `src/admin/lib/queries/offers.ts` — add to `OfferDetailData`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields?: number[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the shared picker component**
|
||||||
|
|
||||||
|
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: indices are keyed off the FULL `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<i>`, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
|
||||||
|
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — wire the picker into the three detail pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/pages/OfferDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/IssuedOrderDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/InvoiceDetail.tsx`
|
||||||
|
|
||||||
|
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import the picker (all three pages)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Seed local state from the loaded document**
|
||||||
|
|
||||||
|
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via `useState` initializers keyed on the query, match that):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
|
||||||
|
// when the detail query resolves (same place other fields are seeded):
|
||||||
|
// setSelectedCustomFields(data.selected_custom_fields ?? []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Respect Rules of Hooks: declare this `useState` with the other hooks, before any early `return`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Render the picker in the editable header/meta area**
|
||||||
|
|
||||||
|
Place near the other document-level settings (currency/language). `companySettings` is already loaded on the page via `companySettingsOptions()`. Use the page's existing "is this document editable / not locked by another user" boolean for `disabled` (e.g. `!canEdit` or the locked flag the page already computes):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use whatever the page already calls its edit-gate (e.g. `canEdit`, `editable`, `isLockedByOther`). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Include the selection in the save payload**
|
||||||
|
|
||||||
|
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.tsx`**
|
||||||
|
|
||||||
|
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors (watch `react-hooks/rules-of-hooks` — the new `useState` must precede any early return).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
|
||||||
|
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole server suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (no regressions; new selected-custom-fields cases green).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + lint + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS; client builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (user-driven, dev server is theirs)**
|
||||||
|
|
||||||
|
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final state check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean tree, the feature commits present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (addressed)
|
||||||
|
|
||||||
|
- **Spec coverage:** storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
|
||||||
|
- **Default = none:** `buildAddressLines` filters only when passed an array; the company call always passes the parsed selection (default `[]`), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅
|
||||||
|
- **Positional identity:** indices key off the full `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
|
||||||
|
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
|
||||||
|
- **Open confirmations flagged inline** (prisma import path in tests; whether each PDF route narrows its document `select`) — each has a grep-first instruction rather than an assumption.
|
||||||
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal file
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
# Warehouse (Sklad) Module Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-29
|
||||||
|
**Status:** Approved
|
||||||
|
**Module:** Warehouse/Inventory management for boha-app-ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Full warehouse management module under the Administration section. Tracks material receiving, issuing, reservations, inventory, and project assignment. Uses true FIFO with batch-level tracking, document-driven architecture (header + lines), and integrates with existing projects, number sequences, and NAS file storage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Movements
|
||||||
|
|
||||||
|
- **Receipt (Prijem):** Material enters warehouse. Multi-item document with delivery note attachment.
|
||||||
|
- **Issue (Vydej):** Material leaves warehouse, mandatory project assignment. Multi-item document.
|
||||||
|
- **Reservation:** Virtual hold on stock before issue. Reduces available qty but not physical stock. Issue consumes reservation.
|
||||||
|
- **Inventory (Inventura):** Corrective movements based on physical count vs system count.
|
||||||
|
- **Storno:** Cancel a confirmed receipt or issue. Creates opposite corrective movements, preserves audit trail.
|
||||||
|
|
||||||
|
### Catalog
|
||||||
|
|
||||||
|
- Each material has: catalog number, name, description, category, unit, min quantity.
|
||||||
|
- Categories are user-defined (CRUD).
|
||||||
|
- Units are fixed list: ks, m, kg, bal, sada, m2, m3, l.
|
||||||
|
- Items can be soft-deactivated (is_active=false).
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
|
||||||
|
- Purchase price per unit, recorded bez DPH.
|
||||||
|
- True FIFO: each receipt line creates a batch. Issues consume oldest batches first.
|
||||||
|
- Batch-level tracking with remaining quantity.
|
||||||
|
|
||||||
|
### Delivery Notes
|
||||||
|
|
||||||
|
- File attachment (PDF/image) uploaded to NAS.
|
||||||
|
- Delivery note number and date stored as metadata on receipt header.
|
||||||
|
|
||||||
|
### Suppliers
|
||||||
|
|
||||||
|
- Separate `sklad_suppliers` table (ICO, DIC, contact info).
|
||||||
|
- Not shared with customers table.
|
||||||
|
|
||||||
|
### Locations
|
||||||
|
|
||||||
|
- Warehouse has named locations/shelves (code + name, e.g. "A1 - Regal A, police 1").
|
||||||
|
- Junction table tracks qty per item per location.
|
||||||
|
|
||||||
|
### Projects
|
||||||
|
|
||||||
|
- Every issue is mandatory FK to `projects` table.
|
||||||
|
- No free/issues without project assignment.
|
||||||
|
|
||||||
|
### Min Stock Alerts
|
||||||
|
|
||||||
|
- Each item has optional `min_quantity`.
|
||||||
|
- Dashboard highlights items below minimum.
|
||||||
|
- No email notification (UI alert only).
|
||||||
|
|
||||||
|
### Immutability
|
||||||
|
|
||||||
|
- All movements are immutable once confirmed.
|
||||||
|
- Corrections via storno (cancel receipt/issue) which creates opposite movements.
|
||||||
|
- Draft movements can be edited freely.
|
||||||
|
|
||||||
|
### Numbering
|
||||||
|
|
||||||
|
- Auto-numbering via `number_sequences` on confirm.
|
||||||
|
- Types: `warehouse_receipt` (e.g. PRI-2026-001), `warehouse_issue` (e.g. VYD-2026-001).
|
||||||
|
|
||||||
|
### Issue Documents
|
||||||
|
|
||||||
|
- No PDF generation for issues. Record only in system.
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
- Stock status: all items with qty, min_qty, value, below-min flag.
|
||||||
|
- Project consumption: material consumed per project (filter by project, date range).
|
||||||
|
- Movement log: all receipts + issues with filters.
|
||||||
|
- Below minimum: items under min_quantity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: Document-Driven
|
||||||
|
|
||||||
|
Each movement type is a document (header + lines), matching the existing invoice/offer pattern.
|
||||||
|
|
||||||
|
### Document Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT → CONFIRMED (affects stock)
|
||||||
|
DRAFT → CANCELLED (no stock effect)
|
||||||
|
CONFIRMED → CANCELLED (storno: reverses stock changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
Only CONFIRMED documents affect `sklad_batches`, `sklad_item_locations`, and reservations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum sklad_receipt_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_issue_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_reservation_status {
|
||||||
|
ACTIVE
|
||||||
|
FULFILLED
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum sklad_inventory_status {
|
||||||
|
DRAFT
|
||||||
|
CONFIRMED
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Master Data
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_categories {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
sort_order Int @default(0)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
items sklad_items[]
|
||||||
|
|
||||||
|
@@map("sklad_categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_suppliers {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
ico String? @db.VarChar(20)
|
||||||
|
dic String? @db.VarChar(20)
|
||||||
|
contact_person String? @db.VarChar(255)
|
||||||
|
email String? @db.VarChar(255)
|
||||||
|
phone String? @db.VarChar(50)
|
||||||
|
address String? @db.Text
|
||||||
|
notes String? @db.Text
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
receipts sklad_receipts[]
|
||||||
|
|
||||||
|
@@map("sklad_suppliers")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_locations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
code String @unique @db.VarChar(20)
|
||||||
|
name String @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
items sklad_item_locations[]
|
||||||
|
|
||||||
|
@@map("sklad_locations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_items {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_number String? @unique @db.VarChar(50)
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
description String? @db.Text
|
||||||
|
category_id Int?
|
||||||
|
unit String @db.VarChar(20)
|
||||||
|
min_quantity Decimal? @db.Decimal(12, 3)
|
||||||
|
is_active Boolean @default(true)
|
||||||
|
notes String? @db.Text
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
category sklad_categories? @relation(fields: [category_id], references: [id])
|
||||||
|
batches sklad_batches[]
|
||||||
|
item_locations sklad_item_locations[]
|
||||||
|
|
||||||
|
@@index([category_id], map: "sklad_items_category_id")
|
||||||
|
@@map("sklad_items")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### FIFO Batches & Location Tracking
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_batches {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
receipt_line_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
original_qty Decimal @db.Decimal(12, 3)
|
||||||
|
unit_price Decimal @db.Decimal(12, 2)
|
||||||
|
received_at DateTime? @db.DateTime(0)
|
||||||
|
is_consumed Boolean @default(false)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
|
||||||
|
|
||||||
|
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
|
||||||
|
@@map("sklad_batches")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_item_locations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
location_id Int
|
||||||
|
quantity Decimal @default(0) @db.Decimal(12, 3)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations @relation(fields: [location_id], references: [id])
|
||||||
|
|
||||||
|
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
|
||||||
|
@@map("sklad_item_locations")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receipts
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_receipts {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_number String? @db.VarChar(50)
|
||||||
|
supplier_id Int?
|
||||||
|
delivery_note_number String? @db.VarChar(100)
|
||||||
|
delivery_note_date DateTime? @db.DateTime(0)
|
||||||
|
received_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_receipt_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
|
||||||
|
received_by_user users? @relation(fields: [received_by], references: [id])
|
||||||
|
lines sklad_receipt_lines[]
|
||||||
|
attachments sklad_receipt_attachments[]
|
||||||
|
|
||||||
|
@@map("sklad_receipts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_receipt_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_id Int
|
||||||
|
item_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
unit_price Decimal @db.Decimal(12, 2)
|
||||||
|
location_id Int?
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
batch sklad_batches?
|
||||||
|
|
||||||
|
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
|
||||||
|
@@map("sklad_receipt_lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_receipt_attachments {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
receipt_id Int
|
||||||
|
file_name String @db.VarChar(255)
|
||||||
|
file_mime String @db.VarChar(100)
|
||||||
|
file_size Int
|
||||||
|
file_path String @db.VarChar(500)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
|
||||||
|
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
|
||||||
|
|
||||||
|
@@map("sklad_receipt_attachments")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_issues {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issue_number String? @db.VarChar(50)
|
||||||
|
project_id Int
|
||||||
|
issued_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_issue_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
project projects @relation(fields: [project_id], references: [id])
|
||||||
|
issued_by_user users? @relation(fields: [issued_by], references: [id])
|
||||||
|
lines sklad_issue_lines[]
|
||||||
|
|
||||||
|
@@map("sklad_issues")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_issue_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issue_id Int
|
||||||
|
item_id Int
|
||||||
|
batch_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
location_id Int?
|
||||||
|
reservation_id Int?
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
issue sklad_issues @relation(fields: [issue_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
batch sklad_batches @relation(fields: [batch_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
|
||||||
|
|
||||||
|
@@index([issue_id], map: "sklad_issue_lines_issue_id")
|
||||||
|
@@map("sklad_issue_lines")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reservations
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_reservations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
item_id Int
|
||||||
|
project_id Int
|
||||||
|
quantity Decimal @db.Decimal(12, 3)
|
||||||
|
remaining_qty Decimal @db.Decimal(12, 3)
|
||||||
|
reserved_by Int?
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_reservation_status @default(ACTIVE)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
project projects @relation(fields: [project_id], references: [id])
|
||||||
|
reserved_by_user users? @relation(fields: [reserved_by], references: [id])
|
||||||
|
issue_lines sklad_issue_lines[]
|
||||||
|
|
||||||
|
@@index([item_id, status], map: "sklad_reservations_item_status")
|
||||||
|
@@map("sklad_reservations")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inventory
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model sklad_inventory_sessions {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
session_number String? @db.VarChar(50)
|
||||||
|
notes String? @db.Text
|
||||||
|
status sklad_inventory_status @default(DRAFT)
|
||||||
|
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||||
|
modified_at DateTime? @db.DateTime(0)
|
||||||
|
|
||||||
|
lines sklad_inventory_lines[]
|
||||||
|
|
||||||
|
@@map("sklad_inventory_sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model sklad_inventory_lines {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
session_id Int
|
||||||
|
item_id Int
|
||||||
|
location_id Int?
|
||||||
|
system_qty Decimal @db.Decimal(12, 3)
|
||||||
|
actual_qty Decimal @db.Decimal(12, 3)
|
||||||
|
difference Decimal @db.Decimal(12, 3)
|
||||||
|
notes String? @db.VarChar(255)
|
||||||
|
|
||||||
|
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
|
||||||
|
item sklad_items @relation(fields: [item_id], references: [id])
|
||||||
|
location sklad_locations? @relation(fields: [location_id], references: [id])
|
||||||
|
|
||||||
|
@@index([session_id], map: "sklad_inventory_lines_session_id")
|
||||||
|
@@map("sklad_inventory_lines")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Total: 13 models + 5 enums**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
All under prefix `/api/admin/warehouse`.
|
||||||
|
|
||||||
|
### Items (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------ | ------------------------------------------------------------- |
|
||||||
|
| GET | `/items` | List items (paginated, filter by category, search, below-min) |
|
||||||
|
| GET | `/items/:id` | Item detail with batches, locations, reservations |
|
||||||
|
| POST | `/items` | Create item |
|
||||||
|
| PUT | `/items/:id` | Update item |
|
||||||
|
| DELETE | `/items/:id` | Soft-delete (is_active=false) |
|
||||||
|
|
||||||
|
### Categories (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------- | ------------------------- |
|
||||||
|
| GET | `/categories` | List all |
|
||||||
|
| POST | `/categories` | Create |
|
||||||
|
| PUT | `/categories/:id` | Update |
|
||||||
|
| DELETE | `/categories/:id` | Delete (only if no items) |
|
||||||
|
|
||||||
|
### Suppliers (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------------- | ----------------------------- |
|
||||||
|
| GET | `/suppliers` | List (paginated, search) |
|
||||||
|
| GET | `/suppliers/:id` | Detail |
|
||||||
|
| POST | `/suppliers` | Create |
|
||||||
|
| PUT | `/suppliers/:id` | Update |
|
||||||
|
| DELETE | `/suppliers/:id` | Soft-delete (is_active=false) |
|
||||||
|
|
||||||
|
### Locations (warehouse.manage)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ---------------- | ------------------------------------- |
|
||||||
|
| GET | `/locations` | List all |
|
||||||
|
| POST | `/locations` | Create |
|
||||||
|
| PUT | `/locations/:id` | Update |
|
||||||
|
| DELETE | `/locations/:id` | Delete (only if no items at location) |
|
||||||
|
|
||||||
|
### Receipts (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ----------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| GET | `/receipts` | List (paginated, filter by status/supplier/date) |
|
||||||
|
| GET | `/receipts/:id` | Detail with lines + attachments |
|
||||||
|
| POST | `/receipts` | Create receipt (DRAFT, with lines) |
|
||||||
|
| PUT | `/receipts/:id` | Update receipt + lines (DRAFT only) |
|
||||||
|
| POST | `/receipts/:id/confirm` | Confirm: creates batches, updates locations, generates number |
|
||||||
|
| POST | `/receipts/:id/cancel` | Cancel: storno batches if confirmed |
|
||||||
|
| POST | `/receipts/:id/attachments` | Upload delivery note (multipart) |
|
||||||
|
| DELETE | `/receipts/:id/attachments/:attachmentId` | Delete attachment |
|
||||||
|
|
||||||
|
### Issues (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| GET | `/issues` | List (paginated, filter by status/project/date) |
|
||||||
|
| GET | `/issues/:id` | Detail with lines |
|
||||||
|
| POST | `/issues` | Create issue (DRAFT, with lines) |
|
||||||
|
| PUT | `/issues/:id` | Update issue + lines (DRAFT only) |
|
||||||
|
| POST | `/issues/:id/confirm` | Confirm: decrements batches, updates locations, fulfills reservations, generates number |
|
||||||
|
| POST | `/issues/:id/cancel` | Cancel: restores batch quantities if confirmed |
|
||||||
|
|
||||||
|
### Reservations (warehouse.operate)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------------------------- | ------------------------------------ |
|
||||||
|
| GET | `/reservations` | List (filter by item/project/status) |
|
||||||
|
| POST | `/reservations` | Create reservation |
|
||||||
|
| POST | `/reservations/:id/cancel` | Cancel reservation |
|
||||||
|
|
||||||
|
### Inventory (warehouse.inventory)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | --------------------------------- | --------------------------------------- |
|
||||||
|
| GET | `/inventory-sessions` | List sessions |
|
||||||
|
| GET | `/inventory-sessions/:id` | Detail with lines |
|
||||||
|
| POST | `/inventory-sessions` | Create session (system_qty auto-filled) |
|
||||||
|
| PUT | `/inventory-sessions/:id` | Update lines (DRAFT only) |
|
||||||
|
| POST | `/inventory-sessions/:id/confirm` | Confirm: creates corrective movements |
|
||||||
|
|
||||||
|
### Reports (warehouse.view)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | ------------------------------ | ---------------------------------------------------- |
|
||||||
|
| GET | `/reports/stock-status` | All items with qty, min_qty, value, below-min flag |
|
||||||
|
| GET | `/reports/project-consumption` | Material per project (filter by project, date range) |
|
||||||
|
| GET | `/reports/movement-log` | All movements with filters |
|
||||||
|
| GET | `/reports/below-minimum` | Items below min_quantity |
|
||||||
|
|
||||||
|
### Utility (warehouse.view)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ------ | -------------------------- | -------------------------------------- |
|
||||||
|
| GET | `/items/:id/available-qty` | Available qty (total stock - reserved) |
|
||||||
|
| GET | `/items/:id/batches` | FIFO batch queue for item |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Logic
|
||||||
|
|
||||||
|
### Receipt Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. Generate `receipt_number` via `number_sequences` (type: `warehouse_receipt`)
|
||||||
|
3. For each receipt line:
|
||||||
|
- Create `sklad_batches` row (quantity = original_qty = line qty, unit_price from line, received_at = now)
|
||||||
|
- Upsert `sklad_item_locations` (add qty to specified location)
|
||||||
|
4. Update receipt status to CONFIRMED
|
||||||
|
5. Log audit
|
||||||
|
|
||||||
|
### Receipt Cancel
|
||||||
|
|
||||||
|
**If DRAFT:** Set status to CANCELLED. No stock changes.
|
||||||
|
|
||||||
|
**If CONFIRMED (storno):**
|
||||||
|
|
||||||
|
1. Validate no issue lines have consumed batches from this receipt
|
||||||
|
2. For each receipt line / batch:
|
||||||
|
- If batch is fully consumed (is_consumed=true), reject cancellation
|
||||||
|
- Decrement `sklad_item_locations` by batch remaining quantity
|
||||||
|
- Delete the batch row
|
||||||
|
3. Set receipt status to CANCELLED
|
||||||
|
4. Log audit
|
||||||
|
|
||||||
|
### Issue Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. For each issue line, validate: batch has enough remaining quantity
|
||||||
|
3. Generate `issue_number` via `number_sequences` (type: `warehouse_issue`)
|
||||||
|
4. For each issue line:
|
||||||
|
- Decrement `sklad_batches.quantity`, set `is_consumed=true` if 0
|
||||||
|
- Decrement `sklad_item_locations.quantity`
|
||||||
|
- If `reservation_id` set, decrement `sklad_reservations.remaining_qty`
|
||||||
|
5. Check reservations: if remaining_qty = 0, set status to FULFILLED
|
||||||
|
6. Update issue status to CONFIRMED
|
||||||
|
7. Log audit
|
||||||
|
|
||||||
|
### Issue Cancel
|
||||||
|
|
||||||
|
**If DRAFT:** Set status to CANCELLED. No stock changes.
|
||||||
|
|
||||||
|
**If CONFIRMED (storno):**
|
||||||
|
|
||||||
|
1. For each issue line:
|
||||||
|
- Increment `sklad_batches.quantity`, set `is_consumed=false` if was true
|
||||||
|
- Increment `sklad_item_locations.quantity`
|
||||||
|
- If `reservation_id` set, increment `sklad_reservations.remaining_qty`, set status back to ACTIVE if was FULFILLED
|
||||||
|
2. Set issue status to CANCELLED
|
||||||
|
3. Log audit
|
||||||
|
|
||||||
|
### Auto-FIFO on Issue Creation
|
||||||
|
|
||||||
|
When creating an issue line, if user doesn't pick a specific batch:
|
||||||
|
|
||||||
|
- Service selects oldest unconsumed batches for the item (via `sklad_batches_fifo` index)
|
||||||
|
- May span multiple batches if quantity exceeds one batch's remaining qty
|
||||||
|
- Frontend shows available batches for manual override
|
||||||
|
|
||||||
|
### Reservation Creation
|
||||||
|
|
||||||
|
1. Validate item exists and is_active
|
||||||
|
2. Calculate available_qty = total stock qty - sum of active reservation quantities for this item
|
||||||
|
3. Validate available_qty >= requested quantity
|
||||||
|
4. Create reservation with quantity = remaining_qty
|
||||||
|
5. Does NOT modify `sklad_batches` or `sklad_item_locations`
|
||||||
|
|
||||||
|
### Inventory Confirm (single Prisma transaction)
|
||||||
|
|
||||||
|
1. Validate status is DRAFT
|
||||||
|
2. For each inventory line where difference != 0:
|
||||||
|
- If difference > 0 (more stock than system): create a corrective receipt (type: inventory, auto-confirmed)
|
||||||
|
- If difference < 0 (less stock than system): create a corrective issue (type: inventory, auto-confirmed)
|
||||||
|
3. Generate `session_number` via `number_sequences` (type: `warehouse_inventory`)
|
||||||
|
4. Set status to CONFIRMED
|
||||||
|
5. Log audit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
Entry in `Sidebar.tsx` with permission `warehouse.view`:
|
||||||
|
|
||||||
|
- Icon: package/box icon
|
||||||
|
- Label: "Sklad"
|
||||||
|
- Path: `/warehouse`
|
||||||
|
|
||||||
|
### Pages (lazy-loaded)
|
||||||
|
|
||||||
|
| Route | Component | Permission |
|
||||||
|
| ------------------------------ | ------------------------ | ------------------- |
|
||||||
|
| `/warehouse` | Warehouse | warehouse.view |
|
||||||
|
| `/warehouse/items` | WarehouseItems | warehouse.view |
|
||||||
|
| `/warehouse/items/:id` | WarehouseItemDetail | warehouse.view |
|
||||||
|
| `/warehouse/receipts` | WarehouseReceipts | warehouse.view |
|
||||||
|
| `/warehouse/receipts/new` | WarehouseReceiptForm | warehouse.operate |
|
||||||
|
| `/warehouse/receipts/:id` | WarehouseReceiptDetail | warehouse.view |
|
||||||
|
| `/warehouse/receipts/:id/edit` | WarehouseReceiptForm | warehouse.operate |
|
||||||
|
| `/warehouse/issues` | WarehouseIssues | warehouse.view |
|
||||||
|
| `/warehouse/issues/new` | WarehouseIssueForm | warehouse.operate |
|
||||||
|
| `/warehouse/issues/:id` | WarehouseIssueDetail | warehouse.view |
|
||||||
|
| `/warehouse/issues/:id/edit` | WarehouseIssueForm | warehouse.operate |
|
||||||
|
| `/warehouse/reservations` | WarehouseReservations | warehouse.view |
|
||||||
|
| `/warehouse/inventory` | WarehouseInventory | warehouse.inventory |
|
||||||
|
| `/warehouse/inventory/new` | WarehouseInventoryForm | warehouse.inventory |
|
||||||
|
| `/warehouse/inventory/:id` | WarehouseInventoryDetail | warehouse.inventory |
|
||||||
|
| `/warehouse/reports` | WarehouseReports | warehouse.view |
|
||||||
|
| `/warehouse/suppliers` | WarehouseSuppliers | warehouse.manage |
|
||||||
|
| `/warehouse/locations` | WarehouseLocations | warehouse.manage |
|
||||||
|
| `/warehouse/categories` | WarehouseCategories | warehouse.manage |
|
||||||
|
|
||||||
|
### Warehouse Dashboard
|
||||||
|
|
||||||
|
- Summary cards: total items, total stock value, below-minimum count, active reservations
|
||||||
|
- Below-minimum alert section (highlighted)
|
||||||
|
- Recent movements (last 10)
|
||||||
|
|
||||||
|
### WarehouseReports
|
||||||
|
|
||||||
|
- Tab-based: Stock Status | Project Consumption | Movement Log | Below Minimum
|
||||||
|
- Each tab has filters (date range, project, category, supplier)
|
||||||
|
- Paginated data tables with sorting
|
||||||
|
|
||||||
|
### Key Shared Components
|
||||||
|
|
||||||
|
| Component | Purpose |
|
||||||
|
| ---------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| ItemPicker | Searchable item selector (name + catalog number), used in receipt/issue forms |
|
||||||
|
| BatchPicker | FIFO batch selector for issue lines, shows available qty per batch |
|
||||||
|
| LocationSelect | Location dropdown |
|
||||||
|
| SupplierSelect | Supplier dropdown |
|
||||||
|
| WarehouseMovementTable | Reusable multi-line editor for receipt/issue lines |
|
||||||
|
|
||||||
|
### Query Keys
|
||||||
|
|
||||||
|
```
|
||||||
|
["warehouse"] -> invalidates all
|
||||||
|
["warehouse", "items", filters] -> item list
|
||||||
|
["warehouse", "items", id] -> item detail
|
||||||
|
["warehouse", "receipts", filters] -> receipt list
|
||||||
|
["warehouse", "receipts", id] -> receipt detail
|
||||||
|
["warehouse", "issues", filters] -> issue list
|
||||||
|
["warehouse", "issues", id] -> issue detail
|
||||||
|
["warehouse", "reservations", filters] -> reservation list
|
||||||
|
["warehouse", "inventory", filters] -> inventory sessions
|
||||||
|
["warehouse", "inventory", id] -> inventory detail
|
||||||
|
["warehouse", "suppliers", filters] -> supplier list
|
||||||
|
["warehouse", "locations"] -> location list
|
||||||
|
["warehouse", "categories"] -> category list
|
||||||
|
["warehouse", "reports", type, filters] -> report data
|
||||||
|
```
|
||||||
|
|
||||||
|
Mutation invalidation: broad `["warehouse"]` for any create/update/delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Display Name | Module | Description |
|
||||||
|
| --------------------- | -------------- | ------ | ------------------------------------------------------ |
|
||||||
|
| `warehouse.view` | Zobrazit sklad | sklad | View stock status, items, reports, movement history |
|
||||||
|
| `warehouse.operate` | Prijem a vydej | sklad | Create/confirm receipts, issues, reservations |
|
||||||
|
| `warehouse.manage` | Sprava skladu | sklad | Manage items catalog, suppliers, locations, categories |
|
||||||
|
| `warehouse.inventory` | Inventura | sklad | Create/confirm inventory sessions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Number Sequences
|
||||||
|
|
||||||
|
Three new types registered in `number_sequences`:
|
||||||
|
|
||||||
|
- `warehouse_receipt` - generated on receipt confirm
|
||||||
|
- `warehouse_issue` - generated on issue confirm
|
||||||
|
- `warehouse_inventory` - generated on inventory session confirm
|
||||||
|
|
||||||
|
Pattern configurable via `company_settings`, same as invoices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
Receipt attachments use existing `NasFinancialsManager`:
|
||||||
|
|
||||||
|
- Stored under `warehouse/YYYY/MM/` on NAS
|
||||||
|
- DB metadata in `sklad_receipt_attachments` (file_name, file_mime, file_size, file_path)
|
||||||
|
- Upload via `@fastify/multipart`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
routes/admin/warehouse.ts
|
||||||
|
services/warehouse.service.ts
|
||||||
|
schemas/warehouse.schema.ts
|
||||||
|
admin/
|
||||||
|
pages/
|
||||||
|
Warehouse.tsx
|
||||||
|
WarehouseItems.tsx
|
||||||
|
WarehouseItemDetail.tsx
|
||||||
|
WarehouseReceipts.tsx
|
||||||
|
WarehouseReceiptDetail.tsx
|
||||||
|
WarehouseReceiptForm.tsx
|
||||||
|
WarehouseIssues.tsx
|
||||||
|
WarehouseIssueDetail.tsx
|
||||||
|
WarehouseIssueForm.tsx
|
||||||
|
WarehouseReservations.tsx
|
||||||
|
WarehouseInventory.tsx
|
||||||
|
WarehouseInventoryDetail.tsx
|
||||||
|
WarehouseInventoryForm.tsx
|
||||||
|
WarehouseReports.tsx
|
||||||
|
WarehouseSuppliers.tsx
|
||||||
|
WarehouseLocations.tsx
|
||||||
|
WarehouseCategories.tsx
|
||||||
|
lib/queries/warehouse.ts
|
||||||
|
components/warehouse/
|
||||||
|
ItemPicker.tsx
|
||||||
|
BatchPicker.tsx
|
||||||
|
LocationSelect.tsx
|
||||||
|
SupplierSelect.tsx
|
||||||
|
WarehouseMovementTable.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| ---------------------------------- | ---------------------------------- |
|
||||||
|
| `src/server.ts` | Import + register warehouse routes |
|
||||||
|
| `src/types/index.ts` | Add 8 EntityType values |
|
||||||
|
| `src/admin/AdminApp.tsx` | Lazy imports + routes |
|
||||||
|
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry |
|
||||||
|
| `prisma/schema.prisma` | Add 13 models + 5 enums |
|
||||||
|
| `prisma/seed.ts` | Add 4 permissions |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Entity Types
|
||||||
|
|
||||||
|
New values added to `EntityType` union in `src/types/index.ts`:
|
||||||
|
|
||||||
|
- `warehouse_item`
|
||||||
|
- `warehouse_receipt`
|
||||||
|
- `warehouse_issue`
|
||||||
|
- `warehouse_reservation`
|
||||||
|
- `warehouse_inventory`
|
||||||
|
- `warehouse_supplier`
|
||||||
|
- `warehouse_category`
|
||||||
|
- `warehouse_location`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constraints & Edge Cases
|
||||||
|
|
||||||
|
1. **Batch consumption on cancel:** If any batch from a receipt has been partially or fully consumed by an issue, the receipt cannot be cancelled. User must first cancel the consuming issues.
|
||||||
|
|
||||||
|
2. **Reservation availability:** Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested.
|
||||||
|
|
||||||
|
3. **Location qty consistency:** `sklad_item_locations` is a denormalized cache for quick lookups. It must always stay in sync with `sklad_batches`. The confirm/cancel transactions maintain this.
|
||||||
|
|
||||||
|
4. **FIFO spanning batches:** When auto-FIFO selects batches for an issue line and the quantity spans multiple batches, the service creates **multiple issue lines** (one per batch consumed), all under the same issue document. The user's original requested quantity is preserved across the split lines. This works because each `sklad_issue_lines` row has exactly one `batch_id`.
|
||||||
|
|
||||||
|
5. **Inventory corrective movements:** On confirm, each line with a difference creates an auto-confirmed receipt or issue. The `notes` field of these corrective documents references the inventory session ID and line, so they can be traced back. These corrective documents are fully audited like manual receipts/issues.
|
||||||
|
|
||||||
|
6. **Unit consistency:** Once an item is created with a unit, it cannot be changed if any movements exist. This prevents unit mismatches in stock calculations.
|
||||||
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
450
docs/superpowers/specs/2026-06-05-plan-praci-design.md
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
# Plán prací (Work Schedule) Module Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved
|
||||||
|
**Module:** Work schedule / Rozpis prací for boha-app-ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A new module under the "Docházka" sidebar section that lets a foreman plan
|
||||||
|
each employee's work on a per-day basis, weeks or months in advance, and lets
|
||||||
|
every employee see what they (and the team) are supposed to be doing on any
|
||||||
|
given day.
|
||||||
|
|
||||||
|
The reference for the UX is `simon/plan_boha.pdf` — a hand-rolled Excel grid
|
||||||
|
with employees as columns and dates as rows, each cell holding a free-text
|
||||||
|
task description. The module replaces that spreadsheet with a live, audited,
|
||||||
|
permission-aware grid in the existing admin app.
|
||||||
|
|
||||||
|
A plan entry is **what should happen**. It is intentionally separate from
|
||||||
|
attendance (which records **what actually happened**). The two are not
|
||||||
|
auto-linked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
- Plan entries are stored as **date ranges** (e.g. "PLC upgrade VW USA"
|
||||||
|
from 8.6. to 26.6.), with an optional **per-day override** for the rare
|
||||||
|
case where one day in a range is different (e.g. "Volno po noční").
|
||||||
|
- Each entry has:
|
||||||
|
- `user_id` — the employee this plan is for.
|
||||||
|
- `date_from` / `date_to` — inclusive range.
|
||||||
|
- `project_id` — **optional** FK to the existing `projects` table.
|
||||||
|
- `category` — enum (see below), drives color-coding and reports.
|
||||||
|
- `note` — required free-text task description (max 500 chars).
|
||||||
|
- The project dropdown is populated from the existing `projects` table.
|
||||||
|
No new "task" or "sub-project" entity — the note is the human-readable
|
||||||
|
task description.
|
||||||
|
- A cell can be empty (unassigned / not planned). An empty cell is also
|
||||||
|
clickable for admins — opens the create-entry modal.
|
||||||
|
- A `work_plan_overrides` row covers a single `(user, date)` and takes
|
||||||
|
precedence over the range that would otherwise cover that day.
|
||||||
|
|
||||||
|
### Effective-cell resolution (runtime)
|
||||||
|
|
||||||
|
To compute the cell for user `U` on date `D`:
|
||||||
|
|
||||||
|
1. Look up `work_plan_overrides` for `(U, D)`. If present, use it.
|
||||||
|
2. Otherwise, look up `work_plan_entries` where `user_id = U` and
|
||||||
|
`date_from <= D <= date_to`. If multiple match, return the
|
||||||
|
**last-created** one and write a warning to the audit log.
|
||||||
|
3. Otherwise, the cell is empty.
|
||||||
|
|
||||||
|
### Editing rules
|
||||||
|
|
||||||
|
- **Lock past, free future.** Cells with `shift_date < today` are
|
||||||
|
read-only. Admins can override with `?force=1` on the API; the
|
||||||
|
override is recorded in the audit log.
|
||||||
|
- Today and future are always editable for users with `attendance.manage`.
|
||||||
|
- The modal offers three actions when editing a cell inside an existing
|
||||||
|
range:
|
||||||
|
- "Upravit celý rozsah" — edit the range.
|
||||||
|
- "Upravit pouze tento den" — creates an override.
|
||||||
|
- "Zrušit přiřazení tohoto dne" — creates an override with
|
||||||
|
`category = 'other'` and empty note, leaving the cell empty for
|
||||||
|
that day only.
|
||||||
|
|
||||||
|
### Categories
|
||||||
|
|
||||||
|
A new enum `plan_category` for color-coding and reports:
|
||||||
|
|
||||||
|
| Value | Czech label | Cell color hint |
|
||||||
|
| ------------- | -------------- | --------------- |
|
||||||
|
| `work` | Práce | blue |
|
||||||
|
| `preparation` | Příprava | teal |
|
||||||
|
| `travel` | Cesta / Montáž | amber |
|
||||||
|
| `leave` | Dovolená | green |
|
||||||
|
| `sick` | Nemoc | red |
|
||||||
|
| `training` | Školení | purple |
|
||||||
|
| `other` | Jiné | gray |
|
||||||
|
|
||||||
|
Plan overrides for leave/sick are **informational only**. They do not
|
||||||
|
create or modify `leave_requests` or `attendance` rows. Employees still
|
||||||
|
file leave through the existing `Žádosti` flow.
|
||||||
|
|
||||||
|
### Visibility & permissions
|
||||||
|
|
||||||
|
- **Single sidebar entry** under Docházka: `Plán prací` → `/attendance/plan`.
|
||||||
|
- The sidebar entry is gated by `attendance.manage || attendance.record`
|
||||||
|
(mirrors the existing `Žádosti` entry).
|
||||||
|
- The page is **one** (`PlanWork.tsx`):
|
||||||
|
- If `useAuth().hasPermission('attendance.manage')` → editor mode
|
||||||
|
(grid + edit modal + create/edit/delete affordances).
|
||||||
|
- Otherwise → view-only mode (same grid, read-only detail modal).
|
||||||
|
- All endpoints require `attendance.manage` for writes, and either
|
||||||
|
`attendance.manage` or `attendance.record` for reads. Employees
|
||||||
|
with `attendance.record` are scoped server-side to their own
|
||||||
|
`user_id` for any direct row read; the grid endpoint handles the
|
||||||
|
scoping transparently.
|
||||||
|
- Admin role bypasses all permission checks (existing behavior).
|
||||||
|
|
||||||
|
### Grid
|
||||||
|
|
||||||
|
- Default view is **week** (T23, T24, T25 … matches the PDF's week
|
||||||
|
numbering). A toggle at the top switches to **month** view.
|
||||||
|
- The grid is `employees × dates`. Columns are ordered by
|
||||||
|
role/team first, then alphabetically by name.
|
||||||
|
- Columns are users with the `attendance.record` permission (which
|
||||||
|
includes admin). A "Aktivní" toggle in the toolbar lets the planner
|
||||||
|
hide inactive users.
|
||||||
|
- Each cell shows: a colored chip for the category, the project name
|
||||||
|
(if any) in bold, and the note underneath. Empty cells are blank.
|
||||||
|
- Weekend cells have a subtle yellow background tint to match the
|
||||||
|
PDF's "So 6.6. ★" rows.
|
||||||
|
- Read-only employees see the same grid, but cells are non-clickable
|
||||||
|
for editing. A click opens a read-only detail modal showing project,
|
||||||
|
category, note, "Vytvořil: <user> · <date>" (the entry's
|
||||||
|
`created_by`), and (if the cell is part of a range) "Patří do
|
||||||
|
rozsahu: <date_from> – <date_to>".
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
Every create/update/delete of `work_plan_entries` and
|
||||||
|
`work_plan_overrides` writes a row to `audit_logs` with
|
||||||
|
`entity_type = 'work_plan_entry' | 'work_plan_override'` and full
|
||||||
|
`old_values` / `new_values` JSON, following the same pattern as
|
||||||
|
`attendance` and `projects`. The `?force=1` past-edit override is
|
||||||
|
recorded in the description field.
|
||||||
|
|
||||||
|
### Non-goals (out of scope for v1)
|
||||||
|
|
||||||
|
- No automatic link to attendance (plan stays independent).
|
||||||
|
- No time-slot granularity (full days only).
|
||||||
|
- No shift / payroll calculation.
|
||||||
|
- No drag-fill on the grid.
|
||||||
|
- No "task list" entity — projects and free text only.
|
||||||
|
- No notification/email when a plan is changed.
|
||||||
|
- No browser tests (the codebase has none; manual verification in dev).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Module placement
|
||||||
|
|
||||||
|
- New module under the **Docházka** sidebar section.
|
||||||
|
- Reuses the existing `attendance.shift_date` concept (date column on
|
||||||
|
the `attendance` table) — the planner lives next to the calendar it
|
||||||
|
informs.
|
||||||
|
- No "administrativa" module exists; the sidebar "Administrativa"
|
||||||
|
section is the commercial back-office (offers, orders, invoices,
|
||||||
|
projects, customers) and is not the right home for HR-style planning.
|
||||||
|
|
||||||
|
### File layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── routes/admin/plan.ts # Fastify routes
|
||||||
|
├── services/plan.service.ts # Business logic
|
||||||
|
├── schemas/plan.schema.ts # Zod validation
|
||||||
|
└── __tests__/plan.test.ts # Vitest + Supertest tests
|
||||||
|
|
||||||
|
src/admin/
|
||||||
|
├── pages/PlanWork.tsx # The single page (editor or view-only)
|
||||||
|
├── hooks/usePlanWork.ts # State, modal, mutations
|
||||||
|
├── lib/queries/plan.ts # React Query queryOptions
|
||||||
|
└── components/
|
||||||
|
├── PlanGrid.tsx # Week/month grid (shared by both modes)
|
||||||
|
├── PlanCellModal.tsx # Edit modal (create / edit-range / override)
|
||||||
|
├── PlanCellDetailModal.tsx # Read-only detail modal
|
||||||
|
├── PlanRangeChips.tsx # Cell content chip with category color
|
||||||
|
└── plan.css # Module-specific styles only
|
||||||
|
```
|
||||||
|
|
||||||
|
The new CSS file holds only category color helpers and grid-specific
|
||||||
|
overrides. All form/modal/table styling reuses existing classes
|
||||||
|
(`.admin-table`, `.admin-table-sticky`, `FormField`, `FormModal`,
|
||||||
|
`ConfirmModal`, `AdminDatePicker`, etc.) — no new design tokens.
|
||||||
|
|
||||||
|
### React Query keys
|
||||||
|
|
||||||
|
Per the project's "invalidate the full domain" convention:
|
||||||
|
|
||||||
|
- `['plan', 'grid', { dateFrom, dateTo, view }]`
|
||||||
|
- `['plan', 'entries', { userId, dateFrom, dateTo }]`
|
||||||
|
- `['plan', 'overrides', { userId, dateFrom, dateTo }]`
|
||||||
|
- `['plan', 'users']`
|
||||||
|
|
||||||
|
A mutation on any plan row invalidates `['plan']` (covers all four
|
||||||
|
sub-queries by prefix match).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### New enum
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum plan_category {
|
||||||
|
work
|
||||||
|
preparation
|
||||||
|
travel
|
||||||
|
leave
|
||||||
|
sick
|
||||||
|
training
|
||||||
|
other
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New table: `work_plan_entries`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| ------------ | ------------------------ | ------------------------------------------------------- |
|
||||||
|
| `id` | int PK autoincrement | |
|
||||||
|
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||||
|
| `date_from` | Date | NOT NULL — first day of the range |
|
||||||
|
| `date_to` | Date | NOT NULL — last day of the range, must be ≥ `date_from` |
|
||||||
|
| `project_id` | int? FK → `projects(id)` | nullable — unlinked text entries are allowed |
|
||||||
|
| `category` | enum `plan_category` | NOT NULL, default `'work'` |
|
||||||
|
| `note` | varchar(500) | NOT NULL — free-text task description |
|
||||||
|
| `created_by` | int FK → `users(id)` | who entered it |
|
||||||
|
| `created_at` | DateTime | Prisma default `now()` |
|
||||||
|
| `updated_at` | DateTime | Prisma default `now()` |
|
||||||
|
| `is_deleted` | bool? default false | soft-delete |
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
|
||||||
|
- `(user_id, date_from)`
|
||||||
|
- `(user_id, date_to)`
|
||||||
|
- `(project_id)`
|
||||||
|
- `(date_from, date_to)` — for cross-user queries like
|
||||||
|
"what's planned for project X this month?"
|
||||||
|
|
||||||
|
### New table: `work_plan_overrides`
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
| ------------ | ------------------------ | ------------------------------------------------- |
|
||||||
|
| `id` | int PK autoincrement | |
|
||||||
|
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||||||
|
| `shift_date` | Date | NOT NULL — the one day being overridden |
|
||||||
|
| `project_id` | int? FK → `projects(id)` | nullable |
|
||||||
|
| `category` | enum `plan_category` | NOT NULL — usually `'leave'`, `'sick'`, `'other'` |
|
||||||
|
| `note` | varchar(500) | NOT NULL |
|
||||||
|
| `created_by` | int FK → `users(id)` | |
|
||||||
|
| `created_at` | DateTime | |
|
||||||
|
| `updated_at` | DateTime | |
|
||||||
|
| `is_deleted` | bool? default false | soft-delete |
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
|
||||||
|
- **Unique** `(user_id, shift_date)` — exactly one non-deleted
|
||||||
|
override per user-day. MySQL unique indexes don't ignore
|
||||||
|
soft-deleted rows, so the service layer enforces "at most one
|
||||||
|
active override per `(user_id, shift_date)`" in application code
|
||||||
|
(the database unique constraint exists as a final safety net and
|
||||||
|
may be temporarily violated by soft-deleted rows, which is
|
||||||
|
acceptable). When restoring a soft-deleted override, the service
|
||||||
|
re-validates that no active override exists for the same day.
|
||||||
|
- `(shift_date)` — for cross-user "what's overridden today" queries.
|
||||||
|
|
||||||
|
### Read semantics (recap)
|
||||||
|
|
||||||
|
For user `U` and date `D`, the "effective" cell is:
|
||||||
|
|
||||||
|
1. Override `(U, D)` if it exists and is not soft-deleted.
|
||||||
|
2. Otherwise the latest `work_plan_entry` whose range covers `D`
|
||||||
|
for user `U` and is not soft-deleted. If multiple match, pick
|
||||||
|
the latest by `created_at` and write a warning to `audit_logs`.
|
||||||
|
3. Otherwise, empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Surface
|
||||||
|
|
||||||
|
REST endpoints under `/api/admin/plan`. All routes use the existing
|
||||||
|
Zod-validated, `requirePermission`, `success`/`error` helpers, and
|
||||||
|
`logAudit` pattern. Czech error messages, following project convention.
|
||||||
|
|
||||||
|
| Method | Path | Permission | Body / Query | Returns |
|
||||||
|
| ------ | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| GET | `/plan/grid` | `attendance.manage` OR `attendance.record` | `?date_from=&date_to=&view=week\|month` (range ≤ 92 days) | `{ days, users, cells }` — effective cells ready to render |
|
||||||
|
| GET | `/plan/entries` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_entries` rows in range |
|
||||||
|
| GET | `/plan/overrides` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_overrides` rows in range |
|
||||||
|
| GET | `/plan/users` | `attendance.manage` OR `attendance.record` | — | users with `attendance.record`, ordered by role/team then name |
|
||||||
|
| POST | `/plan/entries` | `attendance.manage` | `{ user_id, date_from, date_to, project_id?, category, note }` | created entry |
|
||||||
|
| PATCH | `/plan/entries/:id` | `attendance.manage` | partial of create body | updated entry |
|
||||||
|
| DELETE | `/plan/entries/:id` | `attendance.manage` | `?force=1` to bypass past-date lock | `{ ok: true }` (soft-delete) |
|
||||||
|
| POST | `/plan/overrides` | `attendance.manage` | `{ user_id, shift_date, project_id?, category, note }` | created override |
|
||||||
|
| PATCH | `/plan/overrides/:id` | `attendance.manage` | partial of create body | updated override |
|
||||||
|
| DELETE | `/plan/overrides/:id` | `attendance.manage` | `?force=1` | `{ ok: true }` (soft-delete) |
|
||||||
|
| GET | `/plan/audit` | `settings.audit` | `?entity_type=&entity_id=` | audit log entries for a row |
|
||||||
|
|
||||||
|
### Past-date lock
|
||||||
|
|
||||||
|
Server-enforced. POST/PATCH/DELETE with any `shift_date` / `date_from`
|
||||||
|
in the past returns 403 with a Czech error message:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.
|
||||||
|
```
|
||||||
|
|
||||||
|
With `?force=1` and `attendance.manage`, the operation succeeds and
|
||||||
|
the audit log records `description = 'force-edit past date'`.
|
||||||
|
|
||||||
|
### Employee scoping
|
||||||
|
|
||||||
|
`GET /plan/entries` and `GET /plan/overrides` accept `?user_id=`. For
|
||||||
|
a user with only `attendance.record`, the service silently overrides
|
||||||
|
the query to scope to `user_id = auth.user.id`. The grid endpoint
|
||||||
|
returns the same data either way (employees see the full grid in
|
||||||
|
view-only mode).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Detail
|
||||||
|
|
||||||
|
### `PlanWork.tsx`
|
||||||
|
|
||||||
|
- Mounts and calls `useAuth()`. If `hasPermission('attendance.manage')`
|
||||||
|
→ editor state. Otherwise → view-only state.
|
||||||
|
- Renders the toolbar (view toggle, date navigator, active-toggle,
|
||||||
|
print) and `PlanGrid`.
|
||||||
|
- Owns the modal state machine for `PlanCellModal` (editor) or
|
||||||
|
`PlanCellDetailModal` (view).
|
||||||
|
|
||||||
|
### `PlanGrid.tsx`
|
||||||
|
|
||||||
|
- Pure presentational. Takes `users`, `days`, `cells` as props.
|
||||||
|
- Renders a `<table>` with sticky first column (date) and sticky
|
||||||
|
header (employee names).
|
||||||
|
- Each cell is a `<button>` (admin) or `<div>` (view-only) with the
|
||||||
|
colored chip + note.
|
||||||
|
- Calls `onCellClick(userId, date)` for the parent to open the modal.
|
||||||
|
- Reuses `.admin-table` and `.admin-table-sticky` from existing
|
||||||
|
stylesheets.
|
||||||
|
|
||||||
|
### `PlanCellModal.tsx` (editor)
|
||||||
|
|
||||||
|
Three modes, decided by what the user clicked:
|
||||||
|
|
||||||
|
- **Create** — empty cell. Fields: `user_id` (locked to clicked user,
|
||||||
|
can be switched), `date_from` (default = clicked date), `date_to`
|
||||||
|
(default = clicked date; "Jeden den" / "Rozsah dnů" toggle),
|
||||||
|
`project_id` (dropdown, populated from `projects`, nullable),
|
||||||
|
`category` (select), `note` (textarea, required).
|
||||||
|
- **Edit range** — cell is part of an existing range. Loads the
|
||||||
|
full range into the form. "Upravit celý rozsah" button is the
|
||||||
|
primary action.
|
||||||
|
- **Edit day in range** — cell is part of a range. Three buttons:
|
||||||
|
"Upravit celý rozsah" / "Upravit pouze tento den" /
|
||||||
|
"Zrušit přiřazení tohoto dne".
|
||||||
|
- **Edit single-day override** — cell is itself a `work_plan_overrides`
|
||||||
|
row. Standard edit form.
|
||||||
|
- Bottom: delete button (with `ConfirmModal`).
|
||||||
|
|
||||||
|
### `PlanCellDetailModal.tsx` (view-only)
|
||||||
|
|
||||||
|
Read-only. Shows project, category, note, "Vytvořil: <full name of
|
||||||
|
`created_by`> · <`created_at` in local Czech time>", and (if the
|
||||||
|
cell is part of a range) "Patří do rozsahu: <date_from> – <date_to>".
|
||||||
|
For override cells, the "Vytvořil" line refers to the override's own
|
||||||
|
`created_by` (not the parent range's). No edit, no delete.
|
||||||
|
|
||||||
|
### `usePlanWork.ts`
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- `view: 'week' | 'month'`, `anchorDate`, `filterActive`
|
||||||
|
- `effectiveCells` from `['plan', 'grid']` query
|
||||||
|
- `entries` and `overrides` for the cell being edited
|
||||||
|
- `modalState: { mode, userId, date, entryId?, overrideId? }`
|
||||||
|
- `createEntry`, `updateEntry`, `deleteEntry`,
|
||||||
|
`createOverride`, `updateOverride`, `deleteOverride` —
|
||||||
|
React Query mutations that invalidate `['plan']`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Single new migration:
|
||||||
|
|
||||||
|
`prisma/migrations/<timestamp>_add_work_plan/`
|
||||||
|
|
||||||
|
- `migration.sql`:
|
||||||
|
1. `CREATE TABLE work_plan_entries (...)` with columns and indexes.
|
||||||
|
2. `CREATE TABLE work_plan_overrides (...)` with columns and indexes.
|
||||||
|
3. `CREATE TYPE plan_category` (or in-table enum, per Prisma's
|
||||||
|
MySQL output).
|
||||||
|
|
||||||
|
No data migration. No `seed.ts` change. No new permission keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`src/__tests__/plan.test.ts` — Vitest + Supertest against the real
|
||||||
|
test database (`.env.test`):
|
||||||
|
|
||||||
|
1. **Effective-cell resolution** — range + override returns the
|
||||||
|
override for that day, the range for the other days, `null`
|
||||||
|
for uncovered days.
|
||||||
|
2. **Past-date lock** — POST with `date_from < today` returns 403
|
||||||
|
for users without `attendance.manage`; allowed with `?force=1`
|
||||||
|
for users with `attendance.manage`; audit log records the force.
|
||||||
|
3. **Employee self-view scoping** — employee with `attendance.record`
|
||||||
|
calling `GET /plan/entries?user_id=<other>` is scoped to self.
|
||||||
|
4. **Soft-delete + is_deleted** — deleted rows are excluded from
|
||||||
|
`/plan/grid` but kept in `/plan/audit`.
|
||||||
|
5. **Project FK nullability** — entries with `project_id = null`
|
||||||
|
and `category = 'leave'` are valid and render correctly.
|
||||||
|
6. **Range overlap** — two ranges for the same `(user, day)` does
|
||||||
|
not error; the latest one wins; a warning is logged.
|
||||||
|
7. **Audit log on every write** — POST/PATCH/DELETE each insert
|
||||||
|
exactly one `audit_logs` row with `entity_type`,
|
||||||
|
`old_values`, `new_values`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Plan
|
||||||
|
|
||||||
|
1. Bump `package.json` version (minor, since new module).
|
||||||
|
2. `npm run build` — confirm clean compile.
|
||||||
|
3. Commit + push to Gitea.
|
||||||
|
4. Tarball + deploy to production via the existing release script
|
||||||
|
(per `CLAUDE.md` "Release Process" section).
|
||||||
|
5. `npx prisma migrate deploy` on prod.
|
||||||
|
6. `pm2 restart app-ts --update-env`.
|
||||||
|
7. Smoke test: log in, open Docházka → Plán prací, add a one-day
|
||||||
|
entry for yourself, reload, confirm it shows.
|
||||||
|
|
||||||
|
The deployment follows the same pattern as the warehouse module
|
||||||
|
release (see commit `5233db2` and the `2026-05-29-warehouse-module`
|
||||||
|
spec for reference).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items / future work
|
||||||
|
|
||||||
|
- Month view polish if the toggle proves popular.
|
||||||
|
- Drag-fill on the grid (out of scope for v1).
|
||||||
|
- Optional tie-in to attendance: pre-fill `notes` on
|
||||||
|
`AttendanceCreate` from today's plan (read-only hint). Not
|
||||||
|
pursued in v1.
|
||||||
|
- "Patří do rozsahu" link in the detail modal: clickable
|
||||||
|
jump to the range's full detail.
|
||||||
|
- Per-team filtering (if a "team" concept is added to `users` later).
|
||||||
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal file
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# CSS → MUI Migration — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-06
|
||||||
|
**Status:** Approved design, pending implementation plan
|
||||||
|
**Topic:** Replace hand-written CSS with MUI (Material UI), themed to the existing brand, with a modern "Soft-SaaS + dense tables" look, rolled out incrementally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Migrate the admin frontend off its ~7,100 lines of hand-written CSS onto **MUI (Material UI)** as the styling system, while simultaneously delivering a **modern visual refresh**. Dark/light theming and the existing design tokens are preserved (they already exist and work). The migration is **incremental and always shippable** — never a frozen big-bang.
|
||||||
|
|
||||||
|
This is explicitly a **styling + component-layer migration**. Application behavior, data flow, API contracts, and business logic remain identical (one deliberate exception: modal accessibility — see §5 / §11). Backend code is not touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current state (audit findings, 2026-06-06)
|
||||||
|
|
||||||
|
The premise "we want dark/light mode" is largely **already met**. The audit found:
|
||||||
|
|
||||||
|
- **19 CSS files, ~7,101 lines** under `src/admin/`. Largest: `components.css` (1,224), `plan.css` (1,152), `offers.css` (641), `layout.css` (582), `dashboard.css` (525), `forms.css` (510), `attendance.css` (455), `base.css` (411). Smallest: `responsive.css` (6 lines, effectively an empty placeholder). The token file `variables.css` (164) and `plan.css` are special-cased in §6.
|
||||||
|
- **A complete CSS-variable token system** in `variables.css`: spacing scale, colors, radii, motion durations, fonts, safe-area insets.
|
||||||
|
- **A full dark + light theme** via `[data-theme="dark"]` / `[data-theme="light"]`. The attribute is owned and written by **`src/context/ThemeContext.tsx`** (`useTheme()` / `toggleTheme()`, which calls `document.documentElement.setAttribute("data-theme", …)`). `Sidebar.tsx`, `AdminLayout.tsx`, and `Login.tsx` only **consume** it via `useTheme()`.
|
||||||
|
- **Distinctive fonts already chosen:** Urbanist (headings), Plus Jakarta Sans (body), DM Mono (mono).
|
||||||
|
- **Stack:** React 18.3, Vite 8, TypeScript (strict), TanStack Query v5, **framer-motion** (used broadly — see §9: modals, toasts, the AdminLayout logout transition, dashboard cards, Login), react-datepicker, react-quill-new, Leaflet, @dnd-kit. No Tailwind / MUI / Emotion direct deps; no PostCSS config (PostCSS ships only transitively under Vite).
|
||||||
|
- **Type skew (pre-existing):** `react`/`react-dom` are `^18.3.1` but `@types/react`/`@types/react-dom` are `^19.x`. MUI's component types are sensitive to the `@types/react` major version — see §11 / §14.
|
||||||
|
|
||||||
|
**Implication:** the migration is not about adding theming; it's about replacing the CSS plumbing and modernizing the look while carrying the existing tokens forward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Locked decisions
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Goal | Both — re-plumb CSS **and** modern visual refresh |
|
||||||
|
| Approach | Component library (not a utility framework) |
|
||||||
|
| Library | **MUI (Material UI)** — target **v7** + **MUI X v8** (pickers), themed to the brand |
|
||||||
|
| Look | **"Soft-SaaS shell (B) + dense data tables (D)"** — airy modern chrome, dense efficient tables |
|
||||||
|
| Theming | Existing CSS-var tokens become the source; one `data-theme` attribute drives both MUI and legacy CSS (mechanism verified against MUI v7 docs — §4.1) |
|
||||||
|
| Roll-out | Incremental, foundation-first; each phase is one or more normal releases |
|
||||||
|
| Pilot page | **Vozidla** (vehicles) — focused CRUD pilot |
|
||||||
|
| Heavy tables | MUI `Table` styled dense, reusing existing `useTableSort` + `usePaginatedQuery` hooks (no DataGrid, no paid license) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
The tokens become the **single shared palette** that feeds both worlds during the migration, so migrated and unmigrated pages stay on the same colors/typography. Structure modernizes per page (see §7 for the token-level vs component-level split).
|
||||||
|
|
||||||
|
```
|
||||||
|
Tokens (colors, fonts, radii, motion, dark/light) ← refreshed once, early
|
||||||
|
│ (identical palette feeds both sides)
|
||||||
|
├───────────────► legacy CSS ── unmigrated pages ┐
|
||||||
|
│ ├─ coexist, same palette
|
||||||
|
└──► MUI theme (createTheme + colorSchemes) ──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Foundation UI kit (src/admin/ui/*) ──► migrated pages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.1 Theming bridge (`src/admin/theme.ts`)
|
||||||
|
|
||||||
|
- One `theme.ts` builds the MUI theme from the tokens:
|
||||||
|
- **Palette:** `primary` = brand red (`#d63031` dark / `#c73030` light), plus `success`/`warning`/`error`/`info` mapped to existing semantic tokens.
|
||||||
|
- **Typography:** Urbanist for `h1–h6`, Plus Jakarta Sans for body, DM Mono for numeric/monospace contexts.
|
||||||
|
- **Shape/spacing:** `borderRadius` and spacing from the token scale.
|
||||||
|
- **`components` overrides:** the Soft-SaaS defaults (rounded cards, pill buttons, soft shadows, tinted chips) baked in so every MUI component is on-brand by default.
|
||||||
|
- **Dark/light keeps the existing toggle (verified against MUI v7 docs).** MUI's CSS theme variables support a custom color-scheme selector that targets the existing attribute exactly:
|
||||||
|
```ts
|
||||||
|
createTheme({
|
||||||
|
cssVariables: { colorSchemeSelector: "[data-theme='%s']" },
|
||||||
|
colorSchemes: { light: true, dark: true },
|
||||||
|
// …palette/typography/components
|
||||||
|
});
|
||||||
|
```
|
||||||
|
This emits `[data-theme="light"]{…}` and `[data-theme="dark"]{…}`, matching the attribute `ThemeContext` already writes. One attribute drives MUI _and_ legacy CSS → no second toggle, 1:1 dark parity.
|
||||||
|
- **One attribute owner.** `ThemeContext` remains the sole writer of `data-theme` on `<html>`. Its `toggleTheme()` must **also call MUI's `setMode()`** so MUI's internal color-scheme state stays in sync with the attribute. Do **not** also enable MUI's `InitColorSchemeScript` attribute-writing (avoid two writers). Wired in Phase 0.
|
||||||
|
|
||||||
|
### 4.2 Coexistence (the safety mechanism)
|
||||||
|
|
||||||
|
- Use MUI's **`ScopedCssBaseline`** around migrated page subtrees only, so MUI's CSS reset applies _inside_ migrated pages without disturbing unmigrated ones still on `base.css`. No global reset until teardown.
|
||||||
|
- The `CssVarsProvider`/`ThemeProvider` must sit **above** every `ScopedCssBaseline`, and `data-theme` must live on a common ancestor (`<html>`, as today) so the `[data-theme='%s']` selector resolves inside scoped subtrees.
|
||||||
|
- Styling engine: MUI default (**Emotion**). SPA only (Vite, no SSR), so no SSR-cache complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Foundation UI kit (`src/admin/ui/`)
|
||||||
|
|
||||||
|
**Principle: pages never import `@mui` directly.** A thin wrapper kit bakes in brand defaults and **keeps the current component APIs and Czech labels**, so migrating a page is mostly swapping imports, not rewriting logic, and tuning a default touches one file, not 57.
|
||||||
|
|
||||||
|
| Today | → Foundation kit (`src/admin/ui/`) | MUI base |
|
||||||
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||||
|
| `FormModal` / `ConfirmModal` | `<Modal>` — preserves **FormModal's** full prop set (`submitDisabled`, `subtitle`, `loading`); ConfirmModal needs only `title`/`message`/`type`/`confirmVariant`/`loading` | `Dialog` |
|
||||||
|
| `.admin-table*` + `useTableSort` + `usePaginatedQuery` | **`<DataTable>`** (the dense "D" look) | `Table` |
|
||||||
|
| `AdminDatePicker` (react-datepicker) | `<DatePicker>` — `cs` locale, `dd.MM.yyyy` | MUI X Pickers (free) |
|
||||||
|
| `.admin-btn` | `<Button>` (pill / red-gradient default) | `Button` |
|
||||||
|
| `.admin-form-*` inputs/selects | `<TextField>` / `<Select>` / `<Switch>` | MUI form controls |
|
||||||
|
| `AlertContext` toasts | `<Toast>` — keeps `useAlert()` API | `Snackbar` |
|
||||||
|
| `AdminLayout` sidebar/topbar | `<AppShell>` — the soft-SaaS shell | `Box` / `Drawer` |
|
||||||
|
| KPI tiles | `<KpiCard>` / `<Card>` | `Card` |
|
||||||
|
| Tabs | `<Tabs>` | `Tabs` |
|
||||||
|
|
||||||
|
**Modal behavior change (deliberate a11y gain — not a 1:1 match).** Today's modals use `useModalLock` (a ref-counted body-scroll lock, used in **8 places**, including non-`FormModal` spots like Dashboard, OfferDetail, ReceivedInvoices, WarehouseItemDetail) plus `role="dialog"`, but have **no focus trap, no initial-focus, no return-focus**. MUI `Dialog` adds scroll-lock _and_ a focus trap + return-focus. This is an intentional improvement; it is called out so it isn't a surprise. **Audit all 8 `useModalLock` call sites** — those not backed by `FormModal`/`ConfirmModal` either migrate to `<Modal>` or keep `useModalLock` until their page migrates. Verify stacked/nested modal scroll-lock still works.
|
||||||
|
|
||||||
|
### 5.1 Dense `<DataTable>` (centerpiece)
|
||||||
|
|
||||||
|
MUI `Table` styled to the compact "D" look — uppercase micro headers, **DM Mono right-aligned numerics**, tinted status `Chip`s, subtle row hover — wired to the **existing `useTableSort` (column sort) and `usePaginatedQuery` (list + pagination) hooks**. The current `.admin-table-responsive` horizontal-scroll wrapper carries over as MUI's `TableContainer`.
|
||||||
|
|
||||||
|
- **Numbers stay formatted by the existing utilities.** Numeric/currency cells continue to render through `src/admin/utils/formatters.ts` (`formatCurrency` cs-CZ + `Kč`/`€`, `formatKm`, `czechPlural`). `<DataTable>` only _styles_ (DM Mono, right-align); it never re-formats values.
|
||||||
|
- A mobile card-view variant for the densest tables is a future enhancement (out of scope for the first pass).
|
||||||
|
|
||||||
|
### 5.2 Date pickers
|
||||||
|
|
||||||
|
Swap `AdminDatePicker` → MUI X Date Pickers (free **Community** tier; only Range Pickers are Pro, which we don't need) with the `date-fns` adapter and **`cs` locale**. `AdminDatePicker` is used in **three variants**, all covered by Community:
|
||||||
|
|
||||||
|
| Current (react-datepicker) | → MUI X | Format (date-fns tokens) |
|
||||||
|
| -------------------------- | ---------------------------------- | ------------------------ |
|
||||||
|
| date | `DatePicker` | `dd.MM.yyyy` |
|
||||||
|
| `showMonthYearPicker` | `DatePicker` with month/year views | `MM/yyyy` |
|
||||||
|
| `showTimeSelectOnly` | `TimePicker` | `HH:mm` |
|
||||||
|
|
||||||
|
> **Format-token correction:** date-fns tokens are case-sensitive — `MM` = month, `mm` = minutes, `yyyy` = calendar year (`rrrr` = local-week-year, wrong). The format is **`dd.MM.yyyy`**, not `dd.mm.rrrr`. Apply via `LocalizationProvider` `dateFormats` (e.g. `{ keyboardDate: "dd.MM.yyyy" }`).
|
||||||
|
|
||||||
|
Retires a custom component and the `react-datepicker` dependency; MUI's responsive picker handles touch/mobile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CSS end-state (explicit)
|
||||||
|
|
||||||
|
1. **Gone — ~90% of files (~80% of lines).** All per-feature CSS files deleted as their pages migrate: `buttons`, `forms`, `tables`, `invoices`, `offers`, `attendance`, `dashboard`, `warehouse`, `login`, `settings`, `filemanager`, `pagination`, `datepicker`, `layout`, `base`, `components`, **`responsive`** (the 6-line placeholder). What they expressed becomes the MUI theme + `sx`/`styled`.
|
||||||
|
2. **Tokens move, don't die.** `variables.css` is rewritten into `theme.ts` (TypeScript). With MUI's CSS-variable mode, MUI emits those tokens as CSS custom properties at runtime — so the hand-written `variables.css` file is retired, but the values live on in TS as the single source.
|
||||||
|
3. **Stays — a small residue** for the genuinely-bespoke widgets MUI does not provide:
|
||||||
|
- **Plan/gantt grid** (`plan.css`, ~1,152 lines, slimmed) — kept as a custom component with a trimmed stylesheet (largest survivor; ~16% of today's lines, hence "~80% of lines removed" not 90%).
|
||||||
|
- **Quill rich-text editor** and **Leaflet map** — kept, with small theming CSS.
|
||||||
|
- These surviving stylesheets reference the **MUI-emitted CSS variables**, so they stay in sync with the theme and dark/light.
|
||||||
|
|
||||||
|
**Stated precisely:** custom React components stay (their logic is the value); standard ones are re-skinned via MUI and lose their custom CSS; styling comes from the MUI theme; the only custom CSS remaining is the slim stylesheets for the 3 bespoke widgets, which still read the MUI theme tokens. Literally-zero custom CSS would require rebuilding the plan grid from MUI primitives — explicitly **out of scope** (YAGNI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Aesthetic spec ("Soft-SaaS + dense", validated 2026-06-06)
|
||||||
|
|
||||||
|
The refresh is delivered in **two layers**, which is how it reconciles with the "shared palette" coexistence model:
|
||||||
|
|
||||||
|
- **Token-level (palette) — applies app-wide, early.** These are small refinements to the existing tokens (e.g. light canvas `#f5f4f2 → #f4f3f1`, refined status-chip tints). They are updated once in `variables.css`/`theme.ts` in Phase 0, so **both** legacy and migrated pages share the refreshed palette. This means Phase 0 _does_ shift colors slightly app-wide — it is **not** visually a no-op — but layout/structure is unchanged.
|
||||||
|
- **Component-level (the "modern look") — per page, via MUI.** The distinctive new look (red-gradient pill buttons, 16px soft-shadow rounded cards, the soft-SaaS shell, dense tables) is delivered through MUI component styling as each page migrates. Unmigrated pages keep their current structure on the refreshed palette.
|
||||||
|
|
||||||
|
So §4's "coexist" promise is **same palette/typography, not pixel-identical structure**.
|
||||||
|
|
||||||
|
Concrete values implementers must reproduce (light shown):
|
||||||
|
|
||||||
|
- **Canvas:** `#f4f3f1`. **Surfaces/cards:** `#ffffff`, radius **16px**, soft shadow `0 6px 20px rgba(20,20,40,.06), 0 1px 2px rgba(0,0,0,.03)`.
|
||||||
|
- **Sidebar:** transparent on canvas; active item = white pill with soft shadow + red text/icon.
|
||||||
|
- **Top bar:** Urbanist 800 page title; pill search field; circular icon buttons; gradient avatar; primary action = red-gradient pill button.
|
||||||
|
- **Primary button:** `linear-gradient(135deg,#e23a3a,#c01f1f)`, radius `999px`, shadow `0 5px 14px rgba(214,48,49,.32)`.
|
||||||
|
- **Status chips:** pill, soft tinted — paid `#eafaf0`/`#2b8a3e`, overdue `#fdecec`/`#c92a2a`, draft `#fbf3dd`/`#92760b`.
|
||||||
|
- **Dense table:** padding ~`.5rem 1rem`; headers `.64rem` uppercase, letter-spacing `.05em`, muted; **DM Mono** right-aligned numeric cells; row hover `#faf9f7`; row borders `#f5f3f1`; container is a 16px soft card.
|
||||||
|
- **Fonts:** Urbanist 600–800 (headings), Plus Jakarta Sans 400–700 (body), DM Mono 400–500 (figures).
|
||||||
|
|
||||||
|
**Dark mode.** Several §7 values are new and have no current dark counterpart, so dark is **authored in `theme.ts` during Phase 0** by deriving from these light values + the existing dark surfaces (canvas `#0a0a0a`/`#0f0f0f`, glass borders `rgba(255,255,255,.08)`, dark chip tints), not literally "mirrored."
|
||||||
|
|
||||||
|
**Visual references** (persisted, gitignored): `.superpowers/brainstorm/9839-1780760811/content/` — `aesthetic-directions.html`, `refined-hybrid-faktury.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Migration phasing
|
||||||
|
|
||||||
|
Each phase is **one or more** normal `v1.x` releases (realistic total: ~a dozen releases). Each migrated page deletes its own CSS file.
|
||||||
|
|
||||||
|
- **Phase 0 — Foundation.** Install MUI v7 + Emotion + MUI X v8 Pickers; reconcile `@types/react` with the React 18 runtime (§14); write `theme.ts` from tokens **and apply the token-level palette refresh app-wide** (§7); author the dark palette; wire `ScopedCssBaseline` + the `ThemeContext`↔`setMode()` sync; build the `src/admin/ui/` kit; add the **dev-only** `/ui-kit` showcase route (guarded by `import.meta.env.DEV`, tree-shaken from prod). Layout/structure unchanged; colors shift slightly app-wide. Deletes no per-page CSS yet.
|
||||||
|
- **Phase 1 — `<AppShell>` (chrome).** Migrate sidebar/topbar/theme-toggle to the soft-SaaS shell. Every page _inside the AdminLayout route_ sits in the new frame; unmigrated content still uses its own CSS inside it. Retires most of `layout.css`. **Login is NOT touched here** — it is a sibling route outside `AdminLayout` (`AdminApp.tsx:100`) with its own full-screen layout; see Phase 3.
|
||||||
|
- **Phase 2 — Pilot CRUD page (Vozidla / vehicles).** Full end-to-end: list, dense `<DataTable>`, filters, create/edit `<Modal>`. Battle-tests and tunes the foundation. Deletes that page's CSS.
|
||||||
|
- **Phase 3 — Simple CRUD + Login.** Uživatelé (users), Projects, Settings, and **Login** (migrated standalone — keeps its own full-screen layout, theme toggle, and 2FA two-step + shake/animate-out flow; deletes `login.css`).
|
||||||
|
- **Phase 4 — List-heavy** (Offers, Invoices lists). Hammers `<DataTable>`. Invoice/offer **on-screen detail** (Quill, the detail page) re-themed, not rebuilt. (Generated PDFs are out of scope — §13.)
|
||||||
|
- **Phase 5 — Attendance & Leave/Trips** (grids + forms).
|
||||||
|
- **Phase 6 — Warehouse, then Plan + Dashboard polish.**
|
||||||
|
- **Phase 7 — Teardown.** Delete leftover legacy CSS; retire `variables.css` into `theme.ts`; only the slim bespoke-widget CSS remains.
|
||||||
|
|
||||||
|
At no point is the app frozen or un-shippable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Bespoke components & animation
|
||||||
|
|
||||||
|
**Bespoke components stay custom, re-themed (their logic is the value):**
|
||||||
|
|
||||||
|
- **Plan/gantt grid** (`plan.css`) — keep the grid; repaint from tokens; wrap its controls in the kit.
|
||||||
|
- **Leaflet maps** — keep; align container/control styling to the theme.
|
||||||
|
- **Quill rich-text** (`react-quill-new`) — keep; wrap as `<RichTextEditor>`; theme the toolbar/editor. **Server-side DOMPurify + `cleanQuillHtml` sanitization on the PDF and render paths is unaffected and must remain.**
|
||||||
|
- **@dnd-kit** drag-drop — keep (behavior, not styling).
|
||||||
|
|
||||||
|
**Animation (framer-motion).** framer-motion is used in ~40 files, not just modals. Decision: **keep framer-motion** for the animations MUI does not cover — the **AdminLayout logout transition** (scale + blur + fade), **dashboard card entrances**, **Login** transitions, and plan interactions. MUI's own transitions replace framer-motion **only** inside the components that become MUI (`Dialog` for modals, `Snackbar` for toasts). When migrating `AdminLayout`→`<AppShell>` and the dashboard, **consciously re-create or drop** their framer-motion animations (don't lose them silently). `useReducedMotion` continues to gate bespoke animations; MUI honors `prefers-reduced-motion` via its theme transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Testing & verification
|
||||||
|
|
||||||
|
This is a UI restyle, so behavior must stay identical (except the deliberate modal a11y gain, §5).
|
||||||
|
|
||||||
|
- **Backend/API tests** (`auth`, `numbering`) unaffected; run each phase to confirm the server is untouched — they stay green.
|
||||||
|
- **Per-page manual verification checklist** (primary gate): every CRUD action, filter, sort, pagination, and modal works as before; **modal focus order / ESC / backdrop-close** behave; **dark and light** both render; **mobile + desktop** (responsive parity, §11); **zero new console errors**; `tsc --noEmit` and `vite build` pass.
|
||||||
|
- **`/ui-kit` dev route:** renders every foundation component in both themes — instant visual QA when tuning the theme; living reference for page migration. Dev-only (stripped from prod).
|
||||||
|
- **Playwright smoke + before/after screenshots:** a thin per-page script loads the page, runs the core flow, captures light+dark screenshots to diff against the pre-migration baseline. (Playwright is already available in the environment.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Bundle/runtime cost (Emotion) | Measure after Phase 0; tree-shake; acceptable for an internal admin app |
|
||||||
|
| MUI reset clashing with legacy `base.css` mid-migration | `ScopedCssBaseline` around migrated subtrees; provider above it; migrate the shell early |
|
||||||
|
| Dark/light drift between MUI and legacy CSS | One `data-theme` attribute (owned by `ThemeContext`) + `setMode()` sync drives both |
|
||||||
|
| **Modal behavior change** (focus trap, return-focus) | Intentional a11y gain; audit the 8 `useModalLock` sites; add focus/ESC/backdrop to the per-page checklist |
|
||||||
|
| **Responsive parity** — 72 media queries live in the CSS files being deleted | Re-express each page's breakpoints via MUI theme breakpoints / responsive `sx` **when that page migrates** — part of per-page DoD; review high-MQ files (`layout`, `components`, `plan`) explicitly |
|
||||||
|
| **`@types/react` 19 vs React 18.3 skew** | Reconcile types to the React 18 runtime in Phase 0 before building the kit, so the `tsc --noEmit` gate isn't blocked by pre-existing skew |
|
||||||
|
| Czech dates in MUI X Pickers | `date-fns` `cs` locale, **`dd.MM.yyyy`** / `MM/yyyy` / `HH:mm` — verified in Phase 0 |
|
||||||
|
| Czech number/currency formatting | Tables keep `formatters.ts` (`formatCurrency`, `formatKm`); DataTable only styles |
|
||||||
|
| **Per-phase rollback** | Each phase is a self-contained, independently-revertable PR (page migration + its CSS deletion together). Coexistence means reverting one migrated page doesn't affect others. **Phase 1 (AppShell) is the highest-blast-radius phase** — ship behind extra verification; revert = `git revert` the phase PR + redeploy |
|
||||||
|
| Scope creep over a long migration | Strict phase discipline; **restyle only — never refactor logic in the same PR** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Definition of done
|
||||||
|
|
||||||
|
- **Per page:** rendered via the `ui/` kit; its CSS file deleted; all interactions verified; **its media queries re-expressed via MUI breakpoints/`sx`**; modal focus/ESC/backdrop verified; dark + light + mobile + desktop pass; `tsc` + `vite build` green; no new console errors; shipped.
|
||||||
|
- **Overall:** every page migrated; all per-feature CSS deleted (incl. `responsive.css`); `variables.css` retired into `theme.ts`; only the slim bespoke-widget CSS remains; the dev-only `/ui-kit` route reflects the full theme (and stays stripped from prod).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Out of scope / deferred
|
||||||
|
|
||||||
|
- **Puppeteer PDF templates** (`src/routes/admin/invoices-pdf.ts`, `offers-pdf.ts`, `orders-pdf.ts`) use their **own self-contained inline `<style>` + print fonts**, independent of the admin stylesheet — **NOT** part of this migration and not aligned to the MUI theme. (DOMPurify + `cleanQuillHtml` sanitization on those paths is unaffected.) Phase 4's "detail re-themed" refers to the **on-screen** detail page only.
|
||||||
|
- Rebuilding the plan/gantt grid from MUI primitives (kept custom-but-themed).
|
||||||
|
- Mobile card-view table variant (future enhancement).
|
||||||
|
- Any backend, API, or business-logic change.
|
||||||
|
- Replacing Leaflet, Quill, or @dnd-kit (kept).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Open items to confirm during planning (Phase 0)
|
||||||
|
|
||||||
|
- Confirm **MUI v7** + **MUI X v8** as the pinned versions and re-verify the `cssVariables.colorSchemeSelector` + `colorSchemes` API against the installed version's docs.
|
||||||
|
- **Reconcile `@types/react`** with the React 18.3 runtime (or deliberately validate the chosen MUI version against React-18-runtime-with-React-19-types) before committing the kit.
|
||||||
|
- Verify each of the three MUI X picker variants under the `date-fns` `cs` adapter: `DatePicker` (date), `DatePicker` month/year views (replacing `showMonthYearPicker`), `TimePicker` (replacing `showTimeSelectOnly`).- Audit the 8 `useModalLock` call sites and decide migrate-now vs keep-until-page-migrates for each.
|
||||||
|
- Bundle-size budget/target to check against after Phase 0.
|
||||||
|
|
||||||
|
> **Note:** `CLAUDE.md` references a hook named `useListData` that does not exist (the real hook is `usePaginatedQuery`); this spec uses the correct name. Correcting `CLAUDE.md` is advisable but outside this spec's scope.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Manual project & order creation — design
|
||||||
|
|
||||||
|
Date: 2026-06-06
|
||||||
|
Status: approved (pending spec review)
|
||||||
|
Author: Claude + BOHA
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Today both entities are only ever _derived_ from a parent:
|
||||||
|
|
||||||
|
- A **project** is created solely as a side-effect of `createOrderFromQuotation`
|
||||||
|
(`orders.service.ts`). There is **no** `POST /projects`, no `createProject`
|
||||||
|
service, no `CreateProjectSchema`. A manual-create feature existed but was
|
||||||
|
**removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only
|
||||||
|
be created through orders (shared numbering sequence)."_ The old code pulled
|
||||||
|
`project_number` from the shared order/project pool, which consumed an order
|
||||||
|
number and left **gaps in order numbering**. That is the bug we must not repeat.
|
||||||
|
- An **order** is normally created from an offer. A manual `createOrder`
|
||||||
|
(`orders.service.ts`, offer-less) **already exists** and the route already
|
||||||
|
dispatches to it, but there is **no UI**, and it does not create a project.
|
||||||
|
|
||||||
|
We want: (1) manually create projects on `/projects`, (2) manually create
|
||||||
|
orders without an offer on `/orders`.
|
||||||
|
|
||||||
|
## Decisions (locked with the user)
|
||||||
|
|
||||||
|
1. **Project numbering: shared pool, _with_ proper tracking.** Standalone
|
||||||
|
projects take the next **shared** number via `generateSharedNumber()` — the
|
||||||
|
same pool as orders — but we add the tracking/transparency that was missing
|
||||||
|
before (see "Tracking" below). Auto-assigned; no manual number entry.
|
||||||
|
2. **Manual order → project: optional checkbox, default ON.** The order form
|
||||||
|
has a pre-checked "Vytvořit propojený projekt"; when set, the order and its
|
||||||
|
project share the order number (identical to the from-offer flow → no gap).
|
||||||
|
3. **Order form scope: header only.** Customer, customer order number,
|
||||||
|
currency/VAT, scope title/description, notes. No line-item editor.
|
||||||
|
4. **Customer: optional** on both forms (matches the existing nullable schema).
|
||||||
|
5. **Match existing house design** — `FormModal` + `FormField`, the
|
||||||
|
`Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx`
|
||||||
|
header-button + customer-selector pattern, existing list/badge styling.
|
||||||
|
|
||||||
|
## Why the shared pool is safe here (the tracking)
|
||||||
|
|
||||||
|
The shared sequence is effectively a single "zakázkové číslo" pool. An order and
|
||||||
|
its project share one number; a standalone project simply takes the next number
|
||||||
|
in that pool and has no order. The mechanics that make this coherent:
|
||||||
|
|
||||||
|
- **Collision-safe:** `isSharedNumberTaken()` already checks **both**
|
||||||
|
`orders.order_number` and `projects.project_number`, so the same number can
|
||||||
|
never be issued twice across the pool (`numbering.service.ts:161-167`).
|
||||||
|
- **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in
|
||||||
|
`prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock
|
||||||
|
(`getNextSequence`) serialises concurrent consumers.
|
||||||
|
- **Release on delete:** `deleteProject` already calls `releaseSharedNumber`,
|
||||||
|
which decrements the sequence only if the deleted number is the current
|
||||||
|
highest — so deleting a standalone project doesn't leave a permanent tail-gap
|
||||||
|
(`numbering.service.ts:116-158, 319-328`).
|
||||||
|
- **Audit trail:** `logAudit` on create/delete records the number in
|
||||||
|
`newValues`/`oldValues`, so every consumed pool number has a "who/when/what"
|
||||||
|
trail showing it went to a project (not a lost order).
|
||||||
|
- **UI transparency:** the Projects list shows a badge — **"z objednávky"**
|
||||||
|
(order-linked, `order_id != null`) vs **"samostatný"** (standalone) — so a
|
||||||
|
pool number with no order reads as intentional. The create form previews the
|
||||||
|
number it is about to assign (`previewSharedNumber()`).
|
||||||
|
|
||||||
|
## Part A — Manual projects
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **`src/schemas/projects.schema.ts`** — add `CreateProjectSchema`
|
||||||
|
(`z.strictObject`, shared coercers from `schemas/common.ts`, Czech messages):
|
||||||
|
- `name` — required, `z.string().min(1)`.
|
||||||
|
- `customer_id` — optional number (coerced, NaN-guarded).
|
||||||
|
- `responsible_user_id` — optional number.
|
||||||
|
- `status` — optional, default `"aktivni"`.
|
||||||
|
- `start_date`, `end_date` — optional date strings.
|
||||||
|
- `notes` — optional string.
|
||||||
|
- **No** `project_number` field (auto-assigned from the pool).
|
||||||
|
- **`src/services/projects.service.ts`** — add `createProject(data)`:
|
||||||
|
- `prisma.$transaction(async (tx) => …)`: `project_number = await
|
||||||
|
generateSharedNumber(tx)`; `tx.projects.create({ … order_id: null,
|
||||||
|
quotation_id: null, status: data.status ?? "aktivni", … })`.
|
||||||
|
- Validate `customer_id` / `responsible_user_id` exist (when provided) →
|
||||||
|
return `{ error, status: 400 }` (Czech) if not.
|
||||||
|
- After commit: `nasFileManager.createProjectFolder(number, name)` when
|
||||||
|
`isConfigured()` — non-fatal, logged on failure (mirrors the old code).
|
||||||
|
- Return `{ data: project }` (or `{ error, status }`).
|
||||||
|
- **`src/routes/admin/projects.ts`**:
|
||||||
|
- `POST /` guarded by `requirePermission("projects.create")` →
|
||||||
|
`parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit`
|
||||||
|
(`action: "create"`, `entityType: "project"`, `newValues`) →
|
||||||
|
`success(reply, project, 201, "Projekt vytvořen")`.
|
||||||
|
- `GET /next-number` guarded by `projects.create` → `previewSharedNumber()` →
|
||||||
|
`success(reply, { number })`. (Preview only; does not consume.)
|
||||||
|
|
||||||
|
### Frontend (`src/admin/pages/Projects.tsx` + new modal)
|
||||||
|
|
||||||
|
- Header **"Přidat projekt"** button, gated on `hasPermission("projects.create")`,
|
||||||
|
matching the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern.
|
||||||
|
- A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField`
|
||||||
|
rows for name (required), customer (select from customers list), responsible
|
||||||
|
user (select from `userListOptions`), status, start/end dates, notes, plus a
|
||||||
|
read-only "Číslo projektu: `<preview>` (přiděleno automaticky)" line fetched
|
||||||
|
from `GET /projects/next-number` when the modal opens.
|
||||||
|
- `useApiMutation` POST `/api/admin/projects`; on success invalidate
|
||||||
|
**`["projects"]`** (broad domain key, per the invalidation convention) and
|
||||||
|
close modal; surface server errors via `useAlert`.
|
||||||
|
- List: add the **"z objednávky" / "samostatný"** badge (derive from
|
||||||
|
`order_id`). Update the empty-state text (currently "Projekt se vytvoří
|
||||||
|
automaticky při vytvoření objednávky") to mention manual creation.
|
||||||
|
|
||||||
|
## Part B — Manual orders (header only, optional project)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- **`src/schemas/orders.schema.ts`** — add `create_project` to
|
||||||
|
`CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v
|
||||||
|
=== 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path
|
||||||
|
uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.)
|
||||||
|
- **`src/services/orders.service.ts` `createOrder`** — inside the existing
|
||||||
|
transaction, after the order is created, when `body.create_project` is true and
|
||||||
|
there is no quotation link: `tx.projects.create({ project_number: orderNumber,
|
||||||
|
order_id: order.id, customer_id: order.customer_id, name: scope_title ??
|
||||||
|
customer_order_number ?? orderNumber, status: "aktivni" })` — mirrors
|
||||||
|
`createOrderFromQuotation`'s project block, so order + project share the number.
|
||||||
|
Return the project id so the route can audit it.
|
||||||
|
- **`src/routes/admin/orders.ts`** — the manual `POST /` branch already exists
|
||||||
|
(`orders.create`); add a second `logAudit` for the created project when present.
|
||||||
|
|
||||||
|
### Frontend (`src/admin/pages/Orders.tsx` + new modal)
|
||||||
|
|
||||||
|
- Header **"Vytvořit objednávku"** button, gated on `orders.create`.
|
||||||
|
- Header-only `FormModal`: customer (select), customer order number, currency,
|
||||||
|
VAT rate + apply-VAT, language, scope title, scope description, notes, and a
|
||||||
|
pre-checked **"Vytvořit propojený projekt"** checkbox. Read-only next
|
||||||
|
order-number preview via the existing `GET /api/admin/orders/next-number`.
|
||||||
|
- POST `/api/admin/orders` (no `quotationId`); on success invalidate
|
||||||
|
**`["orders"]`** and **`["projects"]`**; errors via `useAlert`.
|
||||||
|
|
||||||
|
## Part C — Permissions
|
||||||
|
|
||||||
|
`projects.create` was removed from the permission set and must come back the
|
||||||
|
**migration** way (per CLAUDE.md — never seed-only for prod data):
|
||||||
|
|
||||||
|
- New migration `prisma/migrations/<ts>_add_projects_create_permission/` that
|
||||||
|
`INSERT`s the `projects.create` permission row and grants it to the **admin**
|
||||||
|
role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use
|
||||||
|
`INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe.
|
||||||
|
- Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev).
|
||||||
|
- `orders.create` already exists — no change.
|
||||||
|
|
||||||
|
## Part D — Tests (`src/__tests__/`)
|
||||||
|
|
||||||
|
Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style:
|
||||||
|
|
||||||
|
- `createProject` assigns the next shared number, increments the `shared`
|
||||||
|
sequence by exactly 1, sets `order_id = null`, writes an audit row.
|
||||||
|
- `createOrder` with `create_project: true` creates an order **and** a project
|
||||||
|
sharing one `order_number`/`project_number`.
|
||||||
|
- Interleaved coherence: order → standalone project → order produces three
|
||||||
|
consecutive shared numbers with no duplicates (the tracking guarantee).
|
||||||
|
- `createOrder` with `create_project: false` creates only the order.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Service returns `{ error, status }` (Czech) for bad customer/user FK and
|
||||||
|
numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a
|
||||||
|
500 with a Czech message by the route, logged via `app.log.error`).
|
||||||
|
- NAS folder creation is non-fatal and logged (never blocks creation).
|
||||||
|
|
||||||
|
## Out of scope (v1) — noted for later
|
||||||
|
|
||||||
|
- Editing order line-items after creation.
|
||||||
|
- Attaching a standalone project to an order later (conflicts with current
|
||||||
|
`order_id` immutability + the `has_order` delete guard).
|
||||||
|
- Manual project-number override (user chose auto-from-pool).
|
||||||
|
- A unified order+project number lookup/search.
|
||||||
|
|
||||||
|
## Affected files (summary)
|
||||||
|
|
||||||
|
- `src/schemas/projects.schema.ts` (add `CreateProjectSchema`)
|
||||||
|
- `src/schemas/orders.schema.ts` (add `create_project`)
|
||||||
|
- `src/services/projects.service.ts` (add `createProject`)
|
||||||
|
- `src/services/orders.service.ts` (`createOrder` optional project)
|
||||||
|
- `src/routes/admin/projects.ts` (`POST /`, `GET /next-number`)
|
||||||
|
- `src/routes/admin/orders.ts` (audit project on manual order)
|
||||||
|
- `src/admin/pages/Projects.tsx` (+ create modal, badge, empty-state)
|
||||||
|
- `src/admin/pages/Orders.tsx` (+ create modal)
|
||||||
|
- `src/admin/lib/queries/projects.ts`, `orders.ts` (mutations/preview queries)
|
||||||
|
- `prisma/migrations/<ts>_add_projects_create_permission/migration.sql`
|
||||||
|
- `prisma/seed.ts` (+ `projects.create`)
|
||||||
|
- `src/__tests__/manual-create.test.ts` (new)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Plan Categories Management — Design Spec
|
||||||
|
|
||||||
|
Date: 2026-06-06
|
||||||
|
Status: Approved (approach A)
|
||||||
|
Area: Work Plan (`/plan-work`)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let managers (`attendance.manage`) **create, rename, recolor, and retire** the
|
||||||
|
categories used by the work plan, via a "Správa kategorií" button above the
|
||||||
|
calendar that opens a form modal with a per-category color picker. Categories
|
||||||
|
become data, not a hardcoded enum.
|
||||||
|
|
||||||
|
## Current state (what's hardcoded today)
|
||||||
|
|
||||||
|
- DB: `plan_category` **enum** (`work, preparation, travel, leave, sick,
|
||||||
|
training, other`) used by `work_plan_entries.category` and
|
||||||
|
`work_plan_overrides.category` (both `NOT NULL`).
|
||||||
|
- Validation: `planCategoryEnum` in `src/schemas/plan.schema.ts`.
|
||||||
|
- Frontend labels: `PLAN_CATEGORIES` / `planCategoryLabel` in
|
||||||
|
`src/admin/lib/queries/plan.ts`.
|
||||||
|
- Colors: per-category CSS variables `--plan-tape-<key>` in `src/admin/plan.css`,
|
||||||
|
applied via `.plan-cell:has(.plan-chip--<key>)::before` (left "tape" bar) and
|
||||||
|
`.plan-chip--<key>` (chip text/border/background via `color-mix`). 14 rules.
|
||||||
|
- Server audit: `PLAN_CATEGORY_LABELS_CS` in `src/utils/planAuditDescription.ts`.
|
||||||
|
- A decorative paper-grain gradient (`plan.css` ~lines 102–107) tints the page
|
||||||
|
background with `--plan-tape-work` / `--plan-tape-training` at 5%. This is
|
||||||
|
cosmetic and **not** category-semantic.
|
||||||
|
|
||||||
|
## Approach (A)
|
||||||
|
|
||||||
|
Introduce a `plan_categories` table. Keep the entry/override `category` column
|
||||||
|
as a **string key** (slug) referencing `plan_categories.key`. Change the column
|
||||||
|
type from the enum to `VARCHAR(50)`; existing values are preserved verbatim.
|
||||||
|
"Remove" means **deactivate** (`is_active = false`), never hard-delete, so
|
||||||
|
historical entries keep resolving their label and color.
|
||||||
|
|
||||||
|
Rejected: a real FK `category_id` (needs per-row backfill + read/write churn for
|
||||||
|
no user-visible benefit); keeping the enum (can't add categories).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
New table `plan_categories`:
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
| ---------- | ------------ | -------------------------------------------- |
|
||||||
|
| id | INT PK AI | |
|
||||||
|
| key | VARCHAR(50) | UNIQUE, slug, immutable after create |
|
||||||
|
| label | VARCHAR(100) | Czech display name, editable |
|
||||||
|
| color | VARCHAR(7) | `#RRGGBB` |
|
||||||
|
| sort_order | INT | display order; new categories append (max+1) |
|
||||||
|
| is_active | BOOLEAN | default true; false = retired |
|
||||||
|
| created_at | DATETIME | default now |
|
||||||
|
| updated_at | DATETIME | auto |
|
||||||
|
|
||||||
|
Column change: `work_plan_entries.category` and `work_plan_overrides.category`
|
||||||
|
`plan_category` → `VARCHAR(50) NOT NULL`. Drop the `plan_category` enum from the
|
||||||
|
Prisma schema once no column uses it.
|
||||||
|
|
||||||
|
## Migration (one tracked Prisma migration)
|
||||||
|
|
||||||
|
`npx prisma migrate dev --name plan_categories` (requires dev server stopped —
|
||||||
|
ask the user first per CLAUDE.md). The generated `migration.sql` will:
|
||||||
|
|
||||||
|
1. `CREATE TABLE plan_categories (...)`.
|
||||||
|
2. `ALTER` the two `category` columns enum → `VARCHAR(50)`.
|
||||||
|
3. `INSERT` the 7 seed rows (so production gets them — not seed-only, per the
|
||||||
|
migration policy). Seed values (key, label, color, sort_order):
|
||||||
|
|
||||||
|
| key | label | color | sort |
|
||||||
|
| ----------- | -------------- | ------- | ---- |
|
||||||
|
| work | Práce | #2563eb | 1 |
|
||||||
|
| preparation | Příprava | #0d9488 | 2 |
|
||||||
|
| travel | Cesta / Montáž | #ca8a04 | 3 |
|
||||||
|
| leave | Dovolená | #16a34a | 4 |
|
||||||
|
| sick | Nemoc | #dc2626 | 5 |
|
||||||
|
| training | Školení | #7c3aed | 6 |
|
||||||
|
| other | Jiné | #6b7280 | 7 |
|
||||||
|
|
||||||
|
After: `npx prisma generate`, commit schema + migration folder.
|
||||||
|
|
||||||
|
## API — `/api/admin/plan/categories`
|
||||||
|
|
||||||
|
All under the existing plan route file group.
|
||||||
|
|
||||||
|
- `GET /` — returns **all** categories (active + inactive), ordered by
|
||||||
|
`sort_order, id`. Readable by `attendance.record` OR `attendance.manage`
|
||||||
|
(every grid viewer needs labels/colors, including for retired categories on
|
||||||
|
historical entries).
|
||||||
|
- `POST /` — create. `attendance.manage`. Body: `{ label, color }`. `key` is
|
||||||
|
auto-slugged from `label` (ASCII, lowercased, deduped); `sort_order = max+1`.
|
||||||
|
- `PATCH /:id` — update `label` / `color` / `is_active`. `attendance.manage`.
|
||||||
|
`key` is immutable.
|
||||||
|
- `DELETE /:id` — deactivate (`is_active = false`). `attendance.manage`.
|
||||||
|
|
||||||
|
Service layer: `src/services/planCategory.service.ts` (plain async functions,
|
||||||
|
`{ data }` / `{ error, status }` envelope). Audit-logged via `logAudit` with a
|
||||||
|
new entity type `plan_category` (add to `EntityType` and the AuditLog +
|
||||||
|
dashboard `ENTITY_TYPE_LABELS` maps, e.g. "Plán prací – kategorie").
|
||||||
|
|
||||||
|
Validation (`src/schemas/plan.schema.ts` or a new `planCategory.schema.ts`):
|
||||||
|
|
||||||
|
- `label`: required, 1–100 chars, trimmed.
|
||||||
|
- `color`: regex `^#[0-9a-fA-F]{6}$`.
|
||||||
|
- `is_active`: boolean (PATCH only).
|
||||||
|
- Entry/override create/update: `category` becomes `z.string().min(1)`; the
|
||||||
|
**service** validates the key exists and `is_active` (reject unknown/inactive
|
||||||
|
with a Czech 400).
|
||||||
|
|
||||||
|
## Validation rules / edge cases
|
||||||
|
|
||||||
|
- **Slug collision:** if the slug derived from a new label already exists,
|
||||||
|
append `-2`, `-3`, … Keys are never shown to users.
|
||||||
|
- **At least one active category:** deactivating the last active category is
|
||||||
|
rejected (Czech 400) so the create form is never empty.
|
||||||
|
- **Deactivate in use:** allowed. Existing entries keep their key; the category
|
||||||
|
still renders (GET returns inactive ones) but is hidden from the create
|
||||||
|
`<select>`.
|
||||||
|
- **Rename/recolor:** applies to all entries with that key (reference is by
|
||||||
|
key) — this is the desired "recolor everywhere" behavior.
|
||||||
|
- **No hard delete** in this iteration (deactivate only). Possible later
|
||||||
|
enhancement: allow hard-delete when zero entries reference the key.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **Button** "Správa kategorií" in `PlanWork.tsx` header, rendered only when
|
||||||
|
`hasPermission("attendance.manage")`.
|
||||||
|
- **Modal** (`PlanCategoriesModal.tsx`, uses `FormModal`): a row per category =
|
||||||
|
`label` text input + native `<input type="color">` swatch + active toggle (or
|
||||||
|
a "retire/restore" control), plus an "add category" row (label + color +
|
||||||
|
add). Mutations invalidate `["plan", "categories"]` **and** `["plan"]` (so the
|
||||||
|
grid recolors) and `["dashboard"]`/`["audit-log"]` (audit feed).
|
||||||
|
- **Category query** `["plan","categories"]` loaded in `PlanWork`, passed to the
|
||||||
|
grid and cell modal. The create/edit plan `<select>` lists **active**
|
||||||
|
categories from this query (replacing hardcoded `PLAN_CATEGORIES`); display
|
||||||
|
uses the **full** map (so retired categories still render).
|
||||||
|
- `planCategoryLabel` becomes a lookup into the categories map (with raw-key
|
||||||
|
fallback). `PLAN_CATEGORIES` constant is removed.
|
||||||
|
|
||||||
|
## Color rendering (the notable refactor)
|
||||||
|
|
||||||
|
Replace the 14 hardcoded per-key rules with a generic, custom-property-driven
|
||||||
|
treatment:
|
||||||
|
|
||||||
|
- `PlanGrid` sets `style={{ "--cat-color": color }}` on the `.plan-cell`
|
||||||
|
`<button>` when the cell has a category (`color` from the categories map). The
|
||||||
|
custom property cascades to the chip and is available to the cell's `::before`
|
||||||
|
tape.
|
||||||
|
- `plan.css`:
|
||||||
|
- `.plan-cell::before` tape uses
|
||||||
|
`background: var(--cat-color, var(--plan-tape-other))` — the graphite
|
||||||
|
`#6b7280` is the defensive neutral fallback when a cell has no category
|
||||||
|
color (replaces the seven `:has(.plan-chip--<key>)::before` rules).
|
||||||
|
- `.plan-chip` (generic) uses `color: var(--cat-color)`,
|
||||||
|
`border-color: color-mix(in srgb, var(--cat-color) 35%, transparent)`,
|
||||||
|
`background: color-mix(in srgb, var(--cat-color) 10%, var(--plan-paper))`
|
||||||
|
(replaces the seven `.plan-chip--<key>` rules).
|
||||||
|
- Visual output is identical for the existing 7; new categories work with no CSS
|
||||||
|
changes.
|
||||||
|
- The decorative paper-grain gradient stays static (keep two literal colors or
|
||||||
|
the two `--plan-tape-*` vars it needs); it is not category-semantic.
|
||||||
|
|
||||||
|
## Audit description
|
||||||
|
|
||||||
|
`src/services/plan.service.ts` builds the audit description with the category
|
||||||
|
label. Since labels now live in the DB, resolve the label from `plan_categories`
|
||||||
|
by key (alongside the existing `resolvePlanLabels` lookups) and pass the plain
|
||||||
|
label into `buildPlanAuditDescription`. `src/utils/planAuditDescription.ts`'s
|
||||||
|
`buildPlanAuditDescription` stays pure (receives the resolved label);
|
||||||
|
`PLAN_CATEGORY_LABELS_CS`/`planCategoryLabel` there are removed or reduced to a
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Vitest (real DB, per project convention):
|
||||||
|
|
||||||
|
- `planCategory.service` CRUD: create (slug generation, sort_order), update
|
||||||
|
(label/color/active), deactivate, and the "last active category" guard.
|
||||||
|
- Color/label validation rejects bad hex and empty label.
|
||||||
|
- Entry create accepts an active key and rejects an unknown/inactive key.
|
||||||
|
|
||||||
|
## Out of scope (YAGNI)
|
||||||
|
|
||||||
|
- Per-category icons.
|
||||||
|
- Drag-to-reorder (order by `sort_order, id`; new categories append).
|
||||||
|
- Hard delete of unused categories (deactivate only).
|
||||||
|
|
||||||
|
## Assumptions to confirm
|
||||||
|
|
||||||
|
- "Remove" = deactivate (history preserved). ✅ implied by approach A.
|
||||||
|
- Native browser color picker (`<input type="color">`), not a custom palette.
|
||||||
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
**Phase 1 of 2.** Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the app's first AI capability: an **admin-only chat assistant embedded on the
|
||||||
|
dashboard** where you can talk to Claude and, crucially, **attach received-invoice
|
||||||
|
PDFs that the AI reads and imports into Přijaté faktury** (extract → you confirm →
|
||||||
|
save). All AI usage is bounded by an **admin-editable monthly budget (default
|
||||||
|
$50)** that hard-stops calls when exhausted.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming)
|
||||||
|
|
||||||
|
- **Scope (Phase 1):** chat + invoice import + budget cap. **No system-data
|
||||||
|
access** (the AI cannot query your invoices/projects/attendance — that's Phase 2).
|
||||||
|
- **Placement:** a `DashAssistant` widget on the **dashboard, at the top under the
|
||||||
|
welcome text** — not a separate page, not a floating bubble.
|
||||||
|
- **Access:** **admins only**, via a new `ai.use` permission.
|
||||||
|
- **Model:** **Claude Sonnet 4.6** (strong vision, low cost).
|
||||||
|
- **Save flow:** extract → **you confirm/edit → save** (never silent auto-save).
|
||||||
|
- **Budget:** **$50/month**, resets each calendar month, **admin-editable in
|
||||||
|
Settings**; AI calls are **blocked** once the month's spend reaches the budget.
|
||||||
|
- **Chat:** **non-streaming** (request → full reply) and **in-browser history
|
||||||
|
only** (not persisted server-side) for v1 — both are easy later upgrades.
|
||||||
|
|
||||||
|
## Non-goals / out of scope (Phase 1)
|
||||||
|
|
||||||
|
- No system-data query tools / function-calling (Phase 2).
|
||||||
|
- No server-side conversation persistence, no streaming.
|
||||||
|
- No new invoice storage path — reuses `POST /received-invoices` verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Greenfield AI integration; the rest of the app is unaffected and works unchanged
|
||||||
|
if `ANTHROPIC_API_KEY` is absent (the assistant widget simply doesn't render and
|
||||||
|
the AI routes return a clear "not configured" error).
|
||||||
|
|
||||||
|
- **Dependency:** `@anthropic-ai/sdk`.
|
||||||
|
- **Env:** `ANTHROPIC_API_KEY` (required for the feature). Optional
|
||||||
|
`AI_MONTHLY_BUDGET_USD` default seed (the live value lives in the DB, below).
|
||||||
|
- **`src/services/ai.service.ts`** — wraps the SDK, owns the model choice + system
|
||||||
|
prompt + cost accounting. Two operations:
|
||||||
|
- `chat(messages)` → `client.messages.create({ model: "claude-sonnet-4-6", … })`,
|
||||||
|
non-streaming, returns `{ reply: string, usage }`.
|
||||||
|
- `extractInvoice(pdfBuffer, fileName)` → `client.messages.create` with the PDF as
|
||||||
|
a `document` content block + `output_config.format` JSON-schema for the
|
||||||
|
received-invoice fields, returns `{ fields, usage }`.
|
||||||
|
- Both call the spend tracker to record usage **and** are gated by the budget
|
||||||
|
guard (below) BEFORE the API call.
|
||||||
|
- Isolated so tests can stub the Anthropic call (no real API in CI).
|
||||||
|
|
||||||
|
## Spend cap (the $50/month budget)
|
||||||
|
|
||||||
|
- **New table `ai_usage`** (migration): `id`, `user_id?`, `kind` ("chat" |
|
||||||
|
"extract"), `model`, `input_tokens`, `output_tokens`, `cost_usd`
|
||||||
|
`@db.Decimal(10,6)`, `created_at @db.Timestamp(0)`, with an index on
|
||||||
|
`created_at`.
|
||||||
|
- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`.
|
||||||
|
Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd =
|
||||||
|
in*inP + out*outP`.
|
||||||
|
- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on
|
||||||
|
`company_settings` (the existing app-config singleton). Admin-editable in
|
||||||
|
Settings.
|
||||||
|
- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where
|
||||||
|
`created_at >= startOfMonth(now)`; if `>= ai_monthly_budget_usd`, return
|
||||||
|
`{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }`. Called at the top
|
||||||
|
of every AI route BEFORE the Claude call.
|
||||||
|
- **Usage read:** `GET /api/admin/ai/usage` → `{ month_spend_usd, budget_usd,
|
||||||
|
remaining_usd }` for the chat's budget indicator and the Settings page.
|
||||||
|
|
||||||
|
## Backend routes — `src/routes/admin/ai.ts`
|
||||||
|
|
||||||
|
All guarded by `requirePermission("ai.use")`.
|
||||||
|
|
||||||
|
- `POST /api/admin/ai/chat` — body `{ messages: {role, content}[] }`. Budget guard →
|
||||||
|
`ai.service.chat` → log usage → `success(reply: string, remaining_usd)`.
|
||||||
|
- `POST /api/admin/ai/extract-invoices` — multipart PDF files (≤ `MAX_UPLOAD_SIZE`,
|
||||||
|
PDF/image only). Budget guard → `extractInvoice` per file → returns the extracted
|
||||||
|
fields per file (NOT saved) + usage. The files are **not** persisted here.
|
||||||
|
- `GET /api/admin/ai/usage` — current-month spend + budget + remaining.
|
||||||
|
|
||||||
|
If `ANTHROPIC_API_KEY` is unset, the POST routes return
|
||||||
|
`{ error: "AI není nakonfigurováno", status: 503 }`.
|
||||||
|
|
||||||
|
## Invoice import flow (extract → confirm → save)
|
||||||
|
|
||||||
|
1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a
|
||||||
|
text note).
|
||||||
|
2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the
|
||||||
|
extracted fields for each.
|
||||||
|
3. The chat renders an **editable review card per invoice** (dodavatel, číslo
|
||||||
|
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
|
||||||
|
pre-filled with the AI's values, plus a duplicate hint if `invoice_number` +
|
||||||
|
`supplier_name` already exists.
|
||||||
|
4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**.
|
||||||
|
5. The frontend submits **the original File + the confirmed metadata** to the
|
||||||
|
**existing `POST /api/admin/received-invoices`** (multipart) — same NAS storage,
|
||||||
|
same server-side VAT recompute, same audit. The AI never writes invoices
|
||||||
|
directly. (The PDF is uploaded twice — once to extract, once to save — which is
|
||||||
|
fine for invoice-sized files and avoids any server-side temp-file handling.)
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **`src/admin/components/dashboard/DashAssistant.tsx`** — the chat widget:
|
||||||
|
message list, input box, attach-file button, and a small budget indicator
|
||||||
|
("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only.
|
||||||
|
Renders the invoice review cards inline (reusing the received-invoice field
|
||||||
|
inputs / kit components).
|
||||||
|
- **`src/admin/pages/Dashboard.tsx`** — render `<DashAssistant />` at the top,
|
||||||
|
directly under the welcome text, **only when** the user is admin / has `ai.use`
|
||||||
|
(and only when AI is configured — a `GET /ai/usage` 503 hides it gracefully).
|
||||||
|
- **`src/admin/lib/queries/ai.ts`** — React Query options/mutations for chat,
|
||||||
|
extract, and usage. On a successful invoice save, invalidate `["received-invoices"]`.
|
||||||
|
- **Settings** — an admin field to set the monthly budget (writes
|
||||||
|
`company_settings.ai_monthly_budget_usd`).
|
||||||
|
|
||||||
|
## Permissions & migration
|
||||||
|
|
||||||
|
One Prisma migration:
|
||||||
|
|
||||||
|
- Create `ai_usage` table.
|
||||||
|
- Add `ai_monthly_budget_usd` to `company_settings` (default 50).
|
||||||
|
- Insert the `ai.use` permission and grant it to the **admin** role (explicit
|
||||||
|
`INSERT`s in `migration.sql`, per the project's migration policy).
|
||||||
|
|
||||||
|
(The admin role bypasses permission checks via `roleName === "admin"`, but the
|
||||||
|
explicit `ai.use` permission lets the access be widened later without code changes
|
||||||
|
and documents intent.)
|
||||||
|
|
||||||
|
## Security / privacy
|
||||||
|
|
||||||
|
- **Phase 1 has no tools**, so the AI cannot take actions on your data — its only
|
||||||
|
effect is the human-confirmed invoice save. A malicious/auto-generated invoice
|
||||||
|
PDF therefore can't trigger anything (the injection-to-action surface arrives in
|
||||||
|
Phase 2 and is designed there).
|
||||||
|
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
|
||||||
|
`.env` (server-side only), never exposed to the browser.
|
||||||
|
- Standard sanitization already applies on the received-invoice render/PDF paths;
|
||||||
|
AI-extracted text flows through the same validated `received_invoices` create.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side, real test DB, **Anthropic call stubbed** (no real API in CI):
|
||||||
|
|
||||||
|
- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6;
|
||||||
|
monthly sum only counts the current month.
|
||||||
|
- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech
|
||||||
|
message; the guard reads the DB budget value.
|
||||||
|
- **Permissions:** all `/ai/*` routes return `403` without `ai.use`.
|
||||||
|
- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`.
|
||||||
|
- **extract field-mapping:** given a stubbed AI response, the route returns the
|
||||||
|
expected `received_invoices` field shape (and computes nothing that the existing
|
||||||
|
create doesn't already).
|
||||||
|
|
||||||
|
Frontend has no component-test harness → gated by `tsc -b` + `build` + a manual
|
||||||
|
check (the dashboard widget renders for admins, hidden otherwise).
|
||||||
|
|
||||||
|
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ships as **v2.1.0** (notable new capability) via the standard process. **Requires a
|
||||||
|
migration**, so the dev server must be stopped for `prisma migrate dev` (ask first),
|
||||||
|
and the release runs `prisma migrate deploy` on prod. `ANTHROPIC_API_KEY` must be
|
||||||
|
set in the production `.env` before the feature works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred: Phase 2 (system-data access) — captured
|
||||||
|
|
||||||
|
A later, separate spec + cycle. The AI gains **read-only tools** (function calling)
|
||||||
|
to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme
|
||||||
|
fakturovali v květnu?". Key design work for Phase 2:
|
||||||
|
|
||||||
|
- A curated set of **read tools** (search invoices, overdue list, project lookup,
|
||||||
|
attendance summary, …), each **scoped to the calling user's permissions** so the
|
||||||
|
AI never returns data the user can't see.
|
||||||
|
- The **agentic tool-loop** (Claude requests a tool → app executes against Prisma →
|
||||||
|
returns results → Claude continues), with a per-conversation tool-call cap to
|
||||||
|
bound cost (within the same monthly budget).
|
||||||
|
- **Prompt-injection hardening:** because the AI will both read untrusted invoice
|
||||||
|
content _and_ hold tools, tools stay **read-only**, results are treated as data,
|
||||||
|
and any state-changing action remains human-confirmed.
|
||||||
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Design — Bulk plan creation (Feature B)
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
**Builds on:** Feature A (multi-record plan cells, max 3 per cell), shipped in v2.0.5. Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a planner assign one project/category to **many employees over a date range
|
||||||
|
in a single action**, instead of creating each person's plan record one by one.
|
||||||
|
A "Hromadné přiřazení" button on **/plan-work** opens a modal that mirrors the
|
||||||
|
existing single-record create form, plus an employee checklist and an "include
|
||||||
|
weekends" toggle.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming)
|
||||||
|
|
||||||
|
- **Modal mirrors single-create** (`Datum od` + "Více dní" → `Datum do`, Projekt,
|
||||||
|
Kategorie, Text poznámky) **plus** a multi-select employee checklist and a
|
||||||
|
**"Zahrnout víkendy"** checkbox.
|
||||||
|
- **Storage = range entries** (`work_plan_entries`), one per contiguous run of
|
||||||
|
eligible days per employee — the same row shape the single-create form produces.
|
||||||
|
- **Weekends:** default off (Mon–Fri only). Only Sat/Sun are excluded — **no
|
||||||
|
separate holiday handling**.
|
||||||
|
- **Cap:** governed by Feature A's `MAX_RECORDS_PER_CELL = 3`. A day already at
|
||||||
|
the cap for an employee is **skipped** (never overwritten), which also splits
|
||||||
|
that employee's range around the skipped day.
|
||||||
|
- **Past dates blocked** (no `force` exposed in the bulk UI).
|
||||||
|
- **Category** picker defaults to **Práce**; **Project** is optional ("bez
|
||||||
|
projektu"); **note** optional, applied to all created entries.
|
||||||
|
|
||||||
|
## Non-goals / out of scope
|
||||||
|
|
||||||
|
- No DB migration (reuses `work_plan_entries` and Feature A's cap logic).
|
||||||
|
- No overwrite/replace of existing records (purely additive under the cap).
|
||||||
|
- No holiday/`svátky` exclusion (only weekends).
|
||||||
|
- No bulk creation of **overrides** — bulk always creates **entries** (the normal
|
||||||
|
layer); single-day exceptions remain the per-cell day-panel's job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage & weekend behaviour
|
||||||
|
|
||||||
|
For each selected employee, the service computes the **eligible days** in
|
||||||
|
`[date_from, date_to]` and groups **contiguous** eligible days into range
|
||||||
|
entries.
|
||||||
|
|
||||||
|
A day `D` is **eligible** for employee `U` when all hold:
|
||||||
|
|
||||||
|
1. `D` is within `[date_from, date_to]` (inclusive).
|
||||||
|
2. `include_weekends` is true, **or** `D` is a weekday (Mon–Fri).
|
||||||
|
3. `U` has fewer than `MAX_RECORDS_PER_CELL` active entries covering `D` (under
|
||||||
|
the cap).
|
||||||
|
|
||||||
|
Contiguous runs of eligible days become one range entry each
|
||||||
|
(`date_from = run start`, `date_to = run end`). Consequences:
|
||||||
|
|
||||||
|
- **Víkendy ON, no cap conflicts:** one continuous range per employee — identical
|
||||||
|
to creating a single multi-day record for everyone.
|
||||||
|
- **Víkendy OFF:** one range per work-week (Mon–Fri chunks), because a weekend day
|
||||||
|
breaks the run.
|
||||||
|
- **A cap-full day** breaks the run too, so the range splits around it (and that
|
||||||
|
`(employee, day)` is counted as skipped).
|
||||||
|
|
||||||
|
In the grid these are ordinary range entries — they show the "součást rozsahu"
|
||||||
|
badge and open the range-vs-this-day chooser on click, exactly like
|
||||||
|
manually-created ranges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Route — `POST /api/admin/plan/entries/bulk`
|
||||||
|
|
||||||
|
- Guard: `requirePermission("attendance.manage")` (same as single entry create).
|
||||||
|
- Body (validated by a new Zod schema `BulkPlanEntrySchema` in
|
||||||
|
`src/schemas/plan.schema.ts`):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
user_ids: number[] // ≥1
|
||||||
|
date_from: string // YYYY-MM-DD
|
||||||
|
date_to: string // YYYY-MM-DD (≥ date_from)
|
||||||
|
include_weekends: boolean
|
||||||
|
project_id?: number | null
|
||||||
|
category: string // default applied client-side; validated server-side
|
||||||
|
note?: string // default ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- On success: one **summary audit** row (`logAudit`, action `create`, entityType
|
||||||
|
`work_plan_entry`, description e.g. `Hromadné přiřazení: <kategorie> <projekt> —
|
||||||
|
N zaměstnanců, <from>–<to> (<created_days> dní)`), then `success(reply,
|
||||||
|
summary, 201, "Hromadné přiřazení dokončeno")`.
|
||||||
|
|
||||||
|
### Service — `bulkCreateEntries(input, actorUserId)` in `src/services/plan.service.ts`
|
||||||
|
|
||||||
|
Up-front validation (fail fast, nothing created):
|
||||||
|
|
||||||
|
- `user_ids.length >= 1` (else `{ error, status: 400 }`).
|
||||||
|
- `date_to >= date_from` (else 400).
|
||||||
|
- `assertNotPastDate(date_from, false)` and `assertNotPastDate(date_to, false)`
|
||||||
|
(bulk blocks past dates; 403).
|
||||||
|
- `assertActiveCategory(category)` (400 if inactive/unknown).
|
||||||
|
|
||||||
|
Then, per employee:
|
||||||
|
|
||||||
|
- Load the employee's active entries overlapping `[date_from, date_to]` once;
|
||||||
|
compute per-day coverage counts.
|
||||||
|
- Walk the days, mark eligible ones (weekday filter + under-cap), group contiguous
|
||||||
|
runs.
|
||||||
|
- For each run, create the range by **reusing `createEntry`**
|
||||||
|
(`{ user_id, date_from: runStart, date_to: runEnd, project_id, category, note }`,
|
||||||
|
`actorUserId`, `force: false`). Reusing `createEntry` keeps cap / past-date /
|
||||||
|
category validation DRY; its cap re-check passes because every day in the run is
|
||||||
|
already under the cap. A `createEntry` that nonetheless returns `{ error }` (rare
|
||||||
|
race) is counted as skipped, not fatal.
|
||||||
|
|
||||||
|
Aggregate and return:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
created_entries: number // range rows created
|
||||||
|
created_days: number // (employee, day) records created
|
||||||
|
skipped_days: number // weekday-eligible (employee, day) slots skipped at the cap
|
||||||
|
users: number // employees processed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`skipped_days` counts only days that passed the weekday filter but were at the
|
||||||
|
cap — weekend days (when excluded) are out of scope, not "skipped".
|
||||||
|
|
||||||
|
Best-effort across employees/segments (no cross-entry transaction): up-front
|
||||||
|
validation already rejects bad input, and the cap-skip is by design. Partial
|
||||||
|
results are reported in the summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Modal — `src/admin/components/BulkPlanModal.tsx`
|
||||||
|
|
||||||
|
Mirrors the single-create `EditForm` fields + bulk-attendance's employee picker
|
||||||
|
(`BulkAttendanceModal.tsx` is the pattern to follow):
|
||||||
|
|
||||||
|
- `Datum od` (DateField) + "Více dní" CheckboxField → `Datum do` (DateField).
|
||||||
|
- **Zaměstnanci** — scrollable `CheckboxField` list (source: the plan users
|
||||||
|
already loaded for the grid, `/plan/users`), with a "Vybrat vše" / "Odznačit
|
||||||
|
vše" toggle and a "Vybráno X z Y" caption; submit disabled when none selected.
|
||||||
|
- **Zahrnout víkendy** CheckboxField (default off).
|
||||||
|
- **Projekt** Select (optional — "— bez projektu —"), **Kategorie** Select
|
||||||
|
(active categories, default `work`/Práce), **Text poznámky** TextField (optional).
|
||||||
|
- A short helper line, e.g. _"Vytvoří záznamy pro vybrané dny u zvolených
|
||||||
|
zaměstnanců. Dny nad limit 3 záznamů se přeskočí."_
|
||||||
|
|
||||||
|
### Wiring — `src/admin/pages/PlanWork.tsx`
|
||||||
|
|
||||||
|
- A "Hromadné přiřazení" Button in the toolbar (next to "Správa kategorií"),
|
||||||
|
rendered only when `canEdit` (`attendance.manage`).
|
||||||
|
- A `bulkCreate` mutation (React Query) → `POST /api/admin/plan/entries/bulk`;
|
||||||
|
on success show an alert summarising the returned counts (e.g. _"Vytvořeno 18
|
||||||
|
záznamů pro 5 zaměstnanců; 4 dny přeskočeny (limit 3 na den)."_), close the
|
||||||
|
modal, and `invalidate: ["plan"]` so the grid refreshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing (`src/__tests__/plan.test.ts` or a new `bulkPlan.test.ts`)
|
||||||
|
|
||||||
|
Backend service tests against the real test DB:
|
||||||
|
|
||||||
|
- **Weekday filter:** weekends-off over a Mon–Sun span creates only Mon–Fri days
|
||||||
|
(and as the expected number of per-work-week ranges); weekends-on creates one
|
||||||
|
continuous range.
|
||||||
|
- **Segmentation:** a cap-full day in the middle splits the range into two; the
|
||||||
|
skipped day is counted in `skipped_days`.
|
||||||
|
- **Cap skip:** an employee already at 3 entries on some days gets those days
|
||||||
|
skipped; the summary reflects `created_days` vs `skipped_days`.
|
||||||
|
- **Multi-employee aggregation:** `users`, `created_entries`, `created_days` sum
|
||||||
|
correctly across the selected employees.
|
||||||
|
- **Validation:** empty `user_ids` → 400; past `date_from` → 403; inactive
|
||||||
|
category → 400; `date_to < date_from` → 400.
|
||||||
|
|
||||||
|
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ships as **v2.0.6** via the standard process (commit → tag → Gitea → tarball →
|
||||||
|
prod deploy → health check). No migration step. The grid refreshes via
|
||||||
|
`["plan"]` invalidation.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# Design — Multi-record plan cells (max 3 per cell)
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
**Feature A of 2.** Feature B (bulk plan creation) is a separate later cycle — see "Deferred: Feature B" at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Today a plan cell (one person, one day) resolves to **exactly one** record: an
|
||||||
|
override hides the planned range, and overlapping range entries collapse to
|
||||||
|
"newest wins". The work plan should instead let a cell hold **up to 3 records**,
|
||||||
|
so a person can have several assignments on the same day (additive) _and_ still
|
||||||
|
get one-day exceptions to a planned range.
|
||||||
|
|
||||||
|
This is the enabling model change. The eventual "bulk assign a project to many
|
||||||
|
employees over a date range" form (Feature B) will create records under this
|
||||||
|
model and respect the 3-per-cell cap.
|
||||||
|
|
||||||
|
## Use cases (both required)
|
||||||
|
|
||||||
|
1. **Additive** — several projects/tasks the same day (e.g. morning project A,
|
||||||
|
afternoon project B). Both records show in the cell.
|
||||||
|
2. **Day exception** — assigned to a multi-day range, but one day differs;
|
||||||
|
replace just that day without splitting the range.
|
||||||
|
|
||||||
|
## Non-goals / out of scope
|
||||||
|
|
||||||
|
- No bulk-create UI (Feature B).
|
||||||
|
- No database migration — the tables already allow multiple rows per
|
||||||
|
(user, day) (the old `work_plan_overrides` uniqueness constraint was dropped in
|
||||||
|
migration `20260605122718_drop_wpo_unique_constraint`).
|
||||||
|
- The max is a fixed constant (3), not user-configurable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Model — "layered" (approved)
|
||||||
|
|
||||||
|
Keep the two existing tables and their meaning; stop collapsing them to one
|
||||||
|
record.
|
||||||
|
|
||||||
|
- **`work_plan_entries` (ranges) = the normal-plan layer.** Multiple active
|
||||||
|
ranges may cover the same day → the **additive** case.
|
||||||
|
- **`work_plan_overrides` (single-day) = the exception layer.** When a day has
|
||||||
|
≥1 active override, the overrides **replace** the range entries for that day
|
||||||
|
(today's "override beats entry" rule, kept) → the **day-exception** case.
|
||||||
|
|
||||||
|
### Resolution rule
|
||||||
|
|
||||||
|
For a given `(user, day)`:
|
||||||
|
|
||||||
|
1. Active overrides for that exact day (`is_deleted = false`). If any →
|
||||||
|
the cell is the overrides, **newest-first, capped at 3**.
|
||||||
|
2. Otherwise → active entries covering that day
|
||||||
|
(`date_from ≤ day ≤ date_to`, `is_deleted = false`), **newest-first, capped at 3**.
|
||||||
|
3. Otherwise → empty.
|
||||||
|
|
||||||
|
The cell is therefore an array of 0–3 `ResolvedCell` items, all from a single
|
||||||
|
layer (overrides **or** entries, never mixed). This preserves the "exception
|
||||||
|
replaces the day" semantic while allowing additive stacking within each layer.
|
||||||
|
|
||||||
|
**Known limitation (accepted):** a range entry and an exception override cannot
|
||||||
|
show together on the same day — an override is all-or-nothing for that day. This
|
||||||
|
matches what "exception" means.
|
||||||
|
|
||||||
|
### Cap (max 3 per cell)
|
||||||
|
|
||||||
|
`MAX_RECORDS_PER_CELL = 3` (single constant in `plan.service.ts`).
|
||||||
|
|
||||||
|
Enforced on create, per layer, per day:
|
||||||
|
|
||||||
|
- **`createOverride`** — reject if the `(user, day)` already has 3 active
|
||||||
|
overrides.
|
||||||
|
- **`createEntry`** — reject if **any** day in the new `[date_from, date_to]`
|
||||||
|
range is already covered by 3 active entries for that user. The error names the
|
||||||
|
first offending date (Czech message). Cap counts entries regardless of whether
|
||||||
|
they are currently hidden by an override (simpler and predictable).
|
||||||
|
|
||||||
|
`createOverride` **changes from replace-semantics to additive-with-cap**: it no
|
||||||
|
longer soft-deletes the existing override for that day. The transaction +
|
||||||
|
row-lock that protected the old "at most one active override" invariant is
|
||||||
|
repurposed to enforce "at most 3" under concurrency. The `replacedData` /
|
||||||
|
`replacedDescription` plumbing (and the route branch that logged the replaced
|
||||||
|
row's soft-delete) is removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
### `src/services/plan.service.ts`
|
||||||
|
|
||||||
|
- `ResolvedCell` interface unchanged (still one record).
|
||||||
|
- `resolveCell(userId, dateStr)` → returns `ResolvedCell[]` (0–3) using the
|
||||||
|
resolution rule above. Replaces the current single-result + "newest wins"
|
||||||
|
`console.warn`.
|
||||||
|
- `resolveGrid(userIds, from, to)` → `cells[userId][dateStr]` becomes
|
||||||
|
`ResolvedCell[]` (empty array, not `null`, for no records). Same two-query
|
||||||
|
load; per (user, day) build the capped array from the right layer.
|
||||||
|
- `createEntry` → add the per-day entry-cap check before insert.
|
||||||
|
- `createOverride` → drop the soft-delete-replace; add the override-cap check;
|
||||||
|
keep the transaction/lock for race safety; drop `replacedData`.
|
||||||
|
- Add `export const MAX_RECORDS_PER_CELL = 3;`.
|
||||||
|
|
||||||
|
### `src/routes/admin/plan.ts`
|
||||||
|
|
||||||
|
- `POST /plan/overrides` — remove the `result.replacedData` audit branch (no
|
||||||
|
longer produced). Everything else unchanged (cap errors surface via the normal
|
||||||
|
`{ error, status }` path).
|
||||||
|
|
||||||
|
### `src/routes/admin/dashboard.ts`
|
||||||
|
|
||||||
|
- `today_plan` is built from `resolveCell` → now an **array**. Map each record
|
||||||
|
with its category label + colour (the existing enrichment, applied per item).
|
||||||
|
`result.today_plan` becomes `TodayPlan[]` (possibly empty).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend changes
|
||||||
|
|
||||||
|
### Types — `src/admin/lib/queries/plan.ts`
|
||||||
|
|
||||||
|
- `GridData.cells: Record<number, Record<string, ResolvedCell[]>>` (was
|
||||||
|
`ResolvedCell | null`).
|
||||||
|
|
||||||
|
### Grid — `src/admin/components/PlanGrid.tsx` + `PlanRangeChips.tsx`
|
||||||
|
|
||||||
|
- A cell renders **up to 3 stacked records**, each a compact row: category
|
||||||
|
colour bar + category label + project (mono). When the cell has **exactly one**
|
||||||
|
record, also show its note (2-line clamp), as today. With 2–3 records, notes
|
||||||
|
are omitted for density.
|
||||||
|
- `--cat-color` is set per record row (not per cell).
|
||||||
|
- `onCellClick(userId, date, cells)` passes the **array**. Empty cell → still a
|
||||||
|
one-click create. Occupied cell → opens the day panel (below).
|
||||||
|
- Past-day / read-only / weekend / today styling unchanged.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Jan N. · Čt 12 ───────┐
|
||||||
|
│▌ PRÁCE · 26710001 │
|
||||||
|
│▌ DOVOLENÁ │
|
||||||
|
│▌ ŠKOLENÍ · 26710044 │
|
||||||
|
└────────────────────────┘ ▌ = category colour bar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cell editor — `src/admin/components/PlanCellModal.tsx`
|
||||||
|
|
||||||
|
New top-level **day-panel** mode shown when a cell has ≥1 record:
|
||||||
|
|
||||||
|
- Lists the day's records (each: colour bar, category, project, optional note)
|
||||||
|
with **edit (✎)** and **delete (🗑)** per record.
|
||||||
|
- **"+ Přidat záznam"** button, disabled at 3 with a hint
|
||||||
|
("Maximum 3 záznamy na den"). Add targets the **showing layer**: an entry in
|
||||||
|
normal mode, an override in exception mode — so the panel reads simply as
|
||||||
|
"records for this day" and the entry/override distinction stays invisible.
|
||||||
|
- Editing a record reuses the existing `EditForm`. Editing a record that belongs
|
||||||
|
to a **multi-day range** still routes through the existing `day-in-range`
|
||||||
|
chooser ("edit whole range" vs "carve out this day" = create override).
|
||||||
|
- Empty cell skips the panel and opens the create form directly (current
|
||||||
|
behaviour).
|
||||||
|
|
||||||
|
`PlanWork.tsx` wires the new panel: cell-click opens it with the array; the
|
||||||
|
panel's per-record actions reuse the existing entry/override create/update/delete
|
||||||
|
mutations and `invalidate: ["plan"]`.
|
||||||
|
|
||||||
|
### Dashboard — `DashTodayPlan.tsx` + `Dashboard.tsx`
|
||||||
|
|
||||||
|
- `today_plan` prop becomes `TodayPlan[]`. Render up to 3 records stacked
|
||||||
|
(reuse the existing single-record card layout per item). Empty array → the
|
||||||
|
existing "Pro dnešek nemáte naplánováno." state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing (`src/__tests__/plan.test.ts`)
|
||||||
|
|
||||||
|
Update existing assertions for the array shape, and add:
|
||||||
|
|
||||||
|
- `resolveCell` returns `[]` for an empty day; one item for a single entry; the
|
||||||
|
override layer (entries hidden) when an override exists.
|
||||||
|
- **Additive:** two entries covering the same day → both returned (newest first).
|
||||||
|
- **Cap display:** 4 overlapping entries on a day → only 3 returned.
|
||||||
|
- **Cap enforcement:** `createEntry` rejects when a day in range already has 3
|
||||||
|
entries (error names the date); `createOverride` rejects at 3 overrides.
|
||||||
|
- **Additive overrides:** `createOverride` no longer soft-deletes the prior
|
||||||
|
override; two overrides on a day coexist.
|
||||||
|
- `resolveGrid` cell arrays match `resolveCell` for the same (user, day).
|
||||||
|
|
||||||
|
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ships as its own release (**v2.0.5**) via the standard process (commit → tag →
|
||||||
|
Gitea → tarball → prod deploy → health check). No migration step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred: Feature B (bulk plan creation) — decisions captured
|
||||||
|
|
||||||
|
A later, separate spec + cycle. Locked decisions from this brainstorming:
|
||||||
|
|
||||||
|
- **Form:** select one project, a date range (from–to), and multiple employees;
|
||||||
|
create the assignment for all of them at once.
|
||||||
|
- **Category:** a category picker in the bulk form, **default Práce**.
|
||||||
|
- **Weekends:** an **"include weekends" checkbox** (off = Mon–Fri only, which
|
||||||
|
requires splitting each person into per-work-week entries; on = one continuous
|
||||||
|
range).
|
||||||
|
- **Conflict handling:** governed by this feature's **max-3-per-cell** rule — for
|
||||||
|
each employee/day, create the record only if the cell is under the cap;
|
||||||
|
otherwise **skip** that day (no error).
|
||||||
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Odin — Multi-Conversation AI Chat — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (design), pending spec review
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single-thread Odin assistant with a multi-conversation chat: a
|
||||||
|
purpose-built UI on the `/odin` page where the user keeps several named
|
||||||
|
conversations (like topics), switches between them via top tabs, and can
|
||||||
|
rename or delete them. All existing assistant capabilities (chat, PDF invoice
|
||||||
|
extraction → review → save, monthly budget) carry over unchanged.
|
||||||
|
|
||||||
|
## Background (current state)
|
||||||
|
|
||||||
|
- `ai_chat_messages` is a single rolling thread per user (`user_id, role,
|
||||||
|
content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history`
|
||||||
|
read/append/clear that one thread.
|
||||||
|
- The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved
|
||||||
|
off the dashboard in the prior change, branch `feat/odin-page`).
|
||||||
|
- Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`)
|
||||||
|
are unaffected by conversations — they don't persist anything themselves.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Titling:** new conversation auto-titles from its first user message; user
|
||||||
|
can rename.
|
||||||
|
- **Per-conversation actions:** rename + delete.
|
||||||
|
- **Existing history:** migrate the current single thread into one conversation
|
||||||
|
per user ("keep as first conversation"); nothing is lost.
|
||||||
|
- **Layout:** top tabs + chat (not a left sidebar).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `ai_conversations` (new)
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
| ---------- | --------------------- | ----------------------------------- |
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| user_id | INT NOT NULL | owner; isolation key |
|
||||||
|
| title | VARCHAR(255) NOT NULL | default "Nová konverzace" |
|
||||||
|
| created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP |
|
||||||
|
| updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency |
|
||||||
|
|
||||||
|
Index: `idx_ai_conv_user (user_id, id)`.
|
||||||
|
|
||||||
|
### `ai_chat_messages` (modified)
|
||||||
|
|
||||||
|
- Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE
|
||||||
|
CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup).
|
||||||
|
- Replace index `idx_ai_chat_user (user_id, id)` with
|
||||||
|
`idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation).
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ai_conversations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||||
|
messages ai_chat_messages[]
|
||||||
|
|
||||||
|
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_chat_messages {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
conversation_id Int
|
||||||
|
role String @db.VarChar(20)
|
||||||
|
content String @db.Text
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration (hand-written SQL; one tracked migration)
|
||||||
|
|
||||||
|
1. `CREATE TABLE ai_conversations (...)`.
|
||||||
|
2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`.
|
||||||
|
3. Backfill one conversation per user who has messages:
|
||||||
|
`INSERT INTO ai_conversations (user_id, title, created_at, updated_at)
|
||||||
|
SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;`
|
||||||
|
4. Point messages at it:
|
||||||
|
`UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id
|
||||||
|
SET m.conversation_id = c.id;`
|
||||||
|
5. Drop the old index, enforce NOT NULL, add the FK + new index:
|
||||||
|
`ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user,
|
||||||
|
MODIFY conversation_id INT NOT NULL,
|
||||||
|
ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id)
|
||||||
|
REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||||
|
ADD INDEX idx_ai_chat_conv (conversation_id, id);`
|
||||||
|
|
||||||
|
The backfill is a no-op when `ai_chat_messages` is empty (prod today), and
|
||||||
|
preserves the dev thread.
|
||||||
|
|
||||||
|
## Service layer (`ai.service.ts`)
|
||||||
|
|
||||||
|
Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` /
|
||||||
|
`clearChatHistory`) with conversation-scoped ones. Every function takes
|
||||||
|
`userId` and verifies the conversation belongs to the user.
|
||||||
|
|
||||||
|
- `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc
|
||||||
|
(stable tab order).
|
||||||
|
- `createConversation(userId, title?)` → the new conversation (default title
|
||||||
|
"Nová konverzace").
|
||||||
|
- `getConversation(userId, id)` → conversation or null (ownership check helper).
|
||||||
|
- `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest,
|
||||||
|
or `{ error, status }` if not owned/found.
|
||||||
|
- `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**;
|
||||||
|
**auto-title**: if the conversation's title is still the default and this batch
|
||||||
|
has a user message, set `title` = first ~60 chars of that user message.
|
||||||
|
- `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` —
|
||||||
|
ownership-checked; delete cascades messages.
|
||||||
|
|
||||||
|
Auto-title keeps titling server-side and robust (no client title logic).
|
||||||
|
|
||||||
|
## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`)
|
||||||
|
|
||||||
|
| Method | Path | Body / result |
|
||||||
|
| ------ | ----------------------------- | ------------------------------------------ |
|
||||||
|
| GET | `/conversations` | → `{ conversations: [...] }` |
|
||||||
|
| POST | `/conversations` | `{ title? }` → `{ conversation }` |
|
||||||
|
| GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) |
|
||||||
|
| POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) |
|
||||||
|
| PATCH | `/conversations/:id` | `{ title }` (1..255) |
|
||||||
|
| DELETE | `/conversations/:id` | → `{ ok: true }` |
|
||||||
|
|
||||||
|
`:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the
|
||||||
|
service surfaced with `error(reply, ...)`. The old `/history` routes are removed.
|
||||||
|
`/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Queries (`lib/queries/ai.ts`)
|
||||||
|
|
||||||
|
- `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`.
|
||||||
|
- `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`,
|
||||||
|
`GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB,
|
||||||
|
same rationale as today: no stale-cache resurrection).
|
||||||
|
- Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`.
|
||||||
|
|
||||||
|
### Components (`src/admin/components/odin/`)
|
||||||
|
|
||||||
|
- **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy,
|
||||||
|
staged attachments, review list. Loads the conversation list; ensures one
|
||||||
|
exists (creates a default if the user has none); loads the active thread;
|
||||||
|
routes send/extract/save through the active conversation. Full-height flex
|
||||||
|
layout: header → tabs → thread (flex-grow, scroll) → review → composer.
|
||||||
|
- **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) +
|
||||||
|
a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat).
|
||||||
|
Rename uses a small Modal with a text field; delete uses ConfirmDialog.
|
||||||
|
- **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns,
|
||||||
|
user turns right-aligned; the "Pracuji…" indicator; empty-state greeting.
|
||||||
|
(Message text colour set on the `Typography` — GlobalStyles pins `p` to
|
||||||
|
text.secondary.)
|
||||||
|
- **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number,
|
||||||
|
Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit.
|
||||||
|
- **`OdinComposer.tsx`** — staged-attachment chips + attach button + message
|
||||||
|
field (Enter to send) + send button; disabled until the active thread loads.
|
||||||
|
|
||||||
|
`DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to
|
||||||
|
create a default and select it; else select the first (or last-active).
|
||||||
|
2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)`
|
||||||
|
loads that thread into the displayed turns (seed-once per id; submit marks
|
||||||
|
the thread live so a late load can't clobber — same guard as today).
|
||||||
|
3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant
|
||||||
|
reply → `POST /conversations/:id/messages` persists both → bump usage. On
|
||||||
|
error: roll back, keep input.
|
||||||
|
4. **Send (attachments):** `POST /extract-invoices` → review cards →
|
||||||
|
client-side summary turn → persist the user+summary turns. Save a card →
|
||||||
|
`POST /received-invoices` (gross/VAT-inclusive, unchanged).
|
||||||
|
5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]`
|
||||||
|
(and reset the active id appropriately on delete).
|
||||||
|
|
||||||
|
### Reuse
|
||||||
|
|
||||||
|
All proven logic carries over verbatim: staged-attach (no auto-send), the three
|
||||||
|
bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`,
|
||||||
|
NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user),
|
||||||
|
budget indicator, gross/VAT save. The only structural change is "one thread" →
|
||||||
|
"active conversation thread", plus the tab/CRUD shell.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }`
|
||||||
|
for a conversation the user doesn't own; routes surface it. No cross-user read
|
||||||
|
or write is possible (every query filters by `user_id`).
|
||||||
|
- Persistence is best-effort and logged (never swallowed), as today.
|
||||||
|
- Auto-create-default and create/rename/delete failures surface a Czech toast.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Backend (Vitest, real DB):
|
||||||
|
|
||||||
|
- Conversation CRUD: create → list → rename → delete (cascade removes messages).
|
||||||
|
- **Isolation:** user A cannot read/append/rename/delete user B's conversation
|
||||||
|
(404), and `listConversations(A)` excludes B's.
|
||||||
|
- Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the
|
||||||
|
title from the first user message and does not overwrite a renamed title.
|
||||||
|
- HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body.
|
||||||
|
|
||||||
|
Frontend: no component tests (consistent with the codebase); gated by
|
||||||
|
`tsc -b` + `npm run build`. Live verification by the user/controller.
|
||||||
|
|
||||||
|
## Out of scope (Phase 2+)
|
||||||
|
|
||||||
|
- Querying system data inside chat (the original deferred Phase-2 scope).
|
||||||
|
- Conversation search, pinning, folders, sharing.
|
||||||
|
- Streaming responses.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Odin → General Assistant (Phase 2+) — Forward-Looking Notes
|
||||||
|
|
||||||
|
**Date:** 2026-06-08 · **Status:** NOT scheduled — design notes only, recorded so Phase 1 choices don't box us in.
|
||||||
|
|
||||||
|
The vision: Odin grows from "chat + invoice import" into a **general assistant** that can **query system data** (invoices, projects, attendance, vehicles…) and **take actions** (create an order, mark paid, draft a quote…). This note assesses whether the Phase-1 architecture supports that, and what to carry forward deliberately.
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
Phase 1 sets us up well; it does **not** box us in. Two things to carry forward on purpose:
|
||||||
|
|
||||||
|
1. **Structured message storage** (content blocks, not a plain string) — cheap to plan now, costly to retrofit later.
|
||||||
|
2. **The assistant acts strictly as the authenticated user** — every tool runs through the existing permission/ownership layer.
|
||||||
|
|
||||||
|
Everything else (conversations, budget/usage, thin services) is already pointing the right way.
|
||||||
|
|
||||||
|
## What Phase 1 already gets right
|
||||||
|
|
||||||
|
- **Conversations** — multi-turn, persisted, per-user, isolated. Exactly the context substrate an agent needs.
|
||||||
|
- **Budget + usage tracking + `ai.use` gate** — the cost/access spine. Tool loops spend more, so this matters _more_, not less.
|
||||||
|
- **Thin service layer** (`{ data } | { error, status }`, ownership-checked) — ideal to wrap as tools without rewriting business logic.
|
||||||
|
- **The extract → review → save flow** — a perfect template for "propose action → human confirms → execute." The `canSave`/`invoices.create` gate added in Phase 1 is the one-tool version of per-action authorization.
|
||||||
|
|
||||||
|
## The one schema thing to note now
|
||||||
|
|
||||||
|
`ai_chat_messages.content` is a **plain string**. Tool use requires persisting **content blocks** (`text` + `tool_use` + `tool_result`), because the model must see the full tool-call history to continue. When Phase 2 lands, store the **raw content-block array** (JSON column, e.g. `content_json`/`blocks`) and keep a derived plain-text for display/search. Don't change it now — just design Phase 2 persistence as "store the blocks," treating the current string as a lossy projection.
|
||||||
|
|
||||||
|
## The core shift (Phase 2)
|
||||||
|
|
||||||
|
1. **Tool use over the existing services.** Define tools (`list_invoices`, `get_project`, `create_order`, …) whose handlers call the **same service functions the routes use**. The SDK tool-runner loops; we execute. Reuses all validation/business logic and — critically — permissions.
|
||||||
|
2. **Authorization is the hard part.** Every tool MUST run **as the user**, through `requirePermission` / service ownership checks. The assistant must never do what the user can't. It is the user's _delegate_, not a privileged actor.
|
||||||
|
3. **Read vs write.** Read/query tools may run autonomously. Write/destructive tools **propose → confirm → execute** (generalize the invoice review card into a generic "action card"). Aligns with the assistant safety rules (confirm side-effecting actions).
|
||||||
|
4. **Audit.** Every action the assistant takes should `logAudit` like a manual action, attributed to the user with a marker (e.g. `via: "odin"`), for a real trail.
|
||||||
|
5. **Cost.** Tool loops are multi-round-trip → 3–10× the tokens of a chat turn. The $50 cap bites faster; consider per-conversation cost display and per-action budget re-checks (the inter-file budget re-check in `extract-invoices` is the pattern).
|
||||||
|
|
||||||
|
## Data access: tools-over-services, NOT RAG
|
||||||
|
|
||||||
|
Recommendation: **function-calling against the real services**, not embeddings/RAG. The data is structured, live, and permissioned — tools give correct, current, authorized answers. RAG over a snapshot would be stale, would leak across permission boundaries, and would duplicate logic. Reserve embeddings for genuinely unstructured document search if it ever comes up.
|
||||||
|
|
||||||
|
## Other forward notes
|
||||||
|
|
||||||
|
- **Model / effort:** a true agentic assistant wants a stronger model and possibly `effort` tuning; Phase-1 Sonnet single-shot is right for now.
|
||||||
|
- **Streaming:** multi-step tool use feels slow without it — add SSE when Phase 2 lands.
|
||||||
|
- **System prompt:** describe the user's role + available tools + the confirm-before-write rule.
|
||||||
|
- **Managed Agents vs self-hosted loop:** Anthropic's Managed Agents host the loop, but for a self-hosted business app with our own services + permissions, **Claude API + tool use with a self-hosted loop** is the better fit — authorization stays in our stack.
|
||||||
|
|
||||||
|
## What NOT to do prematurely
|
||||||
|
|
||||||
|
- Don't add a tool framework, embeddings, or streaming now — Phase 1 is plain chat + a fixed-prompt extractor, and that's the right scope.
|
||||||
|
- Don't widen `ai.use` to non-admins until the per-action authorization story (point 2) is in place.
|
||||||
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
383
docs/superpowers/specs/2026-06-09-issued-orders-design.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# Objednávky vydané (Issued Purchase Orders) — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved (brainstorm) → ready for implementation plan
|
||||||
|
**Author:** brainstorm session
|
||||||
|
|
||||||
|
> **Updated 2026-06-09 (as-built):** Shipped with three deviations from this
|
||||||
|
> brainstorm — **Vydané-first tabs** (Vydané | Přijaté, mirroring Invoices), a
|
||||||
|
> **shared chevron month/year filter** above the tabs that filters both lists,
|
||||||
|
> and a PDF **rebuilt on the shared invoice/order-confirmation template** rather
|
||||||
|
> than the custom layout sketched below. See the affected sections (UI placement,
|
||||||
|
> PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a new document type — **objednávky vydané** (purchase orders you issue to a
|
||||||
|
supplier) — alongside the existing customer orders. You author the PO with line
|
||||||
|
items, it gets an auto-generated number, you track its status, and you export it
|
||||||
|
to PDF to send to the supplier. The existing orders module becomes the
|
||||||
|
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
|
||||||
|
page already splits **Vydané | Přijaté**.
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
|
||||||
|
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
|
||||||
|
procurement loop (ordered → received → invoiced) is a deliberate later phase.
|
||||||
|
- **No new supplier master.** POs reuse the existing `customers` table as generic
|
||||||
|
business partners.
|
||||||
|
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
|
||||||
|
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
|
||||||
|
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
|
||||||
|
received-document concept and does not apply to documents you create.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions log (resolved during brainstorm)
|
||||||
|
|
||||||
|
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
|
||||||
|
quotation → order → project → invoice) stay as the **Přijaté** side, largely
|
||||||
|
unchanged — only surfaced under a new tab. The new **Vydané** side is a
|
||||||
|
brand-new document type. _(Not a `direction` flag on the existing table.)_
|
||||||
|
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
|
||||||
|
used as a generic business partner. No new supplier/vendor master.
|
||||||
|
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
|
||||||
|
received invoices.
|
||||||
|
4. **UI placement.** **Tabs on the Orders page**, mirroring the Invoices page.
|
||||||
|
_(As-built: **Vydané first / Přijaté second** — `Vydané | Přijaté`, tab values
|
||||||
|
`vydane`/`prijate`, persisted in `?tab=` — matching the Invoices page order. A
|
||||||
|
shared **chevron month/year selector** sits above the tabs and filters BOTH
|
||||||
|
lists: Vydané by `order_date`, Přijaté by `created_at`. The pre-existing Přijaté
|
||||||
|
search bug — search term sent but ignored by the backend — was also fixed.)_
|
||||||
|
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
|
||||||
|
mirroring `invoices`/`invoice_items` — _not_ an extension of `orders` and _not_
|
||||||
|
an upload-only record like `received_invoices`.
|
||||||
|
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
|
||||||
|
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
|
||||||
|
use the same per-line-rounded math as `computeInvoiceTotals`.
|
||||||
|
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
|
||||||
|
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
|
||||||
|
assigned **at creation** (even for a `draft`), with release-on-delete.
|
||||||
|
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
|
||||||
|
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
|
||||||
|
permission set across both tabs; **no permission migration needed**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
A self-contained vertical slice following the codebase's established document
|
||||||
|
pattern (the same shape `invoices` uses):
|
||||||
|
|
||||||
|
```
|
||||||
|
prisma/schema.prisma → models issued_orders, issued_order_items + enum
|
||||||
|
src/schemas/issued-orders.schema.ts
|
||||||
|
src/services/issued-orders.service.ts
|
||||||
|
src/services/numbering.service.ts (extend: issued-order number fns)
|
||||||
|
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
|
||||||
|
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
|
||||||
|
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
|
||||||
|
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
|
||||||
|
src/admin/lib/queries/issued-orders.ts
|
||||||
|
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
|
||||||
|
src/admin/AdminApp.tsx (lazy routes for detail/new)
|
||||||
|
src/__tests__/issued-orders.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
|
||||||
|
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
|
||||||
|
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
|
||||||
|
invalidation, deterministic ordering with an `id` tiebreak, locked
|
||||||
|
read-modify-write for numbering).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `issued_orders` (parallels `invoices`)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
|
||||||
|
| `id` | Int PK autoincrement | |
|
||||||
|
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
|
||||||
|
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
|
||||||
|
| `status` | enum `issued_orders_status` | default `draft` |
|
||||||
|
| `currency` | VARCHAR(10) default `"CZK"` | |
|
||||||
|
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
|
||||||
|
| `apply_vat` | Boolean default `true` | |
|
||||||
|
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
|
||||||
|
| `order_date` | Date | date the PO is issued |
|
||||||
|
| `delivery_date` | Date nullable | requested delivery / required-by |
|
||||||
|
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
|
||||||
|
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||||
|
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
|
||||||
|
| `issued_by` | VARCHAR(255) nullable | signatory name |
|
||||||
|
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
|
||||||
|
| `internal_notes` | Text nullable | private, **not** on PDF |
|
||||||
|
| `created_at` | DateTime default now | |
|
||||||
|
| `modified_at` | DateTime @updatedAt | |
|
||||||
|
|
||||||
|
**Relations:** `issued_order_items[]`, `customers?`.
|
||||||
|
**Indexes:** `(customer_id)`, `(status, order_date)`.
|
||||||
|
|
||||||
|
### `issued_order_items` (parallels `invoice_items`)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| ------------------ | ------------------------------------------- | ------------------------------- |
|
||||||
|
| `id` | Int PK | |
|
||||||
|
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
|
||||||
|
| `description` | VARCHAR(500) | main line text |
|
||||||
|
| `item_description` | Text nullable | optional long detail |
|
||||||
|
| `quantity` | Decimal(12,3) default `1.000` | |
|
||||||
|
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
|
||||||
|
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
|
||||||
|
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
|
||||||
|
| `position` | Int default `0` | ordering |
|
||||||
|
|
||||||
|
**Index:** `(issued_order_id)`.
|
||||||
|
|
||||||
|
### `issued_orders_status` enum
|
||||||
|
|
||||||
|
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
|
||||||
|
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Numbering
|
||||||
|
|
||||||
|
A dedicated sequence, exactly like invoices:
|
||||||
|
|
||||||
|
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
|
||||||
|
as the table already enforces). Separate from `"shared"` and `"invoice"`.
|
||||||
|
- New `company_settings` columns:
|
||||||
|
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
|
||||||
|
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
|
||||||
|
`81`; `72` is free and configurable).
|
||||||
|
- New functions in `src/services/numbering.service.ts`, reusing the existing
|
||||||
|
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
|
||||||
|
first-of-year retry):
|
||||||
|
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
|
||||||
|
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
|
||||||
|
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
|
||||||
|
held the current highest number that year (gap prevention), mirroring
|
||||||
|
`releaseInvoiceNumber`.
|
||||||
|
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
|
||||||
|
releases the number per the rule above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend API
|
||||||
|
|
||||||
|
### Schema — `src/schemas/issued-orders.schema.ts`
|
||||||
|
|
||||||
|
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
|
||||||
|
built **only** from the shared coercers in `src/schemas/common.ts`:
|
||||||
|
|
||||||
|
- `customer_id` → `nullableIntIdFromForm`
|
||||||
|
- `quantity` → `positiveNumberFromForm`; `unit_price` → `nonNegativeNumberFromForm`
|
||||||
|
- `vat_rate` → `numberInRange(0, 100)`
|
||||||
|
- `order_date` / `delivery_date` → `isoDateString` (lenient)
|
||||||
|
- text fields → `z.string().max(...)`; optional email-like none here
|
||||||
|
- Czech user-facing messages; English identifiers.
|
||||||
|
|
||||||
|
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
|
||||||
|
banned (silent `NaN`).
|
||||||
|
|
||||||
|
### Service — `src/services/issued-orders.service.ts`
|
||||||
|
|
||||||
|
Plain exported async functions returning `{ data }` or `{ error, status }`:
|
||||||
|
|
||||||
|
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
|
||||||
|
`search` (po_number / supplier name); whitelist sort
|
||||||
|
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
|
||||||
|
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
|
||||||
|
- `getIssuedOrder(id)` — full detail with items, supplier name, and
|
||||||
|
`valid_transitions`.
|
||||||
|
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
|
||||||
|
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
|
||||||
|
- `updateIssuedOrder(id, body)` — validates the status transition against
|
||||||
|
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
|
||||||
|
is editable (`draft`/`sent`); blocks any `po_number` change.
|
||||||
|
- `deleteIssuedOrder(id)` — delete (items cascade) then
|
||||||
|
`releaseIssuedOrderNumber(year, po_number)`.
|
||||||
|
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
|
||||||
|
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
|
||||||
|
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
|
||||||
|
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
|
||||||
|
Falls back line `vat_rate` → doc `vat_rate` → 21.
|
||||||
|
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
|
||||||
|
|
||||||
|
`VALID_TRANSITIONS`:
|
||||||
|
|
||||||
|
```
|
||||||
|
draft → [sent, cancelled]
|
||||||
|
sent → [confirmed, cancelled]
|
||||||
|
confirmed → [completed, cancelled]
|
||||||
|
completed → [] // terminal
|
||||||
|
cancelled → [] // terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routes — `src/routes/admin/issued-orders.ts`
|
||||||
|
|
||||||
|
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
|
||||||
|
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
|
||||||
|
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
|
||||||
|
with `oldValues`/`newValues`). Adding this entity type requires also registering
|
||||||
|
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
|
||||||
|
`EntityType` value), per the single-source-of-truth convention.
|
||||||
|
|
||||||
|
| Method · Path | Guard | Purpose |
|
||||||
|
| ------------------------------------------ | --------------- | -------------------------- |
|
||||||
|
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
|
||||||
|
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
|
||||||
|
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
|
||||||
|
| POST `/api/admin/issued-orders` | `orders.create` | create |
|
||||||
|
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
|
||||||
|
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
|
||||||
|
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PDF export
|
||||||
|
|
||||||
|
> **As-built (2026-06-09):** rather than the custom/compact layout this section
|
||||||
|
> originally sketched, the issued-order PDF was **rebuilt on the shared
|
||||||
|
> invoice / order-confirmation red-accent (#de3a3a) template** (`src/routes/admin/issued-orders-pdf.ts`),
|
||||||
|
> titled **OBJEDNÁVKA**, for visual consistency with the rest of the document
|
||||||
|
> suite. Because a PO is a buying document, the direction is **flipped vs an
|
||||||
|
> invoice**: the selected **customer record renders as "Dodavatel" (supplier)**
|
||||||
|
> and **your company as "Odběratel" (buyer)**. It is generated **on demand** and
|
||||||
|
> streamed as `application/pdf` — **no NAS persistence**. Sanitization,
|
||||||
|
> on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
|
||||||
|
|
||||||
|
New route + template `src/routes/admin/issued-orders-pdf.ts`, reusing
|
||||||
|
`htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
|
||||||
|
|
||||||
|
- **Dodavatel (supplier) block** = the selected `customers` record (name,
|
||||||
|
IČO/`company_id`, DIČ/`vat_id`, address). **Odběratel (buyer) block** = your
|
||||||
|
company (`company_settings`: name, IČO, DIČ, address, logo as base64).
|
||||||
|
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
|
||||||
|
- `cs` / `en` strings selected by `language`. Czech date/currency via the
|
||||||
|
existing formatters.
|
||||||
|
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
|
||||||
|
used by invoices/orders before going into the template (mandatory for any new
|
||||||
|
rich-text-on-PDF field).
|
||||||
|
- **v1 behavior:** generate on demand and stream `application/pdf` with
|
||||||
|
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
|
||||||
|
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
|
||||||
|
isolated follow-up.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **`src/admin/pages/Orders.tsx` — add `Vydané | Přijaté` tabs** using the same
|
||||||
|
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
|
||||||
|
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
|
||||||
|
and permission check preserved unchanged; only presentation is reorganized. The
|
||||||
|
**Vydané** tab renders the new list.
|
||||||
|
_(As-built: `Orders.tsx` became a **parent shell** that owns the `PageHeader` +
|
||||||
|
the shared month/year selector + the tabs; `IssuedOrders.tsx` (Vydané) and
|
||||||
|
`OrdersReceived.tsx` (Přijaté) are **content-only children** receiving `month`/`year`
|
||||||
|
props. `OrdersReceived`'s create modal is **hoisted** to the shell via
|
||||||
|
`createOpen`/`setCreateOpen`, the ReceivedInvoices pattern. **No KPI/stat cards**
|
||||||
|
on the Orders landing — deliberate, since orders have no paid/overdue semantics.)_
|
||||||
|
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
|
||||||
|
status chip (color per status), `order_date`, total (currency), actions (open,
|
||||||
|
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
|
||||||
|
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
|
||||||
|
vydanou" button gated on `orders.create`.
|
||||||
|
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
|
||||||
|
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
|
||||||
|
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
|
||||||
|
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
|
||||||
|
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
|
||||||
|
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
|
||||||
|
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
|
||||||
|
imports come from the `ui/` kit.
|
||||||
|
- **Queries** `src/admin/lib/queries/issued-orders.ts` — `issuedOrderListOptions`,
|
||||||
|
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
|
||||||
|
invalidate the **broad `["issued-orders"]`** domain key.
|
||||||
|
- **Rules of Hooks:** all hooks run before any early permission `return`
|
||||||
|
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
|
||||||
|
`react-hooks/rules-of-hooks` as an error.
|
||||||
|
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
Reuse the existing `orders.*` set for the Vydané tab:
|
||||||
|
|
||||||
|
- `orders.view` — list + detail + PDF view
|
||||||
|
- `orders.create` — create + next-number preview
|
||||||
|
- `orders.edit` — update
|
||||||
|
- `orders.delete` — delete
|
||||||
|
- `orders.export` — PDF export (already seeded)
|
||||||
|
|
||||||
|
This mirrors invoices (issued + received share one `invoices.*` set) and needs
|
||||||
|
**no permission migration**. _(Future option, out of scope for v1: a dedicated
|
||||||
|
`purchase-orders._` set via a seed + migration if purchasing must be locked down
|
||||||
|
separately from sales orders.)\*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status lifecycle
|
||||||
|
|
||||||
|
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
|
||||||
|
|
||||||
|
- **Editable** (header + items): `draft`, `sent`.
|
||||||
|
- **Read-only except a permitted status change**: `confirmed`, `completed`,
|
||||||
|
`cancelled`.
|
||||||
|
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
|
||||||
|
page only renders buttons for valid next states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
|
||||||
|
`buildApp()` (no Prisma mocks):
|
||||||
|
|
||||||
|
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
|
||||||
|
generated number matches the configured pattern.
|
||||||
|
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
|
||||||
|
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
|
||||||
|
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
|
||||||
|
- **Status transitions:** every valid transition succeeds; invalid transitions
|
||||||
|
return the proper error; items become read-only after `confirmed`.
|
||||||
|
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
|
||||||
|
permission (not bare auth).
|
||||||
|
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
A single `prisma migrate dev --name issued_orders`:
|
||||||
|
|
||||||
|
1. Creates `issued_orders` and `issued_order_items` tables + the
|
||||||
|
`issued_orders_status` enum.
|
||||||
|
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
|
||||||
|
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
|
||||||
|
|
||||||
|
Per project rules: **ask the user to stop the dev server first**; commit both
|
||||||
|
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
|
||||||
|
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
|
||||||
|
|
||||||
|
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
|
||||||
|
`npx vitest run`, `npm run lint` (0 errors).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future phases (explicitly out of scope for v1)
|
||||||
|
|
||||||
|
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
|
||||||
|
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
|
||||||
|
Odin pre-filling a received invoice from a PO.
|
||||||
|
- **NAS persistence** of the generated PO PDF (`?save=1`).
|
||||||
|
- **Dedicated `purchase-orders.*` permission set.**
|
||||||
|
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.
|
||||||
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
52
docs/superpowers/specs/2026-06-12-sidebar-icons-design.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Sidebar icon refresh — design
|
||||||
|
|
||||||
|
**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
All 31 sidebar items have icons, but 13 share a glyph with another item
|
||||||
|
(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury
|
||||||
|
uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat
|
||||||
|
the purpose of icons as unique visual anchors.
|
||||||
|
|
||||||
|
## Decisions (user-approved)
|
||||||
|
|
||||||
|
1. **Full refresh** of all items (option B over targeted fix).
|
||||||
|
2. **Tile language (option B of 3 rendered directions):** every item's glyph
|
||||||
|
sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a
|
||||||
|
**soft color wash — one muted hue per menu section**:
|
||||||
|
Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` ·
|
||||||
|
Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`.
|
||||||
|
3. **Odin stays unique:** the only **solid** (gradient) tile and the only
|
||||||
|
animated icon. No animation anywhere else. Red stays reserved for
|
||||||
|
Odin/primary.
|
||||||
|
4. **Glyph set** approved on the full-set screen: receipt for Faktury,
|
||||||
|
business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování,
|
||||||
|
ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for
|
||||||
|
Inventura, shelf rack for Lokace, arrows into/out of a box for
|
||||||
|
Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for
|
||||||
|
Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti,
|
||||||
|
timesheet for Moje historie (docházka), shapes for Kategorie, chart frame
|
||||||
|
for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique
|
||||||
|
in the set): clock (Záznam docházky), calendar (Plán prací), people
|
||||||
|
(Uživatelé), gear (Nastavení), box (Položky).
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark
|
||||||
|
mains, full main/light/dark/contrastText so cssVariables emits `*Channel`
|
||||||
|
tokens) + TS module augmentation.
|
||||||
|
- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs
|
||||||
|
(stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for
|
||||||
|
Odin (its icon ships its own tile).
|
||||||
|
- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor:
|
||||||
|
rgba(<hue>.mainChannel / 0.12)`, glyph color `<hue>.main`, svg 16 px)
|
||||||
|
around every non-`rawIcon` item. No hardcoded hex; washes are
|
||||||
|
channel-alpha so both schemes work.
|
||||||
|
- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique
|
||||||
|
static markup (no two items may ever share a glyph again).
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Animation for non-Odin icons (explicitly rejected), page-level icons,
|
||||||
|
mobile drawer changes (it reuses SidebarNav).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Per-item Sleva (% discount) — Offers + Invoices
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Approved → implementing
|
||||||
|
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
|
||||||
|
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
|
||||||
|
editor and the PDF. Received invoices, issued orders and orders are NOT
|
||||||
|
touched.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Type:** percentage per item (0–100), not an absolute amount.
|
||||||
|
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
|
||||||
|
computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
- **PDF column is conditional:** render the Sleva column only when at least one
|
||||||
|
(included) item has `discount > 0`; otherwise the table is identical to today.
|
||||||
|
- **Detail-page column is always visible** (it's the input).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Add to `quotation_items` and `invoice_items`:
|
||||||
|
|
||||||
|
```
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100
|
||||||
|
```
|
||||||
|
|
||||||
|
Nullable with default `0`, matching the existing money columns. "No sleva" =
|
||||||
|
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
|
||||||
|
`prisma generate`.
|
||||||
|
|
||||||
|
## Line math (single rule)
|
||||||
|
|
||||||
|
`lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)`
|
||||||
|
|
||||||
|
Add a shared backend helper so every site is identical:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/money.ts (new)
|
||||||
|
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
|
||||||
|
qty * unitPrice * (1 - (discountPct || 0) / 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
|
||||||
|
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
|
||||||
|
`0`.
|
||||||
|
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
|
||||||
|
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
|
||||||
|
item create/update.
|
||||||
|
- **invoices.service**: every `computeMoney` `getBase` callback
|
||||||
|
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
|
||||||
|
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
|
||||||
|
net. Persist `discount` on item create/update.
|
||||||
|
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
|
||||||
|
discounted net.
|
||||||
|
|
||||||
|
## Frontend (Section 1 — detail-page editor)
|
||||||
|
|
||||||
|
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
|
||||||
|
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100);
|
||||||
|
per-line total and the footer net total apply it. Mobile card layout gets the
|
||||||
|
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
|
||||||
|
carry `discount`.
|
||||||
|
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
|
||||||
|
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
|
||||||
|
same column; net/VAT/gross totals apply it. Mobile layout too.
|
||||||
|
|
||||||
|
## PDF (Section 2 — conditional column)
|
||||||
|
|
||||||
|
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
|
||||||
|
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
|
||||||
|
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
|
||||||
|
colspans of any full-width rows accordingly.
|
||||||
|
- Line net / document total / VAT always use the discounted net regardless of
|
||||||
|
`hasDiscount`.
|
||||||
|
- `escapeHtml` every interpolation (string-built PDF templates).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
|
||||||
|
(incl. a 100% line = 0, and VAT-on-discounted-net).
|
||||||
|
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
|
||||||
|
- PDF: column present when an item has a discount; absent when none; values
|
||||||
|
escaped.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- No separate "total discount" summary line, no document-level discount.
|
||||||
|
- No changes to received invoices, issued orders, or orders.
|
||||||
|
|
||||||
|
## Migration etiquette
|
||||||
|
|
||||||
|
DB change → user stops the dev server first; create the migration via the
|
||||||
|
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
|
||||||
|
dev and `app_test`, commit schema + migration folder together.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Per-document custom-field print selection
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Approved design — ready for implementation plan
|
||||||
|
**Scope:** offers (quotations), issued orders, invoices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Company custom fields are defined in company settings (`company_settings.custom_fields`,
|
||||||
|
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
|
||||||
|
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
|
||||||
|
|
||||||
|
Today **every** custom field with a value prints on **every** document PDF (offer, issued
|
||||||
|
order, invoice). The only existing per-field control is the company-settings checkbox
|
||||||
|
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
|
||||||
|
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
|
||||||
|
|
||||||
|
The user wants per-document control: on each offer / issued order / invoice **detail page**,
|
||||||
|
tick which company custom fields actually print on that specific document's PDF.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Each offer, issued order, and invoice independently selects which company custom fields
|
||||||
|
print in its PDF sender block.
|
||||||
|
- Selection lives on the document and is editable on its detail page.
|
||||||
|
- Default is **none selected** — existing documents and new drafts print no custom fields
|
||||||
|
until fields are deliberately ticked.
|
||||||
|
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
|
||||||
|
behavior (print all custom fields).
|
||||||
|
- No change to how custom fields are _defined_ in company settings.
|
||||||
|
- No stable per-field IDs — selection is **positional** (see Decisions).
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
|
||||||
|
document prints custom fields only for the indices it explicitly stores.
|
||||||
|
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
|
||||||
|
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
|
||||||
|
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
|
||||||
|
saved document's stored indices point at. Low risk — company fields rarely change, and
|
||||||
|
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
|
||||||
|
over the more robust stable-ID approach.
|
||||||
|
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
|
||||||
|
4. **`showLabel` retained** in company settings, untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data storage
|
||||||
|
|
||||||
|
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
|
||||||
|
|
||||||
|
| Column | Type | Meaning |
|
||||||
|
| ------------------------ | --------- | ------------------------------------------------------------- |
|
||||||
|
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
|
||||||
|
|
||||||
|
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
|
||||||
|
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
|
||||||
|
|
||||||
|
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
|
||||||
|
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
|
||||||
|
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
|
||||||
|
|
||||||
|
### 2. Backend — schemas
|
||||||
|
|
||||||
|
Add to each document's **Create and Update** Zod schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/schemas/offers.schema.ts` — `CreateQuotationSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/issued-orders.schema.ts` — `CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
|
||||||
|
(invoices define the two schemas separately).
|
||||||
|
|
||||||
|
### 3. Backend — services
|
||||||
|
|
||||||
|
In each create/update service, persist the field inside the **existing transaction**:
|
||||||
|
|
||||||
|
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
|
||||||
|
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
|
||||||
|
- No other logic changes; sits alongside the existing header column assignments.
|
||||||
|
|
||||||
|
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
|
||||||
|
`src/services/invoices.service.ts`.
|
||||||
|
|
||||||
|
### 4. Backend — detail responses
|
||||||
|
|
||||||
|
The detail endpoints spread the document row. Decode the stored string back into a
|
||||||
|
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
|
||||||
|
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
|
||||||
|
`selected_custom_fields: number[]` to the corresponding detail interfaces in
|
||||||
|
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
|
||||||
|
|
||||||
|
### 5. PDF rendering
|
||||||
|
|
||||||
|
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
|
||||||
|
company-block `buildAddressLines()` with an optional parameter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
|
||||||
|
includes `i`. Behavior:
|
||||||
|
|
||||||
|
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
|
||||||
|
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
|
||||||
|
DIČ/`vat_id`) are **unaffected** — always built as today.
|
||||||
|
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
|
||||||
|
`Value`) and `field_order` ordering logic is preserved unchanged.
|
||||||
|
|
||||||
|
Only the **company/sender** block is filtered (that's where company custom fields render).
|
||||||
|
The customer block (offers/invoices) and supplier block (issued orders) keep their own
|
||||||
|
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
|
||||||
|
settings, and are not in scope.
|
||||||
|
|
||||||
|
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
|
||||||
|
and passes it into `buildAddressLines(settings, …, selected)`.
|
||||||
|
|
||||||
|
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
|
||||||
|
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
|
||||||
|
|
||||||
|
### 6. Frontend — shared picker component
|
||||||
|
|
||||||
|
New shared component under `src/admin/components/document/` (e.g.
|
||||||
|
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
|
||||||
|
don't fork three copies — per CLAUDE.md document-module conventions).
|
||||||
|
|
||||||
|
Props (shape): the parsed company custom fields (`{name, value}[]` from
|
||||||
|
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
|
||||||
|
`disabled` flag.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
|
||||||
|
so the user knows what each is.
|
||||||
|
- Bound to local state on the detail page, seeded from the document's
|
||||||
|
`selected_custom_fields`.
|
||||||
|
- Hidden/empty when the company has no custom fields defined.
|
||||||
|
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
|
||||||
|
isn't editable or is locked by another user, matching how the rest of the header fields
|
||||||
|
behave on that page.
|
||||||
|
- Included in the page's save payload as `selected_custom_fields: number[]`.
|
||||||
|
|
||||||
|
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
|
||||||
|
each already loads `companySettingsOptions()`, so the field definitions are available with
|
||||||
|
no new query.
|
||||||
|
|
||||||
|
Placement: in the editable header/meta area of each detail page, near the other
|
||||||
|
document-level settings (currency/language/etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
|
||||||
|
idx 0 idx 1 idx 2
|
||||||
|
|
||||||
|
Offer detail page → user ticks "Tel." and "Datová schránka"
|
||||||
|
→ save payload selected_custom_fields: [0, 2]
|
||||||
|
→ service stores quotations.selected_custom_fields = "[0,2]"
|
||||||
|
|
||||||
|
Offer PDF render
|
||||||
|
→ read column "[0,2]" → [0,2]
|
||||||
|
→ buildAddressLines(settings, …, [0,2])
|
||||||
|
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
|
||||||
|
→ sender block prints Tel. and Datová schránka only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
|
||||||
|
|
||||||
|
- Create/update each document with `selected_custom_fields` → column persists the JSON
|
||||||
|
string; empty/omitted → `NULL`.
|
||||||
|
- Detail response decodes to `number[]` (and `[]` for `NULL`).
|
||||||
|
- Schema: non-integer / negative entries rejected; omitted is valid.
|
||||||
|
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
|
||||||
|
selected custom field lines and still includes standard address lines; empty selection ⇒
|
||||||
|
no custom lines; standard lines unaffected.
|
||||||
|
- Regression: customer/supplier custom fields still render regardless of company selection.
|
||||||
|
|
||||||
|
## Migration & rollout
|
||||||
|
|
||||||
|
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
|
||||||
|
`app_test`.
|
||||||
|
- No data backfill (none-selected default is the intended post-ship state).
|
||||||
|
- No production PDF changes for already-archived documents.
|
||||||
|
|
||||||
|
## Affected files (reference)
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` (+ new migration folder)
|
||||||
|
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
|
||||||
|
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
|
||||||
|
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
|
||||||
|
- (detail route handlers, if decoding is done there rather than in the service)
|
||||||
|
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
|
||||||
|
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Status quick actions + project⇄order sync
|
||||||
|
|
||||||
|
**Date:** 2026-07-04 · **Status:** Approved (interactive design w/ owner) · **Scope:** offers, received orders, projects
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Status changes are inconsistent: the Invoices list has a quick chip action (click → confirm → paid),
|
||||||
|
but Offers/ReceivedOrders/Projects tables have inert chips — every change needs the detail page.
|
||||||
|
ProjectDetail edits status via a free combobox in the form (no transitions, no confirm), unlike
|
||||||
|
OfferDetail/OrderDetail's transition buttons. Projects have **no status machine at all** (free-text
|
||||||
|
column). Order→project completion sync exists one-way (`syncProjectStatus`), but finishing a
|
||||||
|
**project** leaves its linked order untouched.
|
||||||
|
|
||||||
|
## Decisions (owner)
|
||||||
|
|
||||||
|
1. **Chip opens a menu** of valid next states (not single-click-advance) → ConfirmDialog per pick,
|
||||||
|
danger styling + cascade notes on destructive/irreversible ones.
|
||||||
|
2. **Reopen allowed**: `dokonceny/zruseny → aktivni` (projects), `dokoncena/stornovana → v_realizaci`
|
||||||
|
(received orders). **Reopening never cascades** to the linked record.
|
||||||
|
3. **Sync scope: finish + cancel, both directions.** Project `dokonceny` ⇄ order `dokoncena`;
|
||||||
|
project `zruseny` ⇄ order `stornovana`.
|
||||||
|
4. Offers quick menu: `Aktivovat` (draft — same number-assignment + PDF-archive semantics as the
|
||||||
|
detail), `Zneplatnit` (danger), and **"Vytvořit objednávku…" which opens the existing
|
||||||
|
create-order modal** (never a raw label flip to `ordered` — that state is owned by the
|
||||||
|
create-order flow).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
**Projects gain a status machine** (`src/services/projects.service.ts`):
|
||||||
|
`VALID_TRANSITIONS = { aktivni: [dokonceny, zruseny], dokonceny: [aktivni], zruseny: [aktivni] }`.
|
||||||
|
`updateProject` validates status changes (token `invalid_transition` → Czech 400 in the route);
|
||||||
|
a legacy/unknown current status may transition to any canonical value (tolerant). `getProject`
|
||||||
|
returns `valid_transitions` (same contract as offers/orders).
|
||||||
|
|
||||||
|
**Project → order cascade** (new), inside one transaction with the project update:
|
||||||
|
|
||||||
|
- project → `dokonceny` ⇒ linked order → `dokoncena` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- project → `zruseny` ⇒ linked order → `stornovana` **iff** order status ∈ {prijata, v_realizaci}
|
||||||
|
- reopen ⇒ no order change; completed/cancelled orders are never resurrected by a cascade
|
||||||
|
- direct `tx` write (no service recursion); sibling projects of the same order are NOT touched
|
||||||
|
(multi-project orders are a rare schema possibility, not a flow; avoid surprise mass updates)
|
||||||
|
- the service returns what cascaded so the route writes an audit row for the order change too
|
||||||
|
|
||||||
|
**Received orders** (`src/services/orders.service.ts`):
|
||||||
|
|
||||||
|
- `VALID_TRANSITIONS` gains reopen: `dokoncena: [v_realizaci]`, `stornovana: [v_realizaci]`
|
||||||
|
- `syncProjectStatus` becomes reopen-aware: given the previous status, a transition OUT of a
|
||||||
|
terminal state (reopen) skips project sync entirely (decision 2). Forward transitions keep the
|
||||||
|
existing mapping (v_realizaci→aktivni no-op in practice, dokoncena→dokonceny,
|
||||||
|
stornovana→zruseny), and the cascaded project change now gets an audit row.
|
||||||
|
- item/section edit guards stay status-based, so a reopened order is editable again (intended).
|
||||||
|
|
||||||
|
No DB migration (status columns are strings already).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
**Shared `StatusChipMenu`** (`src/admin/components/StatusChipMenu.tsx`): renders a `StatusChip`;
|
||||||
|
click opens a small MUI `Menu` of actions; each action either opens a `ConfirmDialog`
|
||||||
|
(danger variant + `loading` during the request; cascade note in the message) or runs a plain
|
||||||
|
`onClick` (the modal-opening offer item). Hidden affordance fixed via `title` tooltip. Chip is
|
||||||
|
plain (non-clickable) when the user lacks the edit permission or no actions exist.
|
||||||
|
|
||||||
|
**Offers list**: draft → `Aktivovat` (confirm notes the number assignment; after success the same
|
||||||
|
fire-and-forget PDF-archive call the detail does); active → `Vytvořit objednávku…` (opens the
|
||||||
|
existing modal) + `Zneplatnit` (danger); ordered → `Zneplatnit` (danger).
|
||||||
|
|
||||||
|
**ReceivedOrders list**: prijata → `Zahájit realizaci`, `Stornovat` (danger, note: linked project
|
||||||
|
will be cancelled); v_realizaci → `Dokončit` (note: linked project will be completed),
|
||||||
|
`Stornovat` (danger); dokoncena/stornovana → `Obnovit` (reopen, no cascade note).
|
||||||
|
|
||||||
|
**Projects list**: aktivni → `Dokončit` (note when `order_id` present: linked order will be
|
||||||
|
completed), `Zrušit` (danger, order-storno note); dokonceny/zruseny → `Obnovit`.
|
||||||
|
|
||||||
|
**ProjectDetail**: status `Select` removed from the form (and status removed from the save
|
||||||
|
payload); header gains transition buttons rendered from `valid_transitions`
|
||||||
|
(`Dokončit projekt` / `Zrušit projekt` danger / `Obnovit projekt`), each via ConfirmDialog with
|
||||||
|
cascade notes, as a status-only PUT with its own mutation
|
||||||
|
(invalidate: projects, orders, offers, invoices, warehouse). OrderDetail keeps its pattern;
|
||||||
|
its transition label shows `Obnovit` when reopening from a terminal state.
|
||||||
|
|
||||||
|
Invalidation for all new status mutations: `["orders","offers","projects","invoices"]`
|
||||||
|
(+`["warehouse"]` on project mutations, matching the page's existing set).
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Service-level (real `app_test` DB): project transition validation (all legal/illegal edges incl.
|
||||||
|
legacy-status tolerance), project→order cascade both mappings + the guard (no cascade onto
|
||||||
|
dokoncena/stornovana orders), reopen-no-cascade both directions, order reopen transitions, and
|
||||||
|
regression: order→project sync still fires on forward transitions.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Offer machine changes (unchanged: draft→active→ordered/invalidated), issued orders, sibling-project
|
||||||
|
propagation, ReceivedOrders detail-page button changes beyond the automatic reopen button.
|
||||||
78
eslint.config.mjs
Normal file
78
eslint.config.mjs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import globals from "globals";
|
||||||
|
import prettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"dist-client/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"prisma/migrations/**",
|
||||||
|
"src/admin/bones/**",
|
||||||
|
"*.config.js",
|
||||||
|
"*.config.mjs",
|
||||||
|
"*.config.ts",
|
||||||
|
".claude/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
// Server + shared code
|
||||||
|
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.node, ...globals.browser },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// TS already resolves identifiers — `no-undef` only false-positives on
|
||||||
|
// global types/ambient names in .ts files.
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-require-imports": "warn",
|
||||||
|
"no-empty": ["warn", { allowEmptyCatch: false }],
|
||||||
|
// Intentional control-character regexes (NUL/path-traversal guards,
|
||||||
|
// combining-marks stripping) — not a defect here.
|
||||||
|
"no-control-regex": "off",
|
||||||
|
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-useless-assignment": "warn",
|
||||||
|
"preserve-caught-error": "warn",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Frontend React code
|
||||||
|
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
|
||||||
|
files: ["scripts/**/*.{js,mjs,cjs}"],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
rules: { "no-undef": "off" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Tests
|
||||||
|
files: ["src/__tests__/**/*.ts"],
|
||||||
|
languageOptions: { globals: { ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettier,
|
||||||
|
);
|
||||||
@@ -13,13 +13,13 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<title>Admin</title>
|
<title>BOHA Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4569
package-lock.json
generated
4569
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.3.8",
|
"version": "2.4.43",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,68 +14,88 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:pull": "prisma db pull",
|
"db:pull": "prisma db pull",
|
||||||
"db:push": "prisma db push",
|
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"seed": "tsx prisma/seed.ts",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@mui/material": "^7.3.11",
|
||||||
|
"@mui/x-date-pickers": "^8.29.0",
|
||||||
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"file-type": "^16.5.4",
|
"file-type": "^22.0.1",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"jsdom": "^29.0.2",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"mariadb": "^3.5.2",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^7.8.0",
|
||||||
"puppeteer": "^24.40.0",
|
"puppeteer": "^24.40.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.7",
|
||||||
"react-datepicker": "^9.1.0",
|
"react-dom": "^19.2.7",
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-quill-new": "^3.8.3",
|
"react-quill-new": "^3.8.3",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/mysql": "^2.15.27",
|
|
||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.6.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.61.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@hono/node-server": "^1.19.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
// Prisma 7 moved the datasource connection URL and seed config out of
|
||||||
|
// schema.prisma / package.json into this file. `import "dotenv/config"` is
|
||||||
|
// required because Prisma 7 no longer auto-loads .env. The runtime database
|
||||||
|
// connection itself is made via the driver adapter in src/config/database.ts.
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
seed: "tsx prisma/seed.ts",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add billing_text column to invoices table
|
|
||||||
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Add lock fields to quotations table for pessimistic locking
|
|
||||||
ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`;
|
|
||||||
ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
CREATE TABLE `invoice_alert_log` (
|
|
||||||
`id` INT NOT NULL AUTO_INCREMENT,
|
|
||||||
`invoice_type` VARCHAR(20) NOT NULL,
|
|
||||||
`invoice_id` INT NOT NULL,
|
|
||||||
`alert_type` VARCHAR(20) NOT NULL,
|
|
||||||
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add file_path column for NAS storage of received invoice files
|
|
||||||
ALTER TABLE `received_invoices` ADD COLUMN `file_path` VARCHAR(500) NULL AFTER `file_data`;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- Remove file_data (BLOB) and file_path columns — files are now stored on NAS
|
|
||||||
-- Path is derived from year, month, and file_name
|
|
||||||
ALTER TABLE `received_invoices` DROP COLUMN `file_data`;
|
|
||||||
ALTER TABLE `received_invoices` DROP COLUMN `file_path`;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
ALTER TABLE `company_settings`
|
|
||||||
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
|
|
||||||
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
|
|
||||||
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ALTER TABLE `company_settings`
|
|
||||||
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
-- System settings columns on company_settings
|
|
||||||
ALTER TABLE `company_settings`
|
|
||||||
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
|
|
||||||
ADD COLUMN `break_duration_short` INT DEFAULT 15,
|
|
||||||
ADD COLUMN `break_duration_long` INT DEFAULT 30,
|
|
||||||
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
|
|
||||||
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
|
|
||||||
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
|
|
||||||
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
|
|
||||||
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
|
|
||||||
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
|
|
||||||
ADD COLUMN `available_currencies` LONGTEXT NULL;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
-- Create new unified permission
|
|
||||||
INSERT INTO permissions (name, display_name, description, module)
|
|
||||||
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
|
|
||||||
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
|
|
||||||
|
|
||||||
-- Grant to all roles that had any of the old 3
|
|
||||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
|
||||||
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
|
|
||||||
FROM role_permissions rp
|
|
||||||
JOIN permissions p ON p.id = rp.permission_id
|
|
||||||
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
|
|
||||||
|
|
||||||
-- Clean up old role_permissions
|
|
||||||
DELETE FROM role_permissions
|
|
||||||
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
|
|
||||||
|
|
||||||
-- Remove old permissions
|
|
||||||
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');
|
|
||||||
@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
|
|||||||
`vat_id` VARCHAR(50) NULL,
|
`vat_id` VARCHAR(50) NULL,
|
||||||
`custom_fields` LONGTEXT NULL,
|
`custom_fields` LONGTEXT NULL,
|
||||||
`logo_data` LONGBLOB NULL,
|
`logo_data` LONGBLOB NULL,
|
||||||
|
`logo_data_dark` MEDIUMBLOB NULL,
|
||||||
`quotation_prefix` VARCHAR(20) NULL,
|
`quotation_prefix` VARCHAR(20) NULL,
|
||||||
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||||
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||||
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
|
|||||||
`order_type_code` VARCHAR(10) NULL,
|
`order_type_code` VARCHAR(10) NULL,
|
||||||
`invoice_type_code` VARCHAR(10) NULL,
|
`invoice_type_code` VARCHAR(10) NULL,
|
||||||
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
|
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`break_threshold_hours` DECIMAL(4, 2) NULL DEFAULT 6.00,
|
||||||
|
`break_duration_short` INTEGER NULL DEFAULT 15,
|
||||||
|
`break_duration_long` INTEGER NULL DEFAULT 30,
|
||||||
|
`clock_rounding_minutes` INTEGER NULL DEFAULT 15,
|
||||||
|
`invoice_alert_email` VARCHAR(255) NULL,
|
||||||
|
`leave_notify_email` VARCHAR(255) NULL,
|
||||||
|
`max_login_attempts` INTEGER NULL DEFAULT 5,
|
||||||
|
`lockout_minutes` INTEGER NULL DEFAULT 15,
|
||||||
|
`max_requests_per_minute` INTEGER NULL DEFAULT 300,
|
||||||
|
`available_vat_rates` LONGTEXT NULL,
|
||||||
|
`available_currencies` LONGTEXT NULL,
|
||||||
|
`smtp_from` VARCHAR(255) NULL,
|
||||||
|
`smtp_from_name` VARCHAR(255) NULL,
|
||||||
|
`offer_number_pattern` VARCHAR(100) NULL,
|
||||||
|
`order_number_pattern` VARCHAR(100) NULL,
|
||||||
|
`invoice_number_pattern` VARCHAR(100) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `company_settings_uuid_key`(`uuid`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
|
|||||||
`tax_date` DATE NULL,
|
`tax_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`issued_by` VARCHAR(255) NULL,
|
`issued_by` VARCHAR(255) NULL,
|
||||||
|
`billing_text` VARCHAR(500) NULL,
|
||||||
|
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||||
`notes` TEXT NULL,
|
`notes` TEXT NULL,
|
||||||
`internal_notes` TEXT NULL,
|
`internal_notes` TEXT NULL,
|
||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_invoices_due_date`(`due_date`),
|
INDEX `idx_invoices_due_date`(`due_date`),
|
||||||
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
|
||||||
|
INDEX `idx_invoices_status_due`(`status`, `due_date`),
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
|
|||||||
`year` INTEGER NULL,
|
`year` INTEGER NULL,
|
||||||
`last_number` INTEGER NULL DEFAULT 0,
|
`last_number` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `quotation_id`(`quotation_id`),
|
INDEX `quotation_id`(`quotation_id`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
|
|||||||
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `projects_project_number_key`(`project_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
|
||||||
INDEX `order_id`(`order_id`),
|
INDEX `order_id`(`order_id`),
|
||||||
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
|
|||||||
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||||
`scope_title` VARCHAR(500) NULL,
|
`scope_title` VARCHAR(500) NULL,
|
||||||
`scope_description` TEXT NULL,
|
`scope_description` TEXT NULL,
|
||||||
|
`locked_by` INTEGER NULL,
|
||||||
|
`locked_at` DATETIME(0) NULL,
|
||||||
`uuid` VARCHAR(36) NULL,
|
`uuid` VARCHAR(36) NULL,
|
||||||
`modified_at` DATETIME(0) NULL,
|
`modified_at` DATETIME(0) NULL,
|
||||||
`sync_version` INTEGER NULL DEFAULT 0,
|
`sync_version` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
|
||||||
INDEX `customer_id`(`customer_id`),
|
INDEX `customer_id`(`customer_id`),
|
||||||
INDEX `idx_quotations_number`(`quotation_number`),
|
INDEX `idx_quotations_number`(`quotation_number`),
|
||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
|
|||||||
`due_date` DATE NULL,
|
`due_date` DATE NULL,
|
||||||
`paid_date` DATE NULL,
|
`paid_date` DATE NULL,
|
||||||
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
|
||||||
`file_data` MEDIUMBLOB NULL,
|
|
||||||
`file_name` VARCHAR(255) NULL,
|
`file_name` VARCHAR(255) NULL,
|
||||||
`file_mime` VARCHAR(100) NULL,
|
`file_mime` VARCHAR(100) NULL,
|
||||||
`file_size` INTEGER UNSIGNED NULL,
|
`file_size` INTEGER UNSIGNED NULL,
|
||||||
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
|
|||||||
`totp_secret` VARCHAR(255) NULL,
|
`totp_secret` VARCHAR(255) NULL,
|
||||||
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
|
||||||
`totp_backup_codes` TEXT NULL,
|
`totp_backup_codes` TEXT NULL,
|
||||||
|
`totp_last_used_counter` INTEGER NULL,
|
||||||
|
|
||||||
UNIQUE INDEX `username`(`username`),
|
UNIQUE INDEX `username`(`username`),
|
||||||
UNIQUE INDEX `email`(`email`),
|
UNIQUE INDEX `email`(`email`),
|
||||||
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
|
|||||||
PRIMARY KEY (`id`)
|
PRIMARY KEY (`id`)
|
||||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `invoice_alert_log` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`invoice_type` VARCHAR(20) NOT NULL,
|
||||||
|
`invoice_id` INTEGER NOT NULL,
|
||||||
|
`alert_type` VARCHAR(20) NOT NULL,
|
||||||
|
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
|
||||||
|
UNIQUE INDEX `invoice_type_invoice_id_alert_type`(`invoice_type`, `invoice_id`, `alert_type`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -674,4 +718,3 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
|
|||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Insert the settings.system permission (also available via seed)
|
||||||
|
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
|
||||||
|
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
|
||||||
|
ON DUPLICATE KEY UPDATE `name` = `name`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable: quotation_items
|
||||||
|
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
|
||||||
|
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
|
||||||
|
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
|
||||||
|
|
||||||
|
-- AlterTable: quotations
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `uuid`;
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
|
||||||
|
|
||||||
|
-- AlterTable: scope_sections
|
||||||
|
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
|
||||||
|
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
|
||||||
|
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
|
||||||
|
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_categories` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_suppliers` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`ico` VARCHAR(20) NULL,
|
||||||
|
`dic` VARCHAR(20) NULL,
|
||||||
|
`contact_person` VARCHAR(255) NULL,
|
||||||
|
`email` VARCHAR(255) NULL,
|
||||||
|
`phone` VARCHAR(50) NULL,
|
||||||
|
`address` TEXT NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_locations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`code` VARCHAR(20) NOT NULL,
|
||||||
|
`name` VARCHAR(100) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sklad_locations_code_key`(`code`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_items` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_number` VARCHAR(50) NULL,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`category_id` INTEGER NULL,
|
||||||
|
`unit` VARCHAR(20) NOT NULL,
|
||||||
|
`min_quantity` DECIMAL(12, 3) NULL,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sklad_items_item_number_key`(`item_number`),
|
||||||
|
INDEX `sklad_items_category_id`(`category_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_batches` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`receipt_line_id` INTEGER NOT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`original_qty` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`unit_price` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`received_at` DATETIME(0) NULL,
|
||||||
|
`is_consumed` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sklad_batches_receipt_line_id_key`(`receipt_line_id`),
|
||||||
|
INDEX `sklad_batches_fifo`(`item_id`, `is_consumed`, `received_at`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_item_locations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`location_id` INTEGER NOT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
UNIQUE INDEX `sklad_item_locations_unique`(`item_id`, `location_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_receipts` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`receipt_number` VARCHAR(50) NULL,
|
||||||
|
`supplier_id` INTEGER NULL,
|
||||||
|
`delivery_note_number` VARCHAR(100) NULL,
|
||||||
|
`delivery_note_date` DATETIME(0) NULL,
|
||||||
|
`received_by` INTEGER NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_receipt_lines` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`receipt_id` INTEGER NOT NULL,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`unit_price` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`location_id` INTEGER NULL,
|
||||||
|
`notes` VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
INDEX `sklad_receipt_lines_receipt_id`(`receipt_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_receipt_attachments` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`receipt_id` INTEGER NOT NULL,
|
||||||
|
`file_name` VARCHAR(255) NOT NULL,
|
||||||
|
`file_mime` VARCHAR(100) NOT NULL,
|
||||||
|
`file_size` INTEGER NOT NULL,
|
||||||
|
`file_path` VARCHAR(500) NOT NULL,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_issues` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`issue_number` VARCHAR(50) NULL,
|
||||||
|
`project_id` INTEGER NOT NULL,
|
||||||
|
`issued_by` INTEGER NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_issue_lines` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`issue_id` INTEGER NOT NULL,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`batch_id` INTEGER NOT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`location_id` INTEGER NULL,
|
||||||
|
`reservation_id` INTEGER NULL,
|
||||||
|
`notes` VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
INDEX `sklad_issue_lines_issue_id`(`issue_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_reservations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`project_id` INTEGER NOT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`remaining_qty` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`reserved_by` INTEGER NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`status` ENUM('ACTIVE', 'FULFILLED', 'CANCELLED') NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `sklad_reservations_item_status`(`item_id`, `status`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_inventory_sessions` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`session_number` VARCHAR(50) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`status` ENUM('DRAFT', 'CONFIRMED') NOT NULL DEFAULT 'DRAFT',
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `sklad_inventory_lines` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`session_id` INTEGER NOT NULL,
|
||||||
|
`item_id` INTEGER NOT NULL,
|
||||||
|
`location_id` INTEGER NULL,
|
||||||
|
`system_qty` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`actual_qty` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`difference` DECIMAL(12, 3) NOT NULL,
|
||||||
|
`notes` VARCHAR(255) NULL,
|
||||||
|
|
||||||
|
INDEX `sklad_inventory_lines_session_id`(`session_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_items` ADD CONSTRAINT `sklad_items_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `sklad_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_receipt_line_id_fkey` FOREIGN KEY (`receipt_line_id`) REFERENCES `sklad_receipt_lines`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_supplier_id_fkey` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_received_by_fkey` FOREIGN KEY (`received_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_batch_id_fkey` FOREIGN KEY (`batch_id`) REFERENCES `sklad_batches`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_reservation_id_fkey` FOREIGN KEY (`reservation_id`) REFERENCES `sklad_reservations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_reserved_by_fkey` FOREIGN KEY (`reserved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `company_settings` ADD COLUMN `warehouse_inventory_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `warehouse_inventory_prefix` VARCHAR(20) NULL,
|
||||||
|
ADD COLUMN `warehouse_issue_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `warehouse_issue_prefix` VARCHAR(20) NULL,
|
||||||
|
ADD COLUMN `warehouse_receipt_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `warehouse_receipt_prefix` VARCHAR(20) NULL;
|
||||||
|
|
||||||
|
-- Set backward-compatible defaults for existing installations
|
||||||
|
UPDATE `company_settings`
|
||||||
|
SET
|
||||||
|
`warehouse_receipt_prefix` = 'PRI',
|
||||||
|
`warehouse_receipt_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
|
||||||
|
`warehouse_issue_prefix` = 'VYD',
|
||||||
|
`warehouse_issue_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
|
||||||
|
`warehouse_inventory_prefix` = 'INV',
|
||||||
|
`warehouse_inventory_number_pattern` = '{PREFIX}-{YYYY}-{NNN}';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Add warehouse module permissions and assign them to the admin role.
|
||||||
|
-- These rows used to be created by `prisma/seed.ts`, which is dev-only and
|
||||||
|
-- must not be run against production (per CLAUDE.md). This migration is
|
||||||
|
-- idempotent: re-running it is a no-op.
|
||||||
|
--
|
||||||
|
-- The 4 permissions match `prisma/seed.ts` lines 284-308 exactly.
|
||||||
|
|
||||||
|
-- 1. Insert the 4 warehouse permissions (INSERT IGNORE skips on duplicate
|
||||||
|
-- name, so re-running the migration is safe).
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('warehouse.view', 'Zobrazit sklad', 'warehouse', 'Prohlížet stav skladu, položky, reporty a historii pohybů', NOW()),
|
||||||
|
('warehouse.operate', 'Příjem a výdej', 'warehouse', 'Vytvářet a potvrzovat příjmy, výdeje a rezervace', NOW()),
|
||||||
|
('warehouse.manage', 'Správa skladu', 'warehouse', 'Spravovat katalog materiálů, dodavatele, lokace a kategorie', NOW()),
|
||||||
|
('warehouse.inventory', 'Inventura', 'warehouse', 'Vytvářet a potvrzovat inventurní sčítkání', NOW());
|
||||||
|
|
||||||
|
-- 2. Assign all 4 permissions to the admin role. INSERT IGNORE on the
|
||||||
|
-- composite primary key (role_id, permission_id) makes this idempotent
|
||||||
|
-- and preserves any other role assignments that already exist.
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name IN (
|
||||||
|
'warehouse.view',
|
||||||
|
'warehouse.operate',
|
||||||
|
'warehouse.manage',
|
||||||
|
'warehouse.inventory'
|
||||||
|
);
|
||||||
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
61
prisma/migrations/20260605120000_add_work_plan/migration.sql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
-- Add work_plan_entries and work_plan_overrides tables plus the plan_category
|
||||||
|
-- enum. Both tables are owned by users (Cascade on delete) and reference
|
||||||
|
-- projects (SetNull). `created_by` uses Restrict so we never silently lose
|
||||||
|
-- the audit trail of who created the entry.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `work_plan_entries` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`date_from` DATE NOT NULL,
|
||||||
|
`date_to` DATE NOT NULL,
|
||||||
|
`project_id` INTEGER NULL,
|
||||||
|
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL DEFAULT 'work',
|
||||||
|
`note` VARCHAR(500) NOT NULL,
|
||||||
|
`created_by` INTEGER NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||||
|
|
||||||
|
INDEX `idx_wpe_user_from`(`user_id`, `date_from`),
|
||||||
|
INDEX `idx_wpe_user_to`(`user_id`, `date_to`),
|
||||||
|
INDEX `idx_wpe_project`(`project_id`),
|
||||||
|
INDEX `idx_wpe_dates`(`date_from`, `date_to`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `work_plan_overrides` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`shift_date` DATE NOT NULL,
|
||||||
|
`project_id` INTEGER NULL,
|
||||||
|
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL,
|
||||||
|
`note` VARCHAR(500) NOT NULL,
|
||||||
|
`created_by` INTEGER NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`is_deleted` BOOLEAN NULL DEFAULT false,
|
||||||
|
|
||||||
|
INDEX `idx_wpo_date`(`shift_date`),
|
||||||
|
UNIQUE INDEX `uniq_wpo_user_date`(`user_id`, `shift_date`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Drop the unique index on (user_id, shift_date) for work_plan_overrides.
|
||||||
|
-- MySQL unique indexes don't ignore soft-deleted rows, which would prevent
|
||||||
|
-- createOverride from soft-deleting an existing override and creating a new
|
||||||
|
-- one for the same (user_id, shift_date). Application code enforces the
|
||||||
|
-- "at most one active override per (user_id, shift_date)" invariant.
|
||||||
|
-- The application-level check is wrapped in a transaction; see
|
||||||
|
-- src/services/plan.service.ts:createOverride.
|
||||||
|
--
|
||||||
|
-- The original index was created in
|
||||||
|
-- 20260605120000_add_work_plan/migration.sql as `uniq_wpo_user_date`.
|
||||||
|
|
||||||
|
-- IMPORTANT ordering: `uniq_wpo_user_date` is the only index covering the
|
||||||
|
-- `user_id` foreign key, and MySQL refuses to drop an index still needed by a
|
||||||
|
-- FK. So we CREATE the replacement non-unique index FIRST (giving the FK
|
||||||
|
-- another covering index), THEN drop the unique one. The reverse order fails
|
||||||
|
-- with "Cannot drop index ... needed in a foreign key constraint" on a fresh
|
||||||
|
-- apply.
|
||||||
|
|
||||||
|
-- Add a non-unique index for the (user_id, shift_date) lookup used by
|
||||||
|
-- createOverride's "is there an active override for this user-day?" query
|
||||||
|
-- and by resolveCell / resolveGrid. This is a plain index, not a unique
|
||||||
|
-- one — multiple rows (active and soft-deleted) can share the same
|
||||||
|
-- (user_id, shift_date) pair.
|
||||||
|
CREATE INDEX `idx_wpo_user_date` ON `work_plan_overrides`(`user_id`, `shift_date`);
|
||||||
|
|
||||||
|
DROP INDEX `uniq_wpo_user_date` ON `work_plan_overrides`;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Add plan_categories table and convert category columns from ENUM to VARCHAR(50)
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `plan_categories` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`key` VARCHAR(50) NOT NULL,
|
||||||
|
`label` VARCHAR(100) NOT NULL,
|
||||||
|
`color` VARCHAR(7) NOT NULL,
|
||||||
|
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`is_active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
|
||||||
|
UNIQUE INDEX `plan_categories_key_key`(`key`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_entries
|
||||||
|
ALTER TABLE `work_plan_entries` MODIFY `category` VARCHAR(50) NOT NULL DEFAULT 'work';
|
||||||
|
|
||||||
|
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_overrides
|
||||||
|
ALTER TABLE `work_plan_overrides` MODIFY `category` VARCHAR(50) NOT NULL;
|
||||||
|
|
||||||
|
-- Seed the categories that were previously the plan_category enum
|
||||||
|
INSERT INTO `plan_categories` (`key`, `label`, `color`, `sort_order`, `is_active`) VALUES
|
||||||
|
('work', 'Práce', '#2563eb', 1, true),
|
||||||
|
('preparation', 'Příprava', '#0d9488', 2, true),
|
||||||
|
('travel', 'Cesta / Montáž', '#ca8a04', 3, true),
|
||||||
|
('leave', 'Dovolená', '#16a34a', 4, true),
|
||||||
|
('sick', 'Nemoc', '#dc2626', 5, true),
|
||||||
|
('training', 'Školení', '#7c3aed', 6, true),
|
||||||
|
('other', 'Jiné', '#6b7280', 7, true);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||||
|
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||||
|
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||||
|
|
||||||
|
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||||
|
|
||||||
|
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name IN ('projects.create');
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_usage` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NULL,
|
||||||
|
`kind` VARCHAR(20) NOT NULL,
|
||||||
|
`model` VARCHAR(50) NOT NULL,
|
||||||
|
`input_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`output_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_usage_created` (`created_at`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `company_settings`
|
||||||
|
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
|
||||||
|
|
||||||
|
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
|
||||||
|
-- the warehouse-permissions migration.
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name = 'ai.use';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AI assistant (Phase 1): per-user chat history (server-side, permanent).
|
||||||
|
|
||||||
|
CREATE TABLE `ai_chat_messages` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`role` VARCHAR(20) NOT NULL,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_chat_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_conversations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||||
|
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||||
|
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||||
|
FROM `ai_chat_messages`
|
||||||
|
GROUP BY `user_id`;
|
||||||
|
|
||||||
|
UPDATE `ai_chat_messages` m
|
||||||
|
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||||
|
SET m.`conversation_id` = c.`id`;
|
||||||
|
|
||||||
|
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
DROP INDEX `idx_ai_chat_user`,
|
||||||
|
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||||
|
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||||
|
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||||
|
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- Audit schema hardening (from the 2026-06-09 codebase audit).
|
||||||
|
--
|
||||||
|
-- ⚠️ PENDING — NOT YET APPLIED. Review, then apply with the dev server stopped:
|
||||||
|
-- npx prisma migrate deploy (prod / already-tracked)
|
||||||
|
-- -- or, in dev, `npx prisma migrate dev` will pick it up as pending.
|
||||||
|
--
|
||||||
|
-- What this does:
|
||||||
|
-- • Warehouse line→header relations now CASCADE on delete (deleting a
|
||||||
|
-- receipt/issue/inventory session removes its lines/attachments).
|
||||||
|
-- • Warehouse line→master-data (item/batch/location) and the financial FKs
|
||||||
|
-- (invoices/orders/projects/quotations → customer/order/quotation) are now
|
||||||
|
-- explicit ON DELETE RESTRICT — preventing deletion of a referenced master
|
||||||
|
-- record / orphaning of a financial record. (Some of these were previously
|
||||||
|
-- the optional-relation default SET NULL; RESTRICT is the safer intent.)
|
||||||
|
-- • UNIQUE on warehouse document numbers (receipt_number/issue_number/
|
||||||
|
-- session_number) — like every other document-number column.
|
||||||
|
-- • New indexes on hot FK filters (sklad_issues/receipts, bank_accounts).
|
||||||
|
--
|
||||||
|
-- ⚠️ BEFORE APPLYING ON PRODUCTION, verify existing data won't violate the new
|
||||||
|
-- constraints: no DUPLICATE non-null receipt/issue/session numbers (else the
|
||||||
|
-- UNIQUE index fails), and no rows that would break the RESTRICT FKs. Test on
|
||||||
|
-- a copy first.
|
||||||
|
--
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_2`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_2`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_2`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_3`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `quotations` DROP FOREIGN KEY `quotations_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_receipt_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_location_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_attachments` DROP FOREIGN KEY `sklad_receipt_attachments_receipt_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_issue_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_location_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_session_id_fkey`;
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_location_id_fkey`;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `idx_bank_accounts_is_default` ON `bank_accounts`(`is_default`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `sklad_receipts_receipt_number_key` ON `sklad_receipts`(`receipt_number`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `sklad_receipts_supplier_id` ON `sklad_receipts`(`supplier_id`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `sklad_receipts_received_by` ON `sklad_receipts`(`received_by`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `sklad_issues_issue_number_key` ON `sklad_issues`(`issue_number`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `sklad_issues_project_id` ON `sklad_issues`(`project_id`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `sklad_issues_issued_by` ON `sklad_issues`(`issued_by`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX `sklad_inventory_sessions_session_number_key` ON `sklad_inventory_sessions`(`session_number`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_3` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `quotations` ADD CONSTRAINT `quotations_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
52
prisma/migrations/20260609085152_issued_orders/migration.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `issued_orders` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`po_number` VARCHAR(50) NULL,
|
||||||
|
`customer_id` INTEGER NULL,
|
||||||
|
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
|
||||||
|
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
|
||||||
|
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||||
|
`apply_vat` BOOLEAN NULL DEFAULT true,
|
||||||
|
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
|
||||||
|
`order_date` DATE NULL,
|
||||||
|
`delivery_date` DATE NULL,
|
||||||
|
`language` VARCHAR(5) NULL DEFAULT 'cs',
|
||||||
|
`delivery_terms` VARCHAR(500) NULL,
|
||||||
|
`payment_terms` VARCHAR(500) NULL,
|
||||||
|
`issued_by` VARCHAR(255) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`internal_notes` TEXT NULL,
|
||||||
|
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
|
||||||
|
INDEX `issued_orders_customer_id`(`customer_id`),
|
||||||
|
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `issued_order_items` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`issued_order_id` INTEGER NOT NULL,
|
||||||
|
`description` VARCHAR(500) NULL,
|
||||||
|
`item_description` TEXT NULL,
|
||||||
|
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
|
||||||
|
`unit` VARCHAR(20) NULL,
|
||||||
|
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
|
||||||
|
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
|
||||||
|
INDEX `issued_order_id`(`issued_order_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `company_settings` ADD COLUMN `project_number_pattern` VARCHAR(100) NULL,
|
||||||
|
ADD COLUMN `project_type_code` VARCHAR(10) NULL;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Drop the three DEAD document "export" permissions and make the 12 surviving
|
||||||
|
-- document permissions reproducible on a fresh production database.
|
||||||
|
--
|
||||||
|
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
|
||||||
|
-- backend — every PDF/export route is guarded by `*.view`. They only ever
|
||||||
|
-- toggled frontend buttons and showed up as dead checkboxes in role management.
|
||||||
|
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
|
||||||
|
-- enforced on >=1 route and must survive.
|
||||||
|
--
|
||||||
|
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
|
||||||
|
-- fresh production DB never had ANY of them. This migration both removes the 3
|
||||||
|
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
|
||||||
|
-- prisma/seed.ts column set exactly (name, display_name, module, description,
|
||||||
|
-- created_at; role_permissions has only the composite PK).
|
||||||
|
--
|
||||||
|
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
|
||||||
|
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
|
||||||
|
-- unique `permissions.name` key and on the `role_permissions` composite PK.
|
||||||
|
|
||||||
|
-- 1. Remove the dead export role assignments first (FK-safe: children before
|
||||||
|
-- parents). No-op when the permissions/assignments don't exist.
|
||||||
|
DELETE FROM `role_permissions`
|
||||||
|
WHERE `permission_id` IN (
|
||||||
|
SELECT `id` FROM `permissions`
|
||||||
|
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Remove the dead export permissions themselves.
|
||||||
|
DELETE FROM `permissions`
|
||||||
|
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
|
||||||
|
|
||||||
|
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
|
||||||
|
-- IGNORE skips any that already exist (unique `name`). display_name /
|
||||||
|
-- description / module match prisma/seed.ts verbatim.
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
|
||||||
|
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
|
||||||
|
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
|
||||||
|
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
|
||||||
|
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
|
||||||
|
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
|
||||||
|
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
|
||||||
|
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
|
||||||
|
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
|
||||||
|
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
|
||||||
|
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
|
||||||
|
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
|
||||||
|
|
||||||
|
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
|
||||||
|
-- PK (role_id, permission_id) makes this idempotent and preserves any other
|
||||||
|
-- role assignments that already exist.
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name IN (
|
||||||
|
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
|
||||||
|
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
|
||||||
|
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
|
||||||
|
ADD COLUMN `supplier_id` INTEGER NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
|
||||||
|
DROP COLUMN `vat_rate`;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
|
||||||
|
ADD COLUMN `locked_by` INTEGER NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `issued_order_sections` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`issued_order_id` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
`title` VARCHAR(500) NULL,
|
||||||
|
`title_cz` VARCHAR(500) NULL,
|
||||||
|
`content` TEXT NULL,
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `issued_order_sections_order_id`(`issued_order_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
|
||||||
|
DROP COLUMN `issued_by`,
|
||||||
|
DROP COLUMN `notes`,
|
||||||
|
DROP COLUMN `payment_terms`;
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
|
||||||
|
-- the printed notes block; notes become internal-only. Existing printed notes
|
||||||
|
-- are preserved by merging them into internal_notes before the column drop
|
||||||
|
-- (both are Quill HTML — concatenation is valid markup).
|
||||||
|
|
||||||
|
-- Preserve data: move printed notes into internal_notes
|
||||||
|
UPDATE `invoices`
|
||||||
|
SET `internal_notes` = CASE
|
||||||
|
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
|
||||||
|
ELSE CONCAT(`internal_notes`, `notes`)
|
||||||
|
END
|
||||||
|
WHERE `notes` IS NOT NULL AND `notes` <> '';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` DROP COLUMN `notes`;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `invoice_sections` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`invoice_id` INTEGER NOT NULL,
|
||||||
|
`position` INTEGER NULL DEFAULT 0,
|
||||||
|
`title` VARCHAR(500) NULL,
|
||||||
|
`title_cz` VARCHAR(500) NULL,
|
||||||
|
`content` TEXT NULL,
|
||||||
|
`modified_at` DATETIME(0) NULL,
|
||||||
|
|
||||||
|
INDEX `invoice_sections_invoice_id`(`invoice_id`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Suppliers get a structured address (street/city/postal_code/country) like
|
||||||
|
-- customers; the single free-text `address` blob is dropped. Existing data is
|
||||||
|
-- split best-effort: newlines normalized to ", ", first comma segment ->
|
||||||
|
-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city.
|
||||||
|
-- Unparseable one-segment addresses keep their full text in `street`.
|
||||||
|
|
||||||
|
-- AlterTable: add the structured columns
|
||||||
|
ALTER TABLE `sklad_suppliers`
|
||||||
|
ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`,
|
||||||
|
ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`,
|
||||||
|
ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`,
|
||||||
|
ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`;
|
||||||
|
|
||||||
|
-- Preserve data: best-effort split of the legacy free-text address
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET
|
||||||
|
`street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''),
|
||||||
|
`postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'),
|
||||||
|
`city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE(
|
||||||
|
SUBSTRING(
|
||||||
|
REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '),
|
||||||
|
CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2
|
||||||
|
),
|
||||||
|
'[0-9]{3}[ ]?[0-9]{2}', ''))), '')
|
||||||
|
WHERE `address` IS NOT NULL AND `address` <> '';
|
||||||
|
|
||||||
|
-- AlterTable: drop the legacy blob
|
||||||
|
ALTER TABLE `sklad_suppliers` DROP COLUMN `address`;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- Suppliers become a full mirror of the customers model: dedicated
|
||||||
|
-- contact_person/email/phone/notes columns are replaced by the same
|
||||||
|
-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}],
|
||||||
|
-- "field_order":[]}). Existing contact data is preserved as custom fields.
|
||||||
|
|
||||||
|
-- AlterTable: add the custom_fields blob
|
||||||
|
ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`;
|
||||||
|
|
||||||
|
-- Seed an empty container for rows that carry any contact data
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY())
|
||||||
|
WHERE COALESCE(`contact_person`, '') <> ''
|
||||||
|
OR COALESCE(`email`, '') <> ''
|
||||||
|
OR COALESCE(`phone`, '') <> ''
|
||||||
|
OR COALESCE(`notes`, '') <> '';
|
||||||
|
|
||||||
|
-- Preserve data: each legacy column becomes one labeled custom field
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`contact_person`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`email`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`phone`, '') <> '';
|
||||||
|
|
||||||
|
UPDATE `sklad_suppliers`
|
||||||
|
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
|
||||||
|
JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE))
|
||||||
|
WHERE COALESCE(`notes`, '') <> '';
|
||||||
|
|
||||||
|
-- AlterTable: drop the dedicated columns
|
||||||
|
ALTER TABLE `sklad_suppliers`
|
||||||
|
DROP COLUMN `contact_person`,
|
||||||
|
DROP COLUMN `email`,
|
||||||
|
DROP COLUMN `phone`,
|
||||||
|
DROP COLUMN `notes`;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Odin Phase 2a: assistant turns carry structured metadata (tool-call trace,
|
||||||
|
-- later full content blocks). `content` stays the plain-text display
|
||||||
|
-- projection; `content_json` holds the structured form. Anticipated by the
|
||||||
|
-- Phase-2 design notes (2026-06-08).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `ai_chat_messages` ADD COLUMN `content_json` LONGTEXT NULL AFTER `content`;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0);
|
||||||
|
|
||||||
|
|
||||||
|
-- Stock invariants enforced by the database itself: a future cumulative-
|
||||||
|
-- decrement bug (the C2 class — duplicate issue lines driving a batch
|
||||||
|
-- negative and silently vanishing from stock totals) becomes a loud error
|
||||||
|
-- instead of hidden corruption. All databases verified clean of negative
|
||||||
|
-- rows before this migration (2026-06-12).
|
||||||
|
ALTER TABLE `sklad_batches`
|
||||||
|
ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE `sklad_item_locations`
|
||||||
|
ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user