Compare commits
578 Commits
feature/sh
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11a6224fb3 | ||
|
|
8f407b45a9 | ||
|
|
1a64115063 | ||
|
|
7c42087854 | ||
|
|
14c89dec24 | ||
|
|
b1bdf0ac11 | ||
|
|
7635676289 | ||
|
|
903a9475b1 | ||
|
|
13984fcfd4 | ||
|
|
2bd6c19c13 | ||
|
|
374595261f | ||
|
|
b6c056dd1a | ||
|
|
81e1872c1f | ||
|
|
f085f87107 | ||
|
|
d9700a9b2c | ||
|
|
9da6dced01 | ||
|
|
9dfc1fa375 | ||
|
|
5cce3bc613 | ||
|
|
c53ab511bf | ||
|
|
7b9a16fd72 | ||
|
|
8830af2cbb | ||
|
|
b915de2b93 | ||
|
|
29b8c1b2af | ||
|
|
c2b231d5cc | ||
|
|
53a28cf489 | ||
|
|
e8ccc2a533 | ||
|
|
f24c0d8955 | ||
|
|
069ae6ccb1 | ||
|
|
01a580d992 | ||
|
|
c2e670c9a2 | ||
|
|
25042baf71 | ||
|
|
e8d21ee187 | ||
|
|
a8d1446b0d | ||
|
|
2082934cd5 | ||
|
|
4debcd6aa5 | ||
|
|
76adb89caf | ||
|
|
703a6425fe | ||
|
|
e2c9e19b83 | ||
|
|
e2a749e0b6 | ||
|
|
2c0b0ac4c5 | ||
|
|
dd511b236f | ||
|
|
2c860b0cc8 | ||
|
|
f81d0f9594 | ||
|
|
c18b289e4f | ||
|
|
b762bb5b18 | ||
|
|
7c7f955bb5 | ||
|
|
1e6045c534 | ||
|
|
2a9e12a495 | ||
|
|
e0f268e4a1 | ||
|
|
d39bddc22f | ||
|
|
591d348ae5 | ||
|
|
fc88c14b94 | ||
|
|
fd2fc793df | ||
|
|
8380b354cc | ||
|
|
2aaf0fc19a | ||
|
|
f3b7479770 | ||
|
|
65745e368f | ||
|
|
cabd05e0da | ||
|
|
6e71a91d6c | ||
|
|
7dc3dedda5 | ||
|
|
944acf99db | ||
|
|
a9d0244ca2 | ||
|
|
b688f8e114 | ||
|
|
e7e9cfce8c | ||
|
|
27605b4d68 | ||
|
|
ff4b0ed315 | ||
|
|
fe7c15ced1 | ||
|
|
15ff69a031 | ||
|
|
22d78222d3 | ||
|
|
3a6af8bd07 | ||
|
|
4ffc3465e3 | ||
|
|
4b54f2cc17 | ||
|
|
67a75685a9 | ||
|
|
20ce0c9687 | ||
|
|
070c643105 | ||
|
|
980af8bd4f | ||
|
|
1931856d31 | ||
|
|
fe3fb5e627 | ||
|
|
ef47df804f | ||
|
|
7ea05d2982 | ||
|
|
daa65465dd | ||
|
|
de26417247 | ||
|
|
69b2fe1a90 | ||
|
|
5c327a3a21 | ||
|
|
123a912e64 | ||
|
|
78516d8d6c | ||
|
|
9946c9a00e | ||
|
|
4887383dc4 | ||
|
|
a6863d8280 | ||
|
|
edf81dbe57 | ||
|
|
300d4763f5 | ||
|
|
80f5febb51 | ||
|
|
aaaac09212 | ||
|
|
c4400d178f | ||
|
|
a6286eb2b8 | ||
|
|
ee75aead78 | ||
|
|
e3c002b088 | ||
|
|
3beed3dae6 | ||
|
|
33aace71c8 | ||
|
|
da791e2cb7 | ||
|
|
354b9422ed | ||
|
|
3373dccc58 | ||
|
|
06da0d20b9 | ||
|
|
256f1eb3a9 | ||
|
|
5f86ba6b13 | ||
|
|
5addcee0b2 | ||
|
|
3419d7e81d | ||
|
|
78d960bf01 | ||
|
|
8dc3133814 | ||
|
|
1709fad733 | ||
|
|
7f71e8952b | ||
|
|
22d2023e2a | ||
|
|
f73c151da2 | ||
|
|
fa08463e65 | ||
|
|
7226932247 | ||
|
|
6adf40f5a6 | ||
|
|
5214368354 | ||
|
|
f03f6e6f5d | ||
|
|
a112d39321 | ||
|
|
4788294bc4 | ||
|
|
d7416ff793 | ||
|
|
d89247eb02 | ||
|
|
5318f95037 | ||
|
|
c80b00ec76 | ||
|
|
f48476478a | ||
|
|
737f7cada2 | ||
|
|
833879e0e8 | ||
|
|
cb5610d99b | ||
|
|
0434bbb73b | ||
|
|
6e911d79fc | ||
|
|
0bb748cf89 | ||
|
|
ba5d4975af | ||
|
|
d4620148bd | ||
|
|
8d7d54be78 | ||
|
|
c34b94c7db | ||
|
|
55a0028e26 | ||
|
|
17371200ca | ||
|
|
83044077d3 | ||
|
|
a03d9ef6a4 | ||
|
|
fca8ace10d | ||
|
|
d970cbb626 | ||
|
|
6d8c475e67 | ||
|
|
a1c0cef149 | ||
|
|
8f098143fd | ||
|
|
407dc416ec | ||
|
|
61461bb776 | ||
|
|
2a7019c64c | ||
|
|
5b1cbb0c46 | ||
|
|
3d67145af7 | ||
|
|
1c981312d4 | ||
|
|
02d814b935 | ||
|
|
e87f33dcc8 | ||
|
|
6286d4315d | ||
|
|
a1234b8af0 | ||
|
|
bc0a62002b | ||
|
|
52eca27619 | ||
|
|
e4c9e22972 | ||
|
|
20e7d3c51a | ||
|
|
6d5aa0ccab | ||
|
|
7618ae7c6a | ||
|
|
808731387b | ||
|
|
502726cd83 | ||
|
|
a26d8d0f90 | ||
|
|
747088e7cc | ||
|
|
affbbbffbf | ||
|
|
d3023618e1 | ||
|
|
12b430a349 | ||
|
|
2f3e555b5a | ||
|
|
2498effce3 | ||
|
|
2ad3c2dcd4 | ||
|
|
6226ae35ff | ||
|
|
26de439fab | ||
|
|
295d7a92df | ||
|
|
e20ab86d6e | ||
|
|
5c938586b8 | ||
|
|
93b67fba07 | ||
|
|
e4dfae9f1d | ||
|
|
0efcbf448b | ||
|
|
f2f12a2dfa | ||
|
|
ea37b55078 | ||
|
|
cc0ff20ca1 | ||
|
|
6b58709848 | ||
|
|
f2b1262e3d | ||
|
|
7c04598264 | ||
|
|
7def564950 | ||
|
|
278e221c75 | ||
|
|
d9a5f76449 | ||
|
|
b9b707410d | ||
|
|
87675cc73c | ||
|
|
0e083868cb | ||
|
|
94977f7255 | ||
|
|
cf686ef8c5 | ||
|
|
857b48e225 | ||
|
|
f846230d59 | ||
|
|
bcfa18b1e8 | ||
|
|
bb8e6ee60f | ||
|
|
6ebdd78855 | ||
|
|
70cca5d4c0 | ||
|
|
6448d24e20 | ||
|
|
5fb2633bc5 | ||
|
|
75c55fff21 | ||
|
|
8f5de8f1a1 | ||
|
|
db9b481e8d | ||
|
|
cdd22e5f2f | ||
|
|
635b5ce8e1 | ||
|
|
1a476a0e3c | ||
|
|
80b1d6c292 | ||
|
|
deb0f3f602 | ||
|
|
71f168f8fa | ||
|
|
6f63041148 | ||
|
|
399a01904a | ||
|
|
9cc19460bd | ||
|
|
2920f131f8 | ||
|
|
04f622a7f0 | ||
|
|
fadc115412 | ||
|
|
10c53d954e | ||
|
|
29a09ec500 | ||
|
|
6dba080ade | ||
|
|
ab7ca33ac1 | ||
|
|
bc6a15de8f | ||
|
|
a47a9045e6 | ||
|
|
b6e92b4211 | ||
|
|
5222d75064 | ||
|
|
2587b8afd9 | ||
|
|
6c08f10e9d | ||
|
|
6c620d6878 | ||
|
|
072c1a6a3b | ||
|
|
78e14d6378 | ||
|
|
68e71d09ea | ||
|
|
6ac2a0c888 | ||
|
|
66e01119d2 | ||
|
|
8fb33e311d | ||
|
|
f06851fa37 | ||
|
|
e750023fdc | ||
|
|
e2e57fbf26 | ||
|
|
56d6a53a54 | ||
|
|
ee6055934c | ||
|
|
03b3f55400 | ||
|
|
2aab2a21b6 | ||
|
|
a44b276269 | ||
|
|
d150747f83 | ||
|
|
fa9e765e37 | ||
|
|
b0253135e5 | ||
|
|
8e62594eff | ||
|
|
978d9158c0 | ||
|
|
134899114d | ||
|
|
8533a440bc | ||
|
|
9ec422c6e2 | ||
|
|
6c03bf71c2 | ||
|
|
3887cc477d | ||
|
|
0b96d59285 | ||
|
|
a3f317cbeb | ||
|
|
5a9ceb4a94 | ||
|
|
bdc3050a5e | ||
|
|
bc085926a6 | ||
|
|
aa1fb1c6f5 | ||
|
|
26b47aac53 | ||
|
|
8dcd0295e5 | ||
|
|
3206af160a | ||
|
|
b500c801ee | ||
|
|
3f1b8762dd | ||
|
|
a6f9046b42 | ||
|
|
2cf91bddea | ||
|
|
e1e4187ded | ||
|
|
e02796c310 | ||
|
|
5d9e96033e | ||
|
|
cc618960e6 | ||
|
|
f9926e7a5d | ||
|
|
03fc2fb7ee | ||
|
|
b6efa3f37e | ||
|
|
85f20eaf1c | ||
|
|
411147efce | ||
|
|
48c3d58f7e | ||
|
|
746d38017f | ||
|
|
01298928c7 | ||
|
|
13ee16452b | ||
|
|
9a57413624 | ||
|
|
8d8250bc17 | ||
|
|
174c6bcedf | ||
|
|
c70f8e7b6d | ||
|
|
6ba1ff57b2 | ||
|
|
a5291483f7 | ||
|
|
e3a9618dc9 | ||
|
|
f30fde553d | ||
|
|
f9c1537ca0 | ||
|
|
208a6db1a6 | ||
|
|
9e1798cc3e | ||
|
|
9e29031703 | ||
|
|
3626192f31 | ||
|
|
d246f8e3ed | ||
|
|
3ddf6900c9 | ||
|
|
aab3ffe262 | ||
|
|
56f129d784 | ||
|
|
7fe35d646a | ||
|
|
31891fae6e | ||
|
|
33ee3a521c | ||
|
|
df581b965a | ||
|
|
6cd7500073 | ||
|
|
20b9251eab | ||
|
|
6f66367282 | ||
|
|
e566514ac0 | ||
|
|
02db84e7f2 | ||
|
|
8adeabce61 | ||
|
|
7e6d7d8580 | ||
|
|
0781f3e13d | ||
|
|
64f1e5831a | ||
|
|
551924c384 | ||
|
|
c889f8e9c8 | ||
|
|
86b5ec0afd | ||
|
|
6bf98b787e | ||
|
|
3532b0bbfb | ||
|
|
6d4d851f1d | ||
|
|
fb9e430ba0 | ||
|
|
73c78dd28f | ||
|
|
509e654123 | ||
|
|
6b7f412341 | ||
|
|
edf051adc7 | ||
|
|
aee09aeb0d | ||
|
|
d15c00c29b | ||
|
|
6c4bcbe300 | ||
|
|
2ff0555493 | ||
|
|
e84ab43b36 | ||
|
|
8134c6af35 | ||
|
|
e05169b7b4 | ||
|
|
df62f15734 | ||
|
|
e26f16bbc2 | ||
|
|
7623ea2f7f | ||
|
|
c19c1c2f34 | ||
|
|
6443a03afd | ||
|
|
bb4229a82d | ||
|
|
e41cead10b | ||
|
|
ecd4d29a38 | ||
|
|
7dfaacd28e | ||
|
|
775a91889f | ||
|
|
3159ba14b9 | ||
|
|
3bef18901a | ||
|
|
a2395f121b | ||
|
|
a1e8a4c464 | ||
|
|
11e5a6d379 | ||
|
|
365369cc31 | ||
|
|
0452dbd179 | ||
|
|
d70fb133b7 | ||
|
|
2064c0833c | ||
|
|
d0947112eb | ||
|
|
c9d9134049 | ||
|
|
91b8f4ca2b | ||
|
|
d56eaa9f02 | ||
|
|
71e1d58ec6 | ||
|
|
382283d0ce | ||
|
|
c29ba6ea69 | ||
|
|
cf5f5c1449 | ||
|
|
d5796b2cb5 | ||
|
|
dd8bfe9fce | ||
|
|
f80ec9797e | ||
|
|
eb158678d4 | ||
|
|
865502a796 | ||
|
|
7a7856bc36 | ||
|
|
756c9b892f | ||
|
|
ccde08b700 | ||
|
|
87f73ee4c2 | ||
|
|
fbbd820512 | ||
|
|
0a92d38ccf | ||
|
|
e4e6d3c74d | ||
|
|
f352d823a8 | ||
|
|
98a96b4fcc | ||
|
|
63483e01c2 | ||
|
|
b247186a0a | ||
|
|
4304ebf7b1 | ||
|
|
4d229c79d5 | ||
|
|
6e995e7fc2 | ||
|
|
eec100dfe8 | ||
|
|
10d64c88e3 | ||
|
|
165a87ce69 | ||
|
|
e5ff036d81 | ||
|
|
326f283d4e | ||
|
|
c048085c8a | ||
|
|
8fcd4d0d53 | ||
|
|
30bdaf1ed5 | ||
|
|
39e09bedd3 | ||
|
|
487fb76776 | ||
|
|
41e563297a | ||
|
|
9743adaed5 | ||
|
|
b179a0274f | ||
|
|
61574c847f | ||
|
|
2eee15be03 | ||
|
|
0ae615cc77 | ||
|
|
7f46b10a42 | ||
|
|
dee385c6db | ||
|
|
207e038315 | ||
|
|
dc3433a036 | ||
|
|
14c5c148b9 | ||
|
|
7fdea2a285 | ||
|
|
e3324f0707 | ||
|
|
0336715103 | ||
|
|
c37ffd6991 | ||
|
|
5a07bcce77 | ||
|
|
ceb962a92a | ||
|
|
4af204daec | ||
|
|
30edda1762 | ||
|
|
5bd06a12dd | ||
|
|
8b63c1cf9e | ||
|
|
1e6b1b7d96 | ||
|
|
e74668c389 | ||
|
|
cf52140bca | ||
|
|
7e44d53bb3 | ||
|
|
fdb485614f | ||
|
|
6b35ffe930 | ||
|
|
9a254105fb | ||
|
|
e73196a249 | ||
|
|
84f77940fd | ||
|
|
3d1cb29a67 | ||
|
|
345b8500cd | ||
|
|
3672d02d6f | ||
|
|
efbfe77deb | ||
|
|
09cf5d8990 | ||
|
|
1e15630708 | ||
|
|
8c02ad9291 | ||
|
|
4c34a01729 | ||
|
|
19cd0e577c | ||
|
|
e096bc66ab | ||
|
|
f22caea1e5 | ||
|
|
208d1ad5d4 | ||
|
|
44527f68cf | ||
|
|
3c7cacc46f | ||
|
|
bbd602a297 | ||
|
|
df2a40b861 | ||
|
|
e29e5ed0a4 | ||
|
|
734b5f807b | ||
|
|
85cce6e707 | ||
|
|
a4da6cdf3a | ||
|
|
f837ca6b23 | ||
|
|
7b326e8ff0 | ||
|
|
680e84d19b | ||
|
|
cf5919a3a0 | ||
|
|
9ce6cd63d1 | ||
|
|
6f5e5f5c30 | ||
|
|
a25fcf209a | ||
|
|
5d0777f67c | ||
|
|
f1d504f985 | ||
|
|
9a070ef5d3 | ||
|
|
3e5bc71535 | ||
|
|
6a4d77d904 | ||
|
|
c35d53266a | ||
|
|
ea79270bff | ||
|
|
975e5c4faf | ||
|
|
f405777463 | ||
|
|
217ec39503 | ||
|
|
e89f81152e | ||
|
|
a34b9a8fb0 | ||
|
|
dc096f5e12 | ||
|
|
a681aadcfa | ||
|
|
29618660aa | ||
|
|
d3c4fdef9d | ||
|
|
4f7cbf3527 | ||
|
|
ad76563543 | ||
|
|
4e973f3d51 | ||
|
|
17bcf8c41f | ||
|
|
a8bf4671fa | ||
|
|
95d0985f3d | ||
|
|
2dd756bbb8 | ||
|
|
3be97b1da2 | ||
|
|
b436db183f | ||
|
|
6508dc6c64 | ||
|
|
b3d39b65b0 | ||
|
|
67c26a973e | ||
|
|
687fff9c74 | ||
|
|
f15f3f5110 | ||
|
|
07ae71fd23 | ||
|
|
9c7fad790f | ||
|
|
26270b2842 | ||
|
|
05729285af | ||
|
|
d713ed5900 | ||
|
|
cfbb4534d8 | ||
|
|
67cff68581 | ||
|
|
b63df394cc | ||
|
|
2a96e61a97 | ||
|
|
be26d241c0 | ||
|
|
2670eb2925 | ||
|
|
75c8e678bf | ||
|
|
ddb3e2bc17 | ||
|
|
613d7aba71 | ||
|
|
7a7eeefe3b | ||
|
|
1c306c571b | ||
|
|
fb56a12297 | ||
|
|
26171fd846 | ||
|
|
5a475a84b5 | ||
|
|
b617d15c62 | ||
|
|
f7ba4f202b | ||
|
|
bb57280c8c | ||
|
|
bbca644b40 | ||
|
|
5221061241 | ||
|
|
0d0596b767 | ||
|
|
dfb360733e | ||
|
|
c1e6689beb | ||
|
|
4e1c6fb333 | ||
|
|
eca7af2d6f | ||
|
|
3ace14fcdb | ||
|
|
69cd40dc95 | ||
|
|
ece614941e | ||
|
|
b47b3253f6 | ||
|
|
889335c579 | ||
|
|
7b657120e9 | ||
|
|
a0cf5099f8 | ||
|
|
82aa207e0d | ||
|
|
301b58f0ba | ||
|
|
4c4a860c76 | ||
|
|
d0310ded28 | ||
|
|
c01ef4579a | ||
|
|
99bec6c7f9 | ||
|
|
c1a303e78c | ||
|
|
193288013e | ||
|
|
39e8add103 | ||
|
|
0f82c9738b | ||
|
|
a4237a6f17 | ||
|
|
20039a07ff | ||
|
|
dfc38a6829 | ||
|
|
0e0d1a5f11 | ||
|
|
ef299f1f4a | ||
|
|
4dbaab060a | ||
|
|
b8811ab5b6 | ||
|
|
5248c26b76 | ||
|
|
eed0fb6eca | ||
|
|
2a9447b506 | ||
|
|
fb94028410 | ||
|
|
25639afe1a | ||
|
|
4426b5f3ef | ||
|
|
3cae2771de | ||
|
|
81f55adb41 | ||
|
|
bd4c88833d | ||
|
|
2374d7a357 | ||
|
|
91730026fd | ||
|
|
9d10b23ba7 | ||
|
|
d0c231ee43 | ||
|
|
58ce8e40c7 | ||
|
|
66b0709e6e | ||
|
|
a2ed33214d | ||
|
|
a3dccd690d | ||
|
|
69313fba34 | ||
|
|
1889c58598 | ||
|
|
e9f0162439 | ||
|
|
2aa4fe9673 | ||
|
|
ccb50e3c62 | ||
|
|
5ce9e66fea | ||
|
|
6492a4672b | ||
|
|
46acb26c42 | ||
|
|
c9aab73a2a | ||
|
|
13a202cca4 | ||
|
|
bdb9c9ca28 | ||
|
|
5ed5a86bad | ||
|
|
520888988e | ||
|
|
de28f87c62 | ||
|
|
81a6c44090 | ||
|
|
f142046dcc | ||
|
|
c5e480af52 | ||
|
|
f89e3e8554 | ||
|
|
1442c57e18 | ||
|
|
0987fb14b2 | ||
|
|
dc22d1e6cb | ||
|
|
e9e9bffd9a | ||
|
|
11694de4e6 | ||
|
|
8f181c687b | ||
|
|
926c058d1e | ||
|
|
1583221232 | ||
|
|
585a65be31 | ||
|
|
2de9b7f6b7 | ||
|
|
323b17185c | ||
|
|
09f44a5685 | ||
|
|
0e67434515 | ||
|
|
f4ab7841fb | ||
|
|
3de4f69a62 | ||
|
|
102be2c24a | ||
|
|
378dc22bb0 | ||
|
|
3a39b25e64 | ||
|
|
32248f8424 | ||
|
|
a1f8776743 | ||
|
|
7e431d645a | ||
|
|
cef4bffd69 | ||
|
|
75391641fd |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,6 +36,8 @@ lerna-debug.log*
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
|
||||
422
README.md
422
README.md
@@ -1,11 +1,29 @@
|
||||
<h1 align="center">
|
||||
<a href="https://worklenz.com" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://app.worklenz.com/assets/icons/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
<img src="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/icon-144x144.png" alt="Worklenz Logo" width="75">
|
||||
</a>
|
||||
<br>
|
||||
Worklenz
|
||||
</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Worklenz/worklenz/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/releases">
|
||||
<img src="https://img.shields.io/github/v/release/Worklenz/worklenz" alt="Release">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/Worklenz/worklenz" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/network/members">
|
||||
<img src="https://img.shields.io/github/forks/Worklenz/worklenz" alt="Forks">
|
||||
</a>
|
||||
<a href="https://github.com/Worklenz/worklenz/issues">
|
||||
<img src="https://img.shields.io/github/issues/Worklenz/worklenz" alt="Issues">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://worklenz.com/task-management/">Task Management</a> |
|
||||
<a href="https://worklenz.com/time-tracking/">Time Tracking</a> |
|
||||
@@ -27,6 +45,24 @@
|
||||
Worklenz is a project management tool designed to help organizations improve their efficiency. It provides a
|
||||
comprehensive solution for managing projects, tasks, and collaboration within teams.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Quick Start (Docker)](#-quick-start-docker---recommended)
|
||||
- [Manual Installation](#️-manual-installation-for-development)
|
||||
- [Deployment](#deployment)
|
||||
- [Local Development](#local-development-with-docker)
|
||||
- [Remote Server Deployment](#remote-server-deployment)
|
||||
- [Configuration](#configuration)
|
||||
- [MinIO Integration](#minio-integration)
|
||||
- [Security](#security)
|
||||
- [Analytics](#analytics)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Project Planning**: Create and organize projects, assign tasks to team members.
|
||||
@@ -50,42 +86,80 @@ This repository contains the frontend and backend code for Worklenz.
|
||||
|
||||
## Getting Started
|
||||
|
||||
These instructions will help you set up and run the Worklenz project on your local machine for development and testing purposes.
|
||||
Choose your preferred setup method below. Docker is recommended for quick setup and testing.
|
||||
|
||||
### Prerequisites
|
||||
### 🚀 Quick Start (Docker - Recommended)
|
||||
|
||||
- Node.js (version 18 or higher)
|
||||
- PostgreSQL database
|
||||
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
|
||||
The fastest way to get Worklenz running locally with all dependencies included.
|
||||
|
||||
### Option 1: Manual Installation
|
||||
**Prerequisites:**
|
||||
- Docker and Docker Compose installed on your system
|
||||
- Git
|
||||
|
||||
1. Clone the repository
|
||||
**Steps:**
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
2. Set up environment variables
|
||||
- Copy the example environment files
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp worklenz-backend/.env.example worklenz-backend/.env
|
||||
```
|
||||
- Update the environment variables with your configuration
|
||||
|
||||
3. Install dependencies
|
||||
2. Start the Docker containers:
|
||||
```bash
|
||||
# Install backend dependencies
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- **Frontend**: http://localhost:5000
|
||||
- **Backend API**: http://localhost:3000
|
||||
- **MinIO Console**: http://localhost:9001 (login: minioadmin/minioadmin)
|
||||
|
||||
4. To stop the services:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
**Alternative startup methods:**
|
||||
- **Windows**: Run `start.bat`
|
||||
- **Linux/macOS**: Run `./start.sh`
|
||||
|
||||
**Video Guide**: For a visual walkthrough of the local Docker deployment process, check out our [step-by-step video guide](https://www.youtube.com/watch?v=AfwAKxJbqLg).
|
||||
|
||||
### 🛠️ Manual Installation (For Development)
|
||||
|
||||
For developers who want to run the services individually or customize the setup.
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js (version 18 or higher)
|
||||
- PostgreSQL (version 15 or higher)
|
||||
- An S3-compatible storage service (like MinIO) or Azure Blob Storage
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp worklenz-backend/.env.template worklenz-backend/.env
|
||||
# Update the environment variables with your configuration
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
# Backend dependencies
|
||||
cd worklenz-backend
|
||||
npm install
|
||||
|
||||
# Install frontend dependencies
|
||||
# Frontend dependencies
|
||||
cd ../worklenz-frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
4. Set up the database
|
||||
4. Set up the database:
|
||||
```bash
|
||||
# Create a PostgreSQL database named worklenz_db
|
||||
cd worklenz-backend
|
||||
@@ -101,49 +175,47 @@ psql -U your_username -d worklenz_db -f database/sql/2_dml.sql
|
||||
psql -U your_username -d worklenz_db -f database/sql/5_database_user.sql
|
||||
```
|
||||
|
||||
5. Start the development servers
|
||||
5. Start the development servers:
|
||||
```bash
|
||||
# In one terminal, start the backend
|
||||
# Terminal 1: Start the backend
|
||||
cd worklenz-backend
|
||||
npm run dev
|
||||
|
||||
# In another terminal, start the frontend
|
||||
# Terminal 2: Start the frontend
|
||||
cd worklenz-frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
6. Access the application at http://localhost:5000
|
||||
|
||||
### Option 2: Docker Setup
|
||||
## Deployment
|
||||
|
||||
The project includes a fully configured Docker setup with:
|
||||
- Frontend React application
|
||||
- Backend server
|
||||
- PostgreSQL database
|
||||
- MinIO for S3-compatible storage
|
||||
For local development, follow the [Quick Start (Docker)](#-quick-start-docker---recommended) section above.
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
### Remote Server Deployment
|
||||
|
||||
2. Start the Docker containers (choose one option):
|
||||
When deploying to a remote server:
|
||||
|
||||
**Using Docker Compose directly**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
1. Set up the environment files with your server's hostname:
|
||||
```bash
|
||||
# For HTTP/WS
|
||||
./update-docker-env.sh your-server-hostname
|
||||
|
||||
# For HTTPS/WSS
|
||||
./update-docker-env.sh your-server-hostname true
|
||||
```
|
||||
|
||||
3. The application will be available at:
|
||||
- Frontend: http://localhost:5000
|
||||
- Backend API: http://localhost:3000
|
||||
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
|
||||
2. Pull and run the latest Docker images:
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. To stop the services:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
3. Access the application through your server's hostname:
|
||||
- Frontend: http://your-server-hostname:5000
|
||||
- Backend API: http://your-server-hostname:3000
|
||||
|
||||
4. **Video Guide**: For a complete walkthrough of deploying Worklenz to a remote server, check out our [deployment video guide](https://www.youtube.com/watch?v=CAZGu2iOXQs&t=10s).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -158,16 +230,46 @@ Worklenz requires several environment variables to be configured for proper oper
|
||||
|
||||
Please refer to the `.env.example` files for a full list of required variables.
|
||||
|
||||
### MinIO Integration
|
||||
The Docker setup uses environment variables to configure the services:
|
||||
|
||||
- **Frontend:**
|
||||
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
|
||||
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
|
||||
|
||||
- **Backend:**
|
||||
- Database connection parameters
|
||||
- Storage configuration
|
||||
- Other backend settings
|
||||
|
||||
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
|
||||
|
||||
## MinIO Integration
|
||||
|
||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||
|
||||
### Working with MinIO
|
||||
|
||||
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
|
||||
|
||||
- **MinIO Console**: http://localhost:9001
|
||||
- Username: minioadmin
|
||||
- Password: minioadmin
|
||||
|
||||
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
|
||||
|
||||
### Backend Storage Configuration
|
||||
|
||||
The backend is pre-configured to use MinIO with the following settings:
|
||||
|
||||
```javascript
|
||||
// S3 credentials with MinIO defaults
|
||||
export const REGION = process.env.AWS_REGION || "us-east-1";
|
||||
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
|
||||
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
|
||||
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
|
||||
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
For production deployments:
|
||||
@@ -178,19 +280,32 @@ For production deployments:
|
||||
4. Enable HTTPS for all public endpoints
|
||||
5. Review and update dependencies regularly
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
|
||||
|
||||
## Security
|
||||
|
||||
If you believe you have found a security vulnerability in Worklenz, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports.
|
||||
|
||||
Email [info@worklenz.com](mailto:info@worklenz.com) to disclose any security vulnerabilities.
|
||||
|
||||
## License
|
||||
## Analytics
|
||||
|
||||
This project is licensed under the [MIT License](LICENSE).
|
||||
Worklenz uses Google Analytics to understand how the application is being used. This helps us improve the application and make better decisions about future development.
|
||||
|
||||
### What We Track
|
||||
- Anonymous usage statistics
|
||||
- Page views and navigation patterns
|
||||
- Feature usage
|
||||
- Browser and device information
|
||||
|
||||
### Privacy
|
||||
- Analytics is opt-in only
|
||||
- No personal information is collected
|
||||
- Users can opt-out at any time
|
||||
- Data is stored according to Google's privacy policy
|
||||
|
||||
### How to Opt-Out
|
||||
If you've previously opted in and want to opt-out:
|
||||
1. Clear your browser's local storage for the Worklenz domain
|
||||
2. Or click the "Decline" button in the analytics notice if it appears
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -240,206 +355,13 @@ This project is licensed under the [MIT License](LICENSE).
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### Contributing
|
||||
## Contributing
|
||||
|
||||
We welcome contributions from the community! If you'd like to contribute, please follow
|
||||
our [contributing guidelines](CONTRIBUTING.md).
|
||||
We welcome contributions from the community! If you'd like to contribute, please follow our [contributing guidelines](CONTRIBUTING.md).
|
||||
|
||||
### License
|
||||
## License
|
||||
|
||||
Worklenz is open source and released under the [GNU Affero General Public License Version 3 (AGPLv3)](LICENSE).
|
||||
|
||||
By contributing to Worklenz, you agree that your contributions will be licensed under its AGPL.
|
||||
|
||||
# Worklenz React
|
||||
|
||||
This repository contains the React version of Worklenz with a Docker setup for easy development and deployment.
|
||||
|
||||
## Getting Started with Docker
|
||||
|
||||
The project includes a fully configured Docker setup with:
|
||||
- Frontend React application
|
||||
- Backend server
|
||||
- PostgreSQL database
|
||||
- MinIO for S3-compatible storage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed on your system
|
||||
- Git
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Worklenz/worklenz.git
|
||||
cd worklenz
|
||||
```
|
||||
|
||||
2. Start the Docker containers (choose one option):
|
||||
|
||||
**Option 1: Using the provided scripts (easiest)**
|
||||
- On Windows:
|
||||
```
|
||||
start.bat
|
||||
```
|
||||
- On Linux/macOS:
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Option 2: Using Docker Compose directly**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. The application will be available at:
|
||||
- Frontend: http://localhost:5000
|
||||
- Backend API: http://localhost:3000
|
||||
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
|
||||
|
||||
4. To stop the services (choose one option):
|
||||
|
||||
**Option 1: Using the provided scripts**
|
||||
- On Windows:
|
||||
```
|
||||
stop.bat
|
||||
```
|
||||
- On Linux/macOS:
|
||||
```bash
|
||||
./stop.sh
|
||||
```
|
||||
|
||||
**Option 2: Using Docker Compose directly**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## MinIO Integration
|
||||
|
||||
The project uses MinIO as an S3-compatible object storage service, which provides an open-source alternative to AWS S3 for development and production.
|
||||
|
||||
### Working with MinIO
|
||||
|
||||
MinIO provides an S3-compatible API, so any code that works with S3 will work with MinIO by simply changing the endpoint URL. The backend has been configured to use MinIO by default, with no additional configuration required.
|
||||
|
||||
- **MinIO Console**: http://localhost:9001
|
||||
- Username: minioadmin
|
||||
- Password: minioadmin
|
||||
|
||||
- **Default Bucket**: worklenz-bucket (created automatically when the containers start)
|
||||
|
||||
### Backend Storage Configuration
|
||||
|
||||
The backend is pre-configured to use MinIO with the following settings:
|
||||
|
||||
```javascript
|
||||
// S3 credentials with MinIO defaults
|
||||
export const REGION = process.env.AWS_REGION || "us-east-1";
|
||||
export const BUCKET = process.env.AWS_BUCKET || "worklenz-bucket";
|
||||
export const S3_URL = process.env.S3_URL || "http://minio:9000/worklenz-bucket";
|
||||
export const S3_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || "minioadmin";
|
||||
export const S3_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY || "minioadmin";
|
||||
```
|
||||
|
||||
The S3 client is initialized with special MinIO configuration:
|
||||
|
||||
```javascript
|
||||
const s3Client = new S3Client({
|
||||
region: REGION,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID || "",
|
||||
secretAccessKey: S3_SECRET_ACCESS_KEY || "",
|
||||
},
|
||||
endpoint: getEndpointFromUrl(), // Extracts endpoint from S3_URL
|
||||
forcePathStyle: true, // Required for MinIO
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
The project uses the following environment file structure:
|
||||
|
||||
- **Frontend**:
|
||||
- `worklenz-frontend/.env.development` - Development environment variables
|
||||
- `worklenz-frontend/.env.production` - Production build variables
|
||||
|
||||
- **Backend**:
|
||||
- `worklenz-backend/.env` - Backend environment variables
|
||||
|
||||
### Setting Up Environment Files
|
||||
|
||||
The Docker environment script will create or overwrite all environment files:
|
||||
|
||||
```bash
|
||||
# For HTTP/WS
|
||||
./update-docker-env.sh your-hostname
|
||||
|
||||
# For HTTPS/WSS
|
||||
./update-docker-env.sh your-hostname true
|
||||
```
|
||||
|
||||
This script generates properly configured environment files for both development and production environments.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Local Development with Docker
|
||||
|
||||
1. Set up the environment files:
|
||||
```bash
|
||||
# For HTTP/WS
|
||||
./update-docker-env.sh
|
||||
|
||||
# For HTTPS/WSS
|
||||
./update-docker-env.sh localhost true
|
||||
```
|
||||
|
||||
2. Run the application using Docker Compose:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- Frontend: http://localhost:5000
|
||||
- Backend API: http://localhost:3000 (or https://localhost:3000 with SSL)
|
||||
|
||||
### Remote Server Deployment
|
||||
|
||||
When deploying to a remote server:
|
||||
|
||||
1. Set up the environment files with your server's hostname:
|
||||
```bash
|
||||
# For HTTP/WS
|
||||
./update-docker-env.sh your-server-hostname
|
||||
|
||||
# For HTTPS/WSS
|
||||
./update-docker-env.sh your-server-hostname true
|
||||
```
|
||||
|
||||
This ensures that the frontend correctly connects to the backend API.
|
||||
|
||||
2. Pull and run the latest Docker images:
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
3. Access the application through your server's hostname:
|
||||
- Frontend: http://your-server-hostname:5000
|
||||
- Backend API: http://your-server-hostname:3000
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
The Docker setup uses environment variables to configure the services:
|
||||
|
||||
- Frontend:
|
||||
- `VITE_API_URL`: URL of the backend API (default: http://backend:3000 for container networking)
|
||||
- `VITE_SOCKET_URL`: WebSocket URL for real-time communication (default: ws://backend:3000)
|
||||
|
||||
- Backend:
|
||||
- Database connection parameters
|
||||
- Storage configuration
|
||||
- Other backend settings
|
||||
|
||||
For custom configuration, edit the `.env` file or the `update-docker-env.sh` script.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js version v16 or newer - [Node.js](https://nodejs.org/en/download/)
|
||||
- Node.js version v20 or newer - [Node.js](https://nodejs.org/en/download/)
|
||||
- PostgreSQL version v15 or newer - [PostgreSQL](https://www.postgresql.org/download/)
|
||||
- S3-compatible storage (like MinIO) for file storage
|
||||
|
||||
@@ -38,7 +38,7 @@ Getting started with development is a breeze! Follow these steps and you'll be c
|
||||
npm start
|
||||
```
|
||||
|
||||
4. Navigate to [http://localhost:5173](http://localhost:5173)
|
||||
4. Navigate to [http://localhost:5173](http://localhost:5173) (development server)
|
||||
|
||||
### Backend installation
|
||||
|
||||
@@ -126,7 +126,7 @@ For an easier setup, you can use Docker and Docker Compose:
|
||||
```
|
||||
|
||||
3. Access the application:
|
||||
- Frontend: http://localhost:5000
|
||||
- Frontend: http://localhost:5000 (Docker production build)
|
||||
- Backend API: http://localhost:3000
|
||||
- MinIO Console: http://localhost:9001 (login with minioadmin/minioadmin)
|
||||
|
||||
|
||||
16
backup.sh
Normal file
16
backup.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# Adjust these as needed:
|
||||
CONTAINER=worklenz_db
|
||||
DB_NAME=worklenz_db
|
||||
DB_USER=postgres
|
||||
BACKUP_DIR=./pg_backups
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
timestamp=$(date +%Y-%m-%d_%H-%M-%S)
|
||||
outfile="${BACKUP_DIR}/${DB_NAME}_${timestamp}.sql"
|
||||
echo "Creating backup $outfile ..."
|
||||
|
||||
docker exec -t "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$outfile"
|
||||
echo "Backup saved to $outfile"
|
||||
@@ -7,8 +7,8 @@ services:
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_started
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./worklenz-frontend/.env.production
|
||||
networks:
|
||||
@@ -26,6 +26,7 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./worklenz-backend/.env
|
||||
networks:
|
||||
@@ -37,6 +38,7 @@ services:
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:-minioadmin}
|
||||
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:-minioadmin}
|
||||
@@ -52,13 +54,14 @@ services:
|
||||
container_name: worklenz_createbuckets
|
||||
depends_on:
|
||||
- minio
|
||||
restart: on-failure
|
||||
entrypoint: >
|
||||
/bin/sh -c '
|
||||
echo "Waiting for MinIO to start...";
|
||||
sleep 15;
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "Attempt $i to connect to MinIO...";
|
||||
if /usr/bin/mc config host add myminio http://minio:9000 minioadmin minioadmin; then
|
||||
if /usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin; then
|
||||
echo "Successfully connected to MinIO!";
|
||||
/usr/bin/mc mb --ignore-existing myminio/worklenz-bucket;
|
||||
/usr/bin/mc policy set public myminio/worklenz-bucket;
|
||||
@@ -80,32 +83,79 @@ services:
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}" ]
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -d ${DB_NAME:-worklenz_db} -U ${DB_USER:-postgres}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
volumes:
|
||||
- worklenz_postgres_data:/var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database
|
||||
target: /docker-entrypoint-initdb.d
|
||||
source: ./worklenz-backend/database/sql
|
||||
target: /docker-entrypoint-initdb.d/sql
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/migrations
|
||||
target: /docker-entrypoint-initdb.d/migrations
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./worklenz-backend/database/00_init.sh
|
||||
target: /docker-entrypoint-initdb.d/00_init.sh
|
||||
consistency: cached
|
||||
- type: bind
|
||||
source: ./pg_backups
|
||||
target: /docker-entrypoint-initdb.d/pg_backups
|
||||
command: >
|
||||
bash -c ' if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y dos2unix
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache dos2unix
|
||||
fi && find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '\''
|
||||
dos2unix "{}" 2>/dev/null || true
|
||||
chmod +x "{}"
|
||||
'\'' \; && exec docker-entrypoint.sh postgres '
|
||||
bash -c '
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y dos2unix
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache dos2unix
|
||||
fi
|
||||
|
||||
find /docker-entrypoint-initdb.d -type f -name "*.sh" -exec sh -c '"'"'
|
||||
for f; do
|
||||
dos2unix "$f" 2>/dev/null || true
|
||||
chmod +x "$f"
|
||||
done
|
||||
'"'"' sh {} +
|
||||
|
||||
exec docker-entrypoint.sh postgres
|
||||
'
|
||||
db-backup:
|
||||
image: postgres:15
|
||||
container_name: worklenz_db_backup
|
||||
environment:
|
||||
POSTGRES_USER: ${DB_USER:-postgres}
|
||||
POSTGRES_DB: ${DB_NAME:-worklenz_db}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./pg_backups:/pg_backups #host dir for backups files
|
||||
#setup bassh loop to backup data evey 24h
|
||||
command: >
|
||||
bash -c 'while true; do
|
||||
sleep 86400;
|
||||
PGPASSWORD=$$POSTGRES_PASSWORD pg_dump -h worklenz_db -U $$POSTGRES_USER -d $$POSTGRES_DB \
|
||||
> /pg_backups/worklenz_db_$$(date +%Y-%m-%d_%H-%M-%S).sql;
|
||||
find /pg_backups -type f -name "*.sql" -mtime +30 -delete;
|
||||
done'
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- worklenz
|
||||
|
||||
volumes:
|
||||
worklenz_postgres_data:
|
||||
worklenz_minio_data:
|
||||
|
||||
pgdata:
|
||||
|
||||
networks:
|
||||
worklenz:
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# Task Breakdown API
|
||||
|
||||
## Get Task Financial Breakdown
|
||||
|
||||
**Endpoint:** `GET /api/project-finance/task/:id/breakdown`
|
||||
|
||||
**Description:** Retrieves detailed financial breakdown for a single task, including members grouped by job roles with labor hours and costs.
|
||||
|
||||
### Parameters
|
||||
|
||||
- `id` (path parameter): UUID of the task
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"body": {
|
||||
"task": {
|
||||
"id": "uuid",
|
||||
"name": "Task Name",
|
||||
"project_id": "uuid",
|
||||
"billable": true,
|
||||
"estimated_hours": 10.5,
|
||||
"logged_hours": 8.25,
|
||||
"estimated_labor_cost": 525.0,
|
||||
"actual_labor_cost": 412.5,
|
||||
"fixed_cost": 100.0,
|
||||
"total_estimated_cost": 625.0,
|
||||
"total_actual_cost": 512.5
|
||||
},
|
||||
"grouped_members": [
|
||||
{
|
||||
"jobRole": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"jobRole": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5,
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"members": [
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "John Doe",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Frontend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.0,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 200.0
|
||||
},
|
||||
{
|
||||
"team_member_id": "uuid",
|
||||
"name": "Jane Smith",
|
||||
"avatar_url": "https://...",
|
||||
"hourly_rate": 50.0,
|
||||
"job_title_name": "Backend Developer",
|
||||
"estimated_hours": 5.25,
|
||||
"logged_hours": 4.25,
|
||||
"estimated_cost": 262.5,
|
||||
"actual_cost": 212.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
- `404 Not Found`: Task not found
|
||||
- `400 Bad Request`: Invalid task ID
|
||||
|
||||
### Usage
|
||||
|
||||
This endpoint is designed to work with the finance drawer component (`@finance-drawer.tsx`) to provide detailed cost breakdown information for individual tasks. The response includes:
|
||||
|
||||
1. **Task Summary**: Overall task financial information
|
||||
2. **Grouped Members**: Members organized by job role with aggregated costs
|
||||
3. **Individual Members**: Detailed breakdown for each team member
|
||||
|
||||
The data structure matches what the finance drawer expects, with members grouped by job roles and individual labor hours and costs calculated based on:
|
||||
- Estimated hours divided equally among assignees
|
||||
- Actual logged time per member
|
||||
- Hourly rates from project rate cards
|
||||
- Fixed costs added to the totals
|
||||
|
||||
### Frontend Usage Example
|
||||
|
||||
```typescript
|
||||
import { projectFinanceApiService } from '@/api/project-finance-ratecard/project-finance.api.service';
|
||||
|
||||
// Fetch task breakdown
|
||||
const fetchTaskBreakdown = async (taskId: string) => {
|
||||
try {
|
||||
const response = await projectFinanceApiService.getTaskBreakdown(taskId);
|
||||
const breakdown = response.body;
|
||||
|
||||
console.log('Task:', breakdown.task);
|
||||
console.log('Grouped Members:', breakdown.grouped_members);
|
||||
console.log('Individual Members:', breakdown.members);
|
||||
|
||||
return breakdown;
|
||||
} catch (error) {
|
||||
console.error('Error fetching task breakdown:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Usage in React component
|
||||
const TaskBreakdownComponent = ({ taskId }: { taskId: string }) => {
|
||||
const [breakdown, setBreakdown] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadBreakdown = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchTaskBreakdown(taskId);
|
||||
setBreakdown(data);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (taskId) {
|
||||
loadBreakdown();
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
if (loading) return <Spin />;
|
||||
if (!breakdown) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{breakdown.task.name}</h3>
|
||||
<p>Total Estimated Cost: ${breakdown.task.total_estimated_cost}</p>
|
||||
<p>Total Actual Cost: ${breakdown.task.total_actual_cost}</p>
|
||||
|
||||
{breakdown.grouped_members.map(group => (
|
||||
<div key={group.jobRole}>
|
||||
<h4>{group.jobRole}</h4>
|
||||
<p>Hours: {group.estimated_hours} | Cost: ${group.estimated_cost}</p>
|
||||
{group.members.map(member => (
|
||||
<div key={member.team_member_id}>
|
||||
{member.name}: {member.estimated_hours}h @ ${member.hourly_rate}/h
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Integration
|
||||
|
||||
This API complements the existing finance endpoints:
|
||||
- `GET /api/project-finance/project/:project_id/tasks` - Get all tasks for a project
|
||||
- `PUT /api/project-finance/task/:task_id/fixed-cost` - Update task fixed cost
|
||||
|
||||
The finance drawer component has been updated to automatically use this API when a task is selected, providing real-time financial breakdown data.
|
||||
429
docs/enhanced-task-management-technical-guide.md
Normal file
429
docs/enhanced-task-management-technical-guide.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Enhanced Task Management: Technical Guide
|
||||
|
||||
## Overview
|
||||
The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
```
|
||||
src/components/task-management/
|
||||
├── TaskListBoard.tsx # Main container with DnD context
|
||||
├── TaskGroup.tsx # Individual group with collapse/expand
|
||||
├── TaskRow.tsx # Task display with rich metadata
|
||||
├── GroupingSelector.tsx # Grouping method switcher
|
||||
└── BulkActionBar.tsx # Bulk operations toolbar
|
||||
```
|
||||
|
||||
### Integration Points
|
||||
The system integrates with existing WorkLenz infrastructure:
|
||||
|
||||
- **Redux Store:** Uses `tasks.slice.ts` for state management
|
||||
- **Types:** Leverages existing TypeScript interfaces
|
||||
- **API Services:** Works with existing task API endpoints
|
||||
- **WebSocket:** Supports real-time updates via existing socket system
|
||||
|
||||
## Core Components
|
||||
|
||||
### TaskListBoard.tsx
|
||||
Main orchestrator component that provides:
|
||||
|
||||
- **DnD Context:** @dnd-kit drag-and-drop functionality
|
||||
- **State Management:** Redux integration for task data
|
||||
- **Event Handling:** Drag events and bulk operations
|
||||
- **Layout Structure:** Header controls and group container
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskListBoardProps {
|
||||
projectId: string; // Required: Project identifier
|
||||
className?: string; // Optional: Additional CSS classes
|
||||
}
|
||||
```
|
||||
|
||||
#### Redux Selectors Used
|
||||
```typescript
|
||||
const {
|
||||
taskGroups, // ITaskListGroup[] - Grouped task data
|
||||
loadingGroups, // boolean - Loading state
|
||||
error, // string | null - Error state
|
||||
groupBy, // IGroupBy - Current grouping method
|
||||
search, // string | null - Search filter
|
||||
archived, // boolean - Show archived tasks
|
||||
} = useSelector((state: RootState) => state.taskReducer);
|
||||
```
|
||||
|
||||
### TaskGroup.tsx
|
||||
Renders individual task groups with:
|
||||
|
||||
- **Collapsible Headers:** Expand/collapse functionality
|
||||
- **Progress Indicators:** Visual completion progress
|
||||
- **Drop Zones:** Accept dropped tasks from other groups
|
||||
- **Group Statistics:** Task counts and completion rates
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskGroupProps {
|
||||
group: ITaskListGroup; // Group data with tasks
|
||||
projectId: string; // Project context
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
selectedTaskIds: string[]; // Selected task IDs
|
||||
onAddTask?: (groupId: string) => void;
|
||||
onToggleCollapse?: (groupId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### TaskRow.tsx
|
||||
Individual task display featuring:
|
||||
|
||||
- **Rich Metadata:** Progress, assignees, labels, due dates
|
||||
- **Drag Handles:** Sortable within and between groups
|
||||
- **Selection:** Multi-select with checkboxes
|
||||
- **Subtask Support:** Expandable hierarchy display
|
||||
|
||||
#### Key Props
|
||||
```typescript
|
||||
interface TaskRowProps {
|
||||
task: IProjectTask; // Task data
|
||||
projectId: string; // Project context
|
||||
groupId: string; // Parent group ID
|
||||
currentGrouping: IGroupBy; // Current grouping mode
|
||||
isSelected: boolean; // Selection state
|
||||
isDragOverlay?: boolean; // Drag overlay rendering
|
||||
index?: number; // Position in group
|
||||
onSelect?: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks?: (taskId: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Redux Integration
|
||||
The system uses existing WorkLenz Redux patterns:
|
||||
|
||||
```typescript
|
||||
// Primary slice used
|
||||
import {
|
||||
fetchTaskGroups, // Async thunk for loading data
|
||||
reorderTasks, // Update task order/group
|
||||
setGroup, // Change grouping method
|
||||
updateTaskStatus, // Update individual task status
|
||||
updateTaskPriority, // Update individual task priority
|
||||
// ... other existing actions
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)`
|
||||
2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization
|
||||
3. **Drag Operations:** `reorderTasks()` updates task positions and properties
|
||||
4. **Real-time Updates:** WebSocket events update Redux state automatically
|
||||
|
||||
## Drag and Drop Implementation
|
||||
|
||||
### DnD Kit Integration
|
||||
Uses @dnd-kit for modern, accessible drag-and-drop:
|
||||
|
||||
```typescript
|
||||
// Sensors for different input methods
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 }
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Drag Event Handling
|
||||
```typescript
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Determine source and target
|
||||
const sourceGroup = findTaskGroup(active.id);
|
||||
const targetGroup = findTargetGroup(over?.id);
|
||||
|
||||
// Update task arrays and dispatch changes
|
||||
dispatch(reorderTasks({
|
||||
activeGroupId: sourceGroup.id,
|
||||
overGroupId: targetGroup.id,
|
||||
fromIndex: sourceIndex,
|
||||
toIndex: targetIndex,
|
||||
task: movedTask,
|
||||
updatedSourceTasks,
|
||||
updatedTargetTasks,
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### Smart Property Updates
|
||||
When tasks are moved between groups, properties update automatically:
|
||||
|
||||
- **Status Grouping:** Moving to "Done" group sets task status to "done"
|
||||
- **Priority Grouping:** Moving to "High" group sets task priority to "high"
|
||||
- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing"
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
### Selection State Management
|
||||
```typescript
|
||||
// Local state for task selection
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||
|
||||
// Selection handlers
|
||||
const handleTaskSelect = (taskId: string, selected: boolean) => {
|
||||
if (selected) {
|
||||
setSelectedTaskIds(prev => [...prev, taskId]);
|
||||
} else {
|
||||
setSelectedTaskIds(prev => prev.filter(id => id !== taskId));
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Context-Aware Actions
|
||||
Bulk actions adapt to current grouping:
|
||||
|
||||
```typescript
|
||||
// Only show status changes when not grouped by status
|
||||
{currentGrouping !== 'status' && (
|
||||
<Dropdown overlay={statusMenu}>
|
||||
<Button>Change Status</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
```
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Memoized Selectors
|
||||
```typescript
|
||||
// Expensive group calculations are memoized
|
||||
const taskGroups = useMemo(() => {
|
||||
return createGroupsFromTasks(tasks, currentGrouping);
|
||||
}, [tasks, currentGrouping]);
|
||||
```
|
||||
|
||||
### Virtual Scrolling Ready
|
||||
For large datasets, the system is prepared for react-window integration:
|
||||
|
||||
```typescript
|
||||
// Large group detection
|
||||
const shouldVirtualize = group.tasks.length > 100;
|
||||
|
||||
return shouldVirtualize ? (
|
||||
<VirtualizedTaskList tasks={group.tasks} />
|
||||
) : (
|
||||
<StandardTaskList tasks={group.tasks} />
|
||||
);
|
||||
```
|
||||
|
||||
### Optimistic Updates
|
||||
UI updates immediately while API calls process in background:
|
||||
|
||||
```typescript
|
||||
// Immediate UI update
|
||||
dispatch(updateTaskStatusOptimistically(taskId, newStatus));
|
||||
|
||||
// API call with rollback on error
|
||||
try {
|
||||
await updateTaskStatus(taskId, newStatus);
|
||||
} catch (error) {
|
||||
dispatch(rollbackTaskStatusUpdate(taskId));
|
||||
}
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoint Strategy
|
||||
```css
|
||||
/* Mobile-first responsive design */
|
||||
.task-row {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.task-row {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.task-row {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progressive Enhancement
|
||||
- **Mobile:** Essential information only
|
||||
- **Tablet:** Additional metadata visible
|
||||
- **Desktop:** Full feature set with optimal layout
|
||||
|
||||
## Accessibility
|
||||
|
||||
### ARIA Implementation
|
||||
```typescript
|
||||
// Proper ARIA labels for screen readers
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Move task ${task.name}`}
|
||||
tabIndex={0}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<DragOutlined />
|
||||
</div>
|
||||
```
|
||||
|
||||
### Keyboard Navigation
|
||||
- **Tab:** Navigate between elements
|
||||
- **Space:** Select/deselect tasks
|
||||
- **Enter:** Activate buttons
|
||||
- **Arrows:** Navigate sortable lists with keyboard sensor
|
||||
|
||||
### Focus Management
|
||||
```typescript
|
||||
// Maintain focus during dynamic updates
|
||||
useEffect(() => {
|
||||
if (shouldFocusTask) {
|
||||
taskRef.current?.focus();
|
||||
}
|
||||
}, [taskGroups]);
|
||||
```
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
### Real-time Updates
|
||||
The system subscribes to existing WorkLenz WebSocket events:
|
||||
|
||||
```typescript
|
||||
// Socket event handlers (existing WorkLenz patterns)
|
||||
socket.on('TASK_STATUS_CHANGED', (data) => {
|
||||
dispatch(updateTaskStatus(data));
|
||||
});
|
||||
|
||||
socket.on('TASK_PROGRESS_UPDATED', (data) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
```
|
||||
|
||||
### Live Collaboration
|
||||
- Multiple users can work simultaneously
|
||||
- Changes appear in real-time
|
||||
- Conflict resolution through server-side validation
|
||||
|
||||
## API Integration
|
||||
|
||||
### Existing Endpoints Used
|
||||
```typescript
|
||||
// Uses existing WorkLenz API services
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
|
||||
// Task data fetching
|
||||
tasksApiService.getTaskList(config);
|
||||
|
||||
// Task updates
|
||||
tasksApiService.updateTask(taskId, changes);
|
||||
|
||||
// Bulk operations
|
||||
tasksApiService.bulkUpdateTasks(taskIds, changes);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
```typescript
|
||||
try {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
} catch (error) {
|
||||
// Display user-friendly error message
|
||||
message.error('Failed to load tasks. Please try again.');
|
||||
logger.error('Task loading error:', error);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Component Testing
|
||||
```typescript
|
||||
// Example test structure
|
||||
describe('TaskListBoard', () => {
|
||||
it('should render task groups correctly', () => {
|
||||
const mockTasks = generateMockTasks(10);
|
||||
render(<TaskListBoard projectId="test-project" />);
|
||||
|
||||
expect(screen.getByText('Tasks (10)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle drag and drop operations', async () => {
|
||||
// Test drag and drop functionality
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
- Redux state management
|
||||
- API service integration
|
||||
- WebSocket event handling
|
||||
- Drag and drop operations
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Organization
|
||||
- Follow existing WorkLenz patterns
|
||||
- Use TypeScript strictly
|
||||
- Implement proper error boundaries
|
||||
- Maintain accessibility standards
|
||||
|
||||
### Performance Considerations
|
||||
- Memoize expensive calculations
|
||||
- Implement virtual scrolling for large datasets
|
||||
- Debounce user input operations
|
||||
- Optimize re-render cycles
|
||||
|
||||
### Styling Standards
|
||||
- Use existing Ant Design components
|
||||
- Follow WorkLenz design system
|
||||
- Implement responsive breakpoints
|
||||
- Maintain dark mode compatibility
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features
|
||||
- Custom column integration
|
||||
- Advanced filtering capabilities
|
||||
- Kanban board view
|
||||
- Enhanced time tracking
|
||||
- Task templates
|
||||
|
||||
### Extension Points
|
||||
The system is designed for easy extension:
|
||||
|
||||
```typescript
|
||||
// Plugin architecture ready
|
||||
interface TaskViewPlugin {
|
||||
name: string;
|
||||
component: React.ComponentType;
|
||||
supportedGroupings: IGroupBy[];
|
||||
}
|
||||
|
||||
const plugins: TaskViewPlugin[] = [
|
||||
{ name: 'kanban', component: KanbanView, supportedGroupings: ['status'] },
|
||||
{ name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] },
|
||||
];
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Bundle Size
|
||||
- Tree-shake unused dependencies
|
||||
- Code-split large components
|
||||
- Optimize asset loading
|
||||
|
||||
### Browser Compatibility
|
||||
- Modern browsers (ES2020+)
|
||||
- Graceful degradation for older browsers
|
||||
- Progressive enhancement approach
|
||||
|
||||
### Performance Monitoring
|
||||
- Track component render times
|
||||
- Monitor API response times
|
||||
- Measure user interaction latency
|
||||
275
docs/enhanced-task-management-user-guide.md
Normal file
275
docs/enhanced-task-management-user-guide.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Enhanced Task Management: User Guide
|
||||
|
||||
## What Is Enhanced Task Management?
|
||||
The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools.
|
||||
|
||||
## Why Use Enhanced Task Management?
|
||||
- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview
|
||||
- **Increased Productivity:** Bulk operations let you update multiple tasks at once
|
||||
- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural
|
||||
- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance
|
||||
- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Accessing Enhanced Task Management
|
||||
1. Navigate to your project workspace
|
||||
2. Look for the enhanced task view option in your project interface
|
||||
3. The system will display your tasks grouped by the current grouping method (default: Status)
|
||||
|
||||
### Understanding the Interface
|
||||
The enhanced task management interface consists of several key areas:
|
||||
|
||||
- **Header Controls:** Task count, grouping selector, and action buttons
|
||||
- **Task Groups:** Collapsible sections containing related tasks
|
||||
- **Individual Tasks:** Rich task cards with metadata and actions
|
||||
- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar)
|
||||
|
||||
## Task Grouping
|
||||
|
||||
### Available Grouping Options
|
||||
You can organize your tasks using three different grouping methods:
|
||||
|
||||
#### 1. Status Grouping (Default)
|
||||
Groups tasks by their current status:
|
||||
- **To Do:** Tasks not yet started
|
||||
- **Doing:** Tasks currently in progress
|
||||
- **Done:** Completed tasks
|
||||
|
||||
#### 2. Priority Grouping
|
||||
Groups tasks by their priority level:
|
||||
- **Critical:** Highest priority, urgent tasks
|
||||
- **High:** Important tasks requiring attention
|
||||
- **Medium:** Standard priority tasks
|
||||
- **Low:** Tasks that can be addressed later
|
||||
|
||||
#### 3. Phase Grouping
|
||||
Groups tasks by project phases:
|
||||
- **Planning:** Tasks in the planning stage
|
||||
- **Development:** Implementation and development tasks
|
||||
- **Testing:** Quality assurance and testing tasks
|
||||
- **Deployment:** Release and deployment tasks
|
||||
|
||||
### Switching Between Groupings
|
||||
1. Locate the "Group by" dropdown in the header controls
|
||||
2. Select your preferred grouping method (Status, Priority, or Phase)
|
||||
3. Tasks will automatically reorganize into the new groups
|
||||
4. Your grouping preference is saved for future sessions
|
||||
|
||||
### Group Features
|
||||
Each task group includes:
|
||||
- **Color-coded headers** with visual indicators
|
||||
- **Task count badges** showing the number of tasks in each group
|
||||
- **Progress indicators** showing completion percentage
|
||||
- **Collapse/expand functionality** to hide or show group contents
|
||||
- **Add task buttons** to quickly create tasks in specific groups
|
||||
|
||||
## Drag and Drop
|
||||
|
||||
### Moving Tasks Within Groups
|
||||
1. Hover over a task to reveal the drag handle (⋮⋮ icon)
|
||||
2. Click and hold the drag handle
|
||||
3. Drag the task to your desired position within the same group
|
||||
4. Release to drop the task in its new position
|
||||
|
||||
### Moving Tasks Between Groups
|
||||
1. Click and hold the drag handle on any task
|
||||
2. Drag the task over a different group
|
||||
3. The target group will highlight to show it can accept the task
|
||||
4. Release to drop the task into the new group
|
||||
5. The task's properties (status, priority, or phase) will automatically update
|
||||
|
||||
### Drag and Drop Benefits
|
||||
- **Instant Updates:** Task properties change automatically when moved between groups
|
||||
- **Visual Feedback:** Clear indicators show where tasks can be dropped
|
||||
- **Keyboard Accessible:** Alternative keyboard controls for accessibility
|
||||
- **Mobile Friendly:** Touch-friendly drag operations on mobile devices
|
||||
|
||||
## Multi-Select and Bulk Operations
|
||||
|
||||
### Selecting Tasks
|
||||
You can select multiple tasks using several methods:
|
||||
|
||||
#### Individual Selection
|
||||
- Click the checkbox next to any task to select it
|
||||
- Click again to deselect
|
||||
|
||||
#### Range Selection
|
||||
- Select the first task in your desired range
|
||||
- Hold Shift and click the last task in the range
|
||||
- All tasks between the first and last will be selected
|
||||
|
||||
#### Multiple Selection
|
||||
- Hold Ctrl (or Cmd on Mac) while clicking tasks
|
||||
- This allows you to select non-consecutive tasks
|
||||
|
||||
### Bulk Actions
|
||||
When you have tasks selected, a blue bulk action bar appears with these options:
|
||||
|
||||
#### Change Status (when not grouped by Status)
|
||||
- Update the status of all selected tasks at once
|
||||
- Choose from available status options in your project
|
||||
|
||||
#### Set Priority (when not grouped by Priority)
|
||||
- Assign the same priority level to all selected tasks
|
||||
- Options include Critical, High, Medium, and Low
|
||||
|
||||
#### More Actions
|
||||
Additional bulk operations include:
|
||||
- **Assign to Member:** Add team members to multiple tasks
|
||||
- **Add Labels:** Apply labels to selected tasks
|
||||
- **Archive Tasks:** Move multiple tasks to archive
|
||||
|
||||
#### Delete Tasks
|
||||
- Permanently remove multiple tasks at once
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
|
||||
### Bulk Action Tips
|
||||
- The bulk action bar only shows relevant options based on your current grouping
|
||||
- You can clear your selection at any time using the "Clear" button
|
||||
- Bulk operations provide immediate feedback and can be undone if needed
|
||||
|
||||
## Task Display Features
|
||||
|
||||
### Rich Task Information
|
||||
Each task displays comprehensive information:
|
||||
|
||||
#### Basic Information
|
||||
- **Task Key:** Unique identifier (e.g., PROJ-123)
|
||||
- **Task Name:** Clear, descriptive title
|
||||
- **Description:** Additional details when available
|
||||
|
||||
#### Visual Indicators
|
||||
- **Progress Bar:** Shows completion percentage (0-100%)
|
||||
- **Priority Indicator:** Color-coded dot showing task importance
|
||||
- **Status Color:** Left border color indicates current status
|
||||
|
||||
#### Team and Collaboration
|
||||
- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible)
|
||||
- **Labels:** Color-coded tags for categorization
|
||||
- **Comment Count:** Number of comments and discussions
|
||||
- **Attachment Count:** Number of files attached to the task
|
||||
|
||||
#### Timing Information
|
||||
- **Due Dates:** When tasks are scheduled to complete
|
||||
- Red text: Overdue tasks
|
||||
- Orange text: Due today or within 3 days
|
||||
- Gray text: Future due dates
|
||||
- **Time Tracking:** Estimated vs. logged time when available
|
||||
|
||||
### Subtask Support
|
||||
Tasks with subtasks include additional features:
|
||||
|
||||
#### Expanding Subtasks
|
||||
- Click the "+X" button next to task names to expand subtasks
|
||||
- Subtasks appear indented below the parent task
|
||||
- Click "−X" to collapse subtasks
|
||||
|
||||
#### Subtask Progress
|
||||
- Parent task progress reflects completion of all subtasks
|
||||
- Individual subtask progress is visible when expanded
|
||||
- Subtask counts show total number of child tasks
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Real-time Updates
|
||||
- Changes made by team members appear instantly
|
||||
- Live collaboration with multiple users
|
||||
- WebSocket connections ensure data synchronization
|
||||
|
||||
### Search and Filtering
|
||||
- Use existing project search and filter capabilities
|
||||
- Enhanced task management respects current filter settings
|
||||
- Search results maintain grouping organization
|
||||
|
||||
### Responsive Design
|
||||
The interface adapts to different screen sizes:
|
||||
|
||||
#### Desktop (Large Screens)
|
||||
- Full feature set with all metadata visible
|
||||
- Optimal drag-and-drop experience
|
||||
- Multi-column layouts where appropriate
|
||||
|
||||
#### Tablet (Medium Screens)
|
||||
- Condensed but functional interface
|
||||
- Touch-friendly interactions
|
||||
- Simplified metadata display
|
||||
|
||||
#### Mobile (Small Screens)
|
||||
- Stacked layout for easy navigation
|
||||
- Large touch targets for selections
|
||||
- Essential information prioritized
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Organizing Your Tasks
|
||||
1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow
|
||||
2. **Use Labels Consistently:** Apply meaningful labels for better categorization
|
||||
3. **Keep Groups Balanced:** Avoid having too many tasks in a single group
|
||||
4. **Regular Maintenance:** Review and update task organization periodically
|
||||
|
||||
### Collaboration Tips
|
||||
1. **Clear Task Names:** Use descriptive titles that everyone understands
|
||||
2. **Proper Assignment:** Assign tasks to appropriate team members
|
||||
3. **Progress Updates:** Keep progress percentages current for accurate project tracking
|
||||
4. **Use Comments:** Communicate about tasks using the comment system
|
||||
|
||||
### Productivity Techniques
|
||||
1. **Batch Similar Operations:** Use bulk actions for efficiency
|
||||
2. **Prioritize Effectively:** Use priority grouping during planning phases
|
||||
3. **Track Progress:** Monitor completion rates using group progress indicators
|
||||
4. **Plan Ahead:** Use due dates and time estimates for better scheduling
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
### Navigation
|
||||
- **Tab:** Move focus between elements
|
||||
- **Enter:** Activate focused button or link
|
||||
- **Esc:** Close open dialogs or clear selections
|
||||
|
||||
### Selection
|
||||
- **Space:** Select/deselect focused task
|
||||
- **Shift + Click:** Range selection
|
||||
- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac)
|
||||
|
||||
### Actions
|
||||
- **Delete:** Remove selected tasks (with confirmation)
|
||||
- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Tasks Not Moving Between Groups
|
||||
- Ensure you have edit permissions for the tasks
|
||||
- Check that you're dragging from the drag handle (⋮⋮ icon)
|
||||
- Verify the target group allows the task type
|
||||
|
||||
#### Bulk Actions Not Working
|
||||
- Confirm tasks are actually selected (checkboxes checked)
|
||||
- Ensure you have appropriate permissions
|
||||
- Check that the action is available for your current grouping
|
||||
|
||||
#### Missing Task Information
|
||||
- Some metadata may be hidden on smaller screens
|
||||
- Try expanding to full screen or using desktop view
|
||||
- Check that task has the required information (assignees, labels, etc.)
|
||||
|
||||
### Performance Tips
|
||||
- For projects with hundreds of tasks, consider using filters to reduce visible tasks
|
||||
- Collapse groups you're not actively working with
|
||||
- Clear selections when not performing bulk operations
|
||||
|
||||
## Getting Help
|
||||
- Contact your workspace administrator for permission-related issues
|
||||
- Check the main WorkLenz documentation for general task management help
|
||||
- Report bugs or feature requests through your organization's support channels
|
||||
|
||||
## What's New
|
||||
This enhanced task management system builds on WorkLenz's solid foundation while adding:
|
||||
- Modern drag-and-drop interfaces
|
||||
- Flexible grouping options
|
||||
- Powerful bulk operation capabilities
|
||||
- Rich visual task displays
|
||||
- Mobile-responsive design
|
||||
- Improved accessibility features
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "worklenz",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -73,8 +73,8 @@ cat > worklenz-backend/.env << EOL
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
SESSION_NAME=worklenz.sid
|
||||
SESSION_SECRET=change_me_in_production
|
||||
COOKIE_SECRET=change_me_in_production
|
||||
SESSION_SECRET=$(openssl rand -base64 48)
|
||||
COOKIE_SECRET=$(openssl rand -base64 48)
|
||||
|
||||
# CORS
|
||||
SOCKET_IO_CORS=${FRONTEND_URL}
|
||||
@@ -123,7 +123,7 @@ SLACK_WEBHOOK=
|
||||
COMMIT_BUILD_IMMEDIATELY=true
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=change_me_in_production
|
||||
JWT_SECRET=$(openssl rand -base64 48)
|
||||
EOL
|
||||
|
||||
echo "Environment configuration updated for ${HOSTNAME} with" $([ "$USE_SSL" = "true" ] && echo "HTTPS/WSS" || echo "HTTP/WS")
|
||||
@@ -138,4 +138,4 @@ echo "Frontend URL: ${FRONTEND_URL}"
|
||||
echo "API URL: ${HTTP_PREFIX}${HOSTNAME}:3000"
|
||||
echo "Socket URL: ${WS_PREFIX}${HOSTNAME}:3000"
|
||||
echo "MinIO Dashboard URL: ${MINIO_DASHBOARD_URL}"
|
||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||
echo "CORS is configured to allow requests from: ${FRONTEND_URL}"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
build
|
||||
.scannerwork
|
||||
coverage
|
||||
.dockerignore
|
||||
.git
|
||||
*.md
|
||||
tests
|
||||
|
||||
|
||||
3
worklenz-backend/.gitignore
vendored
3
worklenz-backend/.gitignore
vendored
@@ -20,9 +20,6 @@ coverage
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
# Use the official Node.js 20 image as a base
|
||||
FROM node:20
|
||||
# --- Stage 1: Build ---
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
ARG RELEASE_VERSION
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
curl \
|
||||
postgresql-server-dev-all \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create and set the working directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install global dependencies
|
||||
RUN npm install -g ts-node typescript grunt grunt-cli
|
||||
|
||||
# Copy package.json and package-lock.json (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install app dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Run the build script to compile TypeScript to JavaScript
|
||||
RUN npm run build
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3000
|
||||
RUN echo "$RELEASE_VERSION" > release
|
||||
|
||||
# --- Stage 2: Production Image ---
|
||||
FROM node:20-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/src/app/package*.json ./
|
||||
COPY --from=builder /usr/src/app/build ./build
|
||||
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=builder /usr/src/app/release ./release
|
||||
COPY --from=builder /usr/src/app/worklenz-email-templates ./worklenz-email-templates
|
||||
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/bin/www"]
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# This script controls the order of SQL file execution during database initialization
|
||||
echo "Starting database initialization..."
|
||||
|
||||
# Check if we have SQL files in expected locations
|
||||
if [ -f "/docker-entrypoint-initdb.d/sql/0_extensions.sql" ]; then
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
echo "Using SQL files from sql/ subdirectory"
|
||||
elif [ -f "/docker-entrypoint-initdb.d/0_extensions.sql" ]; then
|
||||
# First time setup - move files to subdirectory
|
||||
echo "Moving SQL files to sql/ subdirectory..."
|
||||
mkdir -p /docker-entrypoint-initdb.d/sql
|
||||
|
||||
# Move all SQL files (except this script) to the subdirectory
|
||||
for f in /docker-entrypoint-initdb.d/*.sql; do
|
||||
if [ -f "$f" ]; then
|
||||
cp "$f" /docker-entrypoint-initdb.d/sql/
|
||||
echo "Copied $f to sql/ subdirectory"
|
||||
fi
|
||||
done
|
||||
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
else
|
||||
echo "SQL files not found in expected locations!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Execute SQL files in the correct order
|
||||
echo "Executing 0_extensions.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/0_extensions.sql"
|
||||
|
||||
echo "Executing 1_tables.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/1_tables.sql"
|
||||
|
||||
echo "Executing indexes.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/indexes.sql"
|
||||
|
||||
echo "Executing 4_functions.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/4_functions.sql"
|
||||
|
||||
echo "Executing triggers.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/triggers.sql"
|
||||
|
||||
echo "Executing 3_views.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/3_views.sql"
|
||||
|
||||
echo "Executing 2_dml.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/2_dml.sql"
|
||||
|
||||
echo "Executing 5_database_user.sql..."
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -f "$SQL_DIR/5_database_user.sql"
|
||||
|
||||
echo "Database initialization completed successfully"
|
||||
88
worklenz-backend/database/00_init.sh
Normal file
88
worklenz-backend/database/00_init.sh
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting database initialization..."
|
||||
|
||||
SQL_DIR="/docker-entrypoint-initdb.d/sql"
|
||||
MIGRATIONS_DIR="/docker-entrypoint-initdb.d/migrations"
|
||||
BACKUP_DIR="/docker-entrypoint-initdb.d/pg_backups"
|
||||
|
||||
# --------------------------------------------
|
||||
# 🗄️ STEP 1: Attempt to restore latest backup
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
LATEST_BACKUP=$(ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | head -n 1)
|
||||
else
|
||||
LATEST_BACKUP=""
|
||||
fi
|
||||
|
||||
if [ -f "$LATEST_BACKUP" ]; then
|
||||
echo "🗄️ Found latest backup: $LATEST_BACKUP"
|
||||
echo "⏳ Restoring from backup..."
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$LATEST_BACKUP"
|
||||
echo "✅ Backup restoration complete. Skipping schema and migrations."
|
||||
exit 0
|
||||
else
|
||||
echo "ℹ️ No valid backup found. Proceeding with base schema and migrations."
|
||||
fi
|
||||
|
||||
# --------------------------------------------
|
||||
# 🏗️ STEP 2: Continue with base schema setup
|
||||
# --------------------------------------------
|
||||
|
||||
# Create migrations table if it doesn't exist
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMP DEFAULT now()
|
||||
);
|
||||
"
|
||||
|
||||
# List of base schema files to execute in order
|
||||
BASE_SQL_FILES=(
|
||||
"0_extensions.sql"
|
||||
"1_tables.sql"
|
||||
"indexes.sql"
|
||||
"4_functions.sql"
|
||||
"triggers.sql"
|
||||
"3_views.sql"
|
||||
"2_dml.sql"
|
||||
"5_database_user.sql"
|
||||
)
|
||||
|
||||
echo "Running base schema SQL files in order..."
|
||||
|
||||
for file in "${BASE_SQL_FILES[@]}"; do
|
||||
full_path="$SQL_DIR/$file"
|
||||
if [ -f "$full_path" ]; then
|
||||
echo "Executing $file..."
|
||||
psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$full_path"
|
||||
else
|
||||
echo "WARNING: $file not found, skipping."
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✅ Base schema SQL execution complete."
|
||||
|
||||
# --------------------------------------------
|
||||
# 🚀 STEP 3: Apply SQL migrations
|
||||
# --------------------------------------------
|
||||
|
||||
if [ -d "$MIGRATIONS_DIR" ] && compgen -G "$MIGRATIONS_DIR/*.sql" > /dev/null; then
|
||||
echo "Applying migrations..."
|
||||
for f in "$MIGRATIONS_DIR"/*.sql; do
|
||||
version=$(basename "$f")
|
||||
if ! psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT 1 FROM schema_migrations WHERE version = '$version'" | grep -q 1; then
|
||||
echo "Applying migration: $version"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f "$f"
|
||||
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "INSERT INTO schema_migrations (version) VALUES ('$version');"
|
||||
else
|
||||
echo "Skipping already applied migration: $version"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "No migration files found or directory is empty, skipping migrations."
|
||||
fi
|
||||
|
||||
echo "🎉 Database initialization completed successfully."
|
||||
@@ -1,228 +0,0 @@
|
||||
-- Migration: Add recursive task estimation functionality
|
||||
-- This migration adds a function to calculate recursive task estimation including all subtasks
|
||||
-- and modifies the get_task_form_view_model function to include this data
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update the get_task_form_view_model function to include recursive estimation
|
||||
CREATE OR REPLACE FUNCTION public.get_task_form_view_model(_user_id UUID, _team_id UUID, _task_id UUID, _project_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_task JSON;
|
||||
_priorities JSON;
|
||||
_projects JSON;
|
||||
_statuses JSON;
|
||||
_team_members JSON;
|
||||
_assignees JSON;
|
||||
_phases JSON;
|
||||
BEGIN
|
||||
|
||||
-- Select task info
|
||||
SELECT COALESCE(ROW_TO_JSON(rec), '{}'::JSON)
|
||||
INTO _task
|
||||
FROM (WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id,
|
||||
parent_task_id,
|
||||
0 AS level
|
||||
FROM tasks
|
||||
WHERE id = _task_id
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Traverse up to parent tasks
|
||||
SELECT t.id,
|
||||
t.parent_task_id,
|
||||
th.level + 1 AS level
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.id = th.parent_task_id
|
||||
WHERE th.parent_task_id IS NOT NULL)
|
||||
SELECT id,
|
||||
name,
|
||||
description,
|
||||
start_date,
|
||||
end_date,
|
||||
done,
|
||||
total_minutes,
|
||||
priority_id,
|
||||
project_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status_id,
|
||||
parent_task_id,
|
||||
sort_order,
|
||||
(SELECT phase_id FROM task_phase WHERE task_id = tasks.id) AS phase_id,
|
||||
CONCAT((SELECT key FROM projects WHERE id = tasks.project_id), '-', task_no) AS task_key,
|
||||
(SELECT start_time
|
||||
FROM task_timers
|
||||
WHERE task_id = tasks.id
|
||||
AND user_id = _user_id) AS timer_start_time,
|
||||
parent_task_id IS NOT NULL AS is_sub_task,
|
||||
(SELECT COUNT('*')
|
||||
FROM tasks
|
||||
WHERE parent_task_id = tasks.id
|
||||
AND archived IS FALSE) AS sub_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks_with_status_view tt
|
||||
WHERE (tt.parent_task_id = tasks.id OR tt.task_id = tasks.id)
|
||||
AND tt.is_done IS TRUE)
|
||||
AS completed_count,
|
||||
(SELECT COUNT(*) FROM task_attachments WHERE task_id = tasks.id) AS attachments_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(r))), '[]'::JSON)
|
||||
FROM (SELECT task_labels.label_id AS id,
|
||||
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||
FROM task_labels
|
||||
WHERE task_id = tasks.id
|
||||
ORDER BY name) r) AS labels,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = tasks.status_id)) AS status_color,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = _task_id) AS sub_tasks_count,
|
||||
(SELECT name FROM users WHERE id = tasks.reporter_id) AS reporter,
|
||||
(SELECT get_task_assignees(tasks.id)) AS assignees,
|
||||
(SELECT id FROM team_members WHERE user_id = _user_id AND team_id = _team_id) AS team_member_id,
|
||||
billable,
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _priorities
|
||||
FROM (SELECT id, name FROM task_priorities ORDER BY value) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _phases
|
||||
FROM (SELECT id, name FROM project_phases WHERE project_id = _project_id ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _projects
|
||||
FROM (SELECT id, name
|
||||
FROM projects
|
||||
WHERE team_id = _team_id
|
||||
AND (CASE
|
||||
WHEN (is_owner(_user_id, _team_id) OR is_admin(_user_id, _team_id) IS TRUE) THEN TRUE
|
||||
ELSE is_member_of_project(projects.id, _user_id, _team_id) END)
|
||||
ORDER BY name) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _statuses
|
||||
FROM (SELECT id, name FROM task_statuses WHERE project_id = _project_id) rec;
|
||||
|
||||
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(rec))), '[]'::JSON)
|
||||
INTO _team_members
|
||||
FROM (SELECT team_members.id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = team_members.id),
|
||||
(SELECT avatar_url
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = team_members.id)
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id
|
||||
WHERE team_id = _team_id
|
||||
AND team_members.active IS TRUE) rec;
|
||||
|
||||
SELECT get_task_assignees(_task_id) INTO _assignees;
|
||||
|
||||
RETURN JSON_BUILD_OBJECT(
|
||||
'task', _task,
|
||||
'priorities', _priorities,
|
||||
'projects', _projects,
|
||||
'statuses', _statuses,
|
||||
'team_members', _team_members,
|
||||
'assignees', _assignees,
|
||||
'phases', _phases
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,135 @@
|
||||
-- Performance indexes for optimized tasks queries
|
||||
-- Migration: 20250115000000-performance-indexes.sql
|
||||
|
||||
-- Composite index for main task filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for status joins
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for assignees lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id);
|
||||
|
||||
-- Index for phase lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id);
|
||||
|
||||
-- Index for subtask counting
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
|
||||
|
||||
-- Index for labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id);
|
||||
|
||||
-- Index for comments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id);
|
||||
|
||||
-- Index for attachments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id);
|
||||
|
||||
-- Index for work log aggregation
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id);
|
||||
|
||||
-- Index for subscribers check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id);
|
||||
|
||||
-- Index for dependencies check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id);
|
||||
|
||||
-- Index for timers lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
|
||||
ON task_timers(task_id, user_id);
|
||||
|
||||
-- Index for custom columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id);
|
||||
|
||||
-- Index for team member info view optimization
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
|
||||
ON team_members(team_id, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Index for notification settings
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
|
||||
ON notification_settings(user_id, team_id);
|
||||
|
||||
-- Index for task status categories
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
|
||||
ON task_statuses(category_id, project_id);
|
||||
|
||||
-- Index for project phases
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
|
||||
ON project_phases(project_id, sort_index);
|
||||
|
||||
-- Index for task priorities
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
|
||||
ON task_priorities(value);
|
||||
|
||||
-- Index for team labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
|
||||
ON team_labels(team_id);
|
||||
|
||||
-- NEW INDEXES FOR PERFORMANCE OPTIMIZATION --
|
||||
|
||||
-- Composite index for task main query optimization (covers most WHERE conditions)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
|
||||
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for sorting by sort_order with project filter
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for email_invitations to optimize team_member_info_view
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
|
||||
ON email_invitations(team_member_id);
|
||||
|
||||
-- Covering index for task status with category information
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
|
||||
ON task_statuses(id, category_id, project_id);
|
||||
|
||||
-- Index for task aggregation queries (parent task progress calculation)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
|
||||
ON tasks(parent_task_id, status_id, archived)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for project team member filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
|
||||
ON team_members(team_id, active, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Covering index for tasks with frequently accessed columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
|
||||
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task search functionality
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
|
||||
ON tasks USING gin(to_tsvector('english', name))
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for date-based filtering (if used)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
|
||||
ON tasks(project_id, start_date, end_date)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task timers with user filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
|
||||
ON task_timers(user_id, task_id);
|
||||
|
||||
-- Index for sys_task_status_categories lookups
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
|
||||
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);
|
||||
@@ -1,20 +0,0 @@
|
||||
-- Migration: Add currency column to projects table
|
||||
-- Date: 2025-01-17
|
||||
-- Description: Adds project-specific currency support to allow different projects to use different currencies
|
||||
|
||||
-- Add currency column to projects table
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'USD';
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
-- Add constraint to ensure currency codes are uppercase and 3 characters
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_currency_format_check
|
||||
CHECK (currency ~ '^[A-Z]{3}$');
|
||||
|
||||
-- Update existing projects to have a default currency if they don't have one
|
||||
UPDATE projects
|
||||
SET currency = 'USD'
|
||||
WHERE currency IS NULL;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Fix window function error in task sort optimized functions
|
||||
-- Error: window functions are not allowed in UPDATE
|
||||
|
||||
-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
|
||||
IF (_to_index = -1)
|
||||
THEN
|
||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order < _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Replace the second optimized sort function
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Add simple bulk update function as alternative
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
BEGIN
|
||||
-- Simple approach: update each task's sort_order from the provided array
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET
|
||||
sort_order = _update_record.sort_order,
|
||||
status_id = COALESCE(_update_record.status_id, status_id),
|
||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
||||
WHERE id = _update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Create holiday types table
|
||||
CREATE TABLE IF NOT EXISTS holiday_types (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color_code WL_HEX_COLOR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE holiday_types
|
||||
ADD CONSTRAINT holiday_types_pk
|
||||
PRIMARY KEY (id);
|
||||
|
||||
-- Insert default holiday types
|
||||
INSERT INTO holiday_types (name, description, color_code) VALUES
|
||||
('Public Holiday', 'Official public holidays', '#f37070'),
|
||||
('Company Holiday', 'Company-specific holidays', '#70a6f3'),
|
||||
('Personal Holiday', 'Personal or optional holidays', '#75c997'),
|
||||
('Religious Holiday', 'Religious observances', '#fbc84c')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Create organization holidays table
|
||||
CREATE TABLE IF NOT EXISTS organization_holidays (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
organization_id UUID NOT NULL,
|
||||
holiday_type_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE organization_holidays
|
||||
ADD CONSTRAINT organization_holidays_pk
|
||||
PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE organization_holidays
|
||||
ADD CONSTRAINT organization_holidays_organization_id_fk
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE organization_holidays
|
||||
ADD CONSTRAINT organization_holidays_holiday_type_id_fk
|
||||
FOREIGN KEY (holiday_type_id) REFERENCES holiday_types
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
-- Add unique constraint to prevent duplicate holidays on the same date for an organization
|
||||
ALTER TABLE organization_holidays
|
||||
ADD CONSTRAINT organization_holidays_organization_date_unique
|
||||
UNIQUE (organization_id, date);
|
||||
|
||||
-- Create country holidays table for predefined holidays
|
||||
CREATE TABLE IF NOT EXISTS country_holidays (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
country_code CHAR(2) NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE country_holidays
|
||||
ADD CONSTRAINT country_holidays_pk
|
||||
PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE country_holidays
|
||||
ADD CONSTRAINT country_holidays_country_code_fk
|
||||
FOREIGN KEY (country_code) REFERENCES countries(code)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add unique constraint to prevent duplicate holidays for the same country, name, and date
|
||||
ALTER TABLE country_holidays
|
||||
ADD CONSTRAINT country_holidays_country_name_date_unique
|
||||
UNIQUE (country_code, name, date);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_holidays_organization_id ON organization_holidays(organization_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_holidays_date ON organization_holidays(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_country_holidays_country_code ON country_holidays(country_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_country_holidays_date ON country_holidays(date);
|
||||
@@ -0,0 +1,60 @@
|
||||
-- ================================================================
|
||||
-- Sri Lankan Holidays Migration
|
||||
-- ================================================================
|
||||
-- This migration populates Sri Lankan holidays from verified sources
|
||||
--
|
||||
-- SOURCES & VERIFICATION:
|
||||
-- - 2025 data: Verified from official government sources
|
||||
-- - Fixed holidays: Independence Day, May Day, Christmas (all years)
|
||||
-- - Variable holidays: Added only when officially verified
|
||||
--
|
||||
-- MAINTENANCE:
|
||||
-- - Use scripts/update-sri-lankan-holidays.js for updates
|
||||
-- - See docs/sri-lankan-holiday-update-process.md for process
|
||||
-- ================================================================
|
||||
|
||||
-- Insert fixed holidays for multiple years (these never change dates)
|
||||
DO $$
|
||||
DECLARE
|
||||
current_year INT;
|
||||
BEGIN
|
||||
FOR current_year IN 2020..2050 LOOP
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES
|
||||
('LK', 'Independence Day', 'Commemorates the independence of Sri Lanka from British rule in 1948',
|
||||
make_date(current_year, 2, 4), true),
|
||||
('LK', 'May Day', 'International Workers'' Day',
|
||||
make_date(current_year, 5, 1), true),
|
||||
('LK', 'Christmas Day', 'Christian celebration of the birth of Jesus Christ',
|
||||
make_date(current_year, 12, 25), true)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Insert specific holidays for years 2025-2028 (from our JSON data)
|
||||
|
||||
-- 2025 holidays
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES
|
||||
('LK', 'Duruthu Full Moon Poya Day', 'Commemorates the first visit of Buddha to Sri Lanka', '2025-01-13', false),
|
||||
('LK', 'Navam Full Moon Poya Day', 'Commemorates the appointment of Sariputta and Moggallana as Buddha''s chief disciples', '2025-02-12', false),
|
||||
('LK', 'Medin Full Moon Poya Day', 'Commemorates Buddha''s first visit to his father''s palace after enlightenment', '2025-03-14', false),
|
||||
('LK', 'Eid al-Fitr', 'Festival marking the end of Ramadan', '2025-03-31', false),
|
||||
('LK', 'Bak Full Moon Poya Day', 'Commemorates Buddha''s second visit to Sri Lanka', '2025-04-12', false),
|
||||
('LK', 'Good Friday', 'Christian commemoration of the crucifixion of Jesus Christ', '2025-04-18', false),
|
||||
('LK', 'Vesak Full Moon Poya Day', 'Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha', '2025-05-12', false),
|
||||
('LK', 'Day after Vesak Full Moon Poya Day', 'Additional day for Vesak celebrations', '2025-05-13', false),
|
||||
('LK', 'Eid al-Adha', 'Islamic festival of sacrifice', '2025-06-07', false),
|
||||
('LK', 'Poson Full Moon Poya Day', 'Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda', '2025-06-11', false),
|
||||
('LK', 'Esala Full Moon Poya Day', 'Commemorates Buddha''s first sermon and the arrival of the Sacred Tooth Relic', '2025-07-10', false),
|
||||
('LK', 'Nikini Full Moon Poya Day', 'Commemorates the first Buddhist council', '2025-08-09', false),
|
||||
('LK', 'Binara Full Moon Poya Day', 'Commemorates Buddha''s visit to heaven to preach to his mother', '2025-09-07', false),
|
||||
('LK', 'Vap Full Moon Poya Day', 'Marks the end of Buddhist Lent and Buddha''s return from heaven', '2025-10-07', false),
|
||||
('LK', 'Deepavali', 'Hindu Festival of Lights', '2025-10-20', false),
|
||||
('LK', 'Il Full Moon Poya Day', 'Commemorates Buddha''s ordination of sixty disciples', '2025-11-05', false),
|
||||
('LK', 'Unduvap Full Moon Poya Day', 'Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling', '2025-12-04', false)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING;
|
||||
|
||||
-- NOTE: Data for 2026+ should be added only after verification from official sources
|
||||
-- Use the holiday management script to generate templates for new years:
|
||||
-- node update-sri-lankan-holidays.js --poya-template YYYY
|
||||
@@ -603,8 +603,7 @@ BEGIN
|
||||
schedule_id,
|
||||
progress_value,
|
||||
weight,
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level,
|
||||
(SELECT get_task_recursive_estimation(tasks.id)) AS recursive_estimation
|
||||
(SELECT MAX(level) FROM task_hierarchy) AS task_level
|
||||
FROM tasks
|
||||
WHERE id = _task_id) rec;
|
||||
|
||||
@@ -663,89 +662,6 @@ ADD COLUMN IF NOT EXISTS use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS use_time_progress BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- Function to calculate recursive task estimation (including all subtasks)
|
||||
CREATE OR REPLACE FUNCTION get_task_recursive_estimation(_task_id UUID) RETURNS JSON
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_result JSON;
|
||||
_has_subtasks BOOLEAN;
|
||||
BEGIN
|
||||
-- First check if this task has any subtasks
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
) INTO _has_subtasks;
|
||||
|
||||
-- If task has subtasks, calculate recursive estimation excluding parent's own estimation
|
||||
IF _has_subtasks THEN
|
||||
WITH RECURSIVE task_tree AS (
|
||||
-- Start with direct subtasks only (exclude the parent task itself)
|
||||
SELECT
|
||||
id,
|
||||
parent_task_id,
|
||||
COALESCE(total_minutes, 0) as total_minutes,
|
||||
1 as level -- Start at level 1 (subtasks)
|
||||
FROM tasks
|
||||
WHERE parent_task_id = _task_id
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all descendant tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.parent_task_id,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
tt.level + 1 as level
|
||||
FROM tasks t
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
task_counts AS (
|
||||
SELECT
|
||||
COUNT(*) as sub_tasks_count,
|
||||
SUM(total_minutes) as subtasks_total_minutes -- Sum all subtask estimations
|
||||
FROM task_tree
|
||||
)
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', COALESCE(tc.sub_tasks_count, 0),
|
||||
'own_total_minutes', 0, -- Always 0 for parent tasks
|
||||
'subtasks_total_minutes', COALESCE(tc.subtasks_total_minutes, 0),
|
||||
'recursive_total_minutes', COALESCE(tc.subtasks_total_minutes, 0), -- Only subtasks total
|
||||
'recursive_total_hours', FLOOR(COALESCE(tc.subtasks_total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(tc.subtasks_total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM task_counts tc;
|
||||
ELSE
|
||||
-- If task has no subtasks, use its own estimation
|
||||
SELECT JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', COALESCE(total_minutes, 0),
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', COALESCE(total_minutes, 0), -- Use own estimation
|
||||
'recursive_total_hours', FLOOR(COALESCE(total_minutes, 0) / 60),
|
||||
'recursive_remaining_minutes', COALESCE(total_minutes, 0) % 60
|
||||
)
|
||||
INTO _result
|
||||
FROM tasks
|
||||
WHERE id = _task_id;
|
||||
END IF;
|
||||
|
||||
RETURN COALESCE(_result, JSON_BUILD_OBJECT(
|
||||
'sub_tasks_count', 0,
|
||||
'own_total_minutes', 0,
|
||||
'subtasks_total_minutes', 0,
|
||||
'recursive_total_minutes', 0,
|
||||
'recursive_total_hours', 0,
|
||||
'recursive_remaining_minutes', 0
|
||||
));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Add a trigger to reset manual progress when a task gets a new subtask
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_manual_progress() RETURNS TRIGGER AS
|
||||
$$
|
||||
@@ -761,22 +677,6 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Add a trigger to reset parent task estimation when it gets subtasks
|
||||
CREATE OR REPLACE FUNCTION reset_parent_task_estimation() RETURNS TRIGGER AS
|
||||
$$
|
||||
BEGIN
|
||||
-- When a task gets a new subtask (parent_task_id is set), reset the parent's total_minutes to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
IF NEW.parent_task_id IS NOT NULL THEN
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id = NEW.parent_task_id
|
||||
AND total_minutes > 0;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger on the tasks table
|
||||
DROP TRIGGER IF EXISTS reset_parent_manual_progress_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_manual_progress_trigger
|
||||
@@ -784,35 +684,4 @@ AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_manual_progress();
|
||||
|
||||
-- Create the trigger to reset parent task estimation
|
||||
DROP TRIGGER IF EXISTS reset_parent_estimation_trigger ON tasks;
|
||||
CREATE TRIGGER reset_parent_estimation_trigger
|
||||
AFTER INSERT OR UPDATE OF parent_task_id ON tasks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION reset_parent_task_estimation();
|
||||
|
||||
-- Function to reset all existing parent tasks' estimations to 0
|
||||
CREATE OR REPLACE FUNCTION reset_all_parent_task_estimations() RETURNS INTEGER AS
|
||||
$$
|
||||
DECLARE
|
||||
_updated_count INTEGER;
|
||||
BEGIN
|
||||
-- Update all tasks that have subtasks to have 0 estimation
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
GET DIAGNOSTICS _updated_count = ROW_COUNT;
|
||||
|
||||
RETURN _updated_count;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMIT;
|
||||
@@ -145,7 +145,7 @@ BEGIN
|
||||
SET progress_value = NULL,
|
||||
progress_mode = NULL
|
||||
WHERE project_id = _project_id
|
||||
AND progress_mode = _old_mode;
|
||||
AND progress_mode::text::progress_mode_type = _old_mode;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
-- Dropping existing finance_rate_cards table
|
||||
DROP TABLE IF EXISTS finance_rate_cards;
|
||||
-- Creating table to store rate card details
|
||||
CREATE TABLE finance_rate_cards
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Dropping existing finance_project_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_project_rate_card_roles CASCADE;
|
||||
-- Creating table with single id primary key
|
||||
CREATE TABLE finance_project_rate_card_roles
|
||||
(
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
);
|
||||
|
||||
-- Dropping existing finance_rate_card_roles table
|
||||
DROP TABLE IF EXISTS finance_rate_card_roles;
|
||||
-- Creating table to store role-specific rates for rate cards
|
||||
CREATE TABLE finance_rate_card_roles
|
||||
(
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Adding project_rate_card_role_id column to project_members
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
|
||||
-- Adding rate_card column to projects
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE finance_rate_cards
|
||||
ADD COLUMN currency TEXT NOT NULL DEFAULT 'USD';
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Add fixed_cost column to tasks table for project finance functionality
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0);
|
||||
|
||||
-- Add comment to explain the column
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Create organization holiday settings table
|
||||
CREATE TABLE IF NOT EXISTS organization_holiday_settings (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
organization_id UUID NOT NULL,
|
||||
country_code CHAR(2),
|
||||
state_code TEXT,
|
||||
auto_sync_holidays BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE organization_holiday_settings
|
||||
ADD CONSTRAINT organization_holiday_settings_pk
|
||||
PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE organization_holiday_settings
|
||||
ADD CONSTRAINT organization_holiday_settings_organization_id_fk
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations
|
||||
ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE organization_holiday_settings
|
||||
ADD CONSTRAINT organization_holiday_settings_country_code_fk
|
||||
FOREIGN KEY (country_code) REFERENCES countries(code)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
-- Ensure one settings record per organization
|
||||
ALTER TABLE organization_holiday_settings
|
||||
ADD CONSTRAINT organization_holiday_settings_organization_unique
|
||||
UNIQUE (organization_id);
|
||||
|
||||
-- Create index for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_holiday_settings_organization_id ON organization_holiday_settings(organization_id);
|
||||
|
||||
-- Add state holidays table for more granular holiday data
|
||||
CREATE TABLE IF NOT EXISTS state_holidays (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
country_code CHAR(2) NOT NULL,
|
||||
state_code TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
date DATE NOT NULL,
|
||||
is_recurring BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE state_holidays
|
||||
ADD CONSTRAINT state_holidays_pk
|
||||
PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE state_holidays
|
||||
ADD CONSTRAINT state_holidays_country_code_fk
|
||||
FOREIGN KEY (country_code) REFERENCES countries(code)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
-- Add unique constraint to prevent duplicate holidays for the same state, name, and date
|
||||
ALTER TABLE state_holidays
|
||||
ADD CONSTRAINT state_holidays_state_name_date_unique
|
||||
UNIQUE (country_code, state_code, name, date);
|
||||
|
||||
-- Create indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_state_holidays_country_state ON state_holidays(country_code, state_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_state_holidays_date ON state_holidays(date);
|
||||
@@ -118,7 +118,7 @@ BEGIN
|
||||
SELECT SUM(time_spent)
|
||||
FROM task_work_log
|
||||
WHERE task_id = t.id
|
||||
), 0) / 60.0 as logged_minutes
|
||||
), 0) as logged_minutes
|
||||
FROM tasks t
|
||||
WHERE t.id = _task_id
|
||||
)
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
-- Fix Duplicate Sort Orders Script
|
||||
-- This script detects and fixes duplicate sort order values that break task ordering
|
||||
|
||||
-- 1. DETECTION QUERIES - Run these first to see the scope of the problem
|
||||
|
||||
-- Check for duplicates in main sort_order column
|
||||
SELECT
|
||||
project_id,
|
||||
sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, sort_order;
|
||||
|
||||
-- Check for duplicates in status_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
status_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, status_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, status_sort_order;
|
||||
|
||||
-- Check for duplicates in priority_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
priority_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, priority_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, priority_sort_order;
|
||||
|
||||
-- Check for duplicates in phase_sort_order
|
||||
SELECT
|
||||
project_id,
|
||||
phase_sort_order,
|
||||
COUNT(*) as duplicate_count,
|
||||
STRING_AGG(id::text, ', ') as task_ids
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
GROUP BY project_id, phase_sort_order
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY project_id, phase_sort_order;
|
||||
|
||||
-- Note: member_sort_order removed - no longer used
|
||||
|
||||
-- 2. CLEANUP FUNCTIONS
|
||||
|
||||
-- Fix duplicates in main sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
-- For each project, reassign sort_order values to ensure uniqueness
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
-- Reassign sort_order values sequentially for this project
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in status_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY status_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET status_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed status_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in priority_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY priority_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET priority_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Fix duplicates in phase_sort_order column
|
||||
CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY phase_sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET phase_sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Note: fix_member_sort_order_duplicates() removed - no longer needed
|
||||
|
||||
-- Master function to fix all sort order duplicates
|
||||
CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
RAISE NOTICE 'Starting sort order cleanup for all columns...';
|
||||
|
||||
PERFORM fix_sort_order_duplicates();
|
||||
PERFORM fix_status_sort_order_duplicates();
|
||||
PERFORM fix_priority_sort_order_duplicates();
|
||||
PERFORM fix_phase_sort_order_duplicates();
|
||||
|
||||
RAISE NOTICE 'Completed sort order cleanup for all columns';
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 3. VERIFICATION FUNCTION
|
||||
|
||||
-- Verify that duplicates have been fixed
|
||||
CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE(
|
||||
column_name text,
|
||||
project_id uuid,
|
||||
duplicate_count bigint,
|
||||
status text
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
-- Check sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check status_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'status_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.status_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check priority_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'priority_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.priority_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Check phase_sort_order duplicates
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'phase_sort_order'::text as column_name,
|
||||
t.project_id,
|
||||
COUNT(*) as duplicate_count,
|
||||
CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status
|
||||
FROM tasks t
|
||||
WHERE t.project_id IS NOT NULL
|
||||
GROUP BY t.project_id, t.phase_sort_order
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- Note: member_sort_order verification removed - column no longer used
|
||||
|
||||
END
|
||||
$$;
|
||||
|
||||
-- 4. USAGE INSTRUCTIONS
|
||||
|
||||
/*
|
||||
USAGE:
|
||||
|
||||
1. First, run the detection queries to see which projects have duplicates
|
||||
2. Then run this to fix all duplicates:
|
||||
SELECT fix_all_duplicate_sort_orders();
|
||||
3. Finally, verify the fix worked:
|
||||
SELECT * FROM verify_sort_order_integrity();
|
||||
|
||||
If verification returns no rows, all duplicates have been fixed successfully.
|
||||
|
||||
WARNING: This will reassign sort order values based on current order + creation time.
|
||||
Make sure to backup your database before running these functions.
|
||||
*/
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Migration: Add separate sort order columns for different grouping types
|
||||
-- This allows users to maintain different task orders when switching between grouping views
|
||||
|
||||
-- Add new sort order columns
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0;
|
||||
|
||||
-- Initialize new columns with current sort_order values
|
||||
UPDATE tasks SET
|
||||
status_sort_order = sort_order,
|
||||
priority_sort_order = sort_order,
|
||||
phase_sort_order = sort_order,
|
||||
member_sort_order = sort_order
|
||||
WHERE status_sort_order = 0
|
||||
OR priority_sort_order = 0
|
||||
OR phase_sort_order = 0
|
||||
OR member_sort_order = 0;
|
||||
|
||||
-- Add constraints to ensure non-negative values
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0);
|
||||
|
||||
-- Add indexes for performance (since these will be used for ordering)
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order);
|
||||
|
||||
-- Update comments for documentation
|
||||
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
|
||||
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
|
||||
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
|
||||
COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees';
|
||||
@@ -0,0 +1,172 @@
|
||||
-- Migration: Update database functions to handle grouping-specific sort orders
|
||||
|
||||
-- Function to get the appropriate sort column name based on grouping type
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
WHEN 'members' THEN RETURN 'member_sort_order';
|
||||
ELSE RETURN 'sort_order'; -- fallback to general sort_order
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated bulk sort order function to handle different sort columns
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Simple approach: update each task's sort_order from the provided array
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the appropriate sort column and other fields using dynamic SQL
|
||||
-- Only update sort_order if we're using the default sorting
|
||||
IF _sort_column = 'sort_order' THEN
|
||||
UPDATE tasks SET
|
||||
sort_order = _update_record.sort_order,
|
||||
status_id = COALESCE(_update_record.status_id, status_id),
|
||||
priority_id = COALESCE(_update_record.priority_id, priority_id)
|
||||
WHERE id = _update_record.task_id;
|
||||
ELSE
|
||||
-- Update only the grouping-specific sort column, not the main sort_order
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id) ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
END IF;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated main sort order change handler
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_from_index INT;
|
||||
_to_index INT;
|
||||
_task_id UUID;
|
||||
_project_id UUID;
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_batch_size INT := 100;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_from_index = (_body ->> 'from_index')::INT;
|
||||
_to_index = (_body ->> 'to_index')::INT;
|
||||
_from_group = (_body ->> 'from_group')::UUID;
|
||||
_to_group = (_body ->> 'to_group')::UUID;
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
-- Get the appropriate sort column
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Handle group changes
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
|
||||
IF (_group_by = 'status') THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND status_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority') THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group
|
||||
WHERE id = _task_id
|
||||
AND priority_id = _from_group
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase') THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE) THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_task_id, _to_group)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
|
||||
ELSE
|
||||
DELETE FROM task_phase WHERE task_id = _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Handle sort order changes using dynamic SQL
|
||||
IF (_from_index <> _to_index) THEN
|
||||
-- For the main sort_order column, we need to be careful about unique constraints
|
||||
IF _sort_column = 'sort_order' THEN
|
||||
-- Use a transaction-safe approach for the main sort_order column
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort_order for items between old and new position
|
||||
UPDATE tasks SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort_order for items between new and old position
|
||||
UPDATE tasks SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort_order for the moved task
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id;
|
||||
ELSE
|
||||
-- For grouping-specific columns, use dynamic SQL since there's no unique constraint
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort_order for items between old and new position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
|
||||
EXECUTE _sql USING _project_id, _from_index, _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort_order for items between new and old position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
|
||||
EXECUTE _sql USING _project_id, _to_index, _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort_order for the moved task
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2';
|
||||
EXECUTE _sql USING _to_index, _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Migration: Fix sort order constraint violations
|
||||
|
||||
-- First, let's ensure all existing tasks have unique sort_order values within each project
|
||||
-- This is a one-time fix to ensure data consistency
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
_project RECORD;
|
||||
_task RECORD;
|
||||
_counter INTEGER;
|
||||
BEGIN
|
||||
-- For each project, reassign sort_order values to ensure uniqueness
|
||||
FOR _project IN
|
||||
SELECT DISTINCT project_id
|
||||
FROM tasks
|
||||
WHERE project_id IS NOT NULL
|
||||
LOOP
|
||||
_counter := 0;
|
||||
|
||||
-- Reassign sort_order values sequentially for this project
|
||||
FOR _task IN
|
||||
SELECT id
|
||||
FROM tasks
|
||||
WHERE project_id = _project.project_id
|
||||
ORDER BY sort_order, created_at
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = _counter
|
||||
WHERE id = _task.id;
|
||||
|
||||
_counter := _counter + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Now create a better version of our functions that properly handles the constraints
|
||||
|
||||
-- Updated bulk sort order function that avoids sort_order conflicts
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Process each update record
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the grouping-specific sort column and other fields
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id), ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Also update the helper function to be more explicit
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
WHEN 'members' THEN RETURN 'member_sort_order';
|
||||
-- For backward compatibility, still support general sort_order but be explicit
|
||||
WHEN 'general' THEN RETURN 'sort_order';
|
||||
ELSE RETURN 'status_sort_order'; -- Default to status sorting
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated main sort order change handler that avoids conflicts
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_from_index INT;
|
||||
_to_index INT;
|
||||
_task_id UUID;
|
||||
_project_id UUID;
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
_from_index = (_body ->> 'from_index')::INT;
|
||||
_to_index = (_body ->> 'to_index')::INT;
|
||||
_from_group = (_body ->> 'from_group')::UUID;
|
||||
_to_group = (_body ->> 'to_group')::UUID;
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
-- Get the appropriate sort column
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Handle group changes first
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
|
||||
IF (_group_by = 'status') THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority') THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase') THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE) THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_task_id, _to_group)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
|
||||
ELSE
|
||||
DELETE FROM task_phase WHERE task_id = _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Handle sort order changes for the grouping-specific column only
|
||||
IF (_from_index <> _to_index) THEN
|
||||
-- Update the grouping-specific sort order (no unique constraint issues)
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort order for items between old and new position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
|
||||
EXECUTE _sql USING _project_id, _from_index, _to_index;
|
||||
ELSE
|
||||
-- Moving up: increase sort order for items between new and old position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
|
||||
EXECUTE _sql USING _project_id, _to_index, _from_index;
|
||||
END IF;
|
||||
|
||||
-- Set the new sort order for the moved task
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
|
||||
EXECUTE _sql USING _to_index, _task_id;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- Migration: Add survey tables for account setup questionnaire
|
||||
-- Date: 2025-07-24
|
||||
-- Description: Creates tables to store survey questions and user responses for account setup flow
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create surveys table to define different types of surveys
|
||||
CREATE TABLE IF NOT EXISTS surveys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL, -- 'account_setup', 'onboarding', 'feedback'
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_questions table to store individual questions
|
||||
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
question_key VARCHAR(100) NOT NULL, -- Used for localization keys
|
||||
question_type VARCHAR(50) NOT NULL, -- 'single_choice', 'multiple_choice', 'text'
|
||||
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
options JSONB, -- For choice questions, store options as JSON array
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_responses table to track user responses to surveys
|
||||
CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
started_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Create survey_answers table to store individual question answers
|
||||
CREATE TABLE IF NOT EXISTS survey_answers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
|
||||
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
|
||||
answer_text TEXT,
|
||||
answer_json JSONB, -- For multiple choice answers stored as array
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Add performance indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
|
||||
|
||||
-- Add constraints
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
|
||||
|
||||
-- Add unique constraint to prevent duplicate responses per user per survey
|
||||
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
||||
|
||||
-- Add unique constraint to prevent duplicate answers per question per response
|
||||
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
||||
|
||||
-- Insert the default account setup survey
|
||||
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Get the survey ID for inserting questions
|
||||
DO $$
|
||||
DECLARE
|
||||
survey_uuid UUID;
|
||||
BEGIN
|
||||
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||
|
||||
-- Insert survey questions
|
||||
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||
ON CONFLICT DO NOTHING;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
72
worklenz-backend/database/pg-migrations/README.md
Normal file
72
worklenz-backend/database/pg-migrations/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Node-pg-migrate Migrations
|
||||
|
||||
This directory contains database migrations managed by node-pg-migrate.
|
||||
|
||||
## Migration Commands
|
||||
|
||||
- `npm run migrate:create -- migration-name` - Create a new migration file
|
||||
- `npm run migrate:up` - Run all pending migrations
|
||||
- `npm run migrate:down` - Rollback the last migration
|
||||
- `npm run migrate:redo` - Rollback and re-run the last migration
|
||||
|
||||
## Migration File Format
|
||||
|
||||
Migrations are JavaScript files with timestamp prefixes (e.g., `20250115000000_performance-indexes.js`).
|
||||
|
||||
Each migration file exports two functions:
|
||||
- `exports.up` - Contains the forward migration logic
|
||||
- `exports.down` - Contains the rollback logic
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use IF EXISTS/IF NOT EXISTS checks** to make migrations idempotent
|
||||
2. **Test migrations locally** before deploying to production
|
||||
3. **Include rollback logic** in the `down` function for all changes
|
||||
4. **Use descriptive names** for migration files
|
||||
5. **Keep migrations focused** - one logical change per migration
|
||||
|
||||
## Example Migration
|
||||
|
||||
```javascript
|
||||
exports.up = pgm => {
|
||||
// Create table with IF NOT EXISTS
|
||||
pgm.createTable('users', {
|
||||
id: 'id',
|
||||
name: { type: 'varchar(100)', notNull: true },
|
||||
created_at: {
|
||||
type: 'timestamp',
|
||||
notNull: true,
|
||||
default: pgm.func('current_timestamp')
|
||||
}
|
||||
}, { ifNotExists: true });
|
||||
|
||||
// Add index with IF NOT EXISTS
|
||||
pgm.createIndex('users', 'name', {
|
||||
name: 'idx_users_name',
|
||||
ifNotExists: true
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = pgm => {
|
||||
// Drop in reverse order
|
||||
pgm.dropIndex('users', 'name', {
|
||||
name: 'idx_users_name',
|
||||
ifExists: true
|
||||
});
|
||||
|
||||
pgm.dropTable('users', { ifExists: true });
|
||||
};
|
||||
```
|
||||
|
||||
## Migration History
|
||||
|
||||
The `pgmigrations` table tracks which migrations have been run. Do not modify this table manually.
|
||||
|
||||
## Converting from SQL Migrations
|
||||
|
||||
When converting SQL migrations to node-pg-migrate format:
|
||||
|
||||
1. Wrap SQL statements in `pgm.sql()` calls
|
||||
2. Use node-pg-migrate helper methods where possible (createTable, addColumns, etc.)
|
||||
3. Always include `IF EXISTS/IF NOT EXISTS` checks
|
||||
4. Ensure proper rollback logic in the `down` function
|
||||
@@ -12,10 +12,7 @@ CREATE TYPE DEPENDENCY_TYPE AS ENUM ('blocked_by');
|
||||
|
||||
CREATE TYPE SCHEDULE_TYPE AS ENUM ('daily', 'weekly', 'yearly', 'monthly', 'every_x_days', 'every_x_weeks', 'every_x_months');
|
||||
|
||||
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt');
|
||||
|
||||
-- Add progress mode type for tasks progress tracking
|
||||
CREATE TYPE PROGRESS_MODE_TYPE AS ENUM ('manual', 'weighted', 'time', 'default');
|
||||
CREATE TYPE LANGUAGE_TYPE AS ENUM ('en', 'es', 'pt', 'alb', 'de', 'zh_cn');
|
||||
|
||||
-- START: Users
|
||||
CREATE SEQUENCE IF NOT EXISTS users_user_no_seq START 1;
|
||||
@@ -780,15 +777,9 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
estimated_man_days INTEGER DEFAULT 0,
|
||||
hours_per_day INTEGER DEFAULT 8,
|
||||
health_id UUID,
|
||||
estimated_working_days INTEGER DEFAULT 0,
|
||||
use_manual_progress BOOLEAN DEFAULT FALSE,
|
||||
use_weighted_progress BOOLEAN DEFAULT FALSE,
|
||||
use_time_progress BOOLEAN DEFAULT FALSE,
|
||||
currency VARCHAR(3) DEFAULT 'USD'
|
||||
estimated_working_days INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN projects.currency IS 'Project-specific currency code (e.g., USD, EUR, GBP, JPY, etc.)';
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD CONSTRAINT projects_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -1400,36 +1391,32 @@ ALTER TABLE task_work_log
|
||||
CHECK (time_spent >= (0)::NUMERIC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
done BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
total_minutes NUMERIC DEFAULT 0 NOT NULL,
|
||||
archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
task_no BIGINT NOT NULL,
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
end_date TIMESTAMP WITH TIME ZONE,
|
||||
priority_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL,
|
||||
reporter_id UUID NOT NULL,
|
||||
parent_task_id UUID,
|
||||
status_id UUID NOT NULL,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
billable BOOLEAN DEFAULT TRUE,
|
||||
schedule_id UUID,
|
||||
manual_progress BOOLEAN DEFAULT FALSE,
|
||||
progress_value INTEGER DEFAULT NULL,
|
||||
progress_mode PROGRESS_MODE_TYPE DEFAULT 'default',
|
||||
weight INTEGER DEFAULT NULL,
|
||||
fixed_cost DECIMAL(10, 2) DEFAULT 0 CHECK (fixed_cost >= 0)
|
||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
done BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
total_minutes NUMERIC DEFAULT 0 NOT NULL,
|
||||
archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
task_no BIGINT NOT NULL,
|
||||
start_date TIMESTAMP WITH TIME ZONE,
|
||||
end_date TIMESTAMP WITH TIME ZONE,
|
||||
priority_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL,
|
||||
reporter_id UUID NOT NULL,
|
||||
parent_task_id UUID,
|
||||
status_id UUID NOT NULL,
|
||||
completed_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
status_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
priority_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
phase_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
billable BOOLEAN DEFAULT TRUE,
|
||||
schedule_id UUID
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN tasks.fixed_cost IS 'Fixed cost for the task in addition to hourly rate calculations';
|
||||
|
||||
ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_pk
|
||||
PRIMARY KEY (id);
|
||||
@@ -1515,6 +1502,21 @@ ALTER TABLE tasks
|
||||
ADD CONSTRAINT tasks_total_minutes_check
|
||||
CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC));
|
||||
|
||||
-- Add constraints for new sort order columns
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0);
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0);
|
||||
|
||||
-- Add indexes for performance on new sort order columns
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order);
|
||||
|
||||
-- Add comments for documentation
|
||||
COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status';
|
||||
COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority';
|
||||
COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks_assignees (
|
||||
task_id UUID NOT NULL,
|
||||
project_member_id UUID NOT NULL,
|
||||
@@ -2296,36 +2298,59 @@ ALTER TABLE organization_working_days
|
||||
ADD CONSTRAINT org_organization_id_fk
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations;
|
||||
|
||||
-- Finance module tables
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_cards (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
team_id UUID NOT NULL REFERENCES teams (id) ON DELETE CASCADE,
|
||||
name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
currency TEXT NOT NULL DEFAULT 'USD'
|
||||
-- Survey tables for account setup questionnaire
|
||||
CREATE TABLE IF NOT EXISTS surveys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
survey_type VARCHAR(50) DEFAULT 'account_setup' NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_project_rate_card_roles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
job_title_id UUID NOT NULL REFERENCES job_titles (id) ON DELETE CASCADE,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_project_role UNIQUE (project_id, job_title_id)
|
||||
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
question_key VARCHAR(100) NOT NULL,
|
||||
question_type VARCHAR(50) NOT NULL,
|
||||
is_required BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||
options JSONB,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS finance_rate_card_roles (
|
||||
rate_card_id UUID NOT NULL REFERENCES finance_rate_cards (id) ON DELETE CASCADE,
|
||||
job_title_id UUID REFERENCES job_titles(id) ON DELETE SET NULL,
|
||||
rate DECIMAL(10, 2) NOT NULL CHECK (rate >= 0),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
CREATE TABLE IF NOT EXISTS survey_responses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
survey_id UUID REFERENCES surveys(id) ON DELETE CASCADE NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL,
|
||||
is_completed BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
started_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE project_members
|
||||
ADD COLUMN IF NOT EXISTS project_rate_card_role_id UUID REFERENCES finance_project_rate_card_roles(id) ON DELETE SET NULL;
|
||||
CREATE TABLE IF NOT EXISTS survey_answers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
response_id UUID REFERENCES survey_responses(id) ON DELETE CASCADE NOT NULL,
|
||||
question_id UUID REFERENCES survey_questions(id) ON DELETE CASCADE NOT NULL,
|
||||
answer_text TEXT,
|
||||
answer_json JSONB,
|
||||
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS rate_card UUID REFERENCES finance_rate_cards(id) ON DELETE SET NULL;
|
||||
-- Survey table indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_surveys_type_active ON surveys(survey_type, is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_questions_survey_order ON survey_questions(survey_id, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_user_survey ON survey_responses(user_id, survey_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_responses_completed ON survey_responses(survey_id, is_completed);
|
||||
CREATE INDEX IF NOT EXISTS idx_survey_answers_response ON survey_answers(response_id);
|
||||
|
||||
-- Survey table constraints
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_sort_order_check CHECK (sort_order >= 0);
|
||||
ALTER TABLE survey_questions ADD CONSTRAINT survey_questions_type_check CHECK (question_type IN ('single_choice', 'multiple_choice', 'text'));
|
||||
ALTER TABLE survey_responses ADD CONSTRAINT unique_user_survey_response UNIQUE (user_id, survey_id);
|
||||
ALTER TABLE survey_answers ADD CONSTRAINT unique_response_question_answer UNIQUE (response_id, question_id);
|
||||
|
||||
@@ -142,3 +142,25 @@ DROP FUNCTION sys_insert_license_types();
|
||||
INSERT INTO timezones (name, abbrev, utc_offset)
|
||||
SELECT name, abbrev, utc_offset
|
||||
FROM pg_timezone_names;
|
||||
|
||||
-- Insert default account setup survey
|
||||
INSERT INTO surveys (name, description, survey_type, is_active) VALUES
|
||||
('Account Setup Survey', 'Initial questionnaire during account setup to understand user needs', 'account_setup', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert survey questions for account setup survey
|
||||
DO $$
|
||||
DECLARE
|
||||
survey_uuid UUID;
|
||||
BEGIN
|
||||
SELECT id INTO survey_uuid FROM surveys WHERE survey_type = 'account_setup' AND name = 'Account Setup Survey' LIMIT 1;
|
||||
|
||||
-- Insert survey questions
|
||||
INSERT INTO survey_questions (survey_id, question_key, question_type, is_required, sort_order, options) VALUES
|
||||
(survey_uuid, 'organization_type', 'single_choice', true, 1, '["freelancer", "startup", "small_medium_business", "agency", "enterprise", "other"]'),
|
||||
(survey_uuid, 'user_role', 'single_choice', true, 2, '["founder_ceo", "project_manager", "software_developer", "designer", "operations", "other"]'),
|
||||
(survey_uuid, 'main_use_cases', 'multiple_choice', true, 3, '["task_management", "team_collaboration", "resource_planning", "client_communication", "time_tracking", "other"]'),
|
||||
(survey_uuid, 'previous_tools', 'text', false, 4, null),
|
||||
(survey_uuid, 'how_heard_about', 'single_choice', false, 5, '["google_search", "twitter", "linkedin", "friend_colleague", "blog_article", "other"]')
|
||||
ON CONFLICT DO NOTHING;
|
||||
END $$;
|
||||
|
||||
@@ -32,3 +32,37 @@ SELECT u.avatar_url,
|
||||
FROM team_members
|
||||
LEFT JOIN users u ON team_members.user_id = u.id;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Create materialized view for team member info
|
||||
-- This pre-calculates the expensive joins and subqueries from team_member_info_view
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS team_member_info_mv AS
|
||||
SELECT
|
||||
u.avatar_url,
|
||||
COALESCE(u.email, ei.email) AS email,
|
||||
COALESCE(u.name, ei.name) AS name,
|
||||
u.id AS user_id,
|
||||
tm.id AS team_member_id,
|
||||
tm.team_id,
|
||||
tm.active,
|
||||
u.socket_id
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN email_invitations ei ON ei.team_member_id = tm.id
|
||||
WHERE tm.active = TRUE;
|
||||
|
||||
-- Create unique index on the materialized view for fast lookups
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_member_info_mv_team_member_id
|
||||
ON team_member_info_mv(team_member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_member_info_mv_team_user
|
||||
ON team_member_info_mv(team_id, user_id);
|
||||
|
||||
-- Function to refresh the materialized view
|
||||
CREATE OR REPLACE FUNCTION refresh_team_member_info_mv()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY team_member_info_mv;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
@@ -4117,7 +4117,7 @@ BEGIN
|
||||
'color_code_dark', COALESCE((_task_info ->> 'color_code_dark')::TEXT, ''),
|
||||
'total_tasks', COALESCE((_task_info ->> 'total_tasks')::INT, 0),
|
||||
'total_completed', COALESCE((_task_info ->> 'total_completed')::INT, 0),
|
||||
'members', COALESCE((_task_info -> 'members'), '[]'::JSON),
|
||||
'members', COALESCE((_task_info ->> 'members')::JSON, '[]'::JSON),
|
||||
'completed_at', _task_completed_at,
|
||||
'status_category', COALESCE(_status_category, '{}'::JSON),
|
||||
'schedule_id', COALESCE(_schedule_id, 'null'::JSON)
|
||||
@@ -4313,6 +4313,24 @@ BEGIN
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Helper function to get the appropriate sort column name based on grouping type
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
WHEN 'members' THEN RETURN 'member_sort_order';
|
||||
-- For backward compatibility, still support general sort_order but be explicit
|
||||
WHEN 'general' THEN RETURN 'sort_order';
|
||||
ELSE RETURN 'status_sort_order'; -- Default to status sorting
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
@@ -4325,54 +4343,67 @@ DECLARE
|
||||
_from_group UUID;
|
||||
_to_group UUID;
|
||||
_group_by TEXT;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
_project_id = (_body ->> 'project_id')::UUID;
|
||||
_task_id = (_body ->> 'task_id')::UUID;
|
||||
|
||||
_from_index = (_body ->> 'from_index')::INT; -- from sort_order
|
||||
_to_index = (_body ->> 'to_index')::INT; -- to sort_order
|
||||
|
||||
_from_index = (_body ->> 'from_index')::INT;
|
||||
_to_index = (_body ->> 'to_index')::INT;
|
||||
_from_group = (_body ->> 'from_group')::UUID;
|
||||
_to_group = (_body ->> 'to_group')::UUID;
|
||||
|
||||
_group_by = (_body ->> 'group_by')::TEXT;
|
||||
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL)
|
||||
THEN
|
||||
IF (_group_by = 'status')
|
||||
THEN
|
||||
UPDATE tasks SET status_id = _to_group WHERE id = _task_id AND status_id = _from_group;
|
||||
|
||||
-- Get the appropriate sort column
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Handle group changes first
|
||||
IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN
|
||||
IF (_group_by = 'status') THEN
|
||||
UPDATE tasks
|
||||
SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'priority')
|
||||
THEN
|
||||
UPDATE tasks SET priority_id = _to_group WHERE id = _task_id AND priority_id = _from_group;
|
||||
|
||||
IF (_group_by = 'priority') THEN
|
||||
UPDATE tasks
|
||||
SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = _task_id
|
||||
AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF (_group_by = 'phase')
|
||||
THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE)
|
||||
THEN
|
||||
|
||||
IF (_group_by = 'phase') THEN
|
||||
IF (is_null_or_empty(_to_group) IS FALSE) THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_task_id, _to_group)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group;
|
||||
END IF;
|
||||
IF (is_null_or_empty(_to_group) IS TRUE)
|
||||
THEN
|
||||
DELETE
|
||||
FROM task_phase
|
||||
WHERE task_id = _task_id;
|
||||
ELSE
|
||||
DELETE FROM task_phase WHERE task_id = _task_id;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index)
|
||||
THEN
|
||||
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
|
||||
-- Handle sort order changes for the grouping-specific column only
|
||||
IF (_from_index <> _to_index) THEN
|
||||
-- Update the grouping-specific sort order (no unique constraint issues)
|
||||
IF (_to_index > _from_index) THEN
|
||||
-- Moving down: decrease sort order for items between old and new position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3';
|
||||
EXECUTE _sql USING _project_id, _from_index, _to_index;
|
||||
ELSE
|
||||
PERFORM handle_task_list_sort_between_groups(_from_index, _to_index, _task_id, _project_id);
|
||||
-- Moving up: increase sort order for items between new and old position
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3';
|
||||
EXECUTE _sql USING _project_id, _to_index, _from_index;
|
||||
END IF;
|
||||
ELSE
|
||||
PERFORM handle_task_list_sort_inside_group(_from_index, _to_index, _task_id, _project_id);
|
||||
|
||||
-- Set the new sort order for the moved task
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2';
|
||||
EXECUTE _sql USING _to_index, _task_id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -4577,31 +4608,31 @@ BEGIN
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Members', 'ASSIGNEES', 4, TRUE);
|
||||
VALUES (_project_id, 'Status', 'STATUS', 4, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Labels', 'LABELS', 5, TRUE);
|
||||
VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Status', 'STATUS', 6, TRUE);
|
||||
VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Priority', 'PRIORITY', 7, TRUE);
|
||||
VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 8, TRUE);
|
||||
VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Estimation', 'ESTIMATION', 9, FALSE);
|
||||
VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Start Date', 'START_DATE', 10, FALSE);
|
||||
VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Due Date', 'DUE_DATE', 11, TRUE);
|
||||
VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 12, FALSE);
|
||||
VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 13, FALSE);
|
||||
VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 13, FALSE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 14, FALSE);
|
||||
VALUES (_project_id, 'Created Date', 'CREATED_DATE', 14, FALSE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Reporter', 'REPORTER', 15, FALSE);
|
||||
VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 15, FALSE);
|
||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||
VALUES (_project_id, 'Phase', 'PHASE', 16, FALSE);
|
||||
VALUES (_project_id, 'Reporter', 'REPORTER', 16, FALSE);
|
||||
END
|
||||
$$;
|
||||
|
||||
@@ -5401,8 +5432,7 @@ BEGIN
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
estimated_working_days = (_body ->> 'working_days')::INTEGER,
|
||||
estimated_man_days = (_body ->> 'man_days')::INTEGER,
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER,
|
||||
currency = COALESCE(UPPER((_body ->> 'currency')::TEXT), currency)
|
||||
hours_per_day = (_body ->> 'hours_per_day')::INTEGER
|
||||
WHERE id = (_body ->> 'id')::UUID
|
||||
AND team_id = _team_id
|
||||
RETURNING id INTO _project_id;
|
||||
@@ -5486,8 +5516,15 @@ $$
|
||||
DECLARE
|
||||
_iterator NUMERIC := 0;
|
||||
_status_id TEXT;
|
||||
_project_id UUID;
|
||||
_base_sort_order NUMERIC;
|
||||
BEGIN
|
||||
-- Get the project_id from the first status to ensure we update all statuses in the same project
|
||||
SELECT project_id INTO _project_id
|
||||
FROM task_statuses
|
||||
WHERE id = (SELECT TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT) LIMIT 1)::UUID;
|
||||
|
||||
-- Update the sort_order for statuses in the provided order
|
||||
FOR _status_id IN SELECT * FROM JSON_ARRAY_ELEMENTS((_status_ids)::JSON)
|
||||
LOOP
|
||||
UPDATE task_statuses
|
||||
@@ -5496,6 +5533,29 @@ BEGIN
|
||||
_iterator := _iterator + 1;
|
||||
END LOOP;
|
||||
|
||||
-- Get the base sort order for remaining statuses (simple count approach)
|
||||
SELECT COUNT(*) INTO _base_sort_order
|
||||
FROM task_statuses ts2
|
||||
WHERE ts2.project_id = _project_id
|
||||
AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID);
|
||||
|
||||
-- Update remaining statuses with simple sequential numbering
|
||||
-- Reset iterator to start from base_sort_order
|
||||
_iterator := _base_sort_order;
|
||||
|
||||
-- Use a cursor approach to avoid window functions
|
||||
FOR _status_id IN
|
||||
SELECT id::TEXT FROM task_statuses
|
||||
WHERE project_id = _project_id
|
||||
AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID)
|
||||
ORDER BY sort_order
|
||||
LOOP
|
||||
UPDATE task_statuses
|
||||
SET sort_order = _iterator
|
||||
WHERE id = _status_id::UUID;
|
||||
_iterator := _iterator + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN;
|
||||
END
|
||||
$$;
|
||||
@@ -6374,43 +6434,217 @@ BEGIN
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE VIEW project_finance_view AS
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes / 3600.0 as estimated_hours,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) / 3600.0 as total_time_logged,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as estimated_cost,
|
||||
0 as fixed_cost, -- Default to 0 since the column doesn't exist
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as total_budgeted_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) as total_actual_cost,
|
||||
COALESCE((SELECT SUM(rate * (time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON tm.id = pm.team_member_id
|
||||
LEFT JOIN finance_project_rate_card_roles pmr ON pm.project_rate_card_role_id = pmr.id
|
||||
WHERE twl.task_id = t.id), 0) -
|
||||
COALESCE(t.total_minutes / 3600.0 *
|
||||
(SELECT rate FROM finance_project_rate_card_roles
|
||||
WHERE project_id = t.project_id
|
||||
AND id = (SELECT project_rate_card_role_id FROM project_members WHERE team_member_id = t.reporter_id LIMIT 1)
|
||||
LIMIT 1), 0) as variance,
|
||||
t.project_id
|
||||
FROM tasks t;
|
||||
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE
|
||||
IF (_to_index = -1)
|
||||
THEN
|
||||
_to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0);
|
||||
END IF;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order < _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- PERFORMANCE OPTIMIZATION: Optimized version with batching for large datasets
|
||||
CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_offset INT := 0;
|
||||
_affected_rows INT;
|
||||
BEGIN
|
||||
-- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE
|
||||
IF _to_index > _from_index
|
||||
THEN
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order - 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order > _from_index
|
||||
AND sort_order <= _to_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
IF _to_index < _from_index
|
||||
THEN
|
||||
_offset := 0;
|
||||
LOOP
|
||||
UPDATE tasks
|
||||
SET sort_order = sort_order + 1
|
||||
WHERE project_id = _project_id
|
||||
AND sort_order >= _to_index
|
||||
AND sort_order < _from_index
|
||||
AND sort_order > _offset
|
||||
AND sort_order <= _offset + _batch_size;
|
||||
|
||||
GET DIAGNOSTICS _affected_rows = ROW_COUNT;
|
||||
EXIT WHEN _affected_rows = 0;
|
||||
_offset := _offset + _batch_size;
|
||||
END LOOP;
|
||||
END IF;
|
||||
|
||||
UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Updated bulk sort order function that avoids sort_order conflicts
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Process each update record
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the grouping-specific sort column and other fields
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id), ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Function to get the appropriate sort column name based on grouping type
|
||||
CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
CASE _group_by
|
||||
WHEN 'status' THEN RETURN 'status_sort_order';
|
||||
WHEN 'priority' THEN RETURN 'priority_sort_order';
|
||||
WHEN 'phase' THEN RETURN 'phase_sort_order';
|
||||
-- For backward compatibility, still support general sort_order but be explicit
|
||||
WHEN 'general' THEN RETURN 'sort_order';
|
||||
ELSE RETURN 'status_sort_order'; -- Default to status sorting
|
||||
END CASE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Updated bulk sort order function to handle different sort columns
|
||||
CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS
|
||||
$$
|
||||
DECLARE
|
||||
_update_record RECORD;
|
||||
_sort_column TEXT;
|
||||
_sql TEXT;
|
||||
BEGIN
|
||||
-- Get the appropriate sort column based on grouping
|
||||
_sort_column := get_sort_column_name(_group_by);
|
||||
|
||||
-- Process each update record
|
||||
FOR _update_record IN
|
||||
SELECT
|
||||
(item->>'task_id')::uuid as task_id,
|
||||
(item->>'sort_order')::int as sort_order,
|
||||
(item->>'status_id')::uuid as status_id,
|
||||
(item->>'priority_id')::uuid as priority_id,
|
||||
(item->>'phase_id')::uuid as phase_id
|
||||
FROM json_array_elements(_updates) as item
|
||||
LOOP
|
||||
-- Update the grouping-specific sort column and other fields
|
||||
_sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' ||
|
||||
'status_id = COALESCE($2, status_id), ' ||
|
||||
'priority_id = COALESCE($3, priority_id), ' ||
|
||||
'updated_at = CURRENT_TIMESTAMP ' ||
|
||||
'WHERE id = $4';
|
||||
|
||||
EXECUTE _sql USING
|
||||
_update_record.sort_order,
|
||||
_update_record.status_id,
|
||||
_update_record.priority_id,
|
||||
_update_record.task_id;
|
||||
|
||||
-- Handle phase updates separately since it's in a different table
|
||||
IF _update_record.phase_id IS NOT NULL THEN
|
||||
INSERT INTO task_phase (task_id, phase_id)
|
||||
VALUES (_update_record.task_id, _update_record.phase_id)
|
||||
ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@@ -132,3 +132,139 @@ CREATE INDEX IF NOT EXISTS projects_team_id_index
|
||||
CREATE INDEX IF NOT EXISTS projects_team_id_name_index
|
||||
ON projects (team_id, name);
|
||||
|
||||
-- Performance indexes for optimized tasks queries
|
||||
-- From migration: 20250115000000-performance-indexes.sql
|
||||
|
||||
-- Composite index for main task filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_archived_parent
|
||||
ON tasks(project_id, archived, parent_task_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for status joins
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_status_project
|
||||
ON tasks(status_id, project_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for assignees lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_assignees_task_member
|
||||
ON tasks_assignees(task_id, team_member_id);
|
||||
|
||||
-- Index for phase lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_phase_task_phase
|
||||
ON task_phase(task_id, phase_id);
|
||||
|
||||
-- Index for subtask counting
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_archived
|
||||
ON tasks(parent_task_id, archived)
|
||||
WHERE parent_task_id IS NOT NULL AND archived = FALSE;
|
||||
|
||||
-- Index for labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_labels_task_label
|
||||
ON task_labels(task_id, label_id);
|
||||
|
||||
-- Index for comments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_comments_task
|
||||
ON task_comments(task_id);
|
||||
|
||||
-- Index for attachments count
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_attachments_task
|
||||
ON task_attachments(task_id);
|
||||
|
||||
-- Index for work log aggregation
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_work_log_task
|
||||
ON task_work_log(task_id);
|
||||
|
||||
-- Index for subscribers check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_subscribers_task
|
||||
ON task_subscribers(task_id);
|
||||
|
||||
-- Index for dependencies check
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_dependencies_task
|
||||
ON task_dependencies(task_id);
|
||||
|
||||
-- Index for timers lookup
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_task_user
|
||||
ON task_timers(task_id, user_id);
|
||||
|
||||
-- Index for custom columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_cc_column_values_task
|
||||
ON cc_column_values(task_id);
|
||||
|
||||
-- Index for team member info view optimization
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_team_user
|
||||
ON team_members(team_id, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Index for notification settings
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_notification_settings_user_team
|
||||
ON notification_settings(user_id, team_id);
|
||||
|
||||
-- Index for task status categories
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_category
|
||||
ON task_statuses(category_id, project_id);
|
||||
|
||||
-- Index for project phases
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_project_phases_project_sort
|
||||
ON project_phases(project_id, sort_index);
|
||||
|
||||
-- Index for task priorities
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_priorities_value
|
||||
ON task_priorities(value);
|
||||
|
||||
-- Index for team labels
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_labels_team
|
||||
ON team_labels(team_id);
|
||||
|
||||
-- Advanced performance indexes for task optimization
|
||||
|
||||
-- Composite index for task main query optimization (covers most WHERE conditions)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_performance_main
|
||||
ON tasks(project_id, archived, parent_task_id, status_id, priority_id)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for sorting by sort_order with project filter
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_project_sort_order
|
||||
ON tasks(project_id, sort_order)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for email_invitations to optimize team_member_info_view
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_email_invitations_team_member
|
||||
ON email_invitations(team_member_id);
|
||||
|
||||
-- Covering index for task status with category information
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_statuses_covering
|
||||
ON task_statuses(id, category_id, project_id);
|
||||
|
||||
-- Index for task aggregation queries (parent task progress calculation)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_parent_status_archived
|
||||
ON tasks(parent_task_id, status_id, archived)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for project team member filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_team_members_project_lookup
|
||||
ON team_members(team_id, active, user_id)
|
||||
WHERE active = TRUE;
|
||||
|
||||
-- Covering index for tasks with frequently accessed columns
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_covering_main
|
||||
ON tasks(id, project_id, archived, parent_task_id, status_id, priority_id, sort_order, name)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task search functionality
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_name_search
|
||||
ON tasks USING gin(to_tsvector('english', name))
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for date-based filtering (if used)
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_tasks_dates
|
||||
ON tasks(project_id, start_date, end_date)
|
||||
WHERE archived = FALSE;
|
||||
|
||||
-- Index for task timers with user filtering
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_task_timers_user_task
|
||||
ON task_timers(user_id, task_id);
|
||||
|
||||
-- Index for sys_task_status_categories lookups
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_sys_task_status_categories_covering
|
||||
ON sys_task_status_categories(id, color_code, color_code_dark, is_done, is_doing, is_todo);
|
||||
|
||||
|
||||
352
worklenz-backend/docs/HOLIDAY_SYSTEM.md
Normal file
352
worklenz-backend/docs/HOLIDAY_SYSTEM.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# 🌍 Holiday Calendar System
|
||||
|
||||
The Worklenz Holiday Calendar System provides comprehensive holiday management for organizations operating globally.
|
||||
|
||||
## 📋 Features
|
||||
|
||||
- **200+ Countries Supported** - Comprehensive holiday data for countries worldwide
|
||||
- **Multiple Holiday Types** - Public, Company, Personal, and Religious holidays
|
||||
- **Import Country Holidays** - Bulk import official holidays from any supported country
|
||||
- **Manual Holiday Management** - Add, edit, and delete custom holidays
|
||||
- **Recurring Holidays** - Support for annual recurring holidays
|
||||
- **Visual Calendar** - Interactive calendar with color-coded holiday display
|
||||
- **Dark/Light Mode** - Full theme support
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Database Setup
|
||||
|
||||
Run the migration to create the holiday tables:
|
||||
|
||||
```bash
|
||||
# Run the migration
|
||||
psql -d your_database -f database/migrations/20250130000000-add-holiday-calendar.sql
|
||||
```
|
||||
|
||||
### 2. Populate Country Holidays
|
||||
|
||||
Use the npm package to populate holidays for 200+ countries:
|
||||
|
||||
```bash
|
||||
# Run the holiday population script
|
||||
./scripts/run-holiday-population.sh
|
||||
```
|
||||
|
||||
This will populate holidays for years 2020-2030 for all supported countries.
|
||||
|
||||
### 3. Access the Holiday Calendar
|
||||
|
||||
Navigate to **Admin Center → Overview** to access the holiday calendar.
|
||||
|
||||
## 🌐 Supported Countries
|
||||
|
||||
The system includes **200+ countries** across all continents:
|
||||
|
||||
### North America
|
||||
- United States 🇺🇸
|
||||
- Canada 🇨🇦
|
||||
- Mexico 🇲🇽
|
||||
|
||||
### Europe
|
||||
- United Kingdom 🇬🇧
|
||||
- Germany 🇩🇪
|
||||
- France 🇫🇷
|
||||
- Italy 🇮🇹
|
||||
- Spain 🇪🇸
|
||||
- Netherlands 🇳🇱
|
||||
- Belgium 🇧🇪
|
||||
- Switzerland 🇨🇭
|
||||
- Austria 🇦🇹
|
||||
- Sweden 🇸🇪
|
||||
- Norway 🇳🇴
|
||||
- Denmark 🇩🇰
|
||||
- Finland 🇫🇮
|
||||
- Poland 🇵🇱
|
||||
- Czech Republic 🇨🇿
|
||||
- Hungary 🇭🇺
|
||||
- Romania 🇷🇴
|
||||
- Bulgaria 🇧🇬
|
||||
- Croatia 🇭🇷
|
||||
- Slovenia 🇸🇮
|
||||
- Slovakia 🇸🇰
|
||||
- Lithuania 🇱🇹
|
||||
- Latvia 🇱🇻
|
||||
- Estonia 🇪🇪
|
||||
- Ireland 🇮🇪
|
||||
- Portugal 🇵🇹
|
||||
- Greece 🇬🇷
|
||||
- Cyprus 🇨🇾
|
||||
- Malta 🇲🇹
|
||||
- Luxembourg 🇱🇺
|
||||
- Iceland 🇮🇸
|
||||
|
||||
### Asia
|
||||
- China 🇨🇳
|
||||
- Japan 🇯🇵
|
||||
- South Korea 🇰🇷
|
||||
- India 🇮🇳
|
||||
- Pakistan 🇵🇰
|
||||
- Bangladesh 🇧🇩
|
||||
- Sri Lanka 🇱🇰
|
||||
- Nepal 🇳🇵
|
||||
- Thailand 🇹🇭
|
||||
- Vietnam 🇻🇳
|
||||
- Malaysia 🇲🇾
|
||||
- Singapore 🇸🇬
|
||||
- Indonesia 🇮🇩
|
||||
- Philippines 🇵🇭
|
||||
- Myanmar 🇲🇲
|
||||
- Cambodia 🇰🇭
|
||||
- Laos 🇱🇦
|
||||
- Brunei 🇧🇳
|
||||
- Timor-Leste 🇹🇱
|
||||
- Mongolia 🇲🇳
|
||||
- Kazakhstan 🇰🇿
|
||||
- Uzbekistan 🇺🇿
|
||||
- Kyrgyzstan 🇰🇬
|
||||
- Tajikistan 🇹🇯
|
||||
- Turkmenistan 🇹🇲
|
||||
- Afghanistan 🇦🇫
|
||||
- Iran 🇮🇷
|
||||
- Iraq 🇮🇶
|
||||
- Saudi Arabia 🇸🇦
|
||||
- UAE 🇦🇪
|
||||
- Qatar 🇶🇦
|
||||
- Kuwait 🇰🇼
|
||||
- Bahrain 🇧🇭
|
||||
- Oman 🇴🇲
|
||||
- Yemen 🇾🇪
|
||||
- Jordan 🇯🇴
|
||||
- Lebanon 🇱🇧
|
||||
- Syria 🇸🇾
|
||||
- Israel 🇮🇱
|
||||
- Palestine 🇵🇸
|
||||
- Turkey 🇹🇷
|
||||
- Georgia 🇬🇪
|
||||
- Armenia 🇦🇲
|
||||
- Azerbaijan 🇦🇿
|
||||
|
||||
### Oceania
|
||||
- Australia 🇦🇺
|
||||
- New Zealand 🇳🇿
|
||||
- Fiji 🇫🇯
|
||||
- Papua New Guinea 🇵🇬
|
||||
- Solomon Islands 🇸🇧
|
||||
- Vanuatu 🇻🇺
|
||||
- New Caledonia 🇳🇨
|
||||
- French Polynesia 🇵🇫
|
||||
- Tonga 🇹🇴
|
||||
- Samoa 🇼🇸
|
||||
- Kiribati 🇰🇮
|
||||
- Tuvalu 🇹🇻
|
||||
- Nauru 🇳🇷
|
||||
- Palau 🇵🇼
|
||||
- Marshall Islands 🇲🇭
|
||||
- Micronesia 🇫🇲
|
||||
|
||||
### Africa
|
||||
- South Africa 🇿🇦
|
||||
- Egypt 🇪🇬
|
||||
- Nigeria 🇳🇬
|
||||
- Kenya 🇰🇪
|
||||
- Ethiopia 🇪🇹
|
||||
- Tanzania 🇹🇿
|
||||
- Uganda 🇺🇬
|
||||
- Ghana 🇬🇭
|
||||
- Ivory Coast 🇨🇮
|
||||
- Senegal 🇸🇳
|
||||
- Mali 🇲🇱
|
||||
- Burkina Faso 🇧🇫
|
||||
- Niger 🇳🇪
|
||||
- Chad 🇹🇩
|
||||
- Cameroon 🇨🇲
|
||||
- Central African Republic 🇨🇫
|
||||
- Republic of the Congo 🇨🇬
|
||||
- Democratic Republic of the Congo 🇨🇩
|
||||
- Gabon 🇬🇦
|
||||
- Equatorial Guinea 🇬🇶
|
||||
- São Tomé and Príncipe 🇸🇹
|
||||
- Angola 🇦🇴
|
||||
- Zambia 🇿🇲
|
||||
- Zimbabwe 🇿🇼
|
||||
- Botswana 🇧🇼
|
||||
- Namibia 🇳🇦
|
||||
- Lesotho 🇱🇸
|
||||
- Eswatini 🇸🇿
|
||||
- Madagascar 🇲🇬
|
||||
- Mauritius 🇲🇺
|
||||
- Seychelles 🇸🇨
|
||||
- Comoros 🇰🇲
|
||||
- Djibouti 🇩🇯
|
||||
- Somalia 🇸🇴
|
||||
- Eritrea 🇪🇷
|
||||
- Sudan 🇸🇩
|
||||
- South Sudan 🇸🇸
|
||||
- Libya 🇱🇾
|
||||
- Tunisia 🇹🇳
|
||||
- Algeria 🇩🇿
|
||||
- Morocco 🇲🇦
|
||||
- Western Sahara 🇪🇭
|
||||
- Mauritania 🇲🇷
|
||||
- Gambia 🇬🇲
|
||||
- Guinea-Bissau 🇬🇼
|
||||
- Guinea 🇬🇳
|
||||
- Sierra Leone 🇸🇱
|
||||
- Liberia 🇱🇷
|
||||
- Togo 🇹🇬
|
||||
- Benin 🇧🇯
|
||||
|
||||
### South America
|
||||
- Brazil 🇧🇷
|
||||
- Argentina 🇦🇷
|
||||
- Chile 🇨🇱
|
||||
- Colombia 🇨🇴
|
||||
- Peru 🇵🇪
|
||||
- Venezuela 🇻🇪
|
||||
- Ecuador 🇪🇨
|
||||
- Bolivia 🇧🇴
|
||||
- Paraguay 🇵🇾
|
||||
- Uruguay 🇺🇾
|
||||
- Guyana 🇬🇾
|
||||
- Suriname 🇸🇷
|
||||
- Falkland Islands 🇫🇰
|
||||
- French Guiana 🇬🇫
|
||||
|
||||
### Central America & Caribbean
|
||||
- Mexico 🇲🇽
|
||||
- Guatemala 🇬🇹
|
||||
- Belize 🇧🇿
|
||||
- El Salvador 🇸🇻
|
||||
- Honduras 🇭🇳
|
||||
- Nicaragua 🇳🇮
|
||||
- Costa Rica 🇨🇷
|
||||
- Panama 🇵🇦
|
||||
- Cuba 🇨🇺
|
||||
- Jamaica 🇯🇲
|
||||
- Haiti 🇭🇹
|
||||
- Dominican Republic 🇩🇴
|
||||
- Puerto Rico 🇵🇷
|
||||
- Trinidad and Tobago 🇹🇹
|
||||
- Barbados 🇧🇧
|
||||
- Grenada 🇬🇩
|
||||
- Saint Lucia 🇱🇨
|
||||
- Saint Vincent and the Grenadines 🇻🇨
|
||||
- Antigua and Barbuda 🇦🇬
|
||||
- Saint Kitts and Nevis 🇰🇳
|
||||
- Dominica 🇩🇲
|
||||
- Bahamas 🇧🇸
|
||||
- Turks and Caicos Islands 🇹🇨
|
||||
- Cayman Islands 🇰🇾
|
||||
- Bermuda 🇧🇲
|
||||
- Anguilla 🇦🇮
|
||||
- British Virgin Islands 🇻🇬
|
||||
- U.S. Virgin Islands 🇻🇮
|
||||
- Aruba 🇦🇼
|
||||
- Curaçao 🇨🇼
|
||||
- Sint Maarten 🇸🇽
|
||||
- Saint Martin 🇲🇫
|
||||
- Saint Barthélemy 🇧🇱
|
||||
- Guadeloupe 🇬🇵
|
||||
- Martinique 🇲🇶
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### Holiday Types
|
||||
```http
|
||||
GET /api/holidays/types
|
||||
```
|
||||
|
||||
### Organization Holidays
|
||||
```http
|
||||
GET /api/holidays/organization?year=2024
|
||||
POST /api/holidays/organization
|
||||
PUT /api/holidays/organization/:id
|
||||
DELETE /api/holidays/organization/:id
|
||||
```
|
||||
|
||||
### Country Holidays
|
||||
```http
|
||||
GET /api/holidays/countries
|
||||
GET /api/holidays/countries/:country_code?year=2024
|
||||
POST /api/holidays/import
|
||||
```
|
||||
|
||||
### Calendar View
|
||||
```http
|
||||
GET /api/holidays/calendar?year=2024&month=1
|
||||
```
|
||||
|
||||
## 📊 Holiday Types
|
||||
|
||||
The system supports four types of holidays:
|
||||
|
||||
1. **Public Holiday** - Official government holidays (Red)
|
||||
2. **Company Holiday** - Organization-specific holidays (Blue)
|
||||
3. **Personal Holiday** - Personal or optional holidays (Green)
|
||||
4. **Religious Holiday** - Religious observances (Yellow)
|
||||
|
||||
## 🎯 Usage Examples
|
||||
|
||||
### Import US Holidays
|
||||
```javascript
|
||||
const result = await holidayApiService.importCountryHolidays({
|
||||
country_code: 'US',
|
||||
year: 2024
|
||||
});
|
||||
```
|
||||
|
||||
### Add Custom Holiday
|
||||
```javascript
|
||||
const holiday = await holidayApiService.createOrganizationHoliday({
|
||||
name: 'Company Retreat',
|
||||
description: 'Annual team building event',
|
||||
date: '2024-06-15',
|
||||
holiday_type_id: 'company-holiday-id',
|
||||
is_recurring: true
|
||||
});
|
||||
```
|
||||
|
||||
### Get Calendar View
|
||||
```javascript
|
||||
const calendar = await holidayApiService.getHolidayCalendar(2024, 1);
|
||||
```
|
||||
|
||||
## 🔄 Data Sources
|
||||
|
||||
The holiday data is sourced from the `date-holidays` npm package, which provides:
|
||||
|
||||
- **Official government holidays** for 200+ countries
|
||||
- **Religious holidays** (Christian, Islamic, Jewish, Hindu, Buddhist)
|
||||
- **Cultural and traditional holidays**
|
||||
- **Historical and commemorative days**
|
||||
|
||||
## 🛠️ Maintenance
|
||||
|
||||
### Adding New Countries
|
||||
|
||||
1. Add the country to the `countries` table
|
||||
2. Update the `populate-holidays.js` script
|
||||
3. Run the population script
|
||||
|
||||
### Updating Holiday Data
|
||||
|
||||
```bash
|
||||
# Re-run the holiday population script
|
||||
./scripts/run-holiday-population.sh
|
||||
```
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Holidays are stored for years 2020-2030 by default
|
||||
- The system prevents duplicate holidays on the same date
|
||||
- Imported holidays are automatically classified as "Public Holiday" type
|
||||
- All holidays support recurring annual patterns
|
||||
- The calendar view combines organization and country holidays
|
||||
|
||||
## 🎉 Benefits
|
||||
|
||||
- **Global Compliance** - Ensure compliance with local holiday regulations
|
||||
- **Resource Planning** - Better project scheduling and resource allocation
|
||||
- **Team Coordination** - Improved team communication and planning
|
||||
- **Cost Management** - Accurate billing and time tracking
|
||||
- **Cultural Awareness** - Respect for diverse cultural and religious practices
|
||||
@@ -1,77 +0,0 @@
|
||||
-- Fix task hierarchy and reset parent estimations
|
||||
-- This script ensures proper parent-child relationships and resets parent estimations
|
||||
|
||||
-- First, let's see the current task hierarchy
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as actual_subtask_count,
|
||||
t.archived
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
ORDER BY t.name, t.created_at;
|
||||
|
||||
-- Reset all parent task estimations to 0
|
||||
-- This ensures parent tasks don't have their own estimation when they have subtasks
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false;
|
||||
|
||||
-- Verify the results after the update
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT name FROM tasks WHERE id = t.parent_task_id) as parent_name,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count,
|
||||
get_task_recursive_estimation(t.id) as recursive_estimation
|
||||
FROM tasks t
|
||||
WHERE (t.name LIKE '%sub%' OR t.name LIKE '%test task%')
|
||||
AND t.archived = false
|
||||
ORDER BY t.name;
|
||||
|
||||
-- Show the hierarchy in tree format
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Top level tasks (no parent)
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
parent_task_id,
|
||||
total_minutes,
|
||||
0 as level,
|
||||
name as path
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NULL
|
||||
AND (name LIKE '%sub%' OR name LIKE '%test task%')
|
||||
AND archived = false
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Child tasks
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.parent_task_id,
|
||||
t.total_minutes,
|
||||
th.level + 1,
|
||||
th.path || ' > ' || t.name
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived = false
|
||||
)
|
||||
SELECT
|
||||
REPEAT(' ', level) || name as indented_name,
|
||||
total_minutes,
|
||||
get_task_recursive_estimation(id) as recursive_estimation
|
||||
FROM task_hierarchy
|
||||
ORDER BY path;
|
||||
@@ -1,28 +0,0 @@
|
||||
module.exports = {
|
||||
brotli_js: {
|
||||
options: {
|
||||
mode: "brotli",
|
||||
brotli: {
|
||||
mode: 1
|
||||
}
|
||||
},
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
extDot: "last",
|
||||
ext: ".js.br"
|
||||
},
|
||||
gzip_js: {
|
||||
options: {
|
||||
mode: "gzip"
|
||||
},
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: "build/public",
|
||||
src: ["**/*.js"],
|
||||
dest: "build/public",
|
||||
ext: ".js.gz"
|
||||
}]
|
||||
}
|
||||
};
|
||||
10407
worklenz-backend/package-lock.json
generated
10407
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"engines": {
|
||||
"npm": ">=8.11.0",
|
||||
"node": ">=16.13.0",
|
||||
"node": ">=20.0.0",
|
||||
"yarn": "WARNING: Please use npm package manager instead of yarn"
|
||||
},
|
||||
"main": "build/bin/www",
|
||||
@@ -42,9 +42,6 @@
|
||||
"reportFile": "test-reporter.xml",
|
||||
"indent": 4
|
||||
},
|
||||
"overrides": {
|
||||
"rimraf": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.378.0",
|
||||
"@aws-sdk/client-ses": "^3.378.0",
|
||||
@@ -64,6 +61,7 @@
|
||||
"crypto-js": "^4.1.1",
|
||||
"csrf-sync": "^4.2.1",
|
||||
"csurf": "^1.11.0",
|
||||
"date-holidays": "^3.24.4",
|
||||
"debug": "^4.3.4",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.3.0",
|
||||
@@ -88,7 +86,6 @@
|
||||
"passport-local": "^1.0.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.14.1",
|
||||
"pg-native": "^3.3.0",
|
||||
"pug": "^3.0.2",
|
||||
"redis": "^4.6.7",
|
||||
"sanitize-html": "^2.11.0",
|
||||
@@ -96,8 +93,10 @@
|
||||
"sharp": "^0.32.6",
|
||||
"slugify": "^1.6.6",
|
||||
"socket.io": "^4.7.1",
|
||||
"tinymce": "^7.8.0",
|
||||
"uglify-js": "^3.17.4",
|
||||
"winston": "^3.10.0",
|
||||
"worklenz-backend": "file:",
|
||||
"xss-filters": "^1.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -105,15 +104,17 @@
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bluebird": "^3.5.38",
|
||||
"@types/body-parser": "^1.19.2",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/connect-flash": "^0.0.37",
|
||||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cron": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/csurf": "^1.11.2",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-brute": "^1.0.2",
|
||||
"@types/express-brute-redis": "^0.0.4",
|
||||
"@types/express-serve-static-core": "^4.17.34",
|
||||
"@types/express-session": "^1.17.7",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/hpp": "^0.2.2",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
-- Reset all existing parent task estimations to 0
|
||||
-- This script updates all tasks that have subtasks to have 0 estimation
|
||||
|
||||
UPDATE tasks
|
||||
SET total_minutes = 0
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND total_minutes > 0
|
||||
AND archived = false;
|
||||
|
||||
-- Show the results
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.total_minutes as current_estimation,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as subtask_count
|
||||
FROM tasks t
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT parent_task_id
|
||||
FROM tasks
|
||||
WHERE parent_task_id IS NOT NULL
|
||||
AND archived = false
|
||||
)
|
||||
AND archived = false
|
||||
ORDER BY t.name;
|
||||
265
worklenz-backend/scripts/populate-holidays.js
Normal file
265
worklenz-backend/scripts/populate-holidays.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const Holidays = require("date-holidays");
|
||||
const { Pool } = require("pg");
|
||||
const config = require("../build/config/db-config").default;
|
||||
|
||||
// Database connection
|
||||
const pool = new Pool(config);
|
||||
|
||||
// Countries to populate with holidays
|
||||
const countries = [
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "CZ", name: "Czech Republic" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "KR", name: "South Korea" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "PK", name: "Pakistan" },
|
||||
{ code: "BD", name: "Bangladesh" },
|
||||
{ code: "LK", name: "Sri Lanka" },
|
||||
{ code: "NP", name: "Nepal" },
|
||||
{ code: "TH", name: "Thailand" },
|
||||
{ code: "VN", name: "Vietnam" },
|
||||
{ code: "MY", name: "Malaysia" },
|
||||
{ code: "SG", name: "Singapore" },
|
||||
{ code: "ID", name: "Indonesia" },
|
||||
{ code: "PH", name: "Philippines" },
|
||||
{ code: "MM", name: "Myanmar" },
|
||||
{ code: "KH", name: "Cambodia" },
|
||||
{ code: "LA", name: "Laos" },
|
||||
{ code: "BN", name: "Brunei" },
|
||||
{ code: "TL", name: "Timor-Leste" },
|
||||
{ code: "MN", name: "Mongolia" },
|
||||
{ code: "KZ", name: "Kazakhstan" },
|
||||
{ code: "UZ", name: "Uzbekistan" },
|
||||
{ code: "KG", name: "Kyrgyzstan" },
|
||||
{ code: "TJ", name: "Tajikistan" },
|
||||
{ code: "TM", name: "Turkmenistan" },
|
||||
{ code: "AF", name: "Afghanistan" },
|
||||
{ code: "IR", name: "Iran" },
|
||||
{ code: "IQ", name: "Iraq" },
|
||||
{ code: "SA", name: "Saudi Arabia" },
|
||||
{ code: "AE", name: "United Arab Emirates" },
|
||||
{ code: "QA", name: "Qatar" },
|
||||
{ code: "KW", name: "Kuwait" },
|
||||
{ code: "BH", name: "Bahrain" },
|
||||
{ code: "OM", name: "Oman" },
|
||||
{ code: "YE", name: "Yemen" },
|
||||
{ code: "JO", name: "Jordan" },
|
||||
{ code: "LB", name: "Lebanon" },
|
||||
{ code: "SY", name: "Syria" },
|
||||
{ code: "IL", name: "Israel" },
|
||||
{ code: "PS", name: "Palestine" },
|
||||
{ code: "TR", name: "Turkey" },
|
||||
{ code: "GE", name: "Georgia" },
|
||||
{ code: "AM", name: "Armenia" },
|
||||
{ code: "AZ", name: "Azerbaijan" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "FJ", name: "Fiji" },
|
||||
{ code: "PG", name: "Papua New Guinea" },
|
||||
{ code: "SB", name: "Solomon Islands" },
|
||||
{ code: "VU", name: "Vanuatu" },
|
||||
{ code: "NC", name: "New Caledonia" },
|
||||
{ code: "PF", name: "French Polynesia" },
|
||||
{ code: "TO", name: "Tonga" },
|
||||
{ code: "WS", name: "Samoa" },
|
||||
{ code: "KI", name: "Kiribati" },
|
||||
{ code: "TV", name: "Tuvalu" },
|
||||
{ code: "NR", name: "Nauru" },
|
||||
{ code: "PW", name: "Palau" },
|
||||
{ code: "MH", name: "Marshall Islands" },
|
||||
{ code: "FM", name: "Micronesia" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "EG", name: "Egypt" },
|
||||
{ code: "NG", name: "Nigeria" },
|
||||
{ code: "KE", name: "Kenya" },
|
||||
{ code: "ET", name: "Ethiopia" },
|
||||
{ code: "TZ", name: "Tanzania" },
|
||||
{ code: "UG", name: "Uganda" },
|
||||
{ code: "GH", name: "Ghana" },
|
||||
{ code: "CI", name: "Ivory Coast" },
|
||||
{ code: "SN", name: "Senegal" },
|
||||
{ code: "ML", name: "Mali" },
|
||||
{ code: "BF", name: "Burkina Faso" },
|
||||
{ code: "NE", name: "Niger" },
|
||||
{ code: "TD", name: "Chad" },
|
||||
{ code: "CM", name: "Cameroon" },
|
||||
{ code: "CF", name: "Central African Republic" },
|
||||
{ code: "CG", name: "Republic of the Congo" },
|
||||
{ code: "CD", name: "Democratic Republic of the Congo" },
|
||||
{ code: "GA", name: "Gabon" },
|
||||
{ code: "GQ", name: "Equatorial Guinea" },
|
||||
{ code: "ST", name: "São Tomé and Príncipe" },
|
||||
{ code: "AO", name: "Angola" },
|
||||
{ code: "ZM", name: "Zambia" },
|
||||
{ code: "ZW", name: "Zimbabwe" },
|
||||
{ code: "BW", name: "Botswana" },
|
||||
{ code: "NA", name: "Namibia" },
|
||||
{ code: "LS", name: "Lesotho" },
|
||||
{ code: "SZ", name: "Eswatini" },
|
||||
{ code: "MG", name: "Madagascar" },
|
||||
{ code: "MU", name: "Mauritius" },
|
||||
{ code: "SC", name: "Seychelles" },
|
||||
{ code: "KM", name: "Comoros" },
|
||||
{ code: "DJ", name: "Djibouti" },
|
||||
{ code: "SO", name: "Somalia" },
|
||||
{ code: "ER", name: "Eritrea" },
|
||||
{ code: "SD", name: "Sudan" },
|
||||
{ code: "SS", name: "South Sudan" },
|
||||
{ code: "LY", name: "Libya" },
|
||||
{ code: "TN", name: "Tunisia" },
|
||||
{ code: "DZ", name: "Algeria" },
|
||||
{ code: "MA", name: "Morocco" },
|
||||
{ code: "EH", name: "Western Sahara" },
|
||||
{ code: "MR", name: "Mauritania" },
|
||||
{ code: "GM", name: "Gambia" },
|
||||
{ code: "GW", name: "Guinea-Bissau" },
|
||||
{ code: "GN", name: "Guinea" },
|
||||
{ code: "SL", name: "Sierra Leone" },
|
||||
{ code: "LR", name: "Liberia" },
|
||||
{ code: "TG", name: "Togo" },
|
||||
{ code: "BJ", name: "Benin" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "CL", name: "Chile" },
|
||||
{ code: "CO", name: "Colombia" },
|
||||
{ code: "PE", name: "Peru" },
|
||||
{ code: "VE", name: "Venezuela" },
|
||||
{ code: "EC", name: "Ecuador" },
|
||||
{ code: "BO", name: "Bolivia" },
|
||||
{ code: "PY", name: "Paraguay" },
|
||||
{ code: "UY", name: "Uruguay" },
|
||||
{ code: "GY", name: "Guyana" },
|
||||
{ code: "SR", name: "Suriname" },
|
||||
{ code: "FK", name: "Falkland Islands" },
|
||||
{ code: "GF", name: "French Guiana" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "GT", name: "Guatemala" },
|
||||
{ code: "BZ", name: "Belize" },
|
||||
{ code: "SV", name: "El Salvador" },
|
||||
{ code: "HN", name: "Honduras" },
|
||||
{ code: "NI", name: "Nicaragua" },
|
||||
{ code: "CR", name: "Costa Rica" },
|
||||
{ code: "PA", name: "Panama" },
|
||||
{ code: "CU", name: "Cuba" },
|
||||
{ code: "JM", name: "Jamaica" },
|
||||
{ code: "HT", name: "Haiti" },
|
||||
{ code: "DO", name: "Dominican Republic" },
|
||||
{ code: "PR", name: "Puerto Rico" },
|
||||
{ code: "TT", name: "Trinidad and Tobago" },
|
||||
{ code: "BB", name: "Barbados" },
|
||||
{ code: "GD", name: "Grenada" },
|
||||
{ code: "LC", name: "Saint Lucia" },
|
||||
{ code: "VC", name: "Saint Vincent and the Grenadines" },
|
||||
{ code: "AG", name: "Antigua and Barbuda" },
|
||||
{ code: "KN", name: "Saint Kitts and Nevis" },
|
||||
{ code: "DM", name: "Dominica" },
|
||||
{ code: "BS", name: "Bahamas" },
|
||||
{ code: "TC", name: "Turks and Caicos Islands" },
|
||||
{ code: "KY", name: "Cayman Islands" },
|
||||
{ code: "BM", name: "Bermuda" },
|
||||
{ code: "AI", name: "Anguilla" },
|
||||
{ code: "VG", name: "British Virgin Islands" },
|
||||
{ code: "VI", name: "U.S. Virgin Islands" },
|
||||
{ code: "AW", name: "Aruba" },
|
||||
{ code: "CW", name: "Curaçao" },
|
||||
{ code: "SX", name: "Sint Maarten" },
|
||||
{ code: "MF", name: "Saint Martin" },
|
||||
{ code: "BL", name: "Saint Barthélemy" },
|
||||
{ code: "GP", name: "Guadeloupe" },
|
||||
{ code: "MQ", name: "Martinique" }
|
||||
];
|
||||
|
||||
async function populateHolidays() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("Starting holiday population...");
|
||||
|
||||
for (const country of countries) {
|
||||
console.log(`Processing ${country.name} (${country.code})...`);
|
||||
|
||||
try {
|
||||
const hd = new Holidays(country.code);
|
||||
|
||||
// Get holidays for multiple years (2020-2030)
|
||||
for (let year = 2020; year <= 2030; year++) {
|
||||
const holidays = hd.getHolidays(year);
|
||||
|
||||
for (const holiday of holidays) {
|
||||
// Skip if holiday is not a date object
|
||||
if (!holiday.date || typeof holiday.date !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateStr = holiday.date.toISOString().split("T")[0];
|
||||
const name = holiday.name || "Unknown Holiday";
|
||||
const description = holiday.type || "Public Holiday";
|
||||
|
||||
// Insert holiday into database
|
||||
const query = `
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING
|
||||
`;
|
||||
|
||||
await client.query(query, [
|
||||
country.code,
|
||||
name,
|
||||
description,
|
||||
dateStr,
|
||||
true // Most holidays are recurring
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Completed ${country.name}`);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`✗ Error processing ${country.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Holiday population completed!");
|
||||
|
||||
} catch (error) {
|
||||
console.error("Database error:", error);
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
populateHolidays().catch(console.error);
|
||||
25
worklenz-backend/scripts/run-holiday-population.sh
Normal file
25
worklenz-backend/scripts/run-holiday-population.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🌍 Starting Holiday Population Script..."
|
||||
echo "This will populate the database with holidays for 200+ countries using the date-holidays npm package."
|
||||
echo ""
|
||||
|
||||
# Check if Node.js is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js is not installed. Please install Node.js first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the script exists
|
||||
if [ ! -f "scripts/populate-holidays.js" ]; then
|
||||
echo "❌ Holiday population script not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the holiday population script
|
||||
echo "🚀 Running holiday population script..."
|
||||
node scripts/populate-holidays.js
|
||||
|
||||
echo ""
|
||||
echo "✅ Holiday population completed!"
|
||||
echo "You can now use the holiday import feature in the admin center."
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,6 @@ export default class AuthController extends WorklenzControllerBase {
|
||||
// Flash messages sent from passport-local-signup.ts and passport-local-login.ts
|
||||
const errors = req.flash()["error"] || [];
|
||||
const messages = req.flash()["success"] || [];
|
||||
|
||||
// If there are multiple messages, we will send one at a time.
|
||||
const auth_error = errors.length > 0 ? errors[0] : null;
|
||||
const message = messages.length > 0 ? messages[0] : null;
|
||||
|
||||
416
worklenz-backend/src/controllers/holiday-controller.ts
Normal file
416
worklenz-backend/src/controllers/holiday-controller.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import db from "../config/db";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import {
|
||||
ICreateHolidayRequest,
|
||||
IUpdateHolidayRequest,
|
||||
IImportCountryHolidaysRequest,
|
||||
} from "../interfaces/holiday.interface";
|
||||
|
||||
export default class HolidayController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getHolidayTypes(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT id, name, description, color_code, created_at, updated_at
|
||||
FROM holiday_types
|
||||
ORDER BY name;`;
|
||||
const result = await db.query(q);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getOrganizationHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { year } = req.query;
|
||||
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
|
||||
const params = year ? [req.user?.owner_id, year] : [req.user?.owner_id];
|
||||
|
||||
const q = `SELECT oh.id, oh.organization_id, oh.holiday_type_id, oh.name, oh.description,
|
||||
oh.date, oh.is_recurring, oh.created_at, oh.updated_at,
|
||||
ht.name as holiday_type_name, ht.color_code
|
||||
FROM organization_holidays oh
|
||||
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
|
||||
WHERE oh.organization_id = (
|
||||
SELECT id FROM organizations WHERE user_id = $1
|
||||
) ${yearFilter}
|
||||
ORDER BY oh.date;`;
|
||||
|
||||
const result = await db.query(q, params);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async createOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { name, description, date, holiday_type_id, is_recurring = false }: ICreateHolidayRequest = req.body;
|
||||
|
||||
const q = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
|
||||
VALUES (
|
||||
(SELECT id FROM organizations WHERE user_id = $1),
|
||||
$2, $3, $4, $5, $6
|
||||
)
|
||||
RETURNING id;`;
|
||||
|
||||
const result = await db.query(q, [req.user?.owner_id, holiday_type_id, name, description, date, is_recurring]);
|
||||
return res.status(201).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { name, description, date, holiday_type_id, is_recurring }: IUpdateHolidayRequest = req.body;
|
||||
|
||||
const updateFields = [];
|
||||
const values = [req.user?.owner_id, id];
|
||||
let paramIndex = 3;
|
||||
|
||||
if (name !== undefined) {
|
||||
updateFields.push(`name = $${paramIndex++}`);
|
||||
values.push(name);
|
||||
}
|
||||
if (description !== undefined) {
|
||||
updateFields.push(`description = $${paramIndex++}`);
|
||||
values.push(description);
|
||||
}
|
||||
if (date !== undefined) {
|
||||
updateFields.push(`date = $${paramIndex++}`);
|
||||
values.push(date);
|
||||
}
|
||||
if (holiday_type_id !== undefined) {
|
||||
updateFields.push(`holiday_type_id = $${paramIndex++}`);
|
||||
values.push(holiday_type_id);
|
||||
}
|
||||
if (is_recurring !== undefined) {
|
||||
updateFields.push(`is_recurring = $${paramIndex++}`);
|
||||
values.push(is_recurring.toString());
|
||||
}
|
||||
|
||||
if (updateFields.length === 0) {
|
||||
return res.status(400).send(new ServerResponse(false, "No fields to update"));
|
||||
}
|
||||
|
||||
const q = `UPDATE organization_holidays
|
||||
SET ${updateFields.join(", ")}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2 AND organization_id = (
|
||||
SELECT id FROM organizations WHERE user_id = $1
|
||||
)
|
||||
RETURNING id;`;
|
||||
|
||||
const result = await db.query(q, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteOrganizationHoliday(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
|
||||
const q = `DELETE FROM organization_holidays
|
||||
WHERE id = $2 AND organization_id = (
|
||||
SELECT id FROM organizations WHERE user_id = $1
|
||||
)
|
||||
RETURNING id;`;
|
||||
|
||||
const result = await db.query(q, [req.user?.owner_id, id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, "Holiday not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { message: "Holiday deleted successfully" }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { country_code, year } = req.query;
|
||||
|
||||
if (!country_code) {
|
||||
return res.status(400).send(new ServerResponse(false, "Country code is required"));
|
||||
}
|
||||
|
||||
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
|
||||
const params = year ? [country_code, year] : [country_code];
|
||||
|
||||
const q = `SELECT id, country_code, name, description, date, is_recurring, created_at, updated_at
|
||||
FROM country_holidays
|
||||
WHERE country_code = $1 ${yearFilter}
|
||||
ORDER BY date;`;
|
||||
|
||||
const result = await db.query(q, params);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getAvailableCountries(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT DISTINCT c.code, c.name
|
||||
FROM countries c
|
||||
JOIN country_holidays ch ON c.code = ch.country_code
|
||||
ORDER BY c.name;`;
|
||||
|
||||
const result = await db.query(q);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async importCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { country_code, year }: IImportCountryHolidaysRequest = req.body;
|
||||
|
||||
if (!country_code) {
|
||||
return res.status(400).send(new ServerResponse(false, "Country code is required"));
|
||||
}
|
||||
|
||||
// Get organization ID
|
||||
const orgQ = `SELECT id FROM organizations WHERE user_id = $1`;
|
||||
const orgResult = await db.query(orgQ, [req.user?.owner_id]);
|
||||
const organizationId = orgResult.rows[0]?.id;
|
||||
|
||||
if (!organizationId) {
|
||||
return res.status(404).send(new ServerResponse(false, "Organization not found"));
|
||||
}
|
||||
|
||||
// Get default holiday type (Public Holiday)
|
||||
const typeQ = `SELECT id FROM holiday_types WHERE name = 'Public Holiday' LIMIT 1`;
|
||||
const typeResult = await db.query(typeQ);
|
||||
const holidayTypeId = typeResult.rows[0]?.id;
|
||||
|
||||
if (!holidayTypeId) {
|
||||
return res.status(404).send(new ServerResponse(false, "Default holiday type not found"));
|
||||
}
|
||||
|
||||
// Get country holidays for the specified year
|
||||
const yearFilter = year ? `AND EXTRACT(YEAR FROM date) = $2` : "";
|
||||
const params = year ? [country_code, year] : [country_code];
|
||||
|
||||
const holidaysQ = `SELECT name, description, date, is_recurring
|
||||
FROM country_holidays
|
||||
WHERE country_code = $1 ${yearFilter}`;
|
||||
|
||||
const holidaysResult = await db.query(holidaysQ, params);
|
||||
|
||||
if (holidaysResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, "No holidays found for this country and year"));
|
||||
}
|
||||
|
||||
// Import holidays to organization
|
||||
const importQ = `INSERT INTO organization_holidays (organization_id, holiday_type_id, name, description, date, is_recurring)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (organization_id, date) DO NOTHING`;
|
||||
|
||||
let importedCount = 0;
|
||||
for (const holiday of holidaysResult.rows) {
|
||||
try {
|
||||
await db.query(importQ, [
|
||||
organizationId,
|
||||
holidayTypeId,
|
||||
holiday.name,
|
||||
holiday.description,
|
||||
holiday.date,
|
||||
holiday.is_recurring
|
||||
]);
|
||||
importedCount++;
|
||||
} catch (error) {
|
||||
// Skip duplicates
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
message: `Successfully imported ${importedCount} holidays`,
|
||||
imported_count: importedCount
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getHolidayCalendar(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { year, month } = req.query;
|
||||
|
||||
if (!year || !month) {
|
||||
return res.status(400).send(new ServerResponse(false, "Year and month are required"));
|
||||
}
|
||||
|
||||
const q = `SELECT oh.id, oh.name, oh.description, oh.date, oh.is_recurring,
|
||||
ht.name as holiday_type_name, ht.color_code,
|
||||
'organization' as source
|
||||
FROM organization_holidays oh
|
||||
JOIN holiday_types ht ON oh.holiday_type_id = ht.id
|
||||
WHERE oh.organization_id = (
|
||||
SELECT id FROM organizations WHERE user_id = $1
|
||||
)
|
||||
AND EXTRACT(YEAR FROM oh.date) = $2
|
||||
AND EXTRACT(MONTH FROM oh.date) = $3
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT ch.id, ch.name, ch.description, ch.date, ch.is_recurring,
|
||||
'Public Holiday' as holiday_type_name, '#f37070' as color_code,
|
||||
'country' as source
|
||||
FROM country_holidays ch
|
||||
JOIN organizations o ON ch.country_code = (
|
||||
SELECT c.code FROM countries c WHERE c.id = o.country
|
||||
)
|
||||
WHERE o.user_id = $1
|
||||
AND EXTRACT(YEAR FROM ch.date) = $2
|
||||
AND EXTRACT(MONTH FROM ch.date) = $3
|
||||
|
||||
ORDER BY date;`;
|
||||
|
||||
const result = await db.query(q, [req.user?.owner_id, year, month]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async populateCountryHolidays(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Check if this organization has recently populated holidays (within last hour)
|
||||
const recentPopulationCheck = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM organization_holidays
|
||||
WHERE organization_id = (SELECT id FROM organizations WHERE user_id = $1)
|
||||
AND created_at > NOW() - INTERVAL '1 hour'
|
||||
`;
|
||||
|
||||
const recentResult = await db.query(recentPopulationCheck, [req.user?.owner_id]);
|
||||
const recentCount = parseInt(recentResult.rows[0]?.count || '0');
|
||||
|
||||
// If there are recent holidays added, skip population
|
||||
if (recentCount > 10) {
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
success: true,
|
||||
message: "Holidays were recently populated, skipping to avoid duplicates",
|
||||
total_populated: 0,
|
||||
recently_populated: true
|
||||
}));
|
||||
}
|
||||
|
||||
const Holidays = require("date-holidays");
|
||||
|
||||
const countries = [
|
||||
{ code: "US", name: "United States" },
|
||||
{ code: "GB", name: "United Kingdom" },
|
||||
{ code: "CA", name: "Canada" },
|
||||
{ code: "AU", name: "Australia" },
|
||||
{ code: "DE", name: "Germany" },
|
||||
{ code: "FR", name: "France" },
|
||||
{ code: "IT", name: "Italy" },
|
||||
{ code: "ES", name: "Spain" },
|
||||
{ code: "NL", name: "Netherlands" },
|
||||
{ code: "BE", name: "Belgium" },
|
||||
{ code: "CH", name: "Switzerland" },
|
||||
{ code: "AT", name: "Austria" },
|
||||
{ code: "SE", name: "Sweden" },
|
||||
{ code: "NO", name: "Norway" },
|
||||
{ code: "DK", name: "Denmark" },
|
||||
{ code: "FI", name: "Finland" },
|
||||
{ code: "PL", name: "Poland" },
|
||||
{ code: "CZ", name: "Czech Republic" },
|
||||
{ code: "HU", name: "Hungary" },
|
||||
{ code: "RO", name: "Romania" },
|
||||
{ code: "BG", name: "Bulgaria" },
|
||||
{ code: "HR", name: "Croatia" },
|
||||
{ code: "SI", name: "Slovenia" },
|
||||
{ code: "SK", name: "Slovakia" },
|
||||
{ code: "LT", name: "Lithuania" },
|
||||
{ code: "LV", name: "Latvia" },
|
||||
{ code: "EE", name: "Estonia" },
|
||||
{ code: "IE", name: "Ireland" },
|
||||
{ code: "PT", name: "Portugal" },
|
||||
{ code: "GR", name: "Greece" },
|
||||
{ code: "CY", name: "Cyprus" },
|
||||
{ code: "MT", name: "Malta" },
|
||||
{ code: "LU", name: "Luxembourg" },
|
||||
{ code: "IS", name: "Iceland" },
|
||||
{ code: "CN", name: "China" },
|
||||
{ code: "JP", name: "Japan" },
|
||||
{ code: "KR", name: "South Korea" },
|
||||
{ code: "IN", name: "India" },
|
||||
{ code: "BR", name: "Brazil" },
|
||||
{ code: "AR", name: "Argentina" },
|
||||
{ code: "MX", name: "Mexico" },
|
||||
{ code: "ZA", name: "South Africa" },
|
||||
{ code: "NZ", name: "New Zealand" },
|
||||
{ code: "LK", name: "Sri Lanka" }
|
||||
];
|
||||
|
||||
let totalPopulated = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const country of countries) {
|
||||
try {
|
||||
// Special handling for Sri Lanka
|
||||
if (country.code === 'LK') {
|
||||
// Import the holiday data provider
|
||||
const { HolidayDataProvider } = require("../services/holiday-data-provider");
|
||||
|
||||
for (let year = 2020; year <= 2050; year++) {
|
||||
const sriLankanHolidays = await HolidayDataProvider.getSriLankanHolidays(year);
|
||||
|
||||
for (const holiday of sriLankanHolidays) {
|
||||
const query = `
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING
|
||||
`;
|
||||
|
||||
await db.query(query, [
|
||||
'LK',
|
||||
holiday.name,
|
||||
holiday.description,
|
||||
holiday.date,
|
||||
holiday.is_recurring
|
||||
]);
|
||||
|
||||
totalPopulated++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use date-holidays for other countries
|
||||
const hd = new Holidays(country.code);
|
||||
|
||||
for (let year = 2020; year <= 2050; year++) {
|
||||
const holidays = hd.getHolidays(year);
|
||||
|
||||
for (const holiday of holidays) {
|
||||
if (!holiday.date || typeof holiday.date !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dateStr = holiday.date.toISOString().split("T")[0];
|
||||
const name = holiday.name || "Unknown Holiday";
|
||||
const description = holiday.type || "Public Holiday";
|
||||
|
||||
const query = `
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING
|
||||
`;
|
||||
|
||||
await db.query(query, [
|
||||
country.code,
|
||||
name,
|
||||
description,
|
||||
dateStr,
|
||||
true
|
||||
]);
|
||||
|
||||
totalPopulated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errors.push(`${country.name}: ${error?.message || "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
message: `Successfully populated ${totalPopulated} holidays`,
|
||||
total_populated: totalPopulated,
|
||||
errors: errors.length > 0 ? errors : undefined
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,10 @@ export default class HomePageController extends WorklenzControllerBase {
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
AND NOT EXISTS(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE project_id = p.id
|
||||
AND user_id = $2)
|
||||
${groupByClosure}
|
||||
ORDER BY t.end_date ASC`;
|
||||
|
||||
@@ -158,9 +162,13 @@ export default class HomePageController extends WorklenzControllerBase {
|
||||
WHERE category_id NOT IN (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS FALSE))
|
||||
AND NOT EXISTS(SELECT project_id
|
||||
FROM archived_projects
|
||||
WHERE project_id = p.id
|
||||
AND user_id = $3)
|
||||
${groupByClosure}`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId]);
|
||||
const result = await db.query(q, [teamId, userId, userId]);
|
||||
const [row] = result.rows;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -52,11 +52,18 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
const groupBy = req.query.group_by || "status";
|
||||
const billableFilter = req.query.billable_filter || "billable";
|
||||
|
||||
// Get project information including currency
|
||||
// Get project information including currency and organization calculation method
|
||||
const projectQuery = `
|
||||
SELECT id, name, currency
|
||||
FROM projects
|
||||
WHERE id = $1
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.currency,
|
||||
o.calculation_method,
|
||||
o.hours_per_day
|
||||
FROM projects p
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN organizations o ON t.organization_id = o.id
|
||||
WHERE p.id = $1
|
||||
`;
|
||||
const projectResult = await db.query(projectQuery, [projectId]);
|
||||
|
||||
@@ -73,6 +80,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
fprr.project_id,
|
||||
fprr.job_title_id,
|
||||
fprr.rate,
|
||||
fprr.man_day_rate,
|
||||
jt.name as job_title_name
|
||||
FROM finance_project_rate_card_roles fprr
|
||||
LEFT JOIN job_titles jt ON fprr.job_title_id = jt.id
|
||||
@@ -107,6 +115,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count,
|
||||
0 as level,
|
||||
@@ -132,6 +141,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
0 as sub_tasks_count,
|
||||
tt.level + 1 as level,
|
||||
@@ -140,29 +150,100 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
-- Identify leaf tasks (tasks with no children) for proper aggregation
|
||||
leaf_tasks AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM task_tree child_tt
|
||||
WHERE child_tt.parent_task_id = tt.id
|
||||
AND child_tt.root_id = tt.root_id
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as is_leaf
|
||||
FROM task_tree tt
|
||||
),
|
||||
task_costs AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
-- Calculate estimated cost based on estimated hours and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0))
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0) as estimated_cost,
|
||||
-- Calculate actual cost based on time logged and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0) as actual_cost_from_logs
|
||||
FROM task_tree tt
|
||||
-- Calculate estimated cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Man days calculation: use estimated_man_days * man_day_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
-- Use total_minutes if available, otherwise use estimated_seconds
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $3) * COALESCE(fprr.man_day_rate, 0)
|
||||
ELSE ((tt.estimated_seconds / 3600.0) / $3) * COALESCE(fprr.man_day_rate, 0)
|
||||
END
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use estimated_hours * hourly_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
END as estimated_cost,
|
||||
-- Calculate actual cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Man days calculation: convert actual time to man days and multiply by man day rates
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $3)
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
|
||||
END
|
||||
)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use actual time logged * hourly rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
END as actual_cost_from_logs
|
||||
FROM leaf_tasks tt
|
||||
),
|
||||
aggregated_tasks AS (
|
||||
SELECT
|
||||
@@ -174,46 +255,64 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
tc.phase_id,
|
||||
tc.assignees,
|
||||
tc.billable,
|
||||
-- Fixed cost aggregation: include current task + all descendants
|
||||
-- Fixed cost aggregation: sum from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.fixed_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.fixed_cost
|
||||
END as fixed_cost,
|
||||
tc.sub_tasks_count,
|
||||
-- For parent tasks, sum values from descendants only (exclude parent task itself)
|
||||
-- For parent tasks, sum values from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_seconds
|
||||
END as estimated_seconds,
|
||||
-- Sum total_minutes from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.total_time_logged_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.total_minutes), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_minutes
|
||||
END as total_minutes,
|
||||
-- Sum time logged from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_time_logged_seconds
|
||||
END as total_time_logged_seconds,
|
||||
-- Sum estimated cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_cost
|
||||
END as estimated_cost,
|
||||
-- Sum actual cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.actual_cost_from_logs)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.actual_cost_from_logs
|
||||
END as actual_cost_from_logs
|
||||
@@ -224,11 +323,27 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
at.*,
|
||||
(at.estimated_cost + at.fixed_cost) as total_budget,
|
||||
(at.actual_cost_from_logs + at.fixed_cost) as total_actual,
|
||||
((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance
|
||||
((at.estimated_cost + at.fixed_cost) - (at.actual_cost_from_logs + at.fixed_cost)) as variance,
|
||||
-- Add effort variance for man days calculation
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Effort variance in man days: actual man days - estimated man days
|
||||
((at.total_time_logged_seconds / 3600.0) / $3) -
|
||||
((at.estimated_seconds / 3600.0) / $3)
|
||||
ELSE
|
||||
NULL -- No effort variance for hourly projects
|
||||
END as effort_variance_man_days,
|
||||
-- Add actual man days for man days calculation
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
(at.total_time_logged_seconds / 3600.0) / $3
|
||||
ELSE
|
||||
NULL
|
||||
END as actual_man_days
|
||||
FROM aggregated_tasks at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [projectId]);
|
||||
const result = await db.query(q, [projectId, project.calculation_method, project.hours_per_day]);
|
||||
const tasks = result.rows;
|
||||
|
||||
// Add color_code to each assignee and include their rate information using project_members
|
||||
@@ -354,6 +469,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
name: task.name,
|
||||
estimated_seconds: Number(task.estimated_seconds) || 0,
|
||||
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
|
||||
total_minutes: Number(task.total_minutes) || 0,
|
||||
total_time_logged_seconds:
|
||||
Number(task.total_time_logged_seconds) || 0,
|
||||
total_time_logged: formatTimeToHMS(
|
||||
@@ -365,6 +481,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
total_budget: Number(task.total_budget) || 0,
|
||||
total_actual: Number(task.total_actual) || 0,
|
||||
variance: Number(task.variance) || 0,
|
||||
effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null,
|
||||
actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null,
|
||||
members: task.assignees,
|
||||
billable: task.billable,
|
||||
sub_tasks_count: Number(task.sub_tasks_count) || 0,
|
||||
@@ -379,7 +497,9 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
currency: project.currency || "USD"
|
||||
currency: project.currency || "USD",
|
||||
calculation_method: project.calculation_method || "hourly",
|
||||
hours_per_day: Number(project.hours_per_day) || 8
|
||||
}
|
||||
};
|
||||
|
||||
@@ -637,6 +757,25 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
.send(new ServerResponse(false, null, "Parent task ID is required"));
|
||||
}
|
||||
|
||||
// Get project information including currency and organization calculation method
|
||||
const projectQuery = `
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.currency,
|
||||
o.calculation_method,
|
||||
o.hours_per_day
|
||||
FROM projects p
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN organizations o ON t.organization_id = o.id
|
||||
WHERE p.id = $1;
|
||||
`;
|
||||
const projectResult = await db.query(projectQuery, [projectId]);
|
||||
if (projectResult.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Project not found"));
|
||||
}
|
||||
const project = projectResult.rows[0];
|
||||
|
||||
// Build billable filter condition for subtasks
|
||||
let billableCondition = "";
|
||||
if (billableFilter === "billable") {
|
||||
@@ -661,6 +800,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count,
|
||||
0 as level,
|
||||
@@ -686,6 +826,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
0 as sub_tasks_count,
|
||||
tt.level + 1 as level,
|
||||
@@ -694,29 +835,100 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
-- Identify leaf tasks (tasks with no children) for proper aggregation
|
||||
leaf_tasks AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM task_tree child_tt
|
||||
WHERE child_tt.parent_task_id = tt.id
|
||||
AND child_tt.root_id = tt.root_id
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as is_leaf
|
||||
FROM task_tree tt
|
||||
),
|
||||
task_costs AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
-- Calculate estimated cost based on estimated hours and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0))
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0) as estimated_cost,
|
||||
-- Calculate actual cost based on time logged and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0) as actual_cost_from_logs
|
||||
FROM task_tree tt
|
||||
-- Calculate estimated cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $3 = 'man_days' THEN
|
||||
-- Man days calculation: use estimated_man_days * man_day_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
-- Use total_minutes if available, otherwise use estimated_seconds
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $4) * COALESCE(fprr.man_day_rate, 0)
|
||||
ELSE ((tt.estimated_seconds / 3600.0) / $4) * COALESCE(fprr.man_day_rate, 0)
|
||||
END
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use estimated_hours * hourly_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
END as estimated_cost,
|
||||
-- Calculate actual cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $3 = 'man_days' THEN
|
||||
-- Man days calculation: convert actual time to man days and multiply by man day rates
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $4)
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
|
||||
END
|
||||
)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use actual time logged * hourly rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
END as actual_cost_from_logs
|
||||
FROM leaf_tasks tt
|
||||
),
|
||||
aggregated_tasks AS (
|
||||
SELECT
|
||||
@@ -728,46 +940,64 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
tc.phase_id,
|
||||
tc.assignees,
|
||||
tc.billable,
|
||||
-- Fixed cost aggregation: include current task + all descendants
|
||||
-- Fixed cost aggregation: sum from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.fixed_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.fixed_cost
|
||||
END as fixed_cost,
|
||||
tc.sub_tasks_count,
|
||||
-- For subtasks that have their own sub-subtasks, sum values from descendants only
|
||||
-- For subtasks that have their own sub-subtasks, sum values from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_seconds
|
||||
END as estimated_seconds,
|
||||
-- Sum total_minutes from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.total_time_logged_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.total_minutes), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_minutes
|
||||
END as total_minutes,
|
||||
-- Sum time logged from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_time_logged_seconds
|
||||
END as total_time_logged_seconds,
|
||||
-- Sum estimated cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_cost
|
||||
END as estimated_cost,
|
||||
-- Sum actual cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.actual_cost_from_logs)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.actual_cost_from_logs
|
||||
END as actual_cost_from_logs
|
||||
@@ -782,7 +1012,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
FROM aggregated_tasks at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [projectId, parentTaskId]);
|
||||
const result = await db.query(q, [projectId, parentTaskId, project.calculation_method, project.hours_per_day]);
|
||||
const tasks = result.rows;
|
||||
|
||||
// Add color_code to each assignee and include their rate information
|
||||
@@ -839,6 +1069,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
name: task.name,
|
||||
estimated_seconds: Number(task.estimated_seconds) || 0,
|
||||
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
|
||||
total_minutes: Number(task.total_minutes) || 0,
|
||||
total_time_logged_seconds: Number(task.total_time_logged_seconds) || 0,
|
||||
total_time_logged: formatTimeToHMS(
|
||||
Number(task.total_time_logged_seconds) || 0
|
||||
@@ -849,6 +1080,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
total_budget: Number(task.total_budget) || 0,
|
||||
total_actual: Number(task.total_actual) || 0,
|
||||
variance: Number(task.variance) || 0,
|
||||
effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null,
|
||||
actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null,
|
||||
members: task.assignees,
|
||||
billable: task.billable,
|
||||
sub_tasks_count: Number(task.sub_tasks_count) || 0,
|
||||
@@ -866,9 +1099,26 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
const groupBy = (req.query.groupBy as string) || "status";
|
||||
const billableFilter = req.query.billable_filter || "billable";
|
||||
|
||||
// Get project name and currency for filename and export
|
||||
const projectQuery = `SELECT name, currency FROM projects WHERE id = $1`;
|
||||
// Get project information including currency and organization calculation method
|
||||
const projectQuery = `
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
p.currency,
|
||||
o.calculation_method,
|
||||
o.hours_per_day
|
||||
FROM projects p
|
||||
JOIN teams t ON p.team_id = t.id
|
||||
JOIN organizations o ON t.organization_id = o.id
|
||||
WHERE p.id = $1
|
||||
`;
|
||||
const projectResult = await db.query(projectQuery, [projectId]);
|
||||
|
||||
if (projectResult.rows.length === 0) {
|
||||
res.status(404).send(new ServerResponse(false, null, "Project not found"));
|
||||
return;
|
||||
}
|
||||
|
||||
const project = projectResult.rows[0];
|
||||
const projectName = project?.name || "Unknown Project";
|
||||
const projectCurrency = project?.currency || "USD";
|
||||
@@ -914,6 +1164,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
(SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id AND archived = false) as sub_tasks_count,
|
||||
0 as level,
|
||||
@@ -939,6 +1190,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
t.billable,
|
||||
COALESCE(t.fixed_cost, 0) as fixed_cost,
|
||||
COALESCE(t.total_minutes * 60, 0) as estimated_seconds,
|
||||
COALESCE(t.total_minutes, 0) as total_minutes,
|
||||
COALESCE((SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id), 0) as total_time_logged_seconds,
|
||||
0 as sub_tasks_count,
|
||||
tt.level + 1 as level,
|
||||
@@ -947,29 +1199,100 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
INNER JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
WHERE t.archived = false
|
||||
),
|
||||
-- Identify leaf tasks (tasks with no children) for proper aggregation
|
||||
leaf_tasks AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
CASE
|
||||
WHEN NOT EXISTS (
|
||||
SELECT 1 FROM task_tree child_tt
|
||||
WHERE child_tt.parent_task_id = tt.id
|
||||
AND child_tt.root_id = tt.root_id
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as is_leaf
|
||||
FROM task_tree tt
|
||||
),
|
||||
task_costs AS (
|
||||
SELECT
|
||||
tt.*,
|
||||
-- Calculate estimated cost based on estimated hours and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM((tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0))
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0) as estimated_cost,
|
||||
-- Calculate actual cost based on time logged and assignee rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0) as actual_cost_from_logs
|
||||
FROM task_tree tt
|
||||
-- Calculate estimated cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Man days calculation: use estimated_man_days * man_day_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
-- Use total_minutes if available, otherwise use estimated_seconds
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN ((tt.total_minutes / 60.0) / $3) * COALESCE(fprr.man_day_rate, 0)
|
||||
ELSE ((tt.estimated_seconds / 3600.0) / $3) * COALESCE(fprr.man_day_rate, 0)
|
||||
END
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use estimated_hours * hourly_rate
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN tt.total_minutes > 0 THEN (tt.total_minutes / 60.0) * COALESCE(fprr.rate, 0)
|
||||
ELSE (tt.estimated_seconds / 3600.0) * COALESCE(fprr.rate, 0)
|
||||
END
|
||||
)
|
||||
FROM json_array_elements(tt.assignees) AS assignee_json
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = (assignee_json->>'team_member_id')::uuid
|
||||
AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE assignee_json->>'team_member_id' IS NOT NULL
|
||||
), 0)
|
||||
END as estimated_cost,
|
||||
-- Calculate actual cost based on organization calculation method
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Man days calculation: convert actual time to man days and multiply by man day rates
|
||||
COALESCE((
|
||||
SELECT SUM(
|
||||
CASE
|
||||
WHEN COALESCE(fprr.man_day_rate, 0) > 0 THEN
|
||||
COALESCE(fprr.man_day_rate, 0) * ((twl.time_spent / 3600.0) / $3)
|
||||
ELSE
|
||||
-- Fallback to hourly rate if man_day_rate is 0
|
||||
COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0)
|
||||
END
|
||||
)
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
ELSE
|
||||
-- Hourly calculation: use actual time logged * hourly rates
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(fprr.rate, 0) * (twl.time_spent / 3600.0))
|
||||
FROM task_work_log twl
|
||||
LEFT JOIN users u ON twl.user_id = u.id
|
||||
LEFT JOIN team_members tm ON u.id = tm.user_id
|
||||
LEFT JOIN project_members pm ON pm.team_member_id = tm.id AND pm.project_id = tt.project_id
|
||||
LEFT JOIN finance_project_rate_card_roles fprr ON fprr.id = pm.project_rate_card_role_id
|
||||
WHERE twl.task_id = tt.id
|
||||
), 0)
|
||||
END as actual_cost_from_logs
|
||||
FROM leaf_tasks tt
|
||||
),
|
||||
aggregated_tasks AS (
|
||||
SELECT
|
||||
@@ -981,46 +1304,64 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
tc.phase_id,
|
||||
tc.assignees,
|
||||
tc.billable,
|
||||
-- Fixed cost aggregation: include current task + all descendants
|
||||
-- Fixed cost aggregation: sum from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.fixed_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.fixed_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.fixed_cost
|
||||
END as fixed_cost,
|
||||
tc.sub_tasks_count,
|
||||
-- For parent tasks, sum values from descendants only (exclude parent task itself)
|
||||
-- For parent tasks, sum values from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_seconds
|
||||
END as estimated_seconds,
|
||||
-- Sum total_minutes from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.total_time_logged_seconds)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.total_minutes), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_minutes
|
||||
END as total_minutes,
|
||||
-- Sum time logged from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT COALESCE(SUM(leaf_tc.total_time_logged_seconds), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.total_time_logged_seconds
|
||||
END as total_time_logged_seconds,
|
||||
-- Sum estimated cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.estimated_cost)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.estimated_cost), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.estimated_cost
|
||||
END as estimated_cost,
|
||||
-- Sum actual cost from leaf tasks only
|
||||
CASE
|
||||
WHEN tc.level = 0 AND tc.sub_tasks_count > 0 THEN (
|
||||
SELECT SUM(sub_tc.actual_cost_from_logs)
|
||||
FROM task_costs sub_tc
|
||||
WHERE sub_tc.root_id = tc.id AND sub_tc.id != tc.id
|
||||
SELECT COALESCE(SUM(leaf_tc.actual_cost_from_logs), 0)
|
||||
FROM task_costs leaf_tc
|
||||
WHERE leaf_tc.root_id = tc.id
|
||||
AND leaf_tc.is_leaf = true
|
||||
)
|
||||
ELSE tc.actual_cost_from_logs
|
||||
END as actual_cost_from_logs
|
||||
@@ -1031,11 +1372,27 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
at.*,
|
||||
(at.estimated_cost + at.fixed_cost) as total_budget,
|
||||
(at.actual_cost_from_logs + at.fixed_cost) as total_actual,
|
||||
((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance
|
||||
((at.actual_cost_from_logs + at.fixed_cost) - (at.estimated_cost + at.fixed_cost)) as variance,
|
||||
-- Add effort variance for man days calculation
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
-- Effort variance in man days: actual man days - estimated man days
|
||||
((at.total_time_logged_seconds / 3600.0) / $3) -
|
||||
((at.estimated_seconds / 3600.0) / $3)
|
||||
ELSE
|
||||
NULL -- No effort variance for hourly projects
|
||||
END as effort_variance_man_days,
|
||||
-- Add actual man days for man days calculation
|
||||
CASE
|
||||
WHEN $2 = 'man_days' THEN
|
||||
(at.total_time_logged_seconds / 3600.0) / $3
|
||||
ELSE
|
||||
NULL
|
||||
END as actual_man_days
|
||||
FROM aggregated_tasks at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [projectId]);
|
||||
const result = await db.query(q, [projectId, project.calculation_method, project.hours_per_day]);
|
||||
const tasks = result.rows;
|
||||
|
||||
// Add color_code to each assignee and include their rate information using project_members
|
||||
@@ -1161,6 +1518,7 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
name: task.name,
|
||||
estimated_seconds: Number(task.estimated_seconds) || 0,
|
||||
estimated_hours: formatTimeToHMS(Number(task.estimated_seconds) || 0),
|
||||
total_minutes: Number(task.total_minutes) || 0,
|
||||
total_time_logged_seconds:
|
||||
Number(task.total_time_logged_seconds) || 0,
|
||||
total_time_logged: formatTimeToHMS(
|
||||
@@ -1172,6 +1530,8 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
total_budget: Number(task.total_budget) || 0,
|
||||
total_actual: Number(task.total_actual) || 0,
|
||||
variance: Number(task.variance) || 0,
|
||||
effort_variance_man_days: task.effort_variance_man_days ? Number(task.effort_variance_man_days) : null,
|
||||
actual_man_days: task.actual_man_days ? Number(task.actual_man_days) : null,
|
||||
members: task.assignees,
|
||||
billable: task.billable,
|
||||
sub_tasks_count: Number(task.sub_tasks_count) || 0,
|
||||
@@ -1352,4 +1712,149 @@ export default class ProjectfinanceController extends WorklenzControllerBase {
|
||||
message: `Project currency updated to ${currency}`
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateProjectBudget(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.params.project_id;
|
||||
const { budget } = req.body;
|
||||
|
||||
// Validate budget format (must be a non-negative number)
|
||||
if (budget === undefined || budget === null || isNaN(budget) || budget < 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.send(new ServerResponse(false, null, "Invalid budget amount. Budget must be a non-negative number"));
|
||||
}
|
||||
|
||||
// Check if project exists and user has access
|
||||
const projectCheckQuery = `
|
||||
SELECT p.id, p.name, p.budget as current_budget, p.currency
|
||||
FROM projects p
|
||||
WHERE p.id = $1 AND p.team_id = $2
|
||||
`;
|
||||
|
||||
const projectCheckResult = await db.query(projectCheckQuery, [projectId, req.user?.team_id]);
|
||||
|
||||
if (projectCheckResult.rows.length === 0) {
|
||||
return res
|
||||
.status(404)
|
||||
.send(new ServerResponse(false, null, "Project not found or access denied"));
|
||||
}
|
||||
|
||||
const project = projectCheckResult.rows[0];
|
||||
|
||||
// Update project budget
|
||||
const updateQuery = `
|
||||
UPDATE projects
|
||||
SET budget = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND team_id = $3
|
||||
RETURNING id, name, budget, currency;
|
||||
`;
|
||||
|
||||
const result = await db.query(updateQuery, [budget, projectId, req.user?.team_id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res
|
||||
.status(500)
|
||||
.send(new ServerResponse(false, null, "Failed to update project budget"));
|
||||
}
|
||||
|
||||
const updatedProject = result.rows[0];
|
||||
|
||||
// Log the budget change for audit purposes
|
||||
const logQuery = `
|
||||
INSERT INTO project_logs (team_id, project_id, description)
|
||||
VALUES ($1, $2, $3)
|
||||
`;
|
||||
|
||||
const logDescription = `Project budget changed from ${project.current_budget || 0} to ${budget} ${project.currency || "USD"}`;
|
||||
|
||||
try {
|
||||
await db.query(logQuery, [req.user?.team_id, projectId, logDescription]);
|
||||
} catch (error) {
|
||||
console.error("Failed to log budget change:", error);
|
||||
// Don't fail the request if logging fails
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
id: updatedProject.id,
|
||||
name: updatedProject.name,
|
||||
budget: Number(updatedProject.budget),
|
||||
currency: updatedProject.currency,
|
||||
message: `Project budget updated to ${budget} ${project.currency || "USD"}`
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateProjectCalculationMethod(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const projectId = req.params.project_id;
|
||||
const { calculation_method, hours_per_day } = req.body;
|
||||
|
||||
// Validate calculation method
|
||||
if (!["hourly", "man_days"].includes(calculation_method)) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid calculation method. Must be \"hourly\" or \"man_days\""));
|
||||
}
|
||||
|
||||
// Validate hours per day
|
||||
if (hours_per_day && (typeof hours_per_day !== "number" || hours_per_day <= 0)) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid hours per day. Must be a positive number"));
|
||||
}
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE projects
|
||||
SET calculation_method = $1,
|
||||
hours_per_day = COALESCE($2, hours_per_day),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, name, calculation_method, hours_per_day;
|
||||
`;
|
||||
|
||||
const result = await db.query(updateQuery, [calculation_method, hours_per_day, projectId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Project not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
project: result.rows[0],
|
||||
message: "Project calculation method updated successfully"
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateRateCardManDayRate(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { rate_card_role_id } = req.params;
|
||||
const { man_day_rate } = req.body;
|
||||
|
||||
// Validate man day rate
|
||||
if (typeof man_day_rate !== "number" || man_day_rate < 0) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid man day rate. Must be a non-negative number"));
|
||||
}
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET man_day_rate = $1, updated_at = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, project_id, job_title_id, rate, man_day_rate;
|
||||
`;
|
||||
|
||||
const result = await db.query(updateQuery, [man_day_rate, rate_card_role_id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Rate card role not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
rate_card_role: result.rows[0],
|
||||
message: "Man day rate updated successfully"
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {getColor} from "../shared/utils";
|
||||
import TeamMembersController from "./team-members-controller";
|
||||
import {checkTeamSubscriptionStatus} from "../shared/paddle-utils";
|
||||
import {updateUsers} from "../shared/paddle-requests";
|
||||
import {statusExclude} from "../shared/constants";
|
||||
import {statusExclude, TRIAL_MEMBER_LIMIT} from "../shared/constants";
|
||||
import {NotificationsService} from "../services/notifications/notifications.service";
|
||||
|
||||
export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
@@ -118,6 +118,17 @@ export default class ProjectMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Maximum number of life time users reached."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
|
||||
if (currentTrialMembers + 1 > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!userExists && !subscriptionData.is_credit && !subscriptionData.is_custom && subscriptionData.subscription_status !== "trialing") {
|
||||
// if (subscriptionData.subscription_status === "active") {
|
||||
|
||||
@@ -10,18 +10,29 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
// Insert a single role for a project
|
||||
@HandleExceptions()
|
||||
public static async createOne(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { project_id, job_title_id, rate } = req.body;
|
||||
const { project_id, job_title_id, rate, man_day_rate } = req.body;
|
||||
if (!project_id || !job_title_id || typeof rate !== "number") {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
|
||||
// Handle both rate and man_day_rate fields
|
||||
const columns = ["project_id", "job_title_id", "rate"];
|
||||
const values = [project_id, job_title_id, rate];
|
||||
|
||||
if (typeof man_day_rate !== "undefined") {
|
||||
columns.push("man_day_rate");
|
||||
values.push(man_day_rate);
|
||||
}
|
||||
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES (${values.map((_, i) => `$${i + 1}`).join(", ")})
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
|
||||
rate = EXCLUDED.rate${typeof man_day_rate !== "undefined" ? ", man_day_rate = EXCLUDED.man_day_rate" : ""}
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const result = await db.query(q, [project_id, job_title_id, rate]);
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
// Insert multiple roles for a project
|
||||
@@ -31,17 +42,24 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
if (!Array.isArray(roles) || !project_id) {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid input"));
|
||||
}
|
||||
|
||||
// Handle both rate and man_day_rate fields for each role
|
||||
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
typeof role.rate !== "undefined" ? role.rate : 0,
|
||||
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : 0
|
||||
]);
|
||||
|
||||
const q = `
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET rate = EXCLUDED.rate
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id) DO UPDATE SET
|
||||
rate = EXCLUDED.rate,
|
||||
man_day_rate = EXCLUDED.man_day_rate
|
||||
RETURNING *,
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS Jobtitle;
|
||||
(SELECT name FROM job_titles jt WHERE jt.id = finance_project_rate_card_roles.job_title_id) AS jobtitle;
|
||||
`;
|
||||
const flatValues = values.flat();
|
||||
const result = await db.query(q, flatValues);
|
||||
@@ -95,11 +113,21 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async updateById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { id } = req.params;
|
||||
const { job_title_id, rate } = req.body;
|
||||
const { job_title_id, rate, man_day_rate } = req.body;
|
||||
let setClause = "job_title_id = $1, updated_at = NOW()";
|
||||
const values = [job_title_id];
|
||||
if (typeof man_day_rate !== "undefined") {
|
||||
setClause += ", man_day_rate = $2";
|
||||
values.push(man_day_rate);
|
||||
} else {
|
||||
setClause += ", rate = $2";
|
||||
values.push(rate);
|
||||
}
|
||||
values.push(id);
|
||||
const q = `
|
||||
WITH updated AS (
|
||||
UPDATE finance_project_rate_card_roles
|
||||
SET job_title_id = $1, rate = $2, updated_at = NOW()
|
||||
SET ${setClause}
|
||||
WHERE id = $3
|
||||
RETURNING *
|
||||
),
|
||||
@@ -118,7 +146,7 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
FROM jobtitles jt
|
||||
LEFT JOIN members m ON m.project_rate_card_role_id = jt.id;
|
||||
`;
|
||||
const result = await db.query(q, [job_title_id, rate, id]);
|
||||
const result = await db.query(q, values);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows[0]));
|
||||
}
|
||||
|
||||
@@ -209,17 +237,19 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, []));
|
||||
}
|
||||
// Build upsert query for all roles
|
||||
const columns = ["project_id", "job_title_id", "rate", "man_day_rate"];
|
||||
const values = roles.map((role: any) => [
|
||||
project_id,
|
||||
role.job_title_id,
|
||||
role.rate
|
||||
typeof role.rate !== "undefined" ? role.rate : null,
|
||||
typeof role.man_day_rate !== "undefined" ? role.man_day_rate : null
|
||||
]);
|
||||
const q = `
|
||||
WITH upserted AS (
|
||||
INSERT INTO finance_project_rate_card_roles (project_id, job_title_id, rate)
|
||||
VALUES ${values.map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`).join(",")}
|
||||
INSERT INTO finance_project_rate_card_roles (${columns.join(", ")})
|
||||
VALUES ${values.map((_, i) => `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})`).join(",")}
|
||||
ON CONFLICT (project_id, job_title_id)
|
||||
DO UPDATE SET rate = EXCLUDED.rate, updated_at = NOW()
|
||||
DO UPDATE SET rate = EXCLUDED.rate, man_day_rate = EXCLUDED.man_day_rate, updated_at = NOW()
|
||||
RETURNING *
|
||||
),
|
||||
jobtitles AS (
|
||||
@@ -259,4 +289,4 @@ export default class ProjectRateCardController extends WorklenzControllerBase {
|
||||
const result = await db.query(q, [project_id]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const q = `SELECT create_project($1) AS project`;
|
||||
|
||||
req.body.team_id = req.user?.team_id || null;
|
||||
@@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name");
|
||||
const search = (req.query.search || "").toString().trim();
|
||||
|
||||
let searchFilter = "";
|
||||
const params = [req.params.id, req.user?.team_id ?? null, size, offset];
|
||||
if (search) {
|
||||
searchFilter = `
|
||||
AND (
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%'
|
||||
)
|
||||
`;
|
||||
params.push(search);
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS members
|
||||
FROM (SELECT COUNT(*) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id),
|
||||
(SELECT email
|
||||
FROM team_member_info_view
|
||||
WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = project_members.project_id
|
||||
AND id IN (SELECT task_id
|
||||
FROM tasks_assignees
|
||||
WHERE tasks_assignees.project_member_id = project_members.id)
|
||||
AND status_id IN (SELECT id
|
||||
FROM task_statuses
|
||||
WHERE category_id = (SELECT id
|
||||
FROM sys_task_status_categories
|
||||
WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email
|
||||
FROM email_invitations
|
||||
WHERE team_member_id = project_members.team_member_id
|
||||
AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name
|
||||
FROM project_access_levels
|
||||
WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4) t) AS data
|
||||
FROM project_members
|
||||
WHERE project_id = $1) rec;
|
||||
WITH filtered_members AS (
|
||||
SELECT project_members.id,
|
||||
team_member_id,
|
||||
(SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name,
|
||||
(SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email,
|
||||
u.avatar_url,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count,
|
||||
(SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count,
|
||||
EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation,
|
||||
(SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access,
|
||||
(SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title
|
||||
FROM project_members
|
||||
INNER JOIN team_members tm ON project_members.team_member_id = tm.id
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
WHERE project_id = $1
|
||||
${search ? searchFilter : ""}
|
||||
)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM filtered_members) AS total,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT * FROM filtered_members
|
||||
ORDER BY ${sortField} ${sortOrder}
|
||||
LIMIT $3 OFFSET $4
|
||||
) t
|
||||
) AS data
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]);
|
||||
|
||||
const result = await db.query(q, params);
|
||||
const [data] = result.rows;
|
||||
|
||||
for (const member of data?.members.data || []) {
|
||||
for (const member of data?.data || []) {
|
||||
member.progress = member.all_tasks_count > 0
|
||||
? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct));
|
||||
return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -396,6 +389,7 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
projects.phase_label,
|
||||
projects.category_id,
|
||||
projects.currency,
|
||||
projects.budget,
|
||||
(projects.estimated_man_days) AS man_days,
|
||||
(projects.estimated_working_days) AS working_days,
|
||||
(projects.hours_per_day) AS hours_per_day,
|
||||
@@ -757,4 +751,186 @@ export default class ProjectsController extends WorklenzControllerBase {
|
||||
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getGrouped(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
// Use qualified field name for projects to avoid ambiguity
|
||||
const {searchQuery, sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, ["projects.name"]);
|
||||
const groupBy = req.query.groupBy as string || "category";
|
||||
|
||||
const filterByMember = !req.user?.owner && !req.user?.is_admin ?
|
||||
` AND is_member_of_project(projects.id, '${req.user?.id}', $1) ` : "";
|
||||
|
||||
const isFavorites = req.query.filter === "1" ? ` AND EXISTS(SELECT user_id FROM favorite_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)` : "";
|
||||
const isArchived = req.query.filter === "2"
|
||||
? ` AND EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`
|
||||
: ` AND NOT EXISTS(SELECT user_id FROM archived_projects WHERE user_id = '${req.user?.id}' AND project_id = projects.id)`;
|
||||
const categories = this.getFilterByCategoryWhereClosure(req.query.categories as string);
|
||||
const statuses = this.getFilterByStatusWhereClosure(req.query.statuses as string);
|
||||
|
||||
// Determine grouping field and join based on groupBy parameter
|
||||
let groupField = "";
|
||||
let groupName = "";
|
||||
let groupColor = "";
|
||||
let groupJoin = "";
|
||||
let groupByFields = "";
|
||||
let groupOrderBy = "";
|
||||
|
||||
switch (groupBy) {
|
||||
case "client":
|
||||
groupField = "COALESCE(projects.client_id::text, 'no-client')";
|
||||
groupName = "COALESCE(clients.name, 'No Client')";
|
||||
groupColor = "'#688'";
|
||||
groupJoin = "LEFT JOIN clients ON projects.client_id = clients.id";
|
||||
groupByFields = "projects.client_id, clients.name";
|
||||
groupOrderBy = "COALESCE(clients.name, 'No Client')";
|
||||
break;
|
||||
case "status":
|
||||
groupField = "COALESCE(projects.status_id::text, 'no-status')";
|
||||
groupName = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
groupColor = "COALESCE(sys_project_statuses.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN sys_project_statuses ON projects.status_id = sys_project_statuses.id";
|
||||
groupByFields = "projects.status_id, sys_project_statuses.name, sys_project_statuses.color_code";
|
||||
groupOrderBy = "COALESCE(sys_project_statuses.name, 'No Status')";
|
||||
break;
|
||||
case "category":
|
||||
default:
|
||||
groupField = "COALESCE(projects.category_id::text, 'uncategorized')";
|
||||
groupName = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
groupColor = "COALESCE(project_categories.color_code, '#888')";
|
||||
groupJoin = "LEFT JOIN project_categories ON projects.category_id = project_categories.id";
|
||||
groupByFields = "projects.category_id, project_categories.name, project_categories.color_code";
|
||||
groupOrderBy = "COALESCE(project_categories.name, 'Uncategorized')";
|
||||
}
|
||||
|
||||
// Ensure sortField is properly qualified for the inner project query
|
||||
let qualifiedSortField = sortField;
|
||||
if (Array.isArray(sortField)) {
|
||||
qualifiedSortField = sortField[0]; // Take the first field if it's an array
|
||||
}
|
||||
// Replace "projects." with "p2." for the inner query
|
||||
const innerSortField = qualifiedSortField.replace("projects.", "p2.");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS groups
|
||||
FROM (
|
||||
SELECT COUNT(DISTINCT ${groupField}) AS total_groups,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(group_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT ${groupField} AS group_key,
|
||||
${groupName} AS group_name,
|
||||
${groupColor} AS group_color,
|
||||
COUNT(*) AS project_count,
|
||||
(SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(project_data))), '[]'::JSON)
|
||||
FROM (
|
||||
SELECT p2.id,
|
||||
p2.name,
|
||||
(SELECT sys_project_statuses.name FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status,
|
||||
(SELECT sys_project_statuses.color_code FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_color,
|
||||
(SELECT sys_project_statuses.icon FROM sys_project_statuses WHERE sys_project_statuses.id = p2.status_id) AS status_icon,
|
||||
EXISTS(SELECT user_id
|
||||
FROM favorite_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS favorite,
|
||||
EXISTS(SELECT user_id
|
||||
FROM archived_projects
|
||||
WHERE user_id = '${req.user?.id}'
|
||||
AND project_id = p2.id) AS archived,
|
||||
p2.color_code,
|
||||
p2.start_date,
|
||||
p2.end_date,
|
||||
p2.category_id,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id) AS all_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM tasks
|
||||
WHERE archived IS FALSE
|
||||
AND project_id = p2.id
|
||||
AND status_id IN (SELECT task_statuses.id
|
||||
FROM task_statuses
|
||||
WHERE task_statuses.project_id = p2.id
|
||||
AND task_statuses.category_id IN
|
||||
(SELECT sys_task_status_categories.id FROM sys_task_status_categories WHERE sys_task_status_categories.is_done IS TRUE))) AS completed_tasks_count,
|
||||
(SELECT COUNT(*)
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id) AS members_count,
|
||||
(SELECT get_project_members(p2.id)) AS names,
|
||||
(SELECT clients.name FROM clients WHERE clients.id = p2.client_id) AS client_name,
|
||||
(SELECT users.name FROM users WHERE users.id = p2.owner_id) AS project_owner,
|
||||
(SELECT project_categories.name FROM project_categories WHERE project_categories.id = p2.category_id) AS category_name,
|
||||
(SELECT project_categories.color_code
|
||||
FROM project_categories
|
||||
WHERE project_categories.id = p2.category_id) AS category_color,
|
||||
((SELECT project_members.team_member_id as team_member_id
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.project_access_level_id = (SELECT project_access_levels.id FROM project_access_levels WHERE project_access_levels.key = 'PROJECT_MANAGER'))) AS project_manager_team_member_id,
|
||||
(SELECT project_members.default_view
|
||||
FROM project_members
|
||||
WHERE project_members.project_id = p2.id
|
||||
AND project_members.team_member_id = '${req.user?.team_member_id}') AS team_member_default_view,
|
||||
(SELECT CASE
|
||||
WHEN ((SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id) >
|
||||
p2.updated_at)
|
||||
THEN (SELECT MAX(tasks.updated_at)
|
||||
FROM tasks
|
||||
WHERE tasks.archived IS FALSE
|
||||
AND tasks.project_id = p2.id)
|
||||
ELSE p2.updated_at END) AS updated_at
|
||||
FROM projects p2
|
||||
${groupJoin.replace("projects.", "p2.")}
|
||||
WHERE p2.team_id = $1
|
||||
AND ${groupField.replace("projects.", "p2.")} = ${groupField}
|
||||
${categories.replace("projects.", "p2.")}
|
||||
${statuses.replace("projects.", "p2.")}
|
||||
${isArchived.replace("projects.", "p2.")}
|
||||
${isFavorites.replace("projects.", "p2.")}
|
||||
${filterByMember.replace("projects.", "p2.")}
|
||||
${searchQuery.replace("projects.", "p2.")}
|
||||
ORDER BY ${innerSortField} ${sortOrder}
|
||||
) project_data
|
||||
) AS projects
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
GROUP BY ${groupByFields}
|
||||
ORDER BY ${groupOrderBy}
|
||||
LIMIT $2 OFFSET $3
|
||||
) group_data
|
||||
) AS data
|
||||
FROM projects
|
||||
${groupJoin}
|
||||
WHERE projects.team_id = $1 ${categories} ${statuses} ${isArchived} ${isFavorites} ${filterByMember} ${searchQuery}
|
||||
) rec;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
// Process the grouped data
|
||||
for (const group of data?.groups.data || []) {
|
||||
for (const project of group.projects || []) {
|
||||
project.progress = project.all_tasks_count > 0
|
||||
? ((project.completed_tasks_count / project.all_tasks_count) * 100).toFixed(0) : 0;
|
||||
|
||||
project.updated_at_string = moment(project.updated_at).fromNow();
|
||||
|
||||
project.names = this.createTagList(project?.names);
|
||||
project.names.map((a: any) => a.color_code = getColor(a.name));
|
||||
|
||||
if (project.project_manager_team_member_id) {
|
||||
project.project_manager = {
|
||||
id: project.project_manager_team_member_id
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data?.groups || { total_groups: 0, data: [] }));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,20 +7,30 @@ import HandleExceptions from "../decorators/handle-exceptions";
|
||||
|
||||
export default class RateCardController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async create(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
public static async create(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
INSERT INTO finance_rate_cards (team_id, name)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, team_id, created_at, updated_at;
|
||||
`;
|
||||
const result = await db.query(q, [req.user?.team_id || null, req.body.name]);
|
||||
const result = await db.query(q, [
|
||||
req.user?.team_id || null,
|
||||
req.body.name,
|
||||
]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async get(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } = this.toPaginationOptions(req.query, "name");
|
||||
public static async get(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const { searchQuery, sortField, sortOrder, size, offset } =
|
||||
this.toPaginationOptions(req.query, "name");
|
||||
|
||||
const q = `
|
||||
SELECT ROW_TO_JSON(rec) AS rate_cards
|
||||
@@ -43,22 +53,37 @@ export default class RateCardController extends WorklenzControllerBase {
|
||||
const result = await db.query(q, [req.user?.team_id || null, size, offset]);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data.rate_cards || this.paginatedDatasetDefaultStruct));
|
||||
return res
|
||||
.status(200)
|
||||
.send(
|
||||
new ServerResponse(
|
||||
true,
|
||||
data.rate_cards || this.paginatedDatasetDefaultStruct
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
public static async getById(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
// 1. Fetch the rate card
|
||||
const q = `
|
||||
SELECT id, name, team_id, currency, created_at, updated_at
|
||||
FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
const result = await db.query(q, [
|
||||
req.params.id,
|
||||
req.user?.team_id || null,
|
||||
]);
|
||||
const [data] = result.rows;
|
||||
|
||||
if (!data) {
|
||||
return res.status(404).send(new ServerResponse(false, null, "Rate card not found"));
|
||||
return res
|
||||
.status(404)
|
||||
.send(new ServerResponse(false, null, "Rate card not found"));
|
||||
}
|
||||
|
||||
// 2. Fetch job roles with job title names
|
||||
@@ -67,6 +92,7 @@ export default class RateCardController extends WorklenzControllerBase {
|
||||
rcr.job_title_id,
|
||||
jt.name AS jobTitle,
|
||||
rcr.rate,
|
||||
rcr.man_day_rate,
|
||||
rcr.rate_card_id
|
||||
FROM finance_rate_card_roles rcr
|
||||
LEFT JOIN job_titles jt ON rcr.job_title_id = jt.id
|
||||
@@ -85,7 +111,10 @@ export default class RateCardController extends WorklenzControllerBase {
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async update(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
public static async update(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
// 1. Update the rate card
|
||||
const updateRateCardQ = `
|
||||
UPDATE finance_rate_cards
|
||||
@@ -113,9 +142,14 @@ export default class RateCardController extends WorklenzControllerBase {
|
||||
for (const role of req.body.jobRolesList) {
|
||||
if (role.job_title_id) {
|
||||
await db.query(
|
||||
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate)
|
||||
VALUES ($1, $2, $3);`,
|
||||
[req.params.id, role.job_title_id, role.rate ?? 0]
|
||||
`INSERT INTO finance_rate_card_roles (rate_card_id, job_title_id, rate, man_day_rate)
|
||||
VALUES ($1, $2, $3, $4);`,
|
||||
[
|
||||
req.params.id,
|
||||
role.job_title_id,
|
||||
role.rate ?? 0,
|
||||
role.man_day_rate ?? 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,14 +178,21 @@ export default class RateCardController extends WorklenzControllerBase {
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async deleteById(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
public static async deleteById(
|
||||
req: IWorkLenzRequest,
|
||||
res: IWorkLenzResponse
|
||||
): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
DELETE FROM finance_rate_cards
|
||||
WHERE id = $1 AND team_id = $2
|
||||
RETURNING id;
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.user?.team_id || null]);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows.length > 0));
|
||||
const result = await db.query(q, [
|
||||
req.params.id,
|
||||
req.user?.team_id || null,
|
||||
]);
|
||||
return res
|
||||
.status(200)
|
||||
.send(new ServerResponse(true, result.rows.length > 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -415,20 +415,15 @@ export default class ReportingController extends WorklenzControllerBase {
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getMyTeams(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const selectedTeamId = req.user?.team_id;
|
||||
if (!selectedTeamId) {
|
||||
return res.status(400).send(new ServerResponse(false, "No selected team"));
|
||||
}
|
||||
const q = `SELECT team_id AS id, name
|
||||
FROM team_members tm
|
||||
LEFT JOIN teams ON teams.id = tm.team_id
|
||||
WHERE tm.user_id = $1
|
||||
AND tm.team_id = $2
|
||||
AND role_id IN (SELECT id
|
||||
FROM roles
|
||||
WHERE (admin_role IS TRUE OR owner IS TRUE))
|
||||
ORDER BY name;`;
|
||||
const result = await db.query(q, [req.user?.id, selectedTeamId]);
|
||||
const result = await db.query(q, [req.user?.id]);
|
||||
result.rows.forEach((team: any) => team.selected = true);
|
||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Example of updated getMemberTimeSheets method with timezone support
|
||||
// This shows the key changes needed to handle timezones properly
|
||||
|
||||
import moment from "moment-timezone";
|
||||
import db from "../../config/db";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
|
||||
export async function getMemberTimeSheets(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const archived = req.query.archived === "true";
|
||||
const teams = (req.body.teams || []) as string[];
|
||||
const teamIds = teams.map(id => `'${id}'`).join(",");
|
||||
const projects = (req.body.projects || []) as string[];
|
||||
const projectIds = projects.map(p => `'${p}'`).join(",");
|
||||
const {billable} = req.body;
|
||||
|
||||
// Get user timezone from request or database
|
||||
const userTimezone = req.body.timezone || await getUserTimezone(req.user?.id || "");
|
||||
|
||||
if (!teamIds || !projectIds.length)
|
||||
return res.status(200).send(new ServerResponse(true, { users: [], projects: [] }));
|
||||
|
||||
const { duration, date_range } = req.body;
|
||||
|
||||
// Calculate date range with timezone support
|
||||
let startDate: moment.Moment;
|
||||
let endDate: moment.Moment;
|
||||
|
||||
if (date_range && date_range.length === 2) {
|
||||
// Convert user's local dates to their timezone's start/end of day
|
||||
startDate = moment.tz(date_range[0], userTimezone).startOf("day");
|
||||
endDate = moment.tz(date_range[1], userTimezone).endOf("day");
|
||||
} else if (duration === DATE_RANGES.ALL_TIME) {
|
||||
const minDateQuery = `SELECT MIN(COALESCE(start_date, created_at)) as min_date FROM projects WHERE id IN (${projectIds})`;
|
||||
const minDateResult = await db.query(minDateQuery, []);
|
||||
const minDate = minDateResult.rows[0]?.min_date;
|
||||
startDate = minDate ? moment.tz(minDate, userTimezone) : moment.tz("2000-01-01", userTimezone);
|
||||
endDate = moment.tz(userTimezone);
|
||||
} else {
|
||||
// Calculate ranges based on user's timezone
|
||||
const now = moment.tz(userTimezone);
|
||||
|
||||
switch (duration) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = now.clone().subtract(1, "week").startOf("isoWeek");
|
||||
endDate = now.clone().subtract(1, "week").endOf("isoWeek");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
break;
|
||||
default:
|
||||
startDate = now.clone().startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to UTC for database queries
|
||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
// Calculate working days in user's timezone
|
||||
const totalDays = endDate.diff(startDate, "days") + 1;
|
||||
let workingDays = 0;
|
||||
|
||||
const current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, "day")) {
|
||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, "day");
|
||||
}
|
||||
|
||||
// Updated SQL query with proper timezone handling
|
||||
const billableQuery = buildBillableQuery(billable);
|
||||
const archivedClause = archived ? "" : `AND projects.id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = projects.id AND user_id = '${req.user?.id}')`;
|
||||
|
||||
const q = `
|
||||
WITH project_hours AS (
|
||||
SELECT
|
||||
id,
|
||||
COALESCE(hours_per_day, 8) as hours_per_day
|
||||
FROM projects
|
||||
WHERE id IN (${projectIds})
|
||||
),
|
||||
total_working_hours AS (
|
||||
SELECT
|
||||
SUM(hours_per_day) * ${workingDays} as total_hours
|
||||
FROM project_hours
|
||||
)
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
tm.name,
|
||||
tm.color_code,
|
||||
COALESCE(SUM(twl.time_spent), 0) as logged_time,
|
||||
COALESCE(SUM(twl.time_spent), 0) / 3600.0 as value,
|
||||
(SELECT total_hours FROM total_working_hours) as total_working_hours,
|
||||
CASE
|
||||
WHEN (SELECT total_hours FROM total_working_hours) > 0
|
||||
THEN ROUND((COALESCE(SUM(twl.time_spent), 0) / 3600.0) / (SELECT total_hours FROM total_working_hours) * 100, 2)
|
||||
ELSE 0
|
||||
END as utilization_percent,
|
||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0, 2) as utilized_hours,
|
||||
ROUND(COALESCE(SUM(twl.time_spent), 0) / 3600.0 - (SELECT total_hours FROM total_working_hours), 2) as over_under_utilized_hours,
|
||||
'${userTimezone}' as user_timezone,
|
||||
'${startDate.format("YYYY-MM-DD")}' as report_start_date,
|
||||
'${endDate.format("YYYY-MM-DD")}' as report_end_date
|
||||
FROM team_members tm
|
||||
LEFT JOIN users u ON tm.user_id = u.id
|
||||
LEFT JOIN task_work_log twl ON twl.user_id = u.id
|
||||
LEFT JOIN tasks t ON twl.task_id = t.id ${billableQuery}
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE tm.team_id IN (${teamIds})
|
||||
AND (
|
||||
twl.id IS NULL
|
||||
OR (
|
||||
p.id IN (${projectIds})
|
||||
AND twl.created_at >= '${startUtc}'::TIMESTAMP
|
||||
AND twl.created_at <= '${endUtc}'::TIMESTAMP
|
||||
${archivedClause}
|
||||
)
|
||||
)
|
||||
GROUP BY u.id, u.email, tm.name, tm.color_code
|
||||
ORDER BY logged_time DESC`;
|
||||
|
||||
const result = await db.query(q, []);
|
||||
|
||||
// Add timezone context to response
|
||||
const response = {
|
||||
data: result.rows,
|
||||
timezone_info: {
|
||||
user_timezone: userTimezone,
|
||||
report_period: {
|
||||
start: startDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||
end: endDate.format("YYYY-MM-DD HH:mm:ss z"),
|
||||
working_days: workingDays,
|
||||
total_days: totalDays
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
|
||||
async function getUserTimezone(userId: string): Promise<string> {
|
||||
const q = `SELECT tz.name as timezone
|
||||
FROM users u
|
||||
JOIN timezones tz ON u.timezone_id = tz.id
|
||||
WHERE u.id = $1`;
|
||||
const result = await db.query(q, [userId]);
|
||||
return result.rows[0]?.timezone || "UTC";
|
||||
}
|
||||
|
||||
function buildBillableQuery(billable: { billable: boolean; nonBillable: boolean }): string {
|
||||
if (!billable) return "";
|
||||
|
||||
const { billable: isBillable, nonBillable } = billable;
|
||||
|
||||
if (isBillable && nonBillable) {
|
||||
return "";
|
||||
} else if (isBillable) {
|
||||
return " AND tasks.billable IS TRUE";
|
||||
} else if (nonBillable) {
|
||||
return " AND tasks.billable IS FALSE";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -523,19 +523,130 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
sunday: false
|
||||
};
|
||||
|
||||
// Count working days based on organization settings
|
||||
// Get organization ID for holiday queries
|
||||
const orgIdQuery = `SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1`;
|
||||
const orgIdResult = await db.query(orgIdQuery, []);
|
||||
const organizationId = orgIdResult.rows[0]?.organization_id;
|
||||
|
||||
// Fetch organization holidays within the date range
|
||||
const orgHolidaysQuery = `
|
||||
SELECT date
|
||||
FROM organization_holidays
|
||||
WHERE organization_id = $1
|
||||
AND date >= $2::date
|
||||
AND date <= $3::date
|
||||
`;
|
||||
const orgHolidaysResult = await db.query(orgHolidaysQuery, [
|
||||
organizationId,
|
||||
startDate.format('YYYY-MM-DD'),
|
||||
endDate.format('YYYY-MM-DD')
|
||||
]);
|
||||
|
||||
// Fetch country/state holidays if auto-sync is enabled
|
||||
let countryStateHolidays: any[] = [];
|
||||
const holidaySettingsQuery = `
|
||||
SELECT country_code, state_code, auto_sync_holidays
|
||||
FROM organization_holiday_settings
|
||||
WHERE organization_id = $1
|
||||
`;
|
||||
const holidaySettingsResult = await db.query(holidaySettingsQuery, [organizationId]);
|
||||
const holidaySettings = holidaySettingsResult.rows[0];
|
||||
|
||||
if (holidaySettings?.auto_sync_holidays && holidaySettings.country_code) {
|
||||
// Fetch country holidays
|
||||
const countryHolidaysQuery = `
|
||||
SELECT date
|
||||
FROM country_holidays
|
||||
WHERE country_code = $1
|
||||
AND (
|
||||
(is_recurring = false AND date >= $2::date AND date <= $3::date) OR
|
||||
(is_recurring = true AND
|
||||
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
|
||||
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
|
||||
FROM generate_series($2::date, $3::date, '1 day'::interval) d
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
const countryHolidaysResult = await db.query(countryHolidaysQuery, [
|
||||
holidaySettings.country_code,
|
||||
startDate.format('YYYY-MM-DD'),
|
||||
endDate.format('YYYY-MM-DD')
|
||||
]);
|
||||
countryStateHolidays = countryStateHolidays.concat(countryHolidaysResult.rows);
|
||||
|
||||
// Fetch state holidays if state_code is set
|
||||
if (holidaySettings.state_code) {
|
||||
const stateHolidaysQuery = `
|
||||
SELECT date
|
||||
FROM state_holidays
|
||||
WHERE country_code = $1 AND state_code = $2
|
||||
AND (
|
||||
(is_recurring = false AND date >= $3::date AND date <= $4::date) OR
|
||||
(is_recurring = true AND
|
||||
EXTRACT(MONTH FROM date) || '-' || EXTRACT(DAY FROM date) IN (
|
||||
SELECT EXTRACT(MONTH FROM d::date) || '-' || EXTRACT(DAY FROM d::date)
|
||||
FROM generate_series($3::date, $4::date, '1 day'::interval) d
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
const stateHolidaysResult = await db.query(stateHolidaysQuery, [
|
||||
holidaySettings.country_code,
|
||||
holidaySettings.state_code,
|
||||
startDate.format('YYYY-MM-DD'),
|
||||
endDate.format('YYYY-MM-DD')
|
||||
]);
|
||||
countryStateHolidays = countryStateHolidays.concat(stateHolidaysResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a Set of holiday dates for efficient lookup
|
||||
const holidayDates = new Set<string>();
|
||||
|
||||
// Add organization holidays
|
||||
orgHolidaysResult.rows.forEach(row => {
|
||||
holidayDates.add(moment(row.date).format('YYYY-MM-DD'));
|
||||
});
|
||||
|
||||
// Add country/state holidays (handling recurring holidays)
|
||||
countryStateHolidays.forEach(row => {
|
||||
const holidayDate = moment(row.date);
|
||||
if (row.is_recurring) {
|
||||
// For recurring holidays, check each year in the date range
|
||||
let checkDate = startDate.clone().month(holidayDate.month()).date(holidayDate.date());
|
||||
if (checkDate.isBefore(startDate)) {
|
||||
checkDate.add(1, 'year');
|
||||
}
|
||||
while (checkDate.isSameOrBefore(endDate)) {
|
||||
if (checkDate.isSameOrAfter(startDate)) {
|
||||
holidayDates.add(checkDate.format('YYYY-MM-DD'));
|
||||
}
|
||||
checkDate.add(1, 'year');
|
||||
}
|
||||
} else {
|
||||
holidayDates.add(holidayDate.format('YYYY-MM-DD'));
|
||||
}
|
||||
});
|
||||
|
||||
// Count working days based on organization settings, excluding holidays
|
||||
let workingDays = 0;
|
||||
let current = startDate.clone();
|
||||
while (current.isSameOrBefore(endDate, 'day')) {
|
||||
const day = current.isoWeekday();
|
||||
const currentDateStr = current.format('YYYY-MM-DD');
|
||||
|
||||
// Check if it's a working day AND not a holiday
|
||||
if (
|
||||
(day === 1 && workingDaysConfig.monday) ||
|
||||
(day === 2 && workingDaysConfig.tuesday) ||
|
||||
(day === 3 && workingDaysConfig.wednesday) ||
|
||||
(day === 4 && workingDaysConfig.thursday) ||
|
||||
(day === 5 && workingDaysConfig.friday) ||
|
||||
(day === 6 && workingDaysConfig.saturday) ||
|
||||
(day === 7 && workingDaysConfig.sunday)
|
||||
!holidayDates.has(currentDateStr) && (
|
||||
(day === 1 && workingDaysConfig.monday) ||
|
||||
(day === 2 && workingDaysConfig.tuesday) ||
|
||||
(day === 3 && workingDaysConfig.wednesday) ||
|
||||
(day === 4 && workingDaysConfig.thursday) ||
|
||||
(day === 5 && workingDaysConfig.friday) ||
|
||||
(day === 6 && workingDaysConfig.saturday) ||
|
||||
(day === 7 && workingDaysConfig.sunday)
|
||||
)
|
||||
) {
|
||||
workingDays++;
|
||||
}
|
||||
@@ -543,9 +654,9 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
}
|
||||
|
||||
// Get organization working hours
|
||||
const orgWorkingHoursQuery = `SELECT working_hours FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||
const orgWorkingHoursQuery = `SELECT hours_per_day FROM organizations WHERE id = (SELECT t.organization_id FROM teams t WHERE t.id IN (${teamIds}) LIMIT 1)`;
|
||||
const orgWorkingHoursResult = await db.query(orgWorkingHoursQuery, []);
|
||||
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.working_hours || 8;
|
||||
const orgWorkingHours = orgWorkingHoursResult.rows[0]?.hours_per_day || 8;
|
||||
|
||||
// Calculate total working hours with minimum baseline for non-working day scenarios
|
||||
let totalWorkingHours = workingDays * orgWorkingHours;
|
||||
@@ -567,42 +678,18 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const billableQuery = this.buildBillableQueryWithAlias(billable, 't');
|
||||
const members = (req.body.members || []) as string[];
|
||||
|
||||
// Prepare members filter - updated logic to handle Clear All scenario
|
||||
// Prepare members filter
|
||||
let membersFilter = "";
|
||||
if (members.length > 0) {
|
||||
const memberIds = members.map(id => `'${id}'`).join(",");
|
||||
membersFilter = `AND tmiv.team_member_id IN (${memberIds})`;
|
||||
} else {
|
||||
// No members selected - show no data (Clear All scenario)
|
||||
// If no members are selected, we should not show any data
|
||||
// This is different from other filters where no selection means "show all"
|
||||
// For members, no selection should mean "show none" to respect the UI filter state
|
||||
membersFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare projects filter
|
||||
let projectsFilter = "";
|
||||
if (projectIds.length > 0) {
|
||||
projectsFilter = `AND p.id IN (${projectIds})`;
|
||||
} else {
|
||||
// If no projects are selected, don't show any data
|
||||
projectsFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
|
||||
// Prepare categories filter - updated logic
|
||||
let categoriesFilter = "";
|
||||
if (categories.length > 0 && noCategory) {
|
||||
// Both specific categories and "No Category" are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
// Only "No Category" is selected
|
||||
categoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
// Only specific categories are selected
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
categoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
} else {
|
||||
// categories.length === 0 && !noCategory - no categories selected, show nothing
|
||||
categoriesFilter = `AND 1=0`; // This will match no rows
|
||||
}
|
||||
// Note: Members filter works differently - when no members are selected, show nothing
|
||||
|
||||
// Create custom duration clause for twl table alias
|
||||
let customDurationClause = "";
|
||||
@@ -626,7 +713,45 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
customDurationClause = "AND twl.created_at >= (CURRENT_DATE - INTERVAL '3 months')::DATE AND twl.created_at < CURRENT_DATE::DATE + INTERVAL '1 day'";
|
||||
}
|
||||
|
||||
// Prepare conditional filters for the subquery - only apply if selections are made
|
||||
let conditionalProjectsFilter = "";
|
||||
let conditionalCategoriesFilter = "";
|
||||
|
||||
// Only apply project filter if projects are actually selected
|
||||
if (projectIds.length > 0) {
|
||||
conditionalProjectsFilter = `AND p.id IN (${projectIds})`;
|
||||
}
|
||||
|
||||
// Only apply category filter if categories are selected or noCategory is true
|
||||
if (categories.length > 0 && noCategory) {
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
conditionalCategoriesFilter = `AND (p.category_id IS NULL OR p.category_id IN (${categoryIds}))`;
|
||||
} else if (categories.length === 0 && noCategory) {
|
||||
conditionalCategoriesFilter = `AND p.category_id IS NULL`;
|
||||
} else if (categories.length > 0 && !noCategory) {
|
||||
const categoryIds = categories.map(id => `'${id}'`).join(",");
|
||||
conditionalCategoriesFilter = `AND p.category_id IN (${categoryIds})`;
|
||||
}
|
||||
// If no categories and no noCategory, don't filter by category (show all)
|
||||
|
||||
// Check if all filters are unchecked (Clear All scenario) - return no data to avoid overwhelming UI
|
||||
const hasProjectFilter = projectIds.length > 0;
|
||||
const hasCategoryFilter = categories.length > 0 || noCategory;
|
||||
const hasMemberFilter = members.length > 0;
|
||||
// Note: We'll check utilization filter after the query since it's applied post-processing
|
||||
|
||||
if (!hasProjectFilter && !hasCategoryFilter && !hasMemberFilter) {
|
||||
// Still need to check utilization filter, but we'll do a quick check
|
||||
const utilization = (req.body.utilization || []) as string[];
|
||||
const hasUtilizationFilter = utilization.length > 0;
|
||||
|
||||
if (!hasUtilizationFilter) {
|
||||
return res.status(200).send(new ServerResponse(true, { filteredRows: [], totals: { total_time_logs: "0", total_estimated_hours: "0", total_utilization: "0" } }));
|
||||
}
|
||||
}
|
||||
|
||||
// Modified query to start from team members and calculate filtered time logs
|
||||
// This query ensures ALL active team members are included, even if they have no logged time
|
||||
const q = `
|
||||
SELECT
|
||||
tmiv.team_member_id,
|
||||
@@ -639,8 +764,8 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE twl.user_id = tmiv.user_id
|
||||
${customDurationClause}
|
||||
${projectsFilter}
|
||||
${categoriesFilter}
|
||||
${conditionalProjectsFilter}
|
||||
${conditionalCategoriesFilter}
|
||||
${archivedClause}
|
||||
${billableQuery}
|
||||
AND p.team_id = tmiv.team_id
|
||||
@@ -666,15 +791,21 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
const utilizedHours = loggedSeconds / 3600;
|
||||
|
||||
// For individual members, use the same logic as total calculation
|
||||
let memberWorkingHours = totalWorkingHours;
|
||||
if (isNonWorkingPeriod && loggedSeconds > 0) {
|
||||
// Any time logged during non-working period should be treated as over-utilization
|
||||
memberWorkingHours = Math.min(utilizedHours, 1); // Use actual time or 1 hour, whichever is smaller
|
||||
}
|
||||
let memberWorkingHours;
|
||||
let utilizationPercent;
|
||||
|
||||
const utilizationPercent = memberWorkingHours > 0 && loggedSeconds
|
||||
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
|
||||
: 0;
|
||||
if (isNonWorkingPeriod) {
|
||||
// Non-working period: each member's expected working hours is 0
|
||||
memberWorkingHours = 0;
|
||||
// Any time logged during non-working period is overtime
|
||||
utilizationPercent = loggedSeconds > 0 ? 100 : 0; // Show 100+ as numeric 100 for consistency
|
||||
} else {
|
||||
// Normal working period
|
||||
memberWorkingHours = totalWorkingHours;
|
||||
utilizationPercent = memberWorkingHours > 0 && loggedSeconds
|
||||
? ((loggedSeconds / (memberWorkingHours * 3600)) * 100)
|
||||
: 0;
|
||||
}
|
||||
const overUnder = utilizedHours - memberWorkingHours;
|
||||
|
||||
member.value = utilizedHours ? parseFloat(utilizedHours.toFixed(2)) : 0;
|
||||
@@ -699,16 +830,30 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
// Filter to only show selected utilization states
|
||||
filteredRows = result.rows.filter(member => utilization.includes(member.utilization_state));
|
||||
} else {
|
||||
// No utilization states selected - show no data (Clear All scenario)
|
||||
filteredRows = [];
|
||||
// No utilization states selected
|
||||
// If we reached here, it means at least one other filter was applied
|
||||
// so we show all members (don't filter by utilization)
|
||||
filteredRows = result.rows;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const total_time_logs = filteredRows.reduce((sum, member) => sum + parseFloat(member.logged_time || '0'), 0);
|
||||
const total_estimated_hours = totalWorkingHours * filteredRows.length; // Total for all members
|
||||
const total_utilization = total_time_logs > 0 && total_estimated_hours > 0
|
||||
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
let total_estimated_hours;
|
||||
let total_utilization;
|
||||
|
||||
if (isNonWorkingPeriod) {
|
||||
// Non-working period: expected capacity is 0
|
||||
total_estimated_hours = 0;
|
||||
// Special handling for utilization on non-working days
|
||||
total_utilization = total_time_logs > 0 ? "100+" : "0";
|
||||
} else {
|
||||
// Normal working period calculation
|
||||
total_estimated_hours = totalWorkingHours * filteredRows.length;
|
||||
total_utilization = total_time_logs > 0 && total_estimated_hours > 0
|
||||
? ((total_time_logs / (total_estimated_hours * 3600)) * 100).toFixed(1)
|
||||
: '0';
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
filteredRows,
|
||||
@@ -886,4 +1031,4 @@ export default class ReportingAllocationController extends ReportingControllerBa
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import WorklenzControllerBase from "../worklenz-controller-base";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import db from "../../config/db";
|
||||
import moment from "moment-timezone";
|
||||
import { DATE_RANGES } from "../../shared/constants";
|
||||
|
||||
export default abstract class ReportingControllerBaseWithTimezone extends WorklenzControllerBase {
|
||||
|
||||
/**
|
||||
* Get the user's timezone from the database or request
|
||||
* @param userId - The user ID
|
||||
* @returns The user's timezone or 'UTC' as default
|
||||
*/
|
||||
protected static async getUserTimezone(userId: string): Promise<string> {
|
||||
const q = `SELECT tz.name as timezone
|
||||
FROM users u
|
||||
JOIN timezones tz ON u.timezone_id = tz.id
|
||||
WHERE u.id = $1`;
|
||||
const result = await db.query(q, [userId]);
|
||||
return result.rows[0]?.timezone || "UTC";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate date range clause with timezone support
|
||||
* @param key - Date range key (e.g., YESTERDAY, LAST_WEEK)
|
||||
* @param dateRange - Array of date strings
|
||||
* @param userTimezone - User's timezone (e.g., 'America/New_York')
|
||||
* @returns SQL clause for date filtering
|
||||
*/
|
||||
protected static getDateRangeClauseWithTimezone(key: string, dateRange: string[], userTimezone: string) {
|
||||
// For custom date ranges
|
||||
if (dateRange.length === 2) {
|
||||
try {
|
||||
// Handle different date formats that might come from frontend
|
||||
let startDate, endDate;
|
||||
|
||||
// Try to parse the date - it might be a full JS Date string or ISO string
|
||||
if (dateRange[0].includes("GMT") || dateRange[0].includes("(")) {
|
||||
// Parse JavaScript Date toString() format
|
||||
startDate = moment(new Date(dateRange[0]));
|
||||
endDate = moment(new Date(dateRange[1]));
|
||||
} else {
|
||||
// Parse ISO format or other formats
|
||||
startDate = moment(dateRange[0]);
|
||||
endDate = moment(dateRange[1]);
|
||||
}
|
||||
|
||||
// Convert to user's timezone and get start/end of day
|
||||
const start = startDate.tz(userTimezone).startOf("day");
|
||||
const end = endDate.tz(userTimezone).endOf("day");
|
||||
|
||||
// Convert to UTC for database comparison
|
||||
const startUtc = start.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = end.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
|
||||
if (start.isSame(end, "day")) {
|
||||
// Single day selection
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
} catch (error) {
|
||||
console.error("Error parsing date range:", error, { dateRange, userTimezone });
|
||||
// Fallback to current date if parsing fails
|
||||
const now = moment.tz(userTimezone);
|
||||
const startUtc = now.clone().startOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = now.clone().endOf("day").utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
}
|
||||
|
||||
// For predefined ranges, calculate based on user's timezone
|
||||
const now = moment.tz(userTimezone);
|
||||
let startDate, endDate;
|
||||
|
||||
switch (key) {
|
||||
case DATE_RANGES.YESTERDAY:
|
||||
startDate = now.clone().subtract(1, "day").startOf("day");
|
||||
endDate = now.clone().subtract(1, "day").endOf("day");
|
||||
break;
|
||||
case DATE_RANGES.LAST_WEEK:
|
||||
startDate = now.clone().subtract(1, "week").startOf("week");
|
||||
endDate = now.clone().subtract(1, "week").endOf("week");
|
||||
break;
|
||||
case DATE_RANGES.LAST_MONTH:
|
||||
startDate = now.clone().subtract(1, "month").startOf("month");
|
||||
endDate = now.clone().subtract(1, "month").endOf("month");
|
||||
break;
|
||||
case DATE_RANGES.LAST_QUARTER:
|
||||
startDate = now.clone().subtract(3, "months").startOf("day");
|
||||
endDate = now.clone().endOf("day");
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
if (startDate && endDate) {
|
||||
const startUtc = startDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
const endUtc = endDate.utc().format("YYYY-MM-DD HH:mm:ss");
|
||||
return `AND twl.created_at >= '${startUtc}'::TIMESTAMP AND twl.created_at <= '${endUtc}'::TIMESTAMP`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format dates for display in user's timezone
|
||||
* @param date - Date to format
|
||||
* @param userTimezone - User's timezone
|
||||
* @param format - Moment format string
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
protected static formatDateInTimezone(date: string | Date, userTimezone: string, format = "YYYY-MM-DD HH:mm:ss") {
|
||||
return moment.tz(date, userTimezone).format(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get working days count between two dates in user's timezone
|
||||
* @param startDate - Start date
|
||||
* @param endDate - End date
|
||||
* @param userTimezone - User's timezone
|
||||
* @returns Number of working days
|
||||
*/
|
||||
protected static getWorkingDaysInTimezone(startDate: string, endDate: string, userTimezone: string): number {
|
||||
const start = moment.tz(startDate, userTimezone);
|
||||
const end = moment.tz(endDate, userTimezone);
|
||||
let workingDays = 0;
|
||||
|
||||
const current = start.clone();
|
||||
while (current.isSameOrBefore(end, "day")) {
|
||||
// Monday = 1, Friday = 5
|
||||
if (current.isoWeekday() >= 1 && current.isoWeekday() <= 5) {
|
||||
workingDays++;
|
||||
}
|
||||
current.add(1, "day");
|
||||
}
|
||||
|
||||
return workingDays;
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,69 @@ import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { DATE_RANGES, TASK_PRIORITY_COLOR_ALPHA } from "../../shared/constants";
|
||||
import { formatDuration, getColor, int } from "../../shared/utils";
|
||||
import ReportingControllerBase from "./reporting-controller-base";
|
||||
import ReportingControllerBaseWithTimezone from "./reporting-controller-base-with-timezone";
|
||||
import Excel from "exceljs";
|
||||
|
||||
export default class ReportingMembersController extends ReportingControllerBase {
|
||||
export default class ReportingMembersController extends ReportingControllerBaseWithTimezone {
|
||||
|
||||
protected static getPercentage(n: number, total: number) {
|
||||
return +(n ? (n / total) * 100 : 0).toFixed();
|
||||
}
|
||||
|
||||
protected static getCurrentTeamId(req: IWorkLenzRequest): string | null {
|
||||
return req.user?.team_id ?? null;
|
||||
}
|
||||
|
||||
public static convertMinutesToHoursAndMinutes(totalMinutes: number) {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
public static convertSecondsToHoursAndMinutes(seconds: number) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
protected static formatEndDate(endDate: string) {
|
||||
const end = moment(endDate).format("YYYY-MM-DD");
|
||||
const fEndDate = moment(end);
|
||||
return fEndDate;
|
||||
}
|
||||
|
||||
protected static formatCurrentDate() {
|
||||
const current = moment().format("YYYY-MM-DD");
|
||||
const fCurrentDate = moment(current);
|
||||
return fCurrentDate;
|
||||
}
|
||||
|
||||
protected static getDaysLeft(endDate: string): number | null {
|
||||
if (!endDate) return null;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.diff(fCurrentDate, "days");
|
||||
}
|
||||
|
||||
protected static isOverdue(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isBefore(fCurrentDate);
|
||||
}
|
||||
|
||||
protected static isToday(endDate: string): boolean {
|
||||
if (!endDate) return false;
|
||||
|
||||
const fCurrentDate = this.formatCurrentDate();
|
||||
const fEndDate = this.formatEndDate(endDate);
|
||||
|
||||
return fEndDate.isSame(fCurrentDate);
|
||||
}
|
||||
|
||||
private static async getMembers(
|
||||
teamId: string, searchQuery = "",
|
||||
@@ -122,9 +181,6 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
${includeArchived ? "" : `AND t.project_id NOT IN (SELECT project_id FROM archived_projects WHERE project_id = t.project_id AND archived_projects.user_id = '${userId}')`}) AS non_billable_time
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id = $1 ${teamsClause}
|
||||
AND tmiv.team_member_id IN (SELECT team_member_id
|
||||
FROM project_members
|
||||
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
|
||||
${searchQuery}
|
||||
GROUP BY email, name, avatar_url, team_member_id, tmiv.team_id
|
||||
ORDER BY last_user_activity DESC NULLS LAST
|
||||
@@ -132,9 +188,6 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
${pagingClause}) t) AS members
|
||||
FROM team_member_info_view tmiv
|
||||
WHERE tmiv.team_id = $1 ${teamsClause}
|
||||
AND tmiv.team_member_id IN (SELECT team_member_id
|
||||
FROM project_members
|
||||
WHERE project_id IN (SELECT id FROM projects WHERE projects.team_id = tmiv.team_id))
|
||||
${searchQuery}`;
|
||||
const result = await db.query(q, [teamId]);
|
||||
const [data] = result.rows;
|
||||
@@ -534,7 +587,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
dateRange = date_range.split(",");
|
||||
}
|
||||
|
||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "twl");
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration as string || DATE_RANGES.LAST_WEEK, dateRange, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration as string || DATE_RANGES.LAST_WEEK, dateRange, "task_work_log");
|
||||
const memberName = (req.query.member_name as string)?.trim() || null;
|
||||
|
||||
@@ -1085,7 +1140,9 @@ export default class ReportingMembersController extends ReportingControllerBase
|
||||
public static async getMemberTimelogs(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { team_member_id, team_id, duration, date_range, archived, billable } = req.body;
|
||||
|
||||
const durationClause = ReportingMembersController.getDateRangeClauseMembers(duration || DATE_RANGES.LAST_WEEK, date_range, "twl");
|
||||
// Get user timezone for proper date filtering
|
||||
const userTimezone = await this.getUserTimezone(req.user?.id as string);
|
||||
const durationClause = this.getDateRangeClauseWithTimezone(duration || DATE_RANGES.LAST_WEEK, date_range, userTimezone);
|
||||
const minMaxDateClause = this.getMinMaxDates(duration || DATE_RANGES.LAST_WEEK, date_range, "task_work_log");
|
||||
|
||||
const billableQuery = this.buildBillableQuery(billable);
|
||||
@@ -1277,8 +1334,8 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
||||
row.actual_time = int(row.actual_time);
|
||||
row.estimated_time_string = this.convertMinutesToHoursAndMinutes(int(row.estimated_time));
|
||||
row.actual_time_string = this.convertSecondsToHoursAndMinutes(int(row.actual_time));
|
||||
row.days_left = ReportingControllerBase.getDaysLeft(row.end_date);
|
||||
row.is_overdue = ReportingControllerBase.isOverdue(row.end_date);
|
||||
row.days_left = this.getDaysLeft(row.end_date);
|
||||
row.is_overdue = this.isOverdue(row.end_date);
|
||||
if (row.days_left && row.is_overdue) {
|
||||
row.days_left = row.days_left.toString().replace(/-/g, "");
|
||||
}
|
||||
@@ -1376,4 +1433,4 @@ public static async getSingleMemberProjects(req: IWorkLenzRequest, res: IWorkLen
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,13 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
||||
const [workingDays] = workingDaysResults.rows;
|
||||
|
||||
// get organization working hours
|
||||
const getDataHoursq = `SELECT working_hours FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
|
||||
const getDataHoursq = `SELECT hours_per_day FROM organizations WHERE user_id = $1 GROUP BY id LIMIT 1;`;
|
||||
|
||||
const workingHoursResults = await db.query(getDataHoursq, [req.user?.owner_id]);
|
||||
|
||||
const [workingHours] = workingHoursResults.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.working_hours }));
|
||||
return res.status(200).send(new ServerResponse(true, { workingDays: workingDays?.working_days, workingHours: workingHours?.hours_per_day }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
@@ -80,7 +80,7 @@ export default class ScheduleControllerV2 extends WorklenzControllerBase {
|
||||
|
||||
await db.query(updateQuery, [req.user?.owner_id]);
|
||||
|
||||
const getDataHoursq = `UPDATE organizations SET working_hours = $1 WHERE user_id = $2;`;
|
||||
const getDataHoursq = `UPDATE organizations SET hours_per_day = $1 WHERE user_id = $2;`;
|
||||
|
||||
await db.query(getDataHoursq, [workingHours, req.user?.owner_id]);
|
||||
|
||||
|
||||
201
worklenz-backend/src/controllers/survey-controller.ts
Normal file
201
worklenz-backend/src/controllers/survey-controller.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { ISurveySubmissionRequest } from "../interfaces/survey";
|
||||
import db from "../config/db";
|
||||
|
||||
export default class SurveyController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getAccountSetupSurvey(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `
|
||||
SELECT
|
||||
s.id,
|
||||
s.name,
|
||||
s.description,
|
||||
s.survey_type,
|
||||
s.is_active,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', sq.id,
|
||||
'survey_id', sq.survey_id,
|
||||
'question_key', sq.question_key,
|
||||
'question_type', sq.question_type,
|
||||
'is_required', sq.is_required,
|
||||
'sort_order', sq.sort_order,
|
||||
'options', sq.options
|
||||
) ORDER BY sq.sort_order
|
||||
) FILTER (WHERE sq.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS questions
|
||||
FROM surveys s
|
||||
LEFT JOIN survey_questions sq ON s.id = sq.survey_id
|
||||
WHERE s.survey_type = 'account_setup' AND s.is_active = true
|
||||
GROUP BY s.id, s.name, s.description, s.survey_type, s.is_active
|
||||
LIMIT 1;
|
||||
`;
|
||||
|
||||
const result = await db.query(q);
|
||||
const [survey] = result.rows;
|
||||
|
||||
if (!survey) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Account setup survey not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, survey));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async submitSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
const body = req.body as ISurveySubmissionRequest;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
if (!body.survey_id || !body.answers || !Array.isArray(body.answers)) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Invalid survey submission data"));
|
||||
}
|
||||
|
||||
// Check if user has already submitted a response for this survey
|
||||
const existingResponseQuery = `
|
||||
SELECT id FROM survey_responses
|
||||
WHERE user_id = $1 AND survey_id = $2;
|
||||
`;
|
||||
const existingResult = await db.query(existingResponseQuery, [userId, body.survey_id]);
|
||||
|
||||
let responseId: string;
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
// Update existing response
|
||||
responseId = existingResult.rows[0].id;
|
||||
|
||||
const updateResponseQuery = `
|
||||
UPDATE survey_responses
|
||||
SET is_completed = true, completed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
`;
|
||||
await db.query(updateResponseQuery, [responseId]);
|
||||
|
||||
// Delete existing answers
|
||||
const deleteAnswersQuery = `DELETE FROM survey_answers WHERE response_id = $1;`;
|
||||
await db.query(deleteAnswersQuery, [responseId]);
|
||||
} else {
|
||||
// Create new response
|
||||
const createResponseQuery = `
|
||||
INSERT INTO survey_responses (survey_id, user_id, is_completed, completed_at)
|
||||
VALUES ($1, $2, true, NOW())
|
||||
RETURNING id;
|
||||
`;
|
||||
const responseResult = await db.query(createResponseQuery, [body.survey_id, userId]);
|
||||
responseId = responseResult.rows[0].id;
|
||||
}
|
||||
|
||||
// Insert new answers
|
||||
if (body.answers.length > 0) {
|
||||
const answerValues: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
body.answers.forEach((answer, index) => {
|
||||
const baseIndex = index * 4;
|
||||
answerValues.push(`($${baseIndex + 1}, $${baseIndex + 2}, $${baseIndex + 3}, $${baseIndex + 4})`);
|
||||
|
||||
params.push(
|
||||
responseId,
|
||||
answer.question_id,
|
||||
answer.answer_text || null,
|
||||
answer.answer_json ? JSON.stringify(answer.answer_json) : null
|
||||
);
|
||||
});
|
||||
|
||||
const insertAnswersQuery = `
|
||||
INSERT INTO survey_answers (response_id, question_id, answer_text, answer_json)
|
||||
VALUES ${answerValues.join(', ')};
|
||||
`;
|
||||
|
||||
await db.query(insertAnswersQuery, params);
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, { response_id: responseId }));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getUserSurveyResponse(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
const surveyId = req.params.survey_id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT
|
||||
sr.id,
|
||||
sr.survey_id,
|
||||
sr.user_id,
|
||||
sr.is_completed,
|
||||
sr.started_at,
|
||||
sr.completed_at,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'question_id', sa.question_id,
|
||||
'answer_text', sa.answer_text,
|
||||
'answer_json', sa.answer_json
|
||||
)
|
||||
) FILTER (WHERE sa.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS answers
|
||||
FROM survey_responses sr
|
||||
LEFT JOIN survey_answers sa ON sr.id = sa.response_id
|
||||
WHERE sr.user_id = $1 AND sr.survey_id = $2
|
||||
GROUP BY sr.id, sr.survey_id, sr.user_id, sr.is_completed, sr.started_at, sr.completed_at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [userId, surveyId]);
|
||||
const [response] = result.rows;
|
||||
|
||||
if (!response) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Survey response not found"));
|
||||
}
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, response));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async checkAccountSetupSurveyStatus(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "User not authenticated"));
|
||||
}
|
||||
|
||||
const q = `
|
||||
SELECT EXISTS(
|
||||
SELECT 1
|
||||
FROM survey_responses sr
|
||||
INNER JOIN surveys s ON sr.survey_id = s.id
|
||||
WHERE sr.user_id = $1
|
||||
AND s.survey_type = 'account_setup'
|
||||
AND sr.is_completed = true
|
||||
) as is_completed,
|
||||
(
|
||||
SELECT sr.completed_at
|
||||
FROM survey_responses sr
|
||||
INNER JOIN surveys s ON sr.survey_id = s.id
|
||||
WHERE sr.user_id = $1
|
||||
AND s.survey_type = 'account_setup'
|
||||
AND sr.is_completed = true
|
||||
LIMIT 1
|
||||
) as completed_at;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [userId]);
|
||||
const status = result.rows[0] || { is_completed: false, completed_at: null };
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, status));
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,23 @@ export default class TaskPhasesController extends WorklenzControllerBase {
|
||||
if (!req.query.id)
|
||||
return res.status(400).send(new ServerResponse(false, null, "Invalid request"));
|
||||
|
||||
// Use custom name if provided, otherwise use default naming pattern
|
||||
const phaseName = req.body.name?.trim() ||
|
||||
`Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`;
|
||||
|
||||
const q = `
|
||||
INSERT INTO project_phases (name, color_code, project_id, sort_index)
|
||||
VALUES (
|
||||
CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'),
|
||||
$1,
|
||||
$2,
|
||||
(SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1)
|
||||
$3,
|
||||
(SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1)
|
||||
RETURNING id, name, color_code, sort_index;
|
||||
`;
|
||||
|
||||
req.body.color_code = this.DEFAULT_PHASE_COLOR;
|
||||
|
||||
const result = await db.query(q, [req.body.color_code, req.query.id]);
|
||||
const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]);
|
||||
const [data] = result.rows;
|
||||
|
||||
data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA;
|
||||
|
||||
@@ -134,6 +134,25 @@ export default class TaskStatusesController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string);
|
||||
|
||||
if (!hasMoreCategories)
|
||||
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!"));
|
||||
|
||||
const q = `
|
||||
UPDATE task_statuses
|
||||
SET category_id = $2
|
||||
WHERE id = $1
|
||||
AND project_id = $3
|
||||
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
|
||||
`;
|
||||
const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]);
|
||||
const [data] = result.rows;
|
||||
return res.status(200).send(new ServerResponse(true, data));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT update_status_order($1);`;
|
||||
|
||||
@@ -28,50 +28,32 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
if (!id) return [];
|
||||
|
||||
const q = `
|
||||
WITH RECURSIVE task_hierarchy AS (
|
||||
-- Base case: Start with the given task
|
||||
SELECT id, name, 0 as level
|
||||
FROM tasks
|
||||
WHERE id = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recursive case: Get all subtasks
|
||||
SELECT t.id, t.name, th.level + 1
|
||||
FROM tasks t
|
||||
INNER JOIN task_hierarchy th ON t.parent_task_id = th.id
|
||||
WHERE t.archived IS FALSE
|
||||
),
|
||||
time_logs AS (
|
||||
SELECT
|
||||
twl.id,
|
||||
twl.description,
|
||||
twl.time_spent,
|
||||
twl.created_at,
|
||||
twl.user_id,
|
||||
twl.logged_by_timer,
|
||||
twl.task_id,
|
||||
th.name AS task_name,
|
||||
(SELECT name FROM users WHERE users.id = twl.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = twl.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = twl.user_id) AS avatar_url
|
||||
FROM task_work_log twl
|
||||
INNER JOIN task_hierarchy th ON twl.task_id = th.id
|
||||
WITH time_logs AS (
|
||||
--
|
||||
SELECT id,
|
||||
description,
|
||||
time_spent,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
(SELECT name FROM users WHERE users.id = task_work_log.user_id) AS user_name,
|
||||
(SELECT email FROM users WHERE users.id = task_work_log.user_id) AS user_email,
|
||||
(SELECT avatar_url FROM users WHERE users.id = task_work_log.user_id) AS avatar_url
|
||||
FROM task_work_log
|
||||
WHERE task_id = $1
|
||||
--
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
task_id,
|
||||
task_name,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
SELECT id,
|
||||
time_spent,
|
||||
description,
|
||||
created_at,
|
||||
user_id,
|
||||
logged_by_timer,
|
||||
created_at AS start_time,
|
||||
(created_at + INTERVAL '1 second' * time_spent) AS end_time,
|
||||
user_name,
|
||||
user_email,
|
||||
avatar_url
|
||||
FROM time_logs
|
||||
ORDER BY created_at DESC;
|
||||
`;
|
||||
@@ -161,7 +143,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.columns = [
|
||||
{header: "Task Name", key: "task_name", width: 30},
|
||||
{header: "Reporter Name", key: "user_name", width: 25},
|
||||
{header: "Reporter Email", key: "user_email", width: 25},
|
||||
{header: "Start Time", key: "start_time", width: 25},
|
||||
@@ -172,15 +153,14 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
];
|
||||
|
||||
sheet.getCell("A1").value = metadata.project_name;
|
||||
sheet.mergeCells("A1:H1");
|
||||
sheet.mergeCells("A1:G1");
|
||||
sheet.getCell("A1").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getCell("A2").value = `${metadata.name} (${exportDate})`;
|
||||
sheet.mergeCells("A2:H2");
|
||||
sheet.mergeCells("A2:G2");
|
||||
sheet.getCell("A2").alignment = {horizontal: "center"};
|
||||
|
||||
sheet.getRow(4).values = [
|
||||
"Task Name",
|
||||
"Reporter Name",
|
||||
"Reporter Email",
|
||||
"Start Time",
|
||||
@@ -196,7 +176,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
for (const item of results) {
|
||||
totalLogged += parseFloat((item.time_spent || 0).toString());
|
||||
const data = {
|
||||
task_name: item.task_name,
|
||||
user_name: item.user_name,
|
||||
user_email: item.user_email,
|
||||
start_time: moment(item.start_time).add(timezone.hours || 0, "hours").add(timezone.minutes || 0, "minutes").format(timeFormat),
|
||||
@@ -231,7 +210,6 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
};
|
||||
|
||||
sheet.addRow({
|
||||
task_name: "",
|
||||
user_name: "",
|
||||
user_email: "",
|
||||
start_time: "Total",
|
||||
@@ -241,7 +219,7 @@ export default class TaskWorklogController extends WorklenzControllerBase {
|
||||
time_spent: formatDuration(moment.duration(totalLogged, "seconds")),
|
||||
});
|
||||
|
||||
sheet.mergeCells(`A${sheet.rowCount}:G${sheet.rowCount}`);
|
||||
sheet.mergeCells(`A${sheet.rowCount}:F${sheet.rowCount}`);
|
||||
|
||||
sheet.getCell(`A${sheet.rowCount}`).value = "Total";
|
||||
sheet.getCell(`A${sheet.rowCount}`).alignment = {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ITaskGroup {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
color_code: string;
|
||||
color_code_dark: string;
|
||||
category_id: string | null;
|
||||
old_category_id?: string;
|
||||
todo_progress?: number;
|
||||
@@ -81,31 +82,7 @@ export default class TasksControllerBase extends WorklenzControllerBase {
|
||||
task.is_sub_task = !!task.parent_task_id;
|
||||
|
||||
task.time_spent_string = `${task.time_spent.hours}h ${(task.time_spent.minutes)}m`;
|
||||
|
||||
// Use recursive estimation for parent tasks, own estimation for leaf tasks
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let displayMinutes;
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use recursive estimation (sum of all subtasks)
|
||||
displayMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
} else {
|
||||
// For leaf tasks, use their own estimation
|
||||
displayMinutes = task.total_minutes || 0;
|
||||
}
|
||||
|
||||
// Format time string - show "0h" for zero time instead of "0h 0m"
|
||||
const hours = ~~(displayMinutes / 60);
|
||||
const minutes = displayMinutes % 60;
|
||||
|
||||
if (displayMinutes === 0) {
|
||||
task.total_time_string = "0h";
|
||||
} else if (minutes === 0) {
|
||||
task.total_time_string = `${hours}h`;
|
||||
} else {
|
||||
task.total_time_string = `${hours}h ${minutes}m`;
|
||||
}
|
||||
task.total_time_string = `${~~(task.total_minutes / 60)}h ${(task.total_minutes % 60)}m`;
|
||||
|
||||
task.name_color = getColor(task.name);
|
||||
task.priority_color = PriorityColorCodes[task.priority_value] || PriorityColorCodes["0"];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -427,24 +427,9 @@ export default class TasksController extends TasksControllerBase {
|
||||
|
||||
task.names = WorklenzControllerBase.createTagList(task.assignees);
|
||||
|
||||
// Use recursive estimation if task has subtasks, otherwise use own estimation
|
||||
const recursiveEstimation = task.recursive_estimation || {};
|
||||
// Check both the recursive estimation count and the actual database count
|
||||
const hasSubtasks = (task.sub_tasks_count || 0) > 0;
|
||||
|
||||
let totalMinutes, hours, minutes;
|
||||
|
||||
if (hasSubtasks) {
|
||||
// For parent tasks, use the sum of all subtasks' estimation (excluding parent's own estimation)
|
||||
totalMinutes = recursiveEstimation.recursive_total_minutes || 0;
|
||||
hours = recursiveEstimation.recursive_total_hours || 0;
|
||||
minutes = recursiveEstimation.recursive_remaining_minutes || 0;
|
||||
} else {
|
||||
// For tasks without subtasks, use their own estimation
|
||||
totalMinutes = task.total_minutes || 0;
|
||||
hours = Math.floor(totalMinutes / 60);
|
||||
minutes = totalMinutes % 60;
|
||||
}
|
||||
const totalMinutes = task.total_minutes;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
task.total_hours = hours;
|
||||
task.total_minutes = minutes;
|
||||
@@ -623,18 +608,6 @@ export default class TasksController extends TasksControllerBase {
|
||||
return res.status(200).send(new ServerResponse(true, null));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async resetParentTaskEstimations(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const q = `SELECT reset_all_parent_task_estimations() AS updated_count;`;
|
||||
const result = await db.query(q);
|
||||
const [data] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, {
|
||||
message: `Reset estimation for ${data.updated_count} parent tasks`,
|
||||
updated_count: data.updated_count
|
||||
}));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async bulkAssignMembers(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
const { tasks, members, project_id } = req.body;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SocketEvents } from "../socket.io/events";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { formatDuration, getColor } from "../shared/utils";
|
||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA } from "../shared/constants";
|
||||
import { statusExclude, TEAM_MEMBER_TREE_MAP_COLOR_ALPHA, TRIAL_MEMBER_LIMIT } from "../shared/constants";
|
||||
import { checkTeamSubscriptionStatus } from "../shared/paddle-utils";
|
||||
import { updateUsers } from "../shared/paddle-requests";
|
||||
import { NotificationsService } from "../services/notifications/notifications.service";
|
||||
@@ -141,6 +141,17 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Cannot exceed the maximum number of life time users."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
|
||||
if (currentTrialMembers + incrementBy > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks subscription details and updates the user count if applicable.
|
||||
* Sends a response if there is an issue with the subscription.
|
||||
@@ -1081,6 +1092,18 @@ export default class TeamMembersController extends WorklenzControllerBase {
|
||||
return res.status(200).send(new ServerResponse(false, "Please check your subscription status."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks trial user team member limit
|
||||
*/
|
||||
if (subscriptionData.subscription_status === "trialing") {
|
||||
const currentTrialMembers = parseInt(subscriptionData.current_count) || 0;
|
||||
const emailsToAdd = req.body.emails?.length || 1;
|
||||
|
||||
if (currentTrialMembers + emailsToAdd > TRIAL_MEMBER_LIMIT) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Trial users cannot exceed ${TRIAL_MEMBER_LIMIT} team members. Please upgrade to add more members.`));
|
||||
}
|
||||
}
|
||||
|
||||
// if (subscriptionData.status === "trialing") break;
|
||||
if (!subscriptionData.is_credit && !subscriptionData.is_custom) {
|
||||
if (subscriptionData.subscription_status === "active") {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import moment from "moment";
|
||||
import { IWorkLenzRequest } from "../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../interfaces/worklenz-response";
|
||||
|
||||
import db from "../config/db";
|
||||
|
||||
import { ServerResponse } from "../models/server-response";
|
||||
import WorklenzControllerBase from "./worklenz-controller-base";
|
||||
import HandleExceptions from "../decorators/handle-exceptions";
|
||||
import { formatDuration, formatLogText, getColor } from "../shared/utils";
|
||||
|
||||
interface IUserRecentTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
last_activity_at: string;
|
||||
activity_count: number;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
}
|
||||
|
||||
interface IUserTimeLoggedTask {
|
||||
task_id: string;
|
||||
task_name: string;
|
||||
project_id: string;
|
||||
project_name: string;
|
||||
total_time_logged: number;
|
||||
total_time_logged_string: string;
|
||||
last_logged_at: string;
|
||||
logged_by_timer: boolean;
|
||||
project_color?: string;
|
||||
task_status?: string;
|
||||
status_color?: string;
|
||||
log_entries_count?: number;
|
||||
estimated_time?: number;
|
||||
}
|
||||
|
||||
export default class UserActivityLogsController extends WorklenzControllerBase {
|
||||
@HandleExceptions()
|
||||
public static async getRecentTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance and team filtering
|
||||
const q = `
|
||||
SELECT DISTINCT tal.task_id, t.name AS task_name, tal.project_id, p.name AS project_name,
|
||||
MAX(tal.created_at) AS last_activity_at,
|
||||
COUNT(DISTINCT tal.id) AS activity_count,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color
|
||||
FROM task_activity_logs tal
|
||||
INNER JOIN tasks t ON tal.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON tal.project_id = p.id AND p.team_id = $1
|
||||
WHERE tal.user_id = $2
|
||||
AND tal.created_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY tal.task_id, t.name, tal.project_id, p.name, p.color_code, t.status_id
|
||||
ORDER BY MAX(tal.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserRecentTask[] = result.rows;
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
|
||||
@HandleExceptions()
|
||||
public static async getTimeLoggedTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||
if (!req.user) {
|
||||
return res.status(401).send(new ServerResponse(false, null, "Unauthorized"));
|
||||
}
|
||||
|
||||
const { id: userId, team_id: teamId } = req.user;
|
||||
const { offset = 0, limit = 10 } = req.query;
|
||||
|
||||
// Optimized query with better performance, team filtering, and useful additional data
|
||||
const q = `
|
||||
SELECT twl.task_id, t.name AS task_name, t.project_id, p.name AS project_name,
|
||||
SUM(twl.time_spent) AS total_time_logged,
|
||||
MAX(twl.created_at) AS last_logged_at,
|
||||
MAX(twl.logged_by_timer::int)::boolean AS logged_by_timer,
|
||||
p.color_code AS project_color,
|
||||
(SELECT name FROM task_statuses WHERE id = t.status_id) AS task_status,
|
||||
(SELECT color_code
|
||||
FROM sys_task_status_categories
|
||||
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||
COUNT(DISTINCT twl.id) AS log_entries_count,
|
||||
(t.total_minutes * 60) AS estimated_time
|
||||
FROM task_work_log twl
|
||||
INNER JOIN tasks t ON twl.task_id = t.id AND t.archived = FALSE
|
||||
INNER JOIN projects p ON t.project_id = p.id AND p.team_id = $1
|
||||
WHERE twl.user_id = $2
|
||||
AND twl.created_at >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY twl.task_id, t.name, t.project_id, p.name, p.color_code, t.status_id, t.total_minutes
|
||||
HAVING SUM(twl.time_spent) > 0
|
||||
ORDER BY MAX(twl.created_at) DESC
|
||||
LIMIT $3 OFFSET $4;
|
||||
`;
|
||||
|
||||
const result = await db.query(q, [teamId, userId, limit, offset]);
|
||||
const tasks: IUserTimeLoggedTask[] = result.rows.map(task => ({
|
||||
...task,
|
||||
total_time_logged_string: formatDuration(moment.duration(task.total_time_logged, "seconds")),
|
||||
}));
|
||||
|
||||
return res.status(200).send(new ServerResponse(true, tasks));
|
||||
}
|
||||
}
|
||||
@@ -34,29 +34,24 @@ export default abstract class WorklenzControllerBase {
|
||||
const offset = queryParams.search ? 0 : (index - 1) * size;
|
||||
const paging = queryParams.paging || "true";
|
||||
|
||||
// let s = "";
|
||||
// if (typeof searchField === "string") {
|
||||
// s = `${searchField} || ' ' || id::TEXT`;
|
||||
// } else if (Array.isArray(searchField)) {
|
||||
// s = searchField.join(" || ' ' || ");
|
||||
// }
|
||||
|
||||
// const search = (queryParams.search as string || "").trim();
|
||||
// const searchQuery = search ? `AND TO_TSVECTOR(${s}) @@ TO_TSQUERY('${toTsQuery(search)}')` : "";
|
||||
|
||||
const search = (queryParams.search as string || "").trim();
|
||||
|
||||
let s = "";
|
||||
if (typeof searchField === "string") {
|
||||
s = ` ${searchField} ILIKE '%${search}%'`;
|
||||
} else if (Array.isArray(searchField)) {
|
||||
s = searchField.map(index => ` ${index} ILIKE '%${search}%'`).join(" OR ");
|
||||
}
|
||||
|
||||
let searchQuery = "";
|
||||
|
||||
if (search) {
|
||||
searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `;
|
||||
// Properly escape single quotes to prevent SQL syntax errors
|
||||
const escapedSearch = search.replace(/'/g, "''");
|
||||
|
||||
let s = "";
|
||||
if (typeof searchField === "string") {
|
||||
s = ` ${searchField} ILIKE '%${escapedSearch}%'`;
|
||||
} else if (Array.isArray(searchField)) {
|
||||
s = searchField.map(field => ` ${field} ILIKE '%${escapedSearch}%'`).join(" OR ");
|
||||
}
|
||||
|
||||
if (s) {
|
||||
searchQuery = isMemberFilter ? ` (${s}) AND ` : ` AND (${s}) `;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
|
||||
219
worklenz-backend/src/data/sri-lankan-holidays.json
Normal file
219
worklenz-backend/src/data/sri-lankan-holidays.json
Normal file
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"_metadata": {
|
||||
"description": "Sri Lankan Public Holidays Data",
|
||||
"last_updated": "2025-01-31",
|
||||
"sources": {
|
||||
"2025": "Based on official government sources and existing verified data",
|
||||
"note": "All dates should be verified against official sources before use"
|
||||
},
|
||||
"official_sources": [
|
||||
"Central Bank of Sri Lanka - Holiday Circulars",
|
||||
"Department of Meteorology - Astrological calculations",
|
||||
"Ministry of Public Administration - Official gazette",
|
||||
"Buddhist and Pali University - Poya day calculations",
|
||||
"All Ceylon Jamiyyatul Ulama - Islamic calendar",
|
||||
"Hindu Cultural Centre - Hindu calendar"
|
||||
],
|
||||
"verification_process": "Each year should be verified against current official publications before adding to production systems"
|
||||
},
|
||||
"2025": [
|
||||
{
|
||||
"name": "Duruthu Full Moon Poya Day",
|
||||
"date": "2025-01-13",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the first visit of Buddha to Sri Lanka",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Navam Full Moon Poya Day",
|
||||
"date": "2025-02-12",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the appointment of Sariputta and Moggallana as Buddha's chief disciples",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Independence Day",
|
||||
"date": "2025-02-04",
|
||||
"type": "Public",
|
||||
"description": "Commemorates the independence of Sri Lanka from British rule in 1948",
|
||||
"is_recurring": true
|
||||
},
|
||||
{
|
||||
"name": "Medin Full Moon Poya Day",
|
||||
"date": "2025-03-14",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates Buddha's first visit to his father's palace after enlightenment",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Eid al-Fitr",
|
||||
"date": "2025-03-31",
|
||||
"type": "Public",
|
||||
"description": "Festival marking the end of Ramadan",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Bak Full Moon Poya Day",
|
||||
"date": "2025-04-12",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates Buddha's second visit to Sri Lanka",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Sinhala and Tamil New Year Day",
|
||||
"date": "2025-04-13",
|
||||
"type": "Public",
|
||||
"description": "Traditional New Year celebrated by Sinhalese and Tamil communities",
|
||||
"is_recurring": true
|
||||
},
|
||||
{
|
||||
"name": "Day after Sinhala and Tamil New Year",
|
||||
"date": "2025-04-14",
|
||||
"type": "Public",
|
||||
"description": "Second day of traditional New Year celebrations",
|
||||
"is_recurring": true
|
||||
},
|
||||
{
|
||||
"name": "Good Friday",
|
||||
"date": "2025-04-18",
|
||||
"type": "Public",
|
||||
"description": "Christian commemoration of the crucifixion of Jesus Christ",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "May Day",
|
||||
"date": "2025-05-01",
|
||||
"type": "Public",
|
||||
"description": "International Workers' Day",
|
||||
"is_recurring": true
|
||||
},
|
||||
{
|
||||
"name": "Vesak Full Moon Poya Day",
|
||||
"date": "2025-05-12",
|
||||
"type": "Poya",
|
||||
"description": "Most sacred day for Buddhists - commemorates birth, enlightenment and passing of Buddha",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Day after Vesak Full Moon Poya Day",
|
||||
"date": "2025-05-13",
|
||||
"type": "Public",
|
||||
"description": "Additional day for Vesak celebrations",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Eid al-Adha",
|
||||
"date": "2025-06-07",
|
||||
"type": "Public",
|
||||
"description": "Islamic festival of sacrifice",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Poson Full Moon Poya Day",
|
||||
"date": "2025-06-11",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the introduction of Buddhism to Sri Lanka by Arahat Mahinda",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Esala Full Moon Poya Day",
|
||||
"date": "2025-07-10",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates Buddha's first sermon and the arrival of the Sacred Tooth Relic",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Nikini Full Moon Poya Day",
|
||||
"date": "2025-08-09",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the first Buddhist council",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Binara Full Moon Poya Day",
|
||||
"date": "2025-09-07",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates Buddha's visit to heaven to preach to his mother",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Vap Full Moon Poya Day",
|
||||
"date": "2025-10-07",
|
||||
"type": "Poya",
|
||||
"description": "Marks the end of Buddhist Lent and Buddha's return from heaven",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Deepavali",
|
||||
"date": "2025-10-20",
|
||||
"type": "Public",
|
||||
"description": "Hindu Festival of Lights",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Il Full Moon Poya Day",
|
||||
"date": "2025-11-05",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates Buddha's ordination of sixty disciples",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Unduvap Full Moon Poya Day",
|
||||
"date": "2025-12-04",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the arrival of Sanghamitta Theri with the Sacred Bo sapling",
|
||||
"is_recurring": false
|
||||
},
|
||||
{
|
||||
"name": "Christmas Day",
|
||||
"date": "2025-12-25",
|
||||
"type": "Public",
|
||||
"description": "Christian celebration of the birth of Jesus Christ",
|
||||
"is_recurring": true
|
||||
}
|
||||
],
|
||||
"fixed_holidays": [
|
||||
{
|
||||
"name": "Independence Day",
|
||||
"month": 2,
|
||||
"day": 4,
|
||||
"type": "Public",
|
||||
"description": "Commemorates the independence of Sri Lanka from British rule in 1948"
|
||||
},
|
||||
{
|
||||
"name": "May Day",
|
||||
"month": 5,
|
||||
"day": 1,
|
||||
"type": "Public",
|
||||
"description": "International Workers' Day"
|
||||
},
|
||||
{
|
||||
"name": "Christmas Day",
|
||||
"month": 12,
|
||||
"day": 25,
|
||||
"type": "Public",
|
||||
"description": "Christian celebration of the birth of Jesus Christ"
|
||||
}
|
||||
],
|
||||
"variable_holidays_info": {
|
||||
"sinhala_tamil_new_year": {
|
||||
"description": "Sinhala and Tamil New Year dates vary based on astrological calculations. Common patterns:",
|
||||
"common_dates": [
|
||||
{ "pattern": "April 12-13", "years": "Some years" },
|
||||
{ "pattern": "April 13-14", "years": "Most common" },
|
||||
{ "pattern": "April 14-15", "years": "Occasional" }
|
||||
],
|
||||
"note": "These dates should be verified annually from official sources like the Department of Meteorology or astrological authorities"
|
||||
},
|
||||
"poya_days": {
|
||||
"description": "Full moon Poya days follow the lunar calendar and change each year",
|
||||
"note": "Dates should be obtained from Buddhist calendar or astronomical calculations"
|
||||
},
|
||||
"religious_holidays": {
|
||||
"eid_fitr": "Based on Islamic lunar calendar - varies each year",
|
||||
"eid_adha": "Based on Islamic lunar calendar - varies each year",
|
||||
"good_friday": "Based on Easter calculation - varies each year",
|
||||
"deepavali": "Based on Hindu lunar calendar - varies each year"
|
||||
}
|
||||
}
|
||||
}
|
||||
170
worklenz-backend/src/docs/sri-lankan-holiday-update-process.md
Normal file
170
worklenz-backend/src/docs/sri-lankan-holiday-update-process.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Sri Lankan Holiday Annual Update Process
|
||||
|
||||
## Overview
|
||||
This document outlines the process for annually updating Sri Lankan holiday data to ensure accurate utilization calculations.
|
||||
|
||||
## Data Sources & Verification
|
||||
|
||||
### Official Government Sources
|
||||
1. **Central Bank of Sri Lanka**
|
||||
- Holiday circulars (usually published in December for the next year)
|
||||
- Website: [cbsl.gov.lk](https://www.cbsl.gov.lk)
|
||||
|
||||
2. **Department of Meteorology**
|
||||
- Astrological calculations for Sinhala & Tamil New Year
|
||||
- Website: [meteo.gov.lk](http://www.meteo.gov.lk)
|
||||
|
||||
3. **Ministry of Public Administration**
|
||||
- Official gazette notifications
|
||||
- Public holiday declarations
|
||||
|
||||
### Religious Authorities
|
||||
1. **Buddhist Calendar**
|
||||
- Buddhist and Pali University of Sri Lanka
|
||||
- Major temples (Malwatte, Asgiriya)
|
||||
|
||||
2. **Islamic Calendar**
|
||||
- All Ceylon Jamiyyatul Ulama (ACJU)
|
||||
- Colombo Grand Mosque
|
||||
|
||||
3. **Hindu Calendar**
|
||||
- Hindu Cultural Centre
|
||||
- Tamil cultural organizations
|
||||
|
||||
## Annual Update Workflow
|
||||
|
||||
### 1. Preparation (October - November)
|
||||
```bash
|
||||
# Check current data status
|
||||
node update-sri-lankan-holidays.js --list
|
||||
node update-sri-lankan-holidays.js --validate
|
||||
```
|
||||
|
||||
### 2. Research Phase (November - December)
|
||||
For the upcoming year (e.g., 2026):
|
||||
|
||||
1. **Fixed Holidays** ✅ Already handled
|
||||
- Independence Day (Feb 4)
|
||||
- May Day (May 1)
|
||||
- Christmas Day (Dec 25)
|
||||
|
||||
2. **Variable Holidays** ⚠️ Require verification
|
||||
- **Sinhala & Tamil New Year**: Check Department of Meteorology
|
||||
- **Poya Days**: Check Buddhist calendar/temples
|
||||
- **Good Friday**: Calculate from Easter
|
||||
- **Eid al-Fitr & Eid al-Adha**: Check Islamic calendar
|
||||
- **Deepavali**: Check Hindu calendar
|
||||
|
||||
### 3. Data Collection Template
|
||||
```bash
|
||||
# Generate template for the new year
|
||||
node update-sri-lankan-holidays.js --poya-template 2026
|
||||
```
|
||||
|
||||
This will output a template like:
|
||||
```json
|
||||
{
|
||||
"name": "Duruthu Full Moon Poya Day",
|
||||
"date": "2026-??-??",
|
||||
"type": "Poya",
|
||||
"description": "Commemorates the first visit of Buddha to Sri Lanka",
|
||||
"is_recurring": false
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Research Checklist
|
||||
|
||||
#### Sinhala & Tamil New Year
|
||||
- [ ] Check Department of Meteorology announcements
|
||||
- [ ] Verify with astrological authorities
|
||||
- [ ] Confirm if dates are April 12-13, 13-14, or 14-15
|
||||
|
||||
#### Poya Days (12 per year)
|
||||
- [ ] Get Buddhist calendar for the year
|
||||
- [ ] Verify with temples or Buddhist authorities
|
||||
- [ ] Double-check lunar calendar calculations
|
||||
|
||||
#### Religious Holidays
|
||||
- [ ] **Good Friday**: Calculate based on Easter
|
||||
- [ ] **Eid al-Fitr**: Check Islamic calendar/ACJU
|
||||
- [ ] **Eid al-Adha**: Check Islamic calendar/ACJU
|
||||
- [ ] **Deepavali**: Check Hindu calendar/cultural centers
|
||||
|
||||
### 5. Data Entry
|
||||
1. Edit `src/data/sri-lankan-holidays.json`
|
||||
2. Add new year section with verified dates
|
||||
3. Update metadata with sources used
|
||||
|
||||
### 6. Validation & Testing
|
||||
```bash
|
||||
# Validate the new data
|
||||
node update-sri-lankan-holidays.js --validate
|
||||
|
||||
# Generate SQL for database
|
||||
node update-sri-lankan-holidays.js --generate-sql 2026
|
||||
```
|
||||
|
||||
### 7. Database Update
|
||||
1. Create new migration file with the generated SQL
|
||||
2. Test in development environment
|
||||
3. Deploy to production
|
||||
|
||||
### 8. Documentation
|
||||
- Update metadata in JSON file
|
||||
- Document sources used
|
||||
- Note any special circumstances or date changes
|
||||
|
||||
## Emergency Updates
|
||||
|
||||
If holidays are announced late or changed:
|
||||
|
||||
1. **Quick JSON Update**:
|
||||
```bash
|
||||
# Edit the JSON file directly
|
||||
# Add the new/changed holiday
|
||||
```
|
||||
|
||||
2. **Database Hotfix**:
|
||||
```sql
|
||||
INSERT INTO country_holidays (country_code, name, description, date, is_recurring)
|
||||
VALUES ('LK', 'Emergency Holiday', 'Description', 'YYYY-MM-DD', false)
|
||||
ON CONFLICT (country_code, name, date) DO NOTHING;
|
||||
```
|
||||
|
||||
3. **Notify Users**: Consider adding a notification system for holiday changes
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
### Pre-Release Checklist
|
||||
- [ ] All 12 Poya days included for the year
|
||||
- [ ] Sinhala & Tamil New Year dates verified
|
||||
- [ ] Religious holidays cross-checked with multiple sources
|
||||
- [ ] No duplicate dates
|
||||
- [ ] JSON format validation passes
|
||||
- [ ] Database migration tested
|
||||
|
||||
### Post-Release Monitoring
|
||||
- [ ] Monitor utilization calculations for anomalies
|
||||
- [ ] Check user feedback for missed holidays
|
||||
- [ ] Verify against actual government announcements
|
||||
|
||||
## Automation Opportunities
|
||||
|
||||
Future improvements could include:
|
||||
1. **API Integration**: Connect to reliable holiday APIs
|
||||
2. **Web Scraping**: Automated monitoring of official websites
|
||||
3. **Notification System**: Alert when new holidays are announced
|
||||
4. **Validation Service**: Cross-check against multiple sources
|
||||
|
||||
## Contact Information
|
||||
|
||||
For questions about the holiday update process:
|
||||
- Technical issues: Development team
|
||||
- Holiday verification: Sri Lankan team members
|
||||
- Religious holidays: Local community contacts
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0** (2025-01-31): Initial process documentation
|
||||
- **2025 Data**: Verified and included
|
||||
- **2026+ Data**: Pending official source verification
|
||||
54
worklenz-backend/src/interfaces/holiday.interface.ts
Normal file
54
worklenz-backend/src/interfaces/holiday.interface.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export interface IHolidayType {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color_code: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IOrganizationHoliday {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
holiday_type_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
is_recurring: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
holiday_type?: IHolidayType;
|
||||
}
|
||||
|
||||
export interface ICountryHoliday {
|
||||
id: string;
|
||||
country_code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
is_recurring: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ICreateHolidayRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
holiday_type_id: string;
|
||||
is_recurring?: boolean;
|
||||
}
|
||||
|
||||
export interface IUpdateHolidayRequest {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
holiday_type_id?: string;
|
||||
is_recurring?: boolean;
|
||||
}
|
||||
|
||||
export interface IImportCountryHolidaysRequest {
|
||||
country_code: string;
|
||||
year?: number;
|
||||
}
|
||||
37
worklenz-backend/src/interfaces/survey.ts
Normal file
37
worklenz-backend/src/interfaces/survey.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface ISurveyQuestion {
|
||||
id: string;
|
||||
survey_id: string;
|
||||
question_key: string;
|
||||
question_type: 'single_choice' | 'multiple_choice' | 'text';
|
||||
is_required: boolean;
|
||||
sort_order: number;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface ISurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
survey_type: 'account_setup' | 'onboarding' | 'feedback';
|
||||
is_active: boolean;
|
||||
questions?: ISurveyQuestion[];
|
||||
}
|
||||
|
||||
export interface ISurveyAnswer {
|
||||
question_id: string;
|
||||
answer_text?: string;
|
||||
answer_json?: string[];
|
||||
}
|
||||
|
||||
export interface ISurveyResponse {
|
||||
id?: string;
|
||||
survey_id: string;
|
||||
user_id?: string;
|
||||
is_completed: boolean;
|
||||
answers: ISurveyAnswer[];
|
||||
}
|
||||
|
||||
export interface ISurveySubmissionRequest {
|
||||
survey_id: string;
|
||||
answers: ISurveyAnswer[];
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { isProduction } from "../shared/utils";
|
||||
const pgSession = require("connect-pg-simple")(session);
|
||||
|
||||
export default session({
|
||||
name: process.env.SESSION_NAME || "worklenz.sid",
|
||||
name: process.env.SESSION_NAME,
|
||||
secret: process.env.SESSION_SECRET || "development-secret-key",
|
||||
proxy: false,
|
||||
resave: true,
|
||||
saveUninitialized: false,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
rolling: true,
|
||||
store: new pgSession({
|
||||
pool: db.pool,
|
||||
@@ -18,8 +18,10 @@ export default session({
|
||||
}),
|
||||
cookie: {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
// secure: isProduction(),
|
||||
// httpOnly: isProduction(),
|
||||
// sameSite: "none",
|
||||
// domain: isProduction() ? ".worklenz.com" : undefined,
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
}
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import {NextFunction} from "express";
|
||||
|
||||
import {IWorkLenzRequest} from "../../interfaces/worklenz-request";
|
||||
import {IWorkLenzResponse} from "../../interfaces/worklenz-response";
|
||||
import {ServerResponse} from "../../models/server-response";
|
||||
|
||||
export default function (req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||
const {name} = req.body;
|
||||
if (!name || name.trim() === "")
|
||||
return res.status(200).send(new ServerResponse(false, null, "Name is required"));
|
||||
|
||||
req.body.name = req.body.name.trim();
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NextFunction } from "express";
|
||||
import { IWorkLenzRequest } from "../../interfaces/worklenz-request";
|
||||
import { IWorkLenzResponse } from "../../interfaces/worklenz-response";
|
||||
import { ServerResponse } from "../../models/server-response";
|
||||
import { ISurveySubmissionRequest } from "../../interfaces/survey";
|
||||
|
||||
export default function surveySubmissionValidator(req: IWorkLenzRequest, res: IWorkLenzResponse, next: NextFunction): IWorkLenzResponse | void {
|
||||
const body = req.body as ISurveySubmissionRequest;
|
||||
|
||||
if (!body) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Request body is required"));
|
||||
}
|
||||
|
||||
if (!body.survey_id || typeof body.survey_id !== 'string') {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Survey ID is required and must be a string"));
|
||||
}
|
||||
|
||||
if (!body.answers || !Array.isArray(body.answers)) {
|
||||
return res.status(200).send(new ServerResponse(false, null, "Answers are required and must be an array"));
|
||||
}
|
||||
|
||||
// Validate each answer
|
||||
for (let i = 0; i < body.answers.length; i++) {
|
||||
const answer = body.answers[i];
|
||||
|
||||
if (!answer.question_id || typeof answer.question_id !== 'string') {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: Question ID is required and must be a string`));
|
||||
}
|
||||
|
||||
// answer_text and answer_json are both optional - users can submit empty answers
|
||||
|
||||
// Validate answer_text if provided
|
||||
if (answer.answer_text && typeof answer.answer_text !== 'string') {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_text must be a string`));
|
||||
}
|
||||
|
||||
// Validate answer_json if provided
|
||||
if (answer.answer_json && !Array.isArray(answer.answer_json)) {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json must be an array`));
|
||||
}
|
||||
|
||||
// Validate answer_json items are strings
|
||||
if (answer.answer_json) {
|
||||
for (let j = 0; j < answer.answer_json.length; j++) {
|
||||
if (typeof answer.answer_json[j] !== 'string') {
|
||||
return res.status(200).send(new ServerResponse(false, null, `Answer ${i + 1}: answer_json items must be strings`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
@@ -30,7 +30,6 @@ export async function deserialize(user: { id: string | null }, done: IDeserializ
|
||||
const excludedSubscriptionTypes = ["TRIAL", "PADDLE"];
|
||||
const q = `SELECT deserialize_user($1) AS user;`;
|
||||
const result = await db.query(q, [id]);
|
||||
|
||||
if (result.rows.length) {
|
||||
const [data] = result.rows;
|
||||
if (data?.user) {
|
||||
|
||||
@@ -44,6 +44,7 @@ async function handleLogin(req: Request, email: string, password: string, done:
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, false);
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
log_error(error, req.body);
|
||||
return done(error);
|
||||
}
|
||||
|
||||
@@ -47,55 +47,41 @@ async function handleSignUp(req: Request, email: string, password: string, done:
|
||||
// team = Invited team_id if req.body.from_invitation is true
|
||||
const {name, team_name, team_member_id, team_id, timezone} = req.body;
|
||||
|
||||
if (!team_name) {
|
||||
req.flash(ERROR_KEY, "Team name is required");
|
||||
return done(null, null, {message: "Team name is required"});
|
||||
}
|
||||
if (!team_name) return done(null, null, req.flash(ERROR_KEY, "Team name is required"));
|
||||
|
||||
const googleAccountFound = await isGoogleAccountFound(email);
|
||||
if (googleAccountFound) {
|
||||
req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`);
|
||||
return done(null, null, {message: `${req.body.email} is already linked with a Google account.`});
|
||||
}
|
||||
if (googleAccountFound)
|
||||
return done(null, null, req.flash(ERROR_KEY, `${req.body.email} is already linked with a Google account.`));
|
||||
|
||||
try {
|
||||
const user = await registerUser(password, team_id, name, team_name, email, timezone, team_member_id);
|
||||
sendWelcomeEmail(email, name);
|
||||
req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification.");
|
||||
return done(null, user, {message: "Registration successful. Please check your email for verification."});
|
||||
return done(null, user, req.flash(SUCCESS_KEY, "Registration successful. Please check your email for verification."));
|
||||
} catch (error: any) {
|
||||
const message = (error?.message) || "";
|
||||
|
||||
if (message === "ERROR_INVALID_JOINING_EMAIL") {
|
||||
req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`);
|
||||
return done(null, null, {message: `No invitations found for email ${req.body.email}.`});
|
||||
return done(null, null, req.flash(ERROR_KEY, `No invitations found for email ${req.body.email}.`));
|
||||
}
|
||||
|
||||
// if error.message is "email already exists" then it should have the email address in the error message after ":".
|
||||
if (message.includes("EMAIL_EXISTS_ERROR") || error.constraint === "users_google_id_uindex") {
|
||||
const [, value] = error.message.split(":");
|
||||
const errorMsg = `Worklenz account already exists for email ${value}.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
return done(null, null, req.flash(ERROR_KEY, `Worklenz account already exists for email ${value}.`));
|
||||
}
|
||||
|
||||
if (message.includes("TEAM_NAME_EXISTS_ERROR")) {
|
||||
const [, value] = error.message.split(":");
|
||||
const errorMsg = `Team name "${value}" already exists. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${value}" already exists. Please choose a different team name.`));
|
||||
}
|
||||
|
||||
// The Team name is already taken.
|
||||
if (error.constraint === "teams_url_uindex" || error.constraint === "teams_name_uindex") {
|
||||
const errorMsg = `Team name "${team_name}" is already taken. Please choose a different team name.`;
|
||||
req.flash(ERROR_KEY, errorMsg);
|
||||
return done(null, null, {message: errorMsg});
|
||||
return done(null, null, req.flash(ERROR_KEY, `Team name "${team_name}" is already taken. Please choose a different team name.`));
|
||||
}
|
||||
|
||||
log_error(error, req.body);
|
||||
req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE);
|
||||
return done(null, null, {message: DEFAULT_ERROR_MESSAGE});
|
||||
return done(null, null, req.flash(ERROR_KEY, DEFAULT_ERROR_MESSAGE));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
worklenz-backend/src/public/locales/alb/404-page.json
Normal file
4
worklenz-backend/src/public/locales/alb/404-page.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"doesNotExistText": "Na vjen keq, faqja që kërkoni nuk ekziston.",
|
||||
"backHomeButton": "Kthehu në Faqen Kryesore"
|
||||
}
|
||||
31
worklenz-backend/src/public/locales/alb/account-setup.json
Normal file
31
worklenz-backend/src/public/locales/alb/account-setup.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"continue": "Vazhdo",
|
||||
|
||||
"setupYourAccount": "Konfiguro Llogarinë Tënde në Worklenz.",
|
||||
"organizationStepTitle": "Emërtoni Organizatën Tuaj",
|
||||
"organizationStepLabel": "Zgjidhni një emër për llogarinë tuaj në Worklenz.",
|
||||
|
||||
"projectStepTitle": "Krijoni projektin tuaj të parë",
|
||||
"projectStepLabel": "Në cilin projekt po punoni aktualisht?",
|
||||
"projectStepPlaceholder": "p.sh. Plani i Marketingut",
|
||||
|
||||
"tasksStepTitle": "Krijoni detyrat tuaja të para",
|
||||
"tasksStepLabel": "Shkruani disa detyra që do të kryeni në",
|
||||
"tasksStepAddAnother": "Shto një tjetër",
|
||||
|
||||
"emailPlaceholder": "Adresa email",
|
||||
"invalidEmail": "Ju lutemi vendosni një adresë email të vlefshme",
|
||||
"or": "ose",
|
||||
"templateButton": "Importo nga shablloni",
|
||||
"goBack": "Kthehu Mbrapa",
|
||||
"cancel": "Anulo",
|
||||
"create": "Krijo",
|
||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||
"step3InputLabel": "Fto me email",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"skipForNow": "Kalo tani për tani",
|
||||
"formTitle": "Krijoni detyrën tuaj të parë.",
|
||||
"step3Title": "Fto ekipin tënd të punojë me",
|
||||
"maxMembers": " (Mund të ftoni deri në 5 anëtarë)",
|
||||
"maxTasks": " (Mund të krijoni deri në 5 detyra)"
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"title": "Faturimet",
|
||||
"currentBill": "Fatura Aktuale",
|
||||
"configuration": "Konfigurimi",
|
||||
"currentPlanDetails": "Detajet e Planit Aktual",
|
||||
"upgradePlan": "Përmirëso Planin",
|
||||
"cardBodyText01": "Provë falas",
|
||||
"cardBodyText02": "(Plani juaj i provës skadon në 1 muaj 19 ditë)",
|
||||
"redeemCode": "Kodi i Zbritjes",
|
||||
"accountStorage": "Depozita e Llogarisë",
|
||||
"used": "Përdorur:",
|
||||
"remaining": "E mbetur:",
|
||||
"charges": "Tarifat",
|
||||
"tooltip": "Tarifat për ciklin aktual të faturimit",
|
||||
"description": "Përshkrimi",
|
||||
"billingPeriod": "Periudha e Faturimit",
|
||||
"billStatus": "Statusi i Faturës",
|
||||
"perUserValue": "Vlera për Përdorues",
|
||||
"users": "Përdoruesit",
|
||||
|
||||
"amount": "Shuma",
|
||||
"invoices": "Faturat",
|
||||
"transactionId": "ID e Transaksionit",
|
||||
"transactionDate": "Data e Transaksionit",
|
||||
"paymentMethod": "Metoda e Pagesës",
|
||||
"status": "Statusi",
|
||||
"ltdUsers": "Mund të shtoni deri në {{ltd_users}} përdorues.",
|
||||
|
||||
"totalSeats": "Vende totale",
|
||||
"availableSeats": "Vende të disponueshme",
|
||||
"addMoreSeats": "Shto më shumë vende",
|
||||
|
||||
"drawerTitle": "Kodi i Zbritjes",
|
||||
"label": "Kodi i Zbritjes",
|
||||
"drawerPlaceholder": "Vendosni kodin tuaj të zbritjes",
|
||||
"redeemSubmit": "Paraqit",
|
||||
|
||||
"modalTitle": "Zgjidhni planin më të mirë për ekipin tuaj",
|
||||
"seatLabel": "Numri i vendeve",
|
||||
"freePlan": "Plan Falas",
|
||||
"startup": "Startup",
|
||||
"business": "Biznes",
|
||||
"tag": "Më i Popullarizuar",
|
||||
"enterprise": "Ndërmarrje",
|
||||
|
||||
"freeSubtitle": "falas përgjithmonë",
|
||||
"freeUsers": "Më e mira për përdorim personal",
|
||||
"freeText01": "100MB depozitë",
|
||||
"freeText02": "3 projekte",
|
||||
"freeText03": "5 anëtarë të ekipit",
|
||||
|
||||
"startupSubtitle": "ÇMIM I RASTËSISHËM / muaj",
|
||||
"startupUsers": "Deri në 15 përdorues",
|
||||
"startupText01": "25GB depozitë",
|
||||
"startupText02": "Projekte të pakufizuara aktive",
|
||||
"startupText03": "Orar",
|
||||
"startupText04": "Raportim",
|
||||
"startupText05": "Abonohu në projekte",
|
||||
|
||||
"businessSubtitle": "përdorues / muaj",
|
||||
"businessUsers": "16 - 200 përdorues",
|
||||
|
||||
"enterpriseUsers": "200 - 500+ përdorues",
|
||||
|
||||
"footerTitle": "Ju lutemi na jepni një numër kontakti që mund të përdorim për t'ju kontaktuar.",
|
||||
"footerLabel": "Numri i Kontaktit",
|
||||
"footerButton": "Na kontaktoni",
|
||||
|
||||
"redeemCodePlaceHolder": "Vendosni kodin tuaj të zbritjes",
|
||||
"submit": "Paraqit",
|
||||
|
||||
"trialPlan": "Provë Falas",
|
||||
"trialExpireDate": "E vlefshme deri më {{trial_expire_date}}",
|
||||
"trialExpired": "Provat tuaja falas skaduan {{trial_expire_string}}",
|
||||
"trialInProgress": "Provat tuaja falas skadojnë {{trial_expire_string}}",
|
||||
|
||||
"required": "Kjo fushë është e detyrueshme",
|
||||
"invalidCode": "Kod i pavlefshëm",
|
||||
|
||||
"selectPlan": "Zgjidhni planin më të mirë për ekipin tuaj",
|
||||
"changeSubscriptionPlan": "Ndryshoni planin tuaj të abonimit",
|
||||
"noOfSeats": "Numri i vendeve",
|
||||
"annualPlan": "Pro - Vjetor",
|
||||
"monthlyPlan": "Pro - Mujor",
|
||||
"freeForever": "Falas Përgjithmonë",
|
||||
"bestForPersonalUse": "Më e mira për përdorim personal",
|
||||
"storage": "Depozitë",
|
||||
"projects": "Projekte",
|
||||
"teamMembers": "Anëtarët e Ekipit",
|
||||
"unlimitedTeamMembers": "Anëtarë të pakufizuar të ekipit",
|
||||
"unlimitedActiveProjects": "Projekte të pakufizuara aktive",
|
||||
"schedule": "Orar",
|
||||
"reporting": "Raportim",
|
||||
"subscribeToProjects": "Abonohu në projekte",
|
||||
"billedAnnually": "Faturuar çdo vit",
|
||||
"billedMonthly": "Faturuar çdo muaj",
|
||||
|
||||
"pausePlan": "Pauzë Planin",
|
||||
"resumePlan": "Rifillo Planin",
|
||||
"changePlan": "Ndrysho Planin",
|
||||
"cancelPlan": "Anulo Planin",
|
||||
|
||||
"perMonthPerUser": "për përdorues/muaj",
|
||||
"viewInvoice": "Shiko Faturën",
|
||||
"switchToFreePlan": "Kalo në Planin Falas",
|
||||
|
||||
"expirestoday": "sot",
|
||||
"expirestomorrow": "nesër",
|
||||
"expiredDaysAgo": "{{days}} ditë më parë",
|
||||
|
||||
"continueWith": "Vazhdo me {{plan}}",
|
||||
"changeToPlan": "Ndrysho në {{plan}}"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"overview": "Përmbledhje",
|
||||
"name": "Emri i Organizatës",
|
||||
"owner": "Pronari i Organizatës",
|
||||
"admins": "Administruesit e Organizatës",
|
||||
"contactNumber": "Shto Numrin e Kontaktit",
|
||||
"edit": "Redakto"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"membersCount": "Numri i Anëtarëve",
|
||||
"createdAt": "Krijuar më",
|
||||
"projectName": "Emri i Projektit",
|
||||
"teamName": "Emri i Ekipit",
|
||||
"refreshProjects": "Rifresko Projektet",
|
||||
"searchPlaceholder": "Kërkoni sipas emrit të projektit",
|
||||
"deleteProject": "Jeni i sigurt që dëshironi të fshini këtë projekt?",
|
||||
"confirm": "Konfirmo",
|
||||
"cancel": "Anulo",
|
||||
"delete": "Fshi Projektin"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"overview": "Përmbledhje",
|
||||
"users": "Përdoruesit",
|
||||
"teams": "Ekipet",
|
||||
"billing": "Faturimi",
|
||||
"projects": "Projektet",
|
||||
"adminCenter": "Qendra Administrative"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"title": "Ekipet",
|
||||
"subtitle": "ekipet",
|
||||
"tooltip": "Rifresko ekipet",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"addTeam": "Shto Ekip",
|
||||
"team": "Ekipi",
|
||||
"membersCount": "Numri i Anëtarëve",
|
||||
"members": "Anëtarët",
|
||||
"drawerTitle": "Krijo Ekip të Ri",
|
||||
"label": "Emri i Ekipit",
|
||||
"drawerPlaceholder": "Emri",
|
||||
"create": "Krijo",
|
||||
"delete": "Fshi",
|
||||
"settings": "Cilësimet",
|
||||
"popTitle": "Jeni i sigurt?",
|
||||
"message": "Ju lutemi shkruani një Emër",
|
||||
"teamSettings": "Cilësimet e Ekipit",
|
||||
"teamName": "Emri i Ekipit",
|
||||
"teamDescription": "Përshkrimi i Ekipit",
|
||||
"teamMembers": "Anëtarët e Ekipit",
|
||||
"teamMembersCount": "Numri i Anëtarëve të Ekipit",
|
||||
"teamMembersPlaceholder": "Kërko sipas emrit",
|
||||
"addMember": "Shto Anëtar",
|
||||
"add": "Shto",
|
||||
"update": "Përditëso",
|
||||
"teamNamePlaceholder": "Emri i ekipit",
|
||||
"user": "Përdoruesi",
|
||||
"role": "Roli",
|
||||
"owner": "Pronari",
|
||||
"admin": "Administruesi",
|
||||
"member": "Anëtari"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Përdoruesit",
|
||||
"subTitle": "përdoruesit",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"user": "Përdoruesi",
|
||||
"email": "Email",
|
||||
"lastActivity": "Aktiviteti i Fundit",
|
||||
"refresh": "Rifresko përdoruesit"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "Emri",
|
||||
"client": "Klienti",
|
||||
"category": "Kategoria",
|
||||
"status": "Statusi",
|
||||
"tasksProgress": "Përparimi i Detyrave",
|
||||
"updated_at": "E Përditësuar së Fundi",
|
||||
"members": "Anëtarët",
|
||||
"setting": "Cilësimet",
|
||||
"projects": "Projektet",
|
||||
"refreshProjects": "Rifresko projektet",
|
||||
"all": "Të gjitha",
|
||||
"favorites": "Të preferuarit",
|
||||
"archived": "E arkivuar",
|
||||
"placeholder": "Kërko sipas emrit",
|
||||
"archive": "Arkivo",
|
||||
"unarchive": "Çarkivo",
|
||||
"archiveConfirm": "Jeni i sigurt që dëshironi të arkivoni këtë projekt?",
|
||||
"unarchiveConfirm": "Jeni i sigurt që dëshironi të çarkivoni këtë projekt?",
|
||||
"yes": "Po",
|
||||
"no": "Jo",
|
||||
"clickToFilter": "Kliko për të filtruar sipas",
|
||||
"noProjects": "Nuk u gjetën projekte",
|
||||
"addToFavourites": "Shto te të preferuarit",
|
||||
"list": "Lista",
|
||||
"group": "Grupi",
|
||||
"listView": "Pamja e Listës",
|
||||
"groupView": "Pamja e Grupit",
|
||||
"groupBy": {
|
||||
"category": "Kategoria",
|
||||
"client": "Klienti"
|
||||
},
|
||||
"noPermission": "Nuk keni leje për të kryer këtë veprim"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"loggingOut": "Po dilni...",
|
||||
"authenticating": "Po autentikoheni...",
|
||||
"gettingThingsReady": "Po përgatiten gjërat për ju..."
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"headerDescription": "Rivendosni fjalëkalimin tuaj",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
||||
"resetPasswordButton": "Rivendos Fjalëkalimin",
|
||||
"returnToLoginButton": "Kthehu te Hyrja",
|
||||
"passwordResetSuccessMessage": "Një lidhje për rivendosjen e fjalëkalimit është dërguar në email-in tuaj.",
|
||||
"orText": "OSE",
|
||||
"successTitle": "U dërguan udhëzimet për rivendosje!",
|
||||
"successMessage": "Informacioni për rivendosje është dërguar në email-in tuaj. Ju lutemi kontrolloni email-in."
|
||||
}
|
||||
27
worklenz-backend/src/public/locales/alb/auth/login.json
Normal file
27
worklenz-backend/src/public/locales/alb/auth/login.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"headerDescription": "Hyni në llogarinë tuaj",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Vendosni email-in tuaj",
|
||||
"emailRequired": "Ju lutemi vendosni Email-in tuaj!",
|
||||
"passwordLabel": "Fjalëkalimi",
|
||||
"passwordPlaceholder": "Vendosni fjalëkalimin",
|
||||
"passwordRequired": "Ju lutemi vendosni Fjalëkalimin!",
|
||||
"rememberMe": "Më mbaj mend",
|
||||
"loginButton": "Hyr",
|
||||
"signupButton": "Regjistrohu",
|
||||
"forgotPasswordButton": "Keni harruar fjalëkalimin?",
|
||||
"signInWithGoogleButton": "Hyr me Google",
|
||||
"dontHaveAccountText": "Nuk keni llogari?",
|
||||
"orText": "OSE",
|
||||
"successMessage": "Jeni futur me sukses!",
|
||||
"loginError": "Hyrja dështoi",
|
||||
"googleLoginError": "Hyrja përmes Google dështoi",
|
||||
"validationMessages": {
|
||||
"email": "Ju lutemi vendosni një adresë email të vlefshme",
|
||||
"password": "Fjalëkalimi duhet të jetë së paku 8 karaktere"
|
||||
},
|
||||
"errorMessages": {
|
||||
"loginErrorTitle": "Hyrja dështoi",
|
||||
"loginErrorMessage": "Ju lutemi kontrolloni email-in dhe fjalëkalimin dhe provoni përsëri"
|
||||
}
|
||||
}
|
||||
29
worklenz-backend/src/public/locales/alb/auth/signup.json
Normal file
29
worklenz-backend/src/public/locales/alb/auth/signup.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"headerDescription": "Regjistrohuni për të filluar",
|
||||
"nameLabel": "Emri i Plotë",
|
||||
"namePlaceholder": "Shkruani emrin tuaj të plotë",
|
||||
"nameRequired": "Ju lutemi shkruani emrin tuaj të plotë!",
|
||||
"nameMinCharacterRequired": "Emri duhet të jetë së paku 4 karaktere!",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "Shkruani email-in tuaj",
|
||||
"emailRequired": "Ju lutemi shkruani Email-in tuaj!",
|
||||
"passwordLabel": "Fjalëkalimi",
|
||||
"passwordPlaceholder": "Krijoni një fjalëkalim",
|
||||
"passwordRequired": "Ju lutemi krijoni një Fjalëkalim!",
|
||||
"passwordMinCharacterRequired": "Fjalëkalimi duhet të jetë së paku 8 karaktere!",
|
||||
"passwordPatternRequired": "Fjalëkalimi nuk plotëson kërkesat!",
|
||||
"strongPasswordPlaceholder": "Vendosni një fjalëkalim më të fortë",
|
||||
"passwordValidationAltText": "Fjalëkalimi duhet të përmbajë së paku 8 karaktere me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
||||
"signupSuccessMessage": "Jeni regjistruar me sukses!",
|
||||
"privacyPolicyLink": "Politika e Privatësisë",
|
||||
"termsOfUseLink": "Kushtet e Përdorimit",
|
||||
"bySigningUpText": "Duke u regjistruar, ju pranoni",
|
||||
"andText": "dhe",
|
||||
"signupButton": "Regjistrohu",
|
||||
"signInWithGoogleButton": "Hyr me Google",
|
||||
"alreadyHaveAccountText": "Keni tashmë një llogari?",
|
||||
"loginButton": "Hyr",
|
||||
"orText": "OSE",
|
||||
"reCAPTCHAVerificationError": "Gabim në Verifikimin e reCAPTCHA",
|
||||
"reCAPTCHAVerificationErrorMessage": "Nuk mundëm të verifikojmë reCAPTCHA-n tuaj. Ju lutemi provoni përsëri."
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "Verifikoni Email-in për Rivendosje",
|
||||
"description": "Vendosni fjalëkalimin tuaj të ri",
|
||||
"placeholder": "Vendosni fjalëkalimin tuaj të ri",
|
||||
"confirmPasswordPlaceholder": "Konfirmoni fjalëkalimin e ri",
|
||||
"passwordHint": "Të paktën 8 karaktere, me shkronja të mëdha dhe të vogla, një numër dhe një simbol.",
|
||||
"resetPasswordButton": "Rivendos fjalëkalimin",
|
||||
"orText": "Ose",
|
||||
"resendResetEmail": "Dërgo përsëri email-in e rivendosjes",
|
||||
"passwordRequired": "Ju lutemi vendosni fjalëkalimin e ri",
|
||||
"returnToLoginButton": "Kthehu te Hyrja",
|
||||
"confirmPasswordRequired": "Ju lutemi konfirmoni fjalëkalimin e ri",
|
||||
"passwordMismatch": "Fjalëkalimet nuk përputhen"
|
||||
}
|
||||
9
worklenz-backend/src/public/locales/alb/common.json
Normal file
9
worklenz-backend/src/public/locales/alb/common.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"login-success": "Hyrja u krye me sukses!",
|
||||
"login-failed": "Hyrja dështoi. Ju lutemi kontrolloni kredencialet dhe provoni përsëri.",
|
||||
"signup-success": "Regjistrimi u krye me sukses! Mirë se erdhët.",
|
||||
"signup-failed": "Regjistrimi dështoi. Ju lutemi sigurohuni që të gjitha fushat e nevojshme janë plotësuar dhe provoni përsëri.",
|
||||
"reconnecting": "Jeni shkëputur nga serveri.",
|
||||
"connection-lost": "Lidhja me serverin dështoi. Ju lutemi kontrolloni lidhjen tuaj me internet.",
|
||||
"connection-restored": "U lidhët me serverin me sukses"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"formTitle": "Krijoni projektin tuaj të parë",
|
||||
"inputLabel": "Në cilin projekt po punoni aktualisht?",
|
||||
"or": "ose",
|
||||
"templateButton": "Importo nga shablloni",
|
||||
"createFromTemplate": "Krijo nga shablloni",
|
||||
"goBack": "Kthehu Mbrapa",
|
||||
"continue": "Vazhdo",
|
||||
"cancel": "Anulo",
|
||||
"create": "Krijo",
|
||||
"templateDrawerTitle": "Zgjidh nga shabllonet",
|
||||
"createProject": "Krijo Projekt"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"formTitle": "Krijo detyrën tënde të parë.",
|
||||
"inputLabel": "Shkruaj disa detyra që do të kryesh në",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"goBack": "Kthehu mbrapa",
|
||||
"continue": "Vazhdo"
|
||||
}
|
||||
46
worklenz-backend/src/public/locales/alb/home.json
Normal file
46
worklenz-backend/src/public/locales/alb/home.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"todoList": {
|
||||
"title": "Lista e Detyrave",
|
||||
"refreshTasks": "Rifresko detyrat",
|
||||
"addTask": "+ Shto Detyrë",
|
||||
"noTasks": "Asnjë detyrë",
|
||||
"pressEnter": "Shtyp",
|
||||
"toCreate": "për të krijuar.",
|
||||
"markAsDone": "Shëno si të përfunduar"
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projektet",
|
||||
"refreshProjects": "Rifresko projektet",
|
||||
"noRecentProjects": "Aktualisht nuk jeni caktuar në asnjë projekt.",
|
||||
"noFavouriteProjects": "Asnjë projekt i shënuar si i preferuar.",
|
||||
"recent": "Të Fundit",
|
||||
"favourites": "Të Preferuarat"
|
||||
},
|
||||
"tasks": {
|
||||
"assignedToMe": "Më janë caktuar",
|
||||
"assignedByMe": "I kam caktuar",
|
||||
"all": "Të Gjitha",
|
||||
"today": "Sot",
|
||||
"upcoming": "Ardhj",
|
||||
"overdue": "Të vonuara",
|
||||
"noDueDate": "Pa afat",
|
||||
"noTasks": "Asnjë detyrë për të shfaqur.",
|
||||
"addTask": "+ Shto detyrë",
|
||||
"name": "Emri",
|
||||
"project": "Projekti",
|
||||
"status": "Statusi",
|
||||
"dueDate": "Afati",
|
||||
"dueDatePlaceholder": "Cakto Afatin",
|
||||
"tomorrow": "Nesër",
|
||||
"nextWeek": "Javën e Ardhshme",
|
||||
"nextMonth": "Muajin e Ardhshëm",
|
||||
"projectRequired": "Ju lutemi zgjidhni një projekt",
|
||||
"pressTabToSelectDueDateAndProject": "Shtyp Tab për të zgjedhur afatin dhe projektin",
|
||||
"dueOn": "Detyrat me afat më",
|
||||
"taskRequired": "Ju lutemi shtoni një detyrë",
|
||||
"list": "Listë",
|
||||
"calendar": "Kalendar",
|
||||
"tasks": "Detyrat",
|
||||
"refresh": "Rifresko"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"formTitle": "Fto ekipin tënd të punojë me",
|
||||
"inputLabel": "Fto me email",
|
||||
"addAnother": "Shto një tjetër",
|
||||
"goBack": "Kthehu mbrapa",
|
||||
"continue": "Vazhdo",
|
||||
"skipForNow": "Anashkalo tani për tani"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user