Compare commits
1370 Commits
assets-dev
...
v1.22.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f74d65bd | ||
|
|
4a0c34477a | ||
|
|
9821e46012 | ||
|
|
90e49cab7f | ||
|
|
eaf3923a9d | ||
|
|
fcba625540 | ||
|
|
b15016fbd2 | ||
|
|
b94e46aa1f | ||
|
|
9e336dafdc | ||
|
|
0ae782acf0 | ||
|
|
b2c841ef3d | ||
|
|
0fd76cc430 | ||
|
|
5ce49fb7fb | ||
|
|
7cc400a3ca | ||
|
|
993245ffa6 | ||
|
|
f5bb8dd3b4 | ||
|
|
c849132ca1 | ||
|
|
1a75162626 | ||
|
|
975e7bf04d | ||
|
|
b77819ee7e | ||
|
|
2594183b05 | ||
|
|
85ac217596 | ||
|
|
119a56a94a | ||
|
|
9fe63f4a25 | ||
|
|
3978a653e9 | ||
|
|
ed5f599d9f | ||
|
|
ec957e9588 | ||
|
|
cd1cde66ea | ||
|
|
873712c930 | ||
|
|
f452486493 | ||
|
|
210fbce8d0 | ||
|
|
d313336b81 | ||
|
|
b144e9ac02 | ||
|
|
526f542259 | ||
|
|
c0d85ff097 | ||
|
|
75b67f41ee | ||
|
|
4cf4ba9ff3 | ||
|
|
81d2df5be0 | ||
|
|
6840c29ff8 | ||
|
|
eb364d4c73 | ||
|
|
5fb59d2078 | ||
|
|
c292addab4 | ||
|
|
a8178aa584 | ||
|
|
56f9cab005 | ||
|
|
4b7b6cb7da | ||
|
|
7dcc094a53 | ||
|
|
f3fbc16cbc | ||
|
|
da78a5bac3 | ||
|
|
3b11e381f6 | ||
|
|
d683c5d9f1 | ||
|
|
b7d52b97b4 | ||
|
|
b355a514dd | ||
|
|
2d46cc1f1e | ||
|
|
27988dbb61 | ||
|
|
5a260045fd | ||
|
|
55fc572069 | ||
|
|
3f33765f55 | ||
|
|
44e6b09eef | ||
|
|
75c96fd91f | ||
|
|
dc5d0e6128 | ||
|
|
53b0e1ebe2 | ||
|
|
dfd111cf82 | ||
|
|
2c4550f243 | ||
|
|
0808eb6817 | ||
|
|
8ab3466232 | ||
|
|
42f3605899 | ||
|
|
c72fc2f285 | ||
|
|
e0fbf2af46 | ||
|
|
df1001b597 | ||
|
|
27e694cf44 | ||
|
|
383e5d9ec2 | ||
|
|
209d27c59e | ||
|
|
15639edce5 | ||
|
|
e872829e9b | ||
|
|
b0c6ca0ec5 | ||
|
|
a53cd0fdf8 | ||
|
|
eef7c4d427 | ||
|
|
2a4286d418 | ||
|
|
cf26bbd54b | ||
|
|
f554b6c94b | ||
|
|
83dd0a99c0 | ||
|
|
e0d9ef815e | ||
|
|
be229438b2 | ||
|
|
4aa6f7a8bd | ||
|
|
c3b7de4d3f | ||
|
|
ba49e22437 | ||
|
|
aabe2a5f2e | ||
|
|
08eadb73d6 | ||
|
|
325197766f | ||
|
|
5d157ceac1 | ||
|
|
7364f6f5aa | ||
|
|
e1bbaa2b2a | ||
|
|
d89d8b2bbd | ||
|
|
871364b63c | ||
|
|
af6b97fc13 | ||
|
|
18c999fdf0 | ||
|
|
8b2298ee38 | ||
|
|
117b62f3c2 | ||
|
|
e9f469bea8 | ||
|
|
eb20b78903 | ||
|
|
d5a3f5f01e | ||
|
|
496c89713b | ||
|
|
01f2df8629 | ||
|
|
c356f6142e | ||
|
|
324c3ef65d | ||
|
|
3dca4df2cc | ||
|
|
31af40a680 | ||
|
|
16a32cedf6 | ||
|
|
7fe52fa1b9 | ||
|
|
4f345f2232 | ||
|
|
9eb3301656 | ||
|
|
ae399c85e9 | ||
|
|
9a978f9303 | ||
|
|
474f3a305f | ||
|
|
cec199bed6 | ||
|
|
54ac7719c0 | ||
|
|
e8c3a5c270 | ||
|
|
297ef5cec2 | ||
|
|
aa414c4496 | ||
|
|
f38d683014 | ||
|
|
c2ce4f3484 | ||
|
|
ece507a310 | ||
|
|
fa0498cad8 | ||
|
|
2239bf7e96 | ||
|
|
b46c843d5d | ||
|
|
0d03c1acce | ||
|
|
eb48bf1d71 | ||
|
|
e20a890fc4 | ||
|
|
234b68151f | ||
|
|
15bb2c9898 | ||
|
|
550be35a09 | ||
|
|
31099aa202 | ||
|
|
e33d16422b | ||
|
|
ff8f442840 | ||
|
|
8fcd963e68 | ||
|
|
1760b34e53 | ||
|
|
d36599963f | ||
|
|
9962c9cfd4 | ||
|
|
bf3692afa0 | ||
|
|
cb1e6ca24c | ||
|
|
04d3693d31 | ||
|
|
084098debc | ||
|
|
32566c5d80 | ||
|
|
a404a850d5 | ||
|
|
7f1ad2be57 | ||
|
|
5d3bb6361a | ||
|
|
2d5a622bf4 | ||
|
|
77d9632845 | ||
|
|
a1e48699ec | ||
|
|
c659aec522 | ||
|
|
1812ea2a85 | ||
|
|
957a2b7eab | ||
|
|
cb2fa44412 | ||
|
|
4e56b4e87f | ||
|
|
0d82fa0a50 | ||
|
|
4ad1991cb0 | ||
|
|
10028686b4 | ||
|
|
6c7ca130b4 | ||
|
|
e9543ad4e4 | ||
|
|
0c593846f6 | ||
|
|
9782f5b893 | ||
|
|
ae630f4061 | ||
|
|
b1699ec728 | ||
|
|
7364697bc3 | ||
|
|
da99dc51b7 | ||
|
|
269d7e1c88 | ||
|
|
d0a2ae16f6 | ||
|
|
b2651d26c8 | ||
|
|
f1f3b9bd2b | ||
|
|
ae0784fda2 | ||
|
|
e939bd7141 | ||
|
|
1173616af1 | ||
|
|
873e55c945 | ||
|
|
86f3833618 | ||
|
|
a110493400 | ||
|
|
2017ebd81e | ||
|
|
c2d8beef06 | ||
|
|
538e6e8222 | ||
|
|
c526cb8f1c | ||
|
|
8f50495f22 | ||
|
|
5f448d37ea | ||
|
|
1d5970cc5c | ||
|
|
1b9e379361 | ||
|
|
a0fd1cee25 | ||
|
|
52ae3528bb | ||
|
|
afbd3a7398 | ||
|
|
297d7685f5 | ||
|
|
b63a23fd94 | ||
|
|
0be89a8655 | ||
|
|
d7a3d9994b | ||
|
|
efda04cfdc | ||
|
|
cad3b7a5db | ||
|
|
9f84945651 | ||
|
|
065b299e5a | ||
|
|
f3abb26d0d | ||
|
|
d66224942c | ||
|
|
6626514c28 | ||
|
|
19d92395d2 | ||
|
|
c98b317beb | ||
|
|
14a3a870dd | ||
|
|
6f7951518f | ||
|
|
40a04c40ee | ||
|
|
625ae93e1c | ||
|
|
95dd2c004c | ||
|
|
2a0ad52055 | ||
|
|
8f8066101d | ||
|
|
077a1c0ef5 | ||
|
|
c421db13d8 | ||
|
|
2a325f5c2f | ||
|
|
0727894cb2 | ||
|
|
3a59a4a2d1 | ||
|
|
9f1df7a79a | ||
|
|
d0cc9adc93 | ||
|
|
d0f6befa7c | ||
|
|
db08a86f8a | ||
|
|
fb22c610c9 | ||
|
|
ab42160ea1 | ||
|
|
7ad7824d22 | ||
|
|
cb14a5b086 | ||
|
|
e40716e2dc | ||
|
|
6b57346a2d | ||
|
|
c5650b48aa | ||
|
|
169bc57da2 | ||
|
|
3957380164 | ||
|
|
2ab7d8f5b2 | ||
|
|
f73c3a5eb8 | ||
|
|
134938f0e1 | ||
|
|
1a8bf43578 | ||
|
|
3c6a22c6d6 | ||
|
|
95dc627b09 | ||
|
|
dc70c83fc3 | ||
|
|
132d86d8d7 | ||
|
|
f334b90837 | ||
|
|
1275da1ccb | ||
|
|
0bd0be0ad1 | ||
|
|
bca54632d1 | ||
|
|
5101822c0b | ||
|
|
e364335845 | ||
|
|
a096eaee95 | ||
|
|
56550aa821 | ||
|
|
4c948a6be6 | ||
|
|
a9ad34ad7e | ||
|
|
a9f123090e | ||
|
|
a81f4c9f9e | ||
|
|
12a47792b8 | ||
|
|
488bf6fced | ||
|
|
be763b020d | ||
|
|
efd539bad9 | ||
|
|
992cfca6ee | ||
|
|
a83b7c9ae0 | ||
|
|
fa968132a6 | ||
|
|
20f799ec57 | ||
|
|
cd006a95ad | ||
|
|
b270b02563 | ||
|
|
a3ad1a937a | ||
|
|
a126a20d0c | ||
|
|
9cf61eeca9 | ||
|
|
cf0024ec6f | ||
|
|
fe7ddfa9bd | ||
|
|
672ad53541 | ||
|
|
93924bc707 | ||
|
|
f30f1b7919 | ||
|
|
4f7a8a2c31 | ||
|
|
341af00ca2 | ||
|
|
f5547b0db8 | ||
|
|
ae4f7754b4 | ||
|
|
a14ce55394 | ||
|
|
794e2ffe36 | ||
|
|
13d805981e | ||
|
|
4f770a919f | ||
|
|
7ed70afb4a | ||
|
|
75553f5ab6 | ||
|
|
d8cfb43e0b | ||
|
|
6d86b6a3f6 | ||
|
|
8078f22e9f | ||
|
|
b1daf1ebf9 | ||
|
|
6b7fd287cb | ||
|
|
648a062738 | ||
|
|
5dcfc6d289 | ||
|
|
8492c5addb | ||
|
|
8fe0656521 | ||
|
|
a6816dfd42 | ||
|
|
94564058b2 | ||
|
|
c6e1bb1870 | ||
|
|
1e7efe91bc | ||
|
|
9893b14520 | ||
|
|
4f9b149acf | ||
|
|
ff63f9a1a5 | ||
|
|
12d2dc4467 | ||
|
|
9b1409a97b | ||
|
|
4fb4df29d7 | ||
|
|
fd2eb4ca3b | ||
|
|
3303e209d0 | ||
|
|
78e87c0a52 | ||
|
|
ff775012b1 | ||
|
|
e4983dc963 | ||
|
|
0bd5a5c35c | ||
|
|
cb17035fc3 | ||
|
|
a79468f749 | ||
|
|
a332917fc9 | ||
|
|
d0050ef489 | ||
|
|
beb5b2ef9d | ||
|
|
6c2149ba78 | ||
|
|
10f1eae002 | ||
|
|
f20e8f393e | ||
|
|
e64c71447a | ||
|
|
f83dbd3db2 | ||
|
|
4e06436571 | ||
|
|
2edce1df53 | ||
|
|
0eb09c6ea1 | ||
|
|
3fa5aa86f7 | ||
|
|
c7c65344ee | ||
|
|
0b909703b8 | ||
|
|
df74338e08 | ||
|
|
74167d55ad | ||
|
|
2626824fc5 | ||
|
|
fa0dd4b18e | ||
|
|
f2d07d5aef | ||
|
|
3d487f13c6 | ||
|
|
c6b8d5dad6 | ||
|
|
de7f001f1b | ||
|
|
93e9d1c558 | ||
|
|
9e77e1ec16 | ||
|
|
5b0b0bdf2c | ||
|
|
eb9429a245 | ||
|
|
a6f847870b | ||
|
|
f2005f1907 | ||
|
|
222492a78b | ||
|
|
a6caf2de47 | ||
|
|
a7450d4c9e | ||
|
|
a2360a78ea | ||
|
|
0e0e6f42a6 | ||
|
|
26dd7a4d78 | ||
|
|
820d199f68 | ||
|
|
165b74a035 | ||
|
|
c59cdfb6fc | ||
|
|
32977ac0a6 | ||
|
|
7e33c6a238 | ||
|
|
546b09b7bc | ||
|
|
b4d68d40dd | ||
|
|
f5f26c3fa3 | ||
|
|
bc54aae6e3 | ||
|
|
1234197c46 | ||
|
|
95b1382247 | ||
|
|
7345580368 | ||
|
|
6437f3f95d | ||
|
|
1472c5954e | ||
|
|
d1bece41a2 | ||
|
|
73cd8695c5 | ||
|
|
76e0771a68 | ||
|
|
5e305eaf17 | ||
|
|
eef1020137 | ||
|
|
93037d56e6 | ||
|
|
5c2918ee2c | ||
|
|
1224c7b01e | ||
|
|
f7d77f17e7 | ||
|
|
dc1ce1afb1 | ||
|
|
524096c000 | ||
|
|
4a2d5bd4dd | ||
|
|
955c10b996 | ||
|
|
009bb8904c | ||
|
|
0e35cc797a | ||
|
|
17de104af5 | ||
|
|
124288fa0c | ||
|
|
22cf2ccc84 | ||
|
|
efe621ae15 | ||
|
|
13e01b287f | ||
|
|
904dccaa1b | ||
|
|
42172333f5 | ||
|
|
f11240e804 | ||
|
|
bfc46366c9 | ||
|
|
ffad2013bf | ||
|
|
184d024af5 | ||
|
|
793ac1d584 | ||
|
|
7c1ecfd6a7 | ||
|
|
baa87bbd77 | ||
|
|
6758e94a4f | ||
|
|
c3b46e2d3f | ||
|
|
098c7a11fb | ||
|
|
c449bd7e29 | ||
|
|
af386e7075 | ||
|
|
51860ac408 | ||
|
|
421bd3088f | ||
|
|
674e08091a | ||
|
|
5549159ea5 | ||
|
|
ef3736c12d | ||
|
|
6e4291ad6a | ||
|
|
abbc9c0ccc | ||
|
|
70f65febee | ||
|
|
bad8cc43ae | ||
|
|
fc25efa84a | ||
|
|
23cd1448be | ||
|
|
35076b0c61 | ||
|
|
8c060aa8c5 | ||
|
|
543755505a | ||
|
|
00c8c50c80 | ||
|
|
7c20a1a933 | ||
|
|
69717779b6 | ||
|
|
5d02353472 | ||
|
|
0654ff724c | ||
|
|
8ae9661106 | ||
|
|
78a72da39a | ||
|
|
dc7049e757 | ||
|
|
16f2b857f7 | ||
|
|
338b48bb9b | ||
|
|
58caa8c556 | ||
|
|
144ccda231 | ||
|
|
7659f06491 | ||
|
|
b6f1ecc30a | ||
|
|
b94a330c93 | ||
|
|
5d3b3140a3 | ||
|
|
b906a33cf8 | ||
|
|
fc7c240535 | ||
|
|
1399795a5c | ||
|
|
a5da3503dd | ||
|
|
7e175c157e | ||
|
|
68f9523151 | ||
|
|
6e721bf17a | ||
|
|
a9d28b530b | ||
|
|
6026b1e2a3 | ||
|
|
be75ad2617 | ||
|
|
2f4c20b8f0 | ||
|
|
00b9cbe8ca | ||
|
|
f542c86d1a | ||
|
|
4b54c60e87 | ||
|
|
00f175b16b | ||
|
|
f62a66411b | ||
|
|
fb139c06fd | ||
|
|
51cd8bf11c | ||
|
|
79b2bf4a25 | ||
|
|
b2781617ff | ||
|
|
4b1c447bc2 | ||
|
|
8592645e0e | ||
|
|
a6a515cce3 | ||
|
|
d365afc249 | ||
|
|
0d65b35dff | ||
|
|
9de29f4122 | ||
|
|
f4b35e718b | ||
|
|
1dcf550f52 | ||
|
|
f86732e1a8 | ||
|
|
87e84e2574 | ||
|
|
8279162135 | ||
|
|
8de2124435 | ||
|
|
a566adbde1 | ||
|
|
2ffaa84659 | ||
|
|
fc4bf5460c | ||
|
|
a24c08853b | ||
|
|
998c7c0232 | ||
|
|
ffd8243d7b | ||
|
|
8d24279974 | ||
|
|
0f570d8e71 | ||
|
|
51c5cdbfe4 | ||
|
|
3200592b6f | ||
|
|
622bd684a6 | ||
|
|
628e1ef95a | ||
|
|
916092ef18 | ||
|
|
49fae54f1b | ||
|
|
ed0903a59f | ||
|
|
0adb24c28d | ||
|
|
fb4106f057 | ||
|
|
816b216ee4 | ||
|
|
ec34dde51d | ||
|
|
e0b1b426a3 | ||
|
|
89af35bc94 | ||
|
|
253201838e | ||
|
|
074229ab7d | ||
|
|
67fa875028 | ||
|
|
702e51c478 | ||
|
|
300427a5e5 | ||
|
|
477560e098 | ||
|
|
31c5191fee | ||
|
|
66de85079c | ||
|
|
abbff7d4e0 | ||
|
|
3fa46f9806 | ||
|
|
620404292f | ||
|
|
f658c54933 | ||
|
|
8f46d5acc3 | ||
|
|
0357db16b2 | ||
|
|
0142096774 | ||
|
|
8c8480d558 | ||
|
|
61ff676bf4 | ||
|
|
39f89f16ff | ||
|
|
c4f32eda16 | ||
|
|
f3a701bfab | ||
|
|
cec8db5626 | ||
|
|
1cadd07155 | ||
|
|
894a588928 | ||
|
|
280ef71fc1 | ||
|
|
51b0bad8c8 | ||
|
|
9ec01333d3 | ||
|
|
b0f275487a | ||
|
|
0124948ed8 | ||
|
|
c1aa15145f | ||
|
|
49af2e1fe9 | ||
|
|
981515387f | ||
|
|
1f5ffe5084 | ||
|
|
6d5f8535b5 | ||
|
|
2cc2987d94 | ||
|
|
d95d3ee878 | ||
|
|
5b7dc536df | ||
|
|
8b5cde9bbd | ||
|
|
ad52394ce6 | ||
|
|
f9c10d3d8b | ||
|
|
4e305315c2 | ||
|
|
c56fc87826 | ||
|
|
7b2d73dea2 | ||
|
|
f8ea7765a0 | ||
|
|
a931adcb3d | ||
|
|
101f5214f3 | ||
|
|
2f32852840 | ||
|
|
1efeb5f7f9 | ||
|
|
42fce2b60e | ||
|
|
ba8f819eb4 | ||
|
|
14a2dff554 | ||
|
|
5fcdd736f1 | ||
|
|
f37a70fab3 | ||
|
|
a385e9e05b | ||
|
|
1724565f7f | ||
|
|
e18bbd2eeb | ||
|
|
1ecac6270a | ||
|
|
60eb1e39fb | ||
|
|
7ec09103da | ||
|
|
0c8704b08b | ||
|
|
b05cd37cd3 | ||
|
|
abc20dd161 | ||
|
|
39f0545c8f | ||
|
|
4565a76e7c | ||
|
|
5dea5e3497 | ||
|
|
933fe94eac | ||
|
|
cd6050f5f1 | ||
|
|
81322323c4 | ||
|
|
54414613ee | ||
|
|
3713cd6b2c | ||
|
|
3d44b09851 | ||
|
|
6de40f928b | ||
|
|
b90f20ee21 | ||
|
|
f112dc08df | ||
|
|
637f3879db | ||
|
|
e156154855 | ||
|
|
18a2e02525 | ||
|
|
bd56224691 | ||
|
|
ed3e27c8c5 | ||
|
|
fa3bcf27ae | ||
|
|
bead00ad68 | ||
|
|
83f4d35ad0 | ||
|
|
4434c20716 | ||
|
|
d6d7c1f5dd | ||
|
|
94ce44ad02 | ||
|
|
2aa9533d26 | ||
|
|
c9b2fc1291 | ||
|
|
862bb94cbc | ||
|
|
ac504d84ff | ||
|
|
6e4545a058 | ||
|
|
060338d3c4 | ||
|
|
41abffdbbd | ||
|
|
95825dc9f6 | ||
|
|
3974b153a8 | ||
|
|
45273dabe8 | ||
|
|
e33e9203ed | ||
|
|
c07d5dc043 | ||
|
|
ba67d869bc | ||
|
|
28e909a9e0 | ||
|
|
1ebe1503c4 | ||
|
|
4859978e0a | ||
|
|
dc376d7e09 | ||
|
|
eb605fd7b6 | ||
|
|
22ce271aa1 | ||
|
|
cc64bc1723 | ||
|
|
3d5692cdc6 | ||
|
|
a989dc1af4 | ||
|
|
d61b38c674 | ||
|
|
5d51ccd525 | ||
|
|
0ad7fdaa8d | ||
|
|
3aa0ce46ef | ||
|
|
5032dc611a | ||
|
|
244838fcef | ||
|
|
7eecb4c173 | ||
|
|
488956db07 | ||
|
|
9367660af3 | ||
|
|
a878780786 | ||
|
|
bac49e41a3 | ||
|
|
84ac4c2f48 | ||
|
|
cce5a30496 | ||
|
|
d44ffe4b24 | ||
|
|
8b34a4a9e2 | ||
|
|
8b8c92f5bb | ||
|
|
e124cb2dcf | ||
|
|
95355c67c4 | ||
|
|
e945f118a7 | ||
|
|
7f8d3be345 | ||
|
|
93cef0c509 | ||
|
|
c23e8b001e | ||
|
|
ca6ae4cdf4 | ||
|
|
54261e3c3e | ||
|
|
76edb52a1d | ||
|
|
e9aa895159 | ||
|
|
42b1a7862c | ||
|
|
67c59d26de | ||
|
|
07fa11fa87 | ||
|
|
8b1fb35730 | ||
|
|
346275d1b1 | ||
|
|
5a271df560 | ||
|
|
beefbdce11 | ||
|
|
1770545fc0 | ||
|
|
9336363968 | ||
|
|
8241dc83e7 | ||
|
|
ab9f3bc818 | ||
|
|
10cd71be8e | ||
|
|
a3959a6da9 | ||
|
|
8d3b676c03 | ||
|
|
96911c65d3 | ||
|
|
b43107add2 | ||
|
|
0f62199ae7 | ||
|
|
aad1a0705d | ||
|
|
db8ac693e2 | ||
|
|
0408360716 | ||
|
|
7ab0cabc1e | ||
|
|
25f5c06b70 | ||
|
|
5dd7bd9513 | ||
|
|
bc9d51960c | ||
|
|
29a3d410b5 | ||
|
|
b4f4078427 | ||
|
|
890f27cbb9 | ||
|
|
bbd95dafa1 | ||
|
|
286c196ba7 | ||
|
|
eac1280adc | ||
|
|
be7e4f417c | ||
|
|
c4d9a25c03 | ||
|
|
2e749fc76a | ||
|
|
e65507e1b9 | ||
|
|
5042fc718d | ||
|
|
696802e65b | ||
|
|
857fd3df32 | ||
|
|
d7f4fa7711 | ||
|
|
81b3757a17 | ||
|
|
0fd4178ef3 | ||
|
|
73377712d3 | ||
|
|
07737b1609 | ||
|
|
3effc56d84 | ||
|
|
1ca7e5c41b | ||
|
|
7414b40d0f | ||
|
|
975fea9f93 | ||
|
|
3ac62b1583 | ||
|
|
5695f86e16 | ||
|
|
62b51d8383 | ||
|
|
674fdc1194 | ||
|
|
4252657d17 | ||
|
|
9dccf0c352 | ||
|
|
1d2e4bdaa2 | ||
|
|
9b2c988162 | ||
|
|
31ef62dd96 | ||
|
|
7fa910dee6 | ||
|
|
756618a23b | ||
|
|
7fe8d62a6c | ||
|
|
c9d6574acf | ||
|
|
8550ddb52c | ||
|
|
1f54124b45 | ||
|
|
562e34e38b | ||
|
|
1fd8a17305 | ||
|
|
ee9b70260b | ||
|
|
6b91cc456e | ||
|
|
aa214a752a | ||
|
|
377582a48a | ||
|
|
6dd1779049 | ||
|
|
eb69aef769 | ||
|
|
b414902d7e | ||
|
|
4eb2d9fe49 | ||
|
|
27fcf2f0da | ||
|
|
591092e3ed | ||
|
|
2a9c9f457e | ||
|
|
fae2f361a9 | ||
|
|
b02e9a3849 | ||
|
|
8ac3f805a5 | ||
|
|
8ab6848f48 | ||
|
|
21da9cb643 | ||
|
|
9606e343c4 | ||
|
|
a7f62a23fb | ||
|
|
e762652860 | ||
|
|
5ccfd82895 | ||
|
|
95d5ad1784 | ||
|
|
09feebce83 | ||
|
|
8a394aeec5 | ||
|
|
a7af289133 | ||
|
|
eb341e9f58 | ||
|
|
defe28fdc0 | ||
|
|
fafd0d0bcd | ||
|
|
36b359326e | ||
|
|
543a3fae67 | ||
|
|
e3afef5dcd | ||
|
|
cc5b4e35fe | ||
|
|
8727f3075b | ||
|
|
8304de0e69 | ||
|
|
b780ad4253 | ||
|
|
d2aa02d0d0 | ||
|
|
34e07f907b | ||
|
|
a3b8bf26c2 | ||
|
|
b008091137 | ||
|
|
e3587c09e7 | ||
|
|
09a174f190 | ||
|
|
e09116a523 | ||
|
|
f77349faea | ||
|
|
29d503090f | ||
|
|
b850d733f7 | ||
|
|
185442d153 | ||
|
|
c56de5904b | ||
|
|
88afe2f413 | ||
|
|
6a8447a882 | ||
|
|
253afdc16c | ||
|
|
ce17e05a70 | ||
|
|
1b52048b8f | ||
|
|
010938804a | ||
|
|
1cda760798 | ||
|
|
4e5ca82963 | ||
|
|
2854c573dd | ||
|
|
ddd380d2b4 | ||
|
|
6f381d745e | ||
|
|
b18b81c22e | ||
|
|
3bdda78513 | ||
|
|
f007a1be29 | ||
|
|
ee387164d4 | ||
|
|
65ce2c1333 | ||
|
|
65467272a8 | ||
|
|
6410ced280 | ||
|
|
cc8bde3091 | ||
|
|
e4316de66e | ||
|
|
7930000998 | ||
|
|
f3725e53ac | ||
|
|
0ceda51542 | ||
|
|
669812d6f9 | ||
|
|
c19b54df7f | ||
|
|
378214e22c | ||
|
|
5bd3ff389f | ||
|
|
451772506d | ||
|
|
8c4b10f567 | ||
|
|
1e16241217 | ||
|
|
399c3f89cb | ||
|
|
24089711d2 | ||
|
|
d079147540 | ||
|
|
03a7b5199c | ||
|
|
d8c95bb39a | ||
|
|
c9300824f0 | ||
|
|
8c1777f512 | ||
|
|
9ad9bea8ac | ||
|
|
b6e480501e | ||
|
|
cc18255dc9 | ||
|
|
4f482faf58 | ||
|
|
3478361dcb | ||
|
|
cbcd1d96ff | ||
|
|
f79cd726be | ||
|
|
950e23983c | ||
|
|
e2bbd830ed | ||
|
|
457d6e1f9c | ||
|
|
6c54da1d1e | ||
|
|
5f73ea65c7 | ||
|
|
a7e5f65422 | ||
|
|
f05afc8465 | ||
|
|
a57e2b5faf | ||
|
|
3baff61473 | ||
|
|
6ff72d7c33 | ||
|
|
afd6b97dd5 | ||
|
|
748fef89af | ||
|
|
daeb72b474 | ||
|
|
ce8fce52d9 | ||
|
|
b2126a4606 | ||
|
|
f1663e1edc | ||
|
|
b7c894cea3 | ||
|
|
82b14dd378 | ||
|
|
97c55258d3 | ||
|
|
c3d411a854 | ||
|
|
b78c0a63d6 | ||
|
|
434a5fee9f | ||
|
|
d53bb65e3e | ||
|
|
d1f399f7d7 | ||
|
|
49a66176a2 | ||
|
|
804b04c17b | ||
|
|
6d3da9def5 | ||
|
|
760d6f49ad | ||
|
|
58ffcf76f6 | ||
|
|
4e66acaab8 | ||
|
|
655229ff9c | ||
|
|
18bdf0791a | ||
|
|
0806083ed8 | ||
|
|
9057611f8e | ||
|
|
49be94bb61 | ||
|
|
066eb8b72d | ||
|
|
aff7049d2d | ||
|
|
2ddc720723 | ||
|
|
b790f63bb3 | ||
|
|
653b9044b8 | ||
|
|
9e825f5452 | ||
|
|
a6bd06db43 | ||
|
|
21ad46f59d | ||
|
|
29aa40de27 | ||
|
|
3a77ffb593 | ||
|
|
d7491a72ed | ||
|
|
cd9a429270 | ||
|
|
bac9e5aaa2 | ||
|
|
126c9c1f14 | ||
|
|
ddca7c4146 | ||
|
|
6ae0023cd8 | ||
|
|
c2e3551e4f | ||
|
|
f308079f59 | ||
|
|
470fe5949d | ||
|
|
d53412258b | ||
|
|
2f0aaa7188 | ||
|
|
728ec2fb33 | ||
|
|
20579acf55 | ||
|
|
ba7720ff2c | ||
|
|
c2b0e9fa59 | ||
|
|
10d3c9651a | ||
|
|
2221f8c33d | ||
|
|
47416cdf3c | ||
|
|
dafd1d7066 | ||
|
|
b6893210b7 | ||
|
|
937d8a0909 | ||
|
|
5991711a60 | ||
|
|
0afd392c53 | ||
|
|
71d517fbda | ||
|
|
981bb6f4a5 | ||
|
|
cada6fc48f | ||
|
|
0b115a925b | ||
|
|
b056b87c6d | ||
|
|
f8650059d7 | ||
|
|
942fb17cec | ||
|
|
f87cfa33b7 | ||
|
|
f4e081850a | ||
|
|
8983e0b4bd | ||
|
|
24d64897dc | ||
|
|
6289d66c59 | ||
|
|
1281b54d7d | ||
|
|
c6aa12a2aa | ||
|
|
9a72503b39 | ||
|
|
f787395865 | ||
|
|
45dae2eb4d | ||
|
|
4899a9e9d5 | ||
|
|
aebf2caecc | ||
|
|
e8b3fe6052 | ||
|
|
6c2858102a | ||
|
|
b17ea62f95 | ||
|
|
2ca7d118fc | ||
|
|
fae8492cf3 | ||
|
|
56461a3428 | ||
|
|
df9c505961 | ||
|
|
6482524705 | ||
|
|
f4c2c12b9d | ||
|
|
299e16f1e2 | ||
|
|
b099a6658b | ||
|
|
7f1d06588e | ||
|
|
b970d9ed82 | ||
|
|
712c1b6af7 | ||
|
|
5eb8bb8c05 | ||
|
|
47dcb01852 | ||
|
|
7c16051cd3 | ||
|
|
651a3d67e9 | ||
|
|
678df4256e | ||
|
|
57b077f648 | ||
|
|
0258be7a05 | ||
|
|
1f417d0ddc | ||
|
|
bb3983b35d | ||
|
|
bdad810b4b | ||
|
|
81c8d24495 | ||
|
|
09eef74ccb | ||
|
|
c80a6c1d12 | ||
|
|
d11258e90b | ||
|
|
b9457b7448 | ||
|
|
5b2b96c5b6 | ||
|
|
a94fe5ed1b | ||
|
|
461edc93aa | ||
|
|
1a427ef047 | ||
|
|
0ed1d38b37 | ||
|
|
a653ada9b5 | ||
|
|
54da393df7 | ||
|
|
7c78e5ed58 | ||
|
|
3539979ecc | ||
|
|
e8f53b9063 | ||
|
|
08156fda53 | ||
|
|
91c9aca479 | ||
|
|
452aff7ba8 | ||
|
|
012fa5e56f | ||
|
|
08e638d598 | ||
|
|
6cff520fab | ||
|
|
8e136a2873 | ||
|
|
944008b176 | ||
|
|
26fc1648ed | ||
|
|
75783677ea | ||
|
|
55cc6f2640 | ||
|
|
2db40ba4f7 | ||
|
|
a43718427e | ||
|
|
b5169d0d15 | ||
|
|
dcdda3b770 | ||
|
|
227ff783f6 | ||
|
|
33ffc0abe5 | ||
|
|
561e4ab441 | ||
|
|
4b67c5f2f5 | ||
|
|
29d159dd11 | ||
|
|
7adeed96fe | ||
|
|
93362f939b | ||
|
|
a16b701032 | ||
|
|
e3a09004c6 | ||
|
|
68faaebb37 | ||
|
|
4d72882d40 | ||
|
|
f05e66b039 | ||
|
|
28f44bfe61 | ||
|
|
1e3fc96adc | ||
|
|
4e8ecc5447 | ||
|
|
14aefb9b23 | ||
|
|
4cb3c57865 | ||
|
|
105da6be52 | ||
|
|
994c14e70d | ||
|
|
284531ab56 | ||
|
|
484d00e06d | ||
|
|
350275ece1 | ||
|
|
c00da37443 | ||
|
|
d2acd5f7e9 | ||
|
|
98491bb277 | ||
|
|
8e3189d9a0 | ||
|
|
be58389888 | ||
|
|
3f1299d6ed | ||
|
|
d0bc2f5250 | ||
|
|
f8d9828f85 | ||
|
|
99682ad5fd | ||
|
|
8cea4bcc45 | ||
|
|
d9f6fdf492 | ||
|
|
bb77ed8673 | ||
|
|
508d6dc5c3 | ||
|
|
9fd72ba073 | ||
|
|
c24477f90f | ||
|
|
3555ef8f69 | ||
|
|
526b030305 | ||
|
|
caef4a6670 | ||
|
|
e48b138baa | ||
|
|
ecffab176c | ||
|
|
2d9ce87d9c | ||
|
|
f2e4482c2b | ||
|
|
cab852c4d0 | ||
|
|
8f631a9e33 | ||
|
|
6fef06be3a | ||
|
|
b5a0dca8c5 | ||
|
|
5521e46696 | ||
|
|
b006403eff | ||
|
|
794303795d | ||
|
|
00d7cad822 | ||
|
|
4a9260ad37 | ||
|
|
62c56faec0 | ||
|
|
2bce281c7d | ||
|
|
98f0460c24 | ||
|
|
8a1cf06df3 | ||
|
|
da4d1810b7 | ||
|
|
8d23efeb0c | ||
|
|
03329051a9 | ||
|
|
4e11103806 | ||
|
|
636607df99 | ||
|
|
8260debc30 | ||
|
|
0104750c59 | ||
|
|
cbffb8304b | ||
|
|
2233c7ed37 | ||
|
|
2d2f0cbe68 | ||
|
|
14251eaa61 | ||
|
|
edf3f88136 | ||
|
|
0db3a26557 | ||
|
|
c72f8c5923 | ||
|
|
0a2ba097df | ||
|
|
4fe3a05f55 | ||
|
|
685fb6d770 | ||
|
|
42347b4114 | ||
|
|
b68653fc32 | ||
|
|
74eb4093c3 | ||
|
|
be40fbfb53 | ||
|
|
190f9801a8 | ||
|
|
9ac3d812ac | ||
|
|
9e87c2526a | ||
|
|
7f0e7fe4b2 | ||
|
|
df185cef71 | ||
|
|
28574f6203 | ||
|
|
f34dcb5974 | ||
|
|
e3ab99bb36 | ||
|
|
e4a66d68d4 | ||
|
|
090ee1b021 | ||
|
|
2501bafc46 | ||
|
|
37161a79e8 | ||
|
|
fb0edce5b0 | ||
|
|
fe13c7e383 | ||
|
|
910f753c61 | ||
|
|
dcd9361a45 | ||
|
|
58638f67e8 | ||
|
|
423f01c27f | ||
|
|
8350870406 | ||
|
|
737ad7e807 | ||
|
|
d03120778a | ||
|
|
0dd85bae82 | ||
|
|
332a03230a | ||
|
|
f3122c7001 | ||
|
|
c46e97c990 | ||
|
|
2a843ea14e | ||
|
|
c82485520e | ||
|
|
b44d833656 | ||
|
|
58e4314856 | ||
|
|
d9bf8b3787 | ||
|
|
fd49c2d34d | ||
|
|
9f08a80a25 | ||
|
|
522b84e0ec | ||
|
|
f8970a6693 | ||
|
|
91b7e98776 | ||
|
|
2eb6e479e6 | ||
|
|
7c5973d56d | ||
|
|
dda0438d82 | ||
|
|
1bbbcd0c8e | ||
|
|
77f77073b2 | ||
|
|
506fbca15d | ||
|
|
d7f5769ead | ||
|
|
160dd3d1e8 | ||
|
|
de41ce2135 | ||
|
|
e0f97a9bdb | ||
|
|
5f1eaae00b | ||
|
|
721866b782 | ||
|
|
bfd01b0549 | ||
|
|
a0ca9daa97 | ||
|
|
129dbb3cd2 | ||
|
|
d06df8aafb | ||
|
|
bf46380aae | ||
|
|
73b608d133 | ||
|
|
06ce05bcb0 | ||
|
|
62d35686b3 | ||
|
|
7182665584 | ||
|
|
a42f700154 | ||
|
|
dc41f10fb7 | ||
|
|
6a8cd912bd | ||
|
|
dc2505c1ff | ||
|
|
d6a8c17860 | ||
|
|
2fc428c25b | ||
|
|
32831f9e6c | ||
|
|
f74dc38917 | ||
|
|
cf09a7e922 | ||
|
|
7ed9d4e13b | ||
|
|
e18e37752e | ||
|
|
6e6ea85c31 | ||
|
|
e85451d2d6 | ||
|
|
a4cc6f59fc | ||
|
|
d9f3fc8a5c | ||
|
|
a18a544f2f | ||
|
|
a973a10297 | ||
|
|
5bfd9ea9f8 | ||
|
|
f5b4e536b4 | ||
|
|
693b88df22 | ||
|
|
aff88c6078 | ||
|
|
6754511b2a | ||
|
|
68272b43e0 | ||
|
|
a88f68564a | ||
|
|
0d52ce1a3b | ||
|
|
ca5a9f46dd | ||
|
|
b0346cb7d8 | ||
|
|
f154f26e39 | ||
|
|
220d037088 | ||
|
|
2ca10d4c75 | ||
|
|
7bd6442fb6 | ||
|
|
57fa5106cf | ||
|
|
72c8dbccd7 | ||
|
|
b5f52079ed | ||
|
|
7649a9189a | ||
|
|
247a866f3a | ||
|
|
58f34cae64 | ||
|
|
65573e904c | ||
|
|
2f5c21c45d | ||
|
|
fdb2493ae4 | ||
|
|
f4b4dea414 | ||
|
|
334f9b11ca | ||
|
|
b794cff536 | ||
|
|
08d830e0bd | ||
|
|
7d0c147e08 | ||
|
|
e8e672efcb | ||
|
|
631b53773c | ||
|
|
abc0ac26d1 | ||
|
|
407c259aa7 | ||
|
|
736e1c404c | ||
|
|
05c2ccd29e | ||
|
|
32aa3fa9e0 | ||
|
|
f2cf5ce054 | ||
|
|
6d1758c7b7 | ||
|
|
02f4336b8b | ||
|
|
9279cb567b | ||
|
|
70358a6e3a | ||
|
|
2498a20a2e | ||
|
|
0ea469f796 | ||
|
|
c961f3db39 | ||
|
|
4b6cd0d6df | ||
|
|
42c4d0c0fc | ||
|
|
bac4d39f51 | ||
|
|
12f99c0451 | ||
|
|
3daf50e765 | ||
|
|
b5576df9a7 | ||
|
|
a14f3c9e7d | ||
|
|
b68a9f8be1 | ||
|
|
6cd27f5444 | ||
|
|
18d6eeff1a | ||
|
|
a238dcb20c | ||
|
|
66b0632584 | ||
|
|
5d8928e3b6 | ||
|
|
46ddee1111 | ||
|
|
21fbb8fe47 | ||
|
|
1da7dce716 | ||
|
|
b7a38821f3 | ||
|
|
fddc8b9b59 | ||
|
|
c88310a2d3 | ||
|
|
cfcc08fe67 | ||
|
|
33f6aa0316 | ||
|
|
eab198c78b | ||
|
|
485cb2d678 | ||
|
|
3f76cf27d0 | ||
|
|
9d56115ac8 | ||
|
|
4b3fdcab54 | ||
|
|
0132e6129b | ||
|
|
712c456527 | ||
|
|
a2fe7a53cc | ||
|
|
beeec49ccf | ||
|
|
eda0ce2cfd | ||
|
|
34f9e1a2e4 | ||
|
|
2ee895491f | ||
|
|
e8948ce2f1 | ||
|
|
2d9561796f | ||
|
|
543bf922a6 | ||
|
|
a270cf565c | ||
|
|
e72c64b1a9 | ||
|
|
87cf42ddea | ||
|
|
2f1c2de00c | ||
|
|
6857d2be4e | ||
|
|
b91a82eecb | ||
|
|
e139fa12df | ||
|
|
8a6568dcce | ||
|
|
3d5f93dc4e | ||
|
|
15ab7e2adc | ||
|
|
26cf3ffd90 | ||
|
|
3fe5020aa4 | ||
|
|
27dc26b81a | ||
|
|
5efea5e456 | ||
|
|
af5f937c6d | ||
|
|
2f2055d760 | ||
|
|
9331955bcb | ||
|
|
7114e80e9f | ||
|
|
463026c2ac | ||
|
|
7f46ba1191 | ||
|
|
60686f151e | ||
|
|
ca4c6a7128 | ||
|
|
5f73663797 | ||
|
|
f7e1100d75 | ||
|
|
13cda63eb6 | ||
|
|
ea9f190683 | ||
|
|
8511d032eb | ||
|
|
02b32388dc | ||
|
|
e2171096b5 | ||
|
|
3ce3d85ef1 | ||
|
|
35ef3c51dc | ||
|
|
6e162b95fc | ||
|
|
b475380508 | ||
|
|
20391a7d93 | ||
|
|
fe3973c376 | ||
|
|
650ca7035e | ||
|
|
9e15c94d5e | ||
|
|
5a3462f9d5 | ||
|
|
4bf4ac05a4 | ||
|
|
7f7a934382 | ||
|
|
0cafac8f38 | ||
|
|
38cbb7c717 | ||
|
|
90d0303b3b | ||
|
|
62168f7d4f | ||
|
|
6892132128 | ||
|
|
cb715e8a2a | ||
|
|
64fe77005e | ||
|
|
33089a6a0c | ||
|
|
79f04ff750 | ||
|
|
59e57b8d56 | ||
|
|
46b74ae70d | ||
|
|
a6cbabbaba | ||
|
|
ece2092932 | ||
|
|
a0bd028496 | ||
|
|
f214c73b02 | ||
|
|
99d43706cc | ||
|
|
0845564356 | ||
|
|
955dc75078 | ||
|
|
3b48f660b2 | ||
|
|
8e5986b6b9 | ||
|
|
040c4569d5 | ||
|
|
8b052f2764 | ||
|
|
eafd4ee634 | ||
|
|
e53d52c2b7 | ||
|
|
d3fbfb5169 | ||
|
|
34f64f2d33 | ||
|
|
0d39e36a46 | ||
|
|
bc70ca90d5 | ||
|
|
b66717731c | ||
|
|
76ee72c0ea | ||
|
|
e31833a486 | ||
|
|
0ec14e4ed6 | ||
|
|
bd57310ee6 | ||
|
|
2c3b1d3a22 | ||
|
|
c6b23de4f1 | ||
|
|
998449b71c | ||
|
|
5c79767406 | ||
|
|
b29bc60aa9 | ||
|
|
81af8b5e98 | ||
|
|
7b1c9152fa | ||
|
|
f432260f65 | ||
|
|
a1a37e2102 | ||
|
|
b85c38fc5d | ||
|
|
7623835cd0 | ||
|
|
c1eeaf3748 | ||
|
|
f94c0fa609 | ||
|
|
a8807155d7 | ||
|
|
d740c208e7 | ||
|
|
e951f5df1a | ||
|
|
8012c5c3a7 | ||
|
|
ee13da2bb3 | ||
|
|
70b08d2f0a | ||
|
|
8b57956505 | ||
|
|
1aacf5b75f | ||
|
|
240b1b0a64 | ||
|
|
9fef721e60 | ||
|
|
cabfd3499d | ||
|
|
be8a78a795 | ||
|
|
2e6f00cc1f | ||
|
|
b872bbab30 | ||
|
|
ccfa7c4c72 | ||
|
|
df544c114e | ||
|
|
2f21abd777 | ||
|
|
1581970179 | ||
|
|
48164ca420 | ||
|
|
989ff6b2ab | ||
|
|
39391ab607 | ||
|
|
ca48068aa4 | ||
|
|
4496bbdbed | ||
|
|
4bbfe8e792 | ||
|
|
13a29cae8a | ||
|
|
81314c422a | ||
|
|
0b5f733c55 | ||
|
|
0ab6c17ef2 | ||
|
|
f09e519b47 | ||
|
|
5276814156 | ||
|
|
c5b34bda25 | ||
|
|
ab34ac8fff | ||
|
|
56b85aada5 | ||
|
|
07e79e2572 | ||
|
|
bb9927d252 | ||
|
|
e08e1c94fd | ||
|
|
b2934885b1 | ||
|
|
b2fac92338 | ||
|
|
52bfc91781 | ||
|
|
5507ee420d | ||
|
|
8d7201c477 | ||
|
|
0b3174be90 | ||
|
|
b3eeba27b0 | ||
|
|
35b5a2e82d | ||
|
|
4a56f45da9 | ||
|
|
2443223a91 | ||
|
|
9cc28b6789 | ||
|
|
36c3dd6509 | ||
|
|
abf2248d3e | ||
|
|
ac9e854b34 | ||
|
|
6a943f0000 | ||
|
|
1cf28aab92 | ||
|
|
7f9ab5eb96 | ||
|
|
d40f71ff5c | ||
|
|
7936c1243d | ||
|
|
dba45334c0 | ||
|
|
51e35a5838 | ||
|
|
a9d3a39c56 | ||
|
|
f490014415 | ||
|
|
5733c5fff1 | ||
|
|
8e395cdeab | ||
|
|
3f97ae78b3 | ||
|
|
eef3ea7a6f | ||
|
|
325d6ec506 | ||
|
|
69ae56d923 | ||
|
|
ef830ebbb6 | ||
|
|
6591d5aec5 | ||
|
|
5027b876a3 | ||
|
|
f34d744f36 | ||
|
|
e61525de50 | ||
|
|
1fcc8f7a28 | ||
|
|
a0fe934e36 | ||
|
|
0bd667223b | ||
|
|
62f1d6a3f8 | ||
|
|
a69b1259ca | ||
|
|
36a9252ce3 | ||
|
|
48f8c79898 | ||
|
|
ce7b2ec996 | ||
|
|
8b548d699e | ||
|
|
c8971ed41f | ||
|
|
44d28502f4 | ||
|
|
8bd6b02233 | ||
|
|
04ebf1c056 | ||
|
|
fe508b6114 | ||
|
|
b5793d0f2c | ||
|
|
019ca9c58f | ||
|
|
923241e450 | ||
|
|
32527cbf59 | ||
|
|
aca4a01aed | ||
|
|
b78d2e0b1b | ||
|
|
d794ecc8b9 | ||
|
|
9ecdd96ee4 | ||
|
|
09446a7ec7 | ||
|
|
9ea3ea71d9 | ||
|
|
c094193b59 | ||
|
|
ca0d59f7e6 | ||
|
|
a0cf892ea8 | ||
|
|
7aed2a563f | ||
|
|
7640087fb5 | ||
|
|
26e547aa36 | ||
|
|
88b9b9a28b | ||
|
|
c0b661d9a8 | ||
|
|
fa2c323ee0 | ||
|
|
bebcde4144 | ||
|
|
a071cd7a67 | ||
|
|
4ec1299d74 | ||
|
|
fc4a9ac96b | ||
|
|
abcab05fbe | ||
|
|
aed4198add | ||
|
|
315ae1dd34 | ||
|
|
868304ffc8 | ||
|
|
0c5c96c3fe | ||
|
|
1a408be504 | ||
|
|
fff8e5346b | ||
|
|
deee7efbce | ||
|
|
cd5f05c059 | ||
|
|
3ecbd4a555 | ||
|
|
6b76acbaa2 | ||
|
|
a22f73b93d | ||
|
|
c05b9a3aac | ||
|
|
a33e34d2fa | ||
|
|
2e8b172dcc | ||
|
|
773ae4c476 | ||
|
|
229bf8a2a7 | ||
|
|
7566c761ef | ||
|
|
1f4b565bb7 | ||
|
|
fe61cb822d | ||
|
|
df1ba3bb7f | ||
|
|
7e70bef84e | ||
|
|
536201602e | ||
|
|
61c1c58c41 | ||
|
|
a2e1fe50b1 | ||
|
|
7b67a03b90 | ||
|
|
168d296d73 | ||
|
|
c1c183f2fd | ||
|
|
1a03488849 | ||
|
|
ee48296c16 | ||
|
|
9b0620b5cd | ||
|
|
81674811d8 | ||
|
|
a985f72651 | ||
|
|
2264700c53 | ||
|
|
e2fabb1a15 | ||
|
|
71a0628dab | ||
|
|
aa25a2c521 | ||
|
|
db95c35cbb | ||
|
|
5cbefdb710 | ||
|
|
7535a2d6a6 | ||
|
|
9fce60e6fe | ||
|
|
51c81d8111 | ||
|
|
214d04d69f | ||
|
|
6163562235 | ||
|
|
5699356dba | ||
|
|
5d1bcef3c2 | ||
|
|
1d9c75119b | ||
|
|
6bc673302f | ||
|
|
e8a384dacd | ||
|
|
5f16f0f49e | ||
|
|
705820ffad | ||
|
|
283fc81091 | ||
|
|
83b7a94756 | ||
|
|
bf6372b570 | ||
|
|
7a34ac4423 | ||
|
|
a83dc0417d | ||
|
|
efb7c6e21e |
2
.github/helpers/install.sh
vendored
2
.github/helpers/install.sh
vendored
@@ -9,7 +9,7 @@ sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client libmariadb-dev
|
||||
|
||||
pip install frappe-bench
|
||||
git clone "https://github.com/frappe/frappe" --branch "develop" --depth 1
|
||||
git clone "https://github.com/frappe/frappe" --branch "version-15" --depth 1
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,3 +36,4 @@ helpdesk/public/frontend
|
||||
helpdesk/public/node_modules
|
||||
helpdesk/www/helpdesk/index.html
|
||||
desk/components.d.ts
|
||||
desk/stats.html
|
||||
329
AGENTS.md
Normal file
329
AGENTS.md
Normal file
@@ -0,0 +1,329 @@
|
||||
# Copilot Instructions for Frappe Helpdesk
|
||||
|
||||
## Project Overview
|
||||
|
||||
Frappe Helpdesk is an open-source ticket management tool built on the [Frappe Framework](https://github.com/frappe/frappe) (Python backend) and [Frappe UI](https://github.com/frappe/frappe-ui) (Vue.js frontend). It provides agent/customer portals, SLAs, assignment rules, a knowledge base, and saved replies.
|
||||
|
||||
## Tech Stack & Architecture
|
||||
|
||||
- **Backend**: Frappe Framework with Python
|
||||
- **Frontend**: Vue 3 + TypeScript SPA with Vite dev server
|
||||
- **Database**: MariaDB with structured doctypes
|
||||
- **Real-time**: WebSockets via Socket.IO for live updates
|
||||
- **UI Library**: frappe-ui for consistent design components
|
||||
- **Styling**: Tailwind CSS with semantic design tokens
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Backend (Python):** Located in `helpdesk/`.
|
||||
- API endpoints: `helpdesk/api/`
|
||||
- Business logic: `helpdesk/utils.py`, `helpdesk/search.py`, etc.
|
||||
- Extensibility: `helpdesk/extends/`, `helpdesk/overrides/`
|
||||
- **Frontend (Vue.js):** Located in `desk/`.
|
||||
- Main entry: `desk/src/main.js`, `desk/src/App.vue`
|
||||
- Components: `desk/src/components/`
|
||||
- State: `desk/src/stores/`
|
||||
- Routing: `desk/src/router/`
|
||||
- Styles: Tailwind via `desk/index.css`, config in `desk/tailwind.config.js`
|
||||
|
||||
# Frontend: Vue 3 Development Standards
|
||||
|
||||
## Frontend Project Context
|
||||
|
||||
- `desk/src/` contains the main Vue 3 frontend source code
|
||||
- Vue 3.x with Composition API as default approach
|
||||
- TypeScript for type safety and better development experience
|
||||
- Single File Components (`.vue`) with `<script setup lang="ts">` syntax preferred
|
||||
- Vite as the build tool for fast development and optimized builds
|
||||
- Pinia for state management (unlike gameplan which avoids state libraries)
|
||||
- frappe-ui for UI components and data fetching utilities
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Architecture & Component Design
|
||||
|
||||
- Favor the Composition API (`setup` functions and composables) over the Options API
|
||||
- Organize components and composables by feature or domain for scalability
|
||||
- Separate UI-focused components (presentational) from logic-focused components (containers)
|
||||
- Use PascalCase for component names and file names
|
||||
- Keep components small and focused on one concern
|
||||
- Use `<script setup lang="ts">` syntax for brevity and performance
|
||||
- Favor slots and scoped slots for flexible composition
|
||||
|
||||
### TypeScript Integration
|
||||
|
||||
- TypeScript is enabled but not in strict mode (as per actual tsconfig.json)
|
||||
- Use `<script setup lang="ts">` with `defineProps` and `defineEmits`
|
||||
- Leverage `PropType<T>` for typed props and default values
|
||||
- Use interfaces or type aliases for complex prop and state shapes
|
||||
- Define types for event handlers, refs, and `useRoute`/`useRouter` hooks
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
- Always prefer Tailwind CSS for styling
|
||||
- Use utility classes for layout and spacing
|
||||
- Use semantic class names wherever possible:
|
||||
- Background colors: `bg-surface-white`, `bg-surface-gray-1` through `bg-surface-gray-9`, `bg-surface-black`
|
||||
- Text colors: `text-ink-white`, `text-ink-gray-1` through `text-ink-gray-9`, `text-ink-black`
|
||||
- Fill colors: `fill-ink-*`
|
||||
- Placeholder colors: `placeholder-ink-*`
|
||||
- Border colors: `border-outline-white`, `border-outline-gray-1` through `border-outline-gray-5`, `border-outline-black`
|
||||
- Font sizes: `text-xs`, `text-sm`, `text-base`, `text-lg`, `text-xl`, `text-2xl`, `text-3xl`
|
||||
- Multiline text: `text-p-xs`, `text-p-sm`, `text-p-base`, `text-p-lg`, `text-p-xl`, `text-p-2xl`
|
||||
- Always use gray shades for everything, never use color shades even for primary states
|
||||
- Implement mobile-first, responsive design with CSS Grid and Flexbox
|
||||
- Ensure styles are accessible (contrast, focus states)
|
||||
|
||||
### Data Fetching with frappe-ui
|
||||
|
||||
- Use `createResource`, `createListResource`, and `createDocumentResource` from frappe-ui for data fetching
|
||||
- Handle loading, error, and success states explicitly
|
||||
- These are the older but stable composables used throughout the project
|
||||
|
||||
When working with a list of documents:
|
||||
|
||||
```ts
|
||||
const tickets = createListResource({
|
||||
doctype: "HD Ticket",
|
||||
filters: { status: "Open" },
|
||||
fields: ["name", "subject", "modified"],
|
||||
limit: 20,
|
||||
auto: true,
|
||||
});
|
||||
|
||||
// Access data
|
||||
tickets.data; // Array of ticket objects
|
||||
tickets.loading; // Boolean loading state
|
||||
tickets.error; // Error object if any
|
||||
|
||||
// Operations
|
||||
tickets.reload(); // Refresh the list
|
||||
tickets.update({ name: "<id>", subject: "Updated Subject" }); // Update item
|
||||
tickets.insert({ subject: "New Ticket" }); // Add new item
|
||||
tickets.delete("<id>"); // Delete item
|
||||
```
|
||||
|
||||
When working with a single document:
|
||||
|
||||
```ts
|
||||
const ticket = createDocumentResource({
|
||||
doctype: "HD Ticket",
|
||||
name: "<ticket-id>",
|
||||
auto: true,
|
||||
});
|
||||
|
||||
// Access data
|
||||
ticket.doc; // Document object
|
||||
ticket.loading; // Boolean loading state
|
||||
ticket.error; // Error object if any
|
||||
|
||||
// Operations
|
||||
ticket.get(); // Fetch/refresh document
|
||||
ticket.setValue("status", "Resolved"); // Update field
|
||||
ticket.save(); // Save changes
|
||||
ticket.delete(); // Delete document
|
||||
```
|
||||
|
||||
For API calls:
|
||||
|
||||
```ts
|
||||
const apiCall = createResource({
|
||||
url: "helpdesk.api.ticket.assign_ticket_to_agent",
|
||||
onSuccess(data) {
|
||||
// Handle success
|
||||
},
|
||||
onError(error) {
|
||||
// Handle error
|
||||
},
|
||||
});
|
||||
|
||||
// Make the call
|
||||
apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
```
|
||||
|
||||
### Icons
|
||||
|
||||
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
|
||||
- Use Lucide icons for all new components:
|
||||
```vue
|
||||
<template>
|
||||
<LucidePlus class="size-4" />
|
||||
</template>
|
||||
```
|
||||
- For script usage, import Lucide icons like this:
|
||||
```ts
|
||||
import LucidePlus from "~icons/lucide/plus";
|
||||
import LucideTicket from "~icons/lucide/ticket";
|
||||
```
|
||||
- Only use FeatherIcon for maintaining existing components:
|
||||
```vue
|
||||
<template>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
- Use Pinia stores for complex shared state
|
||||
- For simple local state, use `ref` and `reactive` within `setup`
|
||||
- Use `computed` for derived state
|
||||
- Keep state normalized for complex structures
|
||||
|
||||
### Performance & Accessibility
|
||||
|
||||
- Apply `v-once` and `v-memo` for static or infrequently changing elements
|
||||
- Avoid unnecessary watchers; prefer `computed` where possible
|
||||
- Use semantic HTML elements and ARIA attributes
|
||||
- Manage focus for modals and dynamic content
|
||||
- Ensure keyboard navigation for interactive components
|
||||
|
||||
# Backend: Frappe Framework Development Standards
|
||||
|
||||
## Backend Project Context
|
||||
|
||||
- Frappe Framework provides full-stack capabilities with ORM, permissions, and background jobs
|
||||
- MariaDB database with structured DocTypes for business entities
|
||||
- Redis for background workers and caching
|
||||
- Socket.IO for real-time updates
|
||||
|
||||
## Critical Development Workflows
|
||||
|
||||
**Local Development Setup**:
|
||||
|
||||
- Backend runs via `bench start` from frappe-bench directory
|
||||
- Python web server: Assume server is already started on port 8000
|
||||
- Frontend dev server: `cd desk && yarn dev` (typically runs on port 8080)
|
||||
- Site-specific commands: `bench --site helpdesk.test <command>`
|
||||
|
||||
**Data Query Patterns**:
|
||||
|
||||
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
|
||||
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
|
||||
- Example query patterns from helpdesk context
|
||||
|
||||
**Debugging Workflow**:
|
||||
|
||||
- Create debug files like `./helpdesk/debug.py` with `def execute():` function
|
||||
- Run with `bench --site helpdesk.test execute helpdesk.debug.execute`
|
||||
- Use `print()` statements for console output during debugging
|
||||
|
||||
## DocType Architecture Patterns
|
||||
|
||||
- Follow Frappe's DocType conventions for business entities
|
||||
- Implement custom permission logic in `has_permission` hooks
|
||||
- Use proper field types and validation in DocType definitions
|
||||
- Leverage DocType methods for business logic encapsulation
|
||||
|
||||
## Conventions & Patterns
|
||||
|
||||
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`.
|
||||
- **Frontend:** Vue 3 + Vite with TypeScript. Use Pinia for state management. Components auto-imported via `desk/components.d.ts`.
|
||||
- **Testing:** Follow Frappe's conventions for backend tests.
|
||||
- **Localization:** Add translations in `helpdesk/locale/`.
|
||||
- **Custom Logic:** Use `helpdesk/overrides/` for customizations.
|
||||
|
||||
## Utilities and Composables
|
||||
|
||||
- @vueuse/core is available for common Vue utilities
|
||||
- Prefer vueuse composables over custom implementations where applicable
|
||||
- Common composables: `useLocalStorage`, `useDebounce`, `useElementSize`
|
||||
- Always use frappe-ui's data fetching composables (`createResource`, `createListResource`, `createDocumentResource`), never `useFetch`
|
||||
|
||||
## Error Handling & Forms
|
||||
|
||||
- Use global error handlers for uncaught errors
|
||||
- Wrap risky logic in `try/catch` with user-friendly messages
|
||||
- Build forms with controlled `v-model` bindings
|
||||
- Validate on blur or input with debouncing for performance
|
||||
- Ensure accessible labeling and error announcements
|
||||
|
||||
## Integration Points
|
||||
|
||||
- **Frappe Framework:** All backend logic integrates with Frappe ORM, permissions, and hooks (`helpdesk/hooks.py`).
|
||||
- **Frappe UI:** Frontend uses Frappe UI for consistent design components and data fetching.
|
||||
- **Real-time Updates:** WebSockets via Socket.IO for live ticket updates.
|
||||
- **Crowdin:** Translation sync via `crowdin.yml`.
|
||||
|
||||
## Examples
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Create a Python file in `helpdesk/api/`
|
||||
2. Add `@frappe.whitelist()` decorator to functions
|
||||
3. Register in `helpdesk/hooks.py` if needed
|
||||
4. Use `frappe.qb.get_query()` for database queries
|
||||
|
||||
### Adding a Frontend Component
|
||||
|
||||
1. Create Vue component in `desk/src/components/` or `desk/src/pages/`
|
||||
2. Use `<script setup lang="ts">` syntax
|
||||
3. Follow Tailwind semantic class conventions
|
||||
4. Use frappe-ui composables for data fetching
|
||||
5. Add route in `desk/src/router/` if it's a page component
|
||||
|
||||
### Styling Example
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="bg-surface-white border border-outline-gray-2 rounded-lg p-4">
|
||||
<h2 class="text-lg text-ink-gray-9 font-semibold mb-2">Ticket Details</h2>
|
||||
<p class="text-p-sm text-ink-gray-7">{{ ticket.doc.subject }}</p>
|
||||
<LucideTicket class="size-4 text-ink-gray-6" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createDocumentResource } from "frappe-ui";
|
||||
import LucideTicket from "~icons/lucide/ticket";
|
||||
|
||||
const ticket = createDocumentResource({
|
||||
doctype: "HD Ticket",
|
||||
name: props.ticketId,
|
||||
auto: true,
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Key Files & Directories
|
||||
|
||||
### Backend
|
||||
|
||||
- `helpdesk/api/` — Backend API endpoints with @frappe.whitelist() decorators
|
||||
- `helpdesk/utils.py` — Shared backend utilities and helper functions
|
||||
- `helpdesk/hooks.py` — Frappe hooks
|
||||
- `helpdesk/overrides/` — Custom logic and extensions
|
||||
|
||||
### Frontend
|
||||
|
||||
- `desk/src/` — Main Vue 3 frontend source code
|
||||
- `desk/src/components/` — Reusable Vue components
|
||||
- `desk/src/pages/` — Page-level components and routes
|
||||
- `desk/src/stores/` — Pinia stores for state management
|
||||
- `desk/tailwind.config.js` — Tailwind CSS configuration with semantic tokens
|
||||
- `desk/components.d.ts` — Auto-generated component type definitions
|
||||
|
||||
### Development
|
||||
|
||||
- `docker/` — Docker setup for containerized development
|
||||
|
||||
## Code Quality & Standards
|
||||
|
||||
- Follow Vue's official style guide and best practices
|
||||
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
|
||||
- Write meaningful commit messages and maintain clean git history
|
||||
- Document complex logic with JSDoc/TSDoc comments
|
||||
- Only add comments that explain why something is done, not what is done
|
||||
- Keep dependencies updated and audit for security vulnerabilities
|
||||
|
||||
## References
|
||||
|
||||
- [README.md](../README.md) — Full setup and feature list
|
||||
- [Frappe Docs](https://frappeframework.com/docs/) — Framework documentation
|
||||
- [Frappe Helpdesk Docs](https://docs.frappe.io/helpdesk) — Project-specific docs
|
||||
- [Vue 3 Style Guide](https://vuejs.org/style-guide/) — Vue.js best practices
|
||||
- [Tailwind CSS](https://tailwindcss.com/docs) — Utility-first CSS framework
|
||||
|
||||
---
|
||||
|
||||
**For updates, merge new conventions here. If anything is unclear or missing, ask for clarification.**
|
||||
@@ -42,7 +42,7 @@ Managing issues from our customers was a big challenge for us. We were using the
|
||||
|
||||
- **Knowledge Base**: Learn how to create and manage help articles to empower users and reduce tickets.
|
||||
|
||||
- **Canned Responses**: Pre-written replies for common queries to ensure quick and consistent communication.
|
||||
- **Saved Replies**: Pre-written replies for common queries to ensure quick and consistent communication.
|
||||
|
||||
<details open>
|
||||
<summary >View Screenshots</summary>
|
||||
@@ -165,6 +165,7 @@ To setup the repository locally follow the steps mentioned below:
|
||||
1. Start the server by running `bench start`
|
||||
1. In a separate terminal window, create a new site by running `bench new-site helpdesk.test`
|
||||
1. Map your site to localhost with the command `bench --site helpdesk.test add-to-hosts`
|
||||
1. Get the Telephony app. Run `bench get-app https://github.com/frappe/telephony`
|
||||
1. Get the Helpdesk app. Run `bench get-app https://github.com/frappe/helpdesk`
|
||||
1. Run `bench --site helpdesk.test install-app helpdesk`.
|
||||
1. Run `bench build --app helpdesk`
|
||||
@@ -187,6 +188,7 @@ To setup the repository locally follow the steps mentioned below:
|
||||
| Helpdesk Branch | Compatible Frappe Framework Version |
|
||||
|-----------------|-------------------------------------|
|
||||
| main | version-15 |
|
||||
| main | version-16 |
|
||||
| develop | develop branch |
|
||||
|
||||
|
||||
|
||||
5
desk/auto-imports.d.ts
vendored
5
desk/auto-imports.d.ts
vendored
@@ -6,5 +6,8 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
|
||||
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
|
||||
const LucideKeyboard: typeof import('~icons/lucide/keyboard')['default']
|
||||
const LucideSearch: typeof import("~icons/lucide/search")["default"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Helpdesk</title>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -206,12 +205,7 @@
|
||||
<div id="popovers"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script>
|
||||
window.favicon = "{{ favicon }}";
|
||||
window.site_name = "{{ site_name }}";
|
||||
|
||||
document.querySelectorAll("link[rel='icon']").forEach((link) => {
|
||||
link.href = window.favicon;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import * as LucideIcons from "lucide-static";
|
||||
|
||||
const icons = {};
|
||||
for (const icon in LucideIcons) {
|
||||
let iconSvg = LucideIcons[icon];
|
||||
if (icon == "default") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// set stroke-width to 1.5
|
||||
if (iconSvg && iconSvg.includes("stroke-width")) {
|
||||
iconSvg = iconSvg.replace(/stroke-width="2"/g, 'stroke-width="1.5"');
|
||||
}
|
||||
icons[icon] = iconSvg;
|
||||
|
||||
const dashKeys = camelToDash(icon);
|
||||
for (const dashKey of dashKeys) {
|
||||
if (dashKey !== icon) {
|
||||
icons[dashKey] = iconSvg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default icons;
|
||||
|
||||
function camelToDash(key) {
|
||||
// barChart2 -> bar-chart-2
|
||||
const withNumber = key.replace(/[A-Z0-9]/g, (m) => "-" + m.toLowerCase());
|
||||
// barChart2 -> bar-chart2
|
||||
const withoutNumber = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
|
||||
if (withNumber !== withoutNumber) {
|
||||
// both are required because unplugin icon resolver doesn't put a dash before numbers
|
||||
return [withNumber, withoutNumber];
|
||||
}
|
||||
return [withNumber];
|
||||
}
|
||||
@@ -10,50 +10,37 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.22",
|
||||
"@iconify-json/lucide": "^1.1.99",
|
||||
"@iconify-json/ph": "^1.1.5",
|
||||
"@iconify/tools": "^2.2.6",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tiptap/core": "^2.2.4",
|
||||
"@tiptap/core": "^3.11.0",
|
||||
"@twilio/voice-sdk": "^2.15.0",
|
||||
"@vee-validate/zod": "^4.8.2",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vueuse/core": "^10.0.2",
|
||||
"@vueuse/integrations": "^12.0.0",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/integrations": "^13.8.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"echarts": "^5.4.1",
|
||||
"frappe-ui": "0.1.192",
|
||||
"frappe-ui": "0.1.271",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-static": "^0.276.0",
|
||||
"mime": "^3.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"pluralize": "^8.0.0",
|
||||
"sanitize-html": "^2.10.0",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"unplugin-icons": "^0.16.1",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vee-validate": "^4.8.2",
|
||||
"vue": "^3.5.14",
|
||||
"vue-echarts": "^6.5.4",
|
||||
"vue-router": "^4.2.2",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"prosemirror-model": "1.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.1",
|
||||
"prettier": "2.8.4",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.5",
|
||||
"tailwindcss": "^3.4.15"
|
||||
"prettier": "2.8.4",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "0.15.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"prosemirror-model": "1.25.4"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
desk/public/ticketActivity.png
Normal file
BIN
desk/public/ticketActivity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
BIN
desk/public/timers.png
Normal file
BIN
desk/public/timers.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
desk/public/videos/commentVideo.mp4
Normal file
BIN
desk/public/videos/commentVideo.mp4
Normal file
Binary file not shown.
BIN
desk/public/videos/mailVideo.mp4
Normal file
BIN
desk/public/videos/mailVideo.mp4
Normal file
Binary file not shown.
BIN
desk/public/videos/sidebarVideo.mp4
Normal file
BIN
desk/public/videos/sidebarVideo.mp4
Normal file
Binary file not shown.
@@ -2,36 +2,43 @@
|
||||
<FrappeUIProvider>
|
||||
<PortalRoot />
|
||||
</FrappeUIProvider>
|
||||
<KeymapDialog />
|
||||
<Dialogs />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Dialogs } from "@/components/dialogs";
|
||||
import KeymapDialog from "@/pages/KeymapDialog.vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { stopSession } from "@/telemetry";
|
||||
import { FrappeUIProvider, toast } from "frappe-ui";
|
||||
import { FrappeUIProvider, toast, setConfig } from "frappe-ui";
|
||||
import { computed, defineAsyncComponent, h, onMounted, onUnmounted } from "vue";
|
||||
import Wifi from "~icons/lucide/wifi";
|
||||
import WifiOff from "~icons/lucide/wifi-off";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
useConfigStore();
|
||||
import { useFavicon } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { __ } from "./translation";
|
||||
import { isCustomerPortal, getBrowserTimezone } from "./utils";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const { favicon } = storeToRefs(configStore);
|
||||
|
||||
useFavicon(favicon);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("online", () => {
|
||||
toast.create({
|
||||
message: "You are now online",
|
||||
icon: h(Wifi),
|
||||
message: __("You are now online"),
|
||||
icon: h(Wifi, { class: "text-white" }),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
toast.create({
|
||||
message: "You are now offline",
|
||||
icon: h(WifiOff),
|
||||
message: __("You are now offline"),
|
||||
icon: h(WifiOff, { class: "text-white" }),
|
||||
});
|
||||
});
|
||||
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
|
||||
setConfig("systemTimezone", window.timezone?.system || null);
|
||||
});
|
||||
|
||||
const AgentPortalRoot = defineAsyncComponent(
|
||||
@@ -49,8 +56,4 @@ const PortalRoot = computed(() => {
|
||||
return CustomerPortalRoot;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<template>
|
||||
<Popover placement="right-start" class="flex w-full">
|
||||
<Popover
|
||||
placement="right-start"
|
||||
class="flex w-full"
|
||||
trigger="hover"
|
||||
:hoverDelay="0.1"
|
||||
:leaveDelay="0.1"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
@@ -16,14 +22,14 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
class="flex flex-col justify-between mx-3 p-1.5 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
>
|
||||
<div v-for="app in apps.data" key="name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-3 hover:bg-gray-100"
|
||||
class="flex gap-2 rounded items-center hover:bg-gray-100 p-1.5"
|
||||
>
|
||||
<img class="size-8" :src="app.logo" />
|
||||
<img class="size-6" :src="app.logo" />
|
||||
<div class="text-sm" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
@@ -35,8 +41,8 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Popover, createResource } from "frappe-ui";
|
||||
import AppsIcon from "./icons/AppsIcon.vue";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
import AppsIcon from "./icons/AppsIcon.vue";
|
||||
|
||||
const apps = createResource({
|
||||
url: "frappe.apps.get_apps",
|
||||
@@ -48,7 +54,7 @@ const apps = createResource({
|
||||
name: "frappe",
|
||||
logo: "/assets/helpdesk/desk/desk.png",
|
||||
title: "Desk",
|
||||
route: "/app",
|
||||
route: "/desk/helpdesk",
|
||||
},
|
||||
];
|
||||
data.map((app) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ import { useConfigStore } from "@/stores/config";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { call, createResource } from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center truncate">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
v-if="selectedValue"
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: 'Canned Responses',
|
||||
size: '4xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="'Site Down'"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<div
|
||||
v-if="filteredTemplates.length"
|
||||
class="mt-2 grid max-h-[560px] grid-cols-1 md:grid-cols-3 gap-2 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100"
|
||||
@click="emit('apply', template)"
|
||||
>
|
||||
<div class="border-b pb-2 text-base font-semibold">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<TextEditor
|
||||
v-if="template.message"
|
||||
:content="template.message"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<div class="flex h-56 flex-col items-center justify-center">
|
||||
<div class="text-lg text-gray-500">
|
||||
{{ "No templates found" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button
|
||||
label="New Canned Response"
|
||||
@click="
|
||||
() => {
|
||||
$router.push('/canned-responses#new');
|
||||
templates.data = null;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextEditor, createListResource } from "frappe-ui";
|
||||
import { ref, computed, nextTick, watch, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = defineModel();
|
||||
const searchInput = ref("");
|
||||
|
||||
const emit = defineEmits(["apply"]);
|
||||
|
||||
const search = ref("");
|
||||
|
||||
const templates = createListResource({
|
||||
type: "list",
|
||||
doctype: "HD Canned Response",
|
||||
cache: ["cannedResponses", props.doctype],
|
||||
fields: ["title", "message", "modified"],
|
||||
orderBy: "modified desc",
|
||||
pageLength: 99999,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (templates.data == null) {
|
||||
templates.fetch();
|
||||
}
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return (
|
||||
templates.data?.filter((template) => {
|
||||
return template.title.toLowerCase().includes(search.value.toLowerCase());
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
watch(show, (value) => value && nextTick(() => searchInput.value?.el?.focus()));
|
||||
</script>
|
||||
@@ -49,7 +49,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded bg-gray-50 px-4 py-3">
|
||||
<div
|
||||
:id="`comment-${name}`"
|
||||
class="rounded bg-gray-50 transition-colors px-4 py-3"
|
||||
>
|
||||
<TextEditor
|
||||
ref="editorRef"
|
||||
:editor-class="[
|
||||
@@ -59,16 +62,34 @@
|
||||
:content="_content"
|
||||
:editable="editable"
|
||||
:bubble-menu="textEditorMenuButtons"
|
||||
:mentions="userMentions"
|
||||
@change="(event:string) => {_content = event}"
|
||||
@keydown.ctrl.enter.capture.stop="handleSaveComment"
|
||||
@keydown.meta.enter.capture.stop="handleSaveComment"
|
||||
>
|
||||
<template #bottom v-if="editable">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button label="Save" @click="handleSaveComment" variant="solid" />
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
isMobileView
|
||||
? 'Save'
|
||||
: isMac
|
||||
? 'Save (⌘ + ⏎)'
|
||||
: 'Save (Ctrl + ⏎)'
|
||||
"
|
||||
@click="handleSaveComment"
|
||||
variant="solid"
|
||||
/>
|
||||
</div>
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
<div class="flex flex-wrap gap-2" v-if="!editable">
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
v-if="!editable && Boolean(attachments.length)"
|
||||
>
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
@@ -76,6 +97,64 @@
|
||||
:url="a.file_url"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 mt-2"
|
||||
v-if="!editable && enableCommentReactions"
|
||||
>
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
class="flex h-full items-center justify-center rounded-full bg-surface-gray-2 px-2 py-1 text-ink-gray-6 transition hover:bg-surface-gray-3"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<ReactionIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="bg-surface-white rounded-lg shadow-lg p-2 border border-outline-gray-2"
|
||||
>
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
<button
|
||||
v-for="emoji in emojiList"
|
||||
:key="emoji"
|
||||
class="size-6 flex items-center justify-center rounded hover:bg-surface-gray-2 text-lg transition-colors"
|
||||
@click="handleReaction(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
|
||||
<template v-for="reaction in reactionsList" :key="reaction.emoji">
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="bg-surface-gray-7 px-2 py-1 text-center text-p-xs text-ink-white shadow-xl rounded"
|
||||
>
|
||||
<span v-for="(user, idx) in reaction.users" :key="user.user"
|
||||
>{{ user.full_name
|
||||
}}<span v-if="idx < reaction.users.length - 1">, </span></span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors"
|
||||
:class="
|
||||
reaction.current_user_reacted
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
: 'bg-surface-gray-3 text-ink-gray-6 hover:bg-surface-gray-4'
|
||||
"
|
||||
@click="handleReaction(reaction.emoji)"
|
||||
>
|
||||
<span>{{ reaction.emoji }}</span>
|
||||
<span class="font-medium">{{ reaction.count }}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
@@ -97,10 +176,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import ReactionIcon from "@/components/icons/ReactionIcon.vue";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { updateRes as updateComment } from "@/stores/knowledgeBase";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { CommentActivity } from "@/types";
|
||||
import { useDevice } from "@/composables";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import {
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
@@ -113,11 +197,14 @@ import {
|
||||
Avatar,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
Popover,
|
||||
TextEditor,
|
||||
Tooltip,
|
||||
createResource,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { PropType, computed, onMounted, ref } from "vue";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
@@ -126,31 +213,72 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const { getUser } = useUserStore();
|
||||
const { enableCommentReactions } = useConfigStore();
|
||||
|
||||
const { name, creation, content, commenter, commentedBy, attachments } =
|
||||
props.activity;
|
||||
|
||||
const { isMac } = useDevice();
|
||||
const { isMobileView } = useScreenSize();
|
||||
const isTicketMergedComment = computed(() => {
|
||||
const regex = /has been merged with ticket #\d+/;
|
||||
return regex.test(content);
|
||||
});
|
||||
const agentStore = useAgentStore();
|
||||
const userMentions = computed(() => agentStore.dropdown ?? []);
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
const showDialog = ref(false);
|
||||
const editable = ref(false);
|
||||
const _content = ref(content);
|
||||
|
||||
// HTML refs
|
||||
const emojiList = ["👍", "👎", "❤️", "🎉", "👀", "✅"];
|
||||
|
||||
const reactions = ref<
|
||||
Array<{
|
||||
emoji: string;
|
||||
count: number;
|
||||
users: Array<{ user: string; full_name: string }>;
|
||||
current_user_reacted: boolean;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const reactionsList = computed(() => reactions.value || []);
|
||||
|
||||
const fetchReactions = createResource({
|
||||
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.get_reactions",
|
||||
makeParams: () => ({ comment: name }),
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
reactions.value = data || [];
|
||||
},
|
||||
});
|
||||
|
||||
const toggleReaction = createResource({
|
||||
url: "helpdesk.helpdesk.doctype.hd_ticket_comment.hd_ticket_comment.toggle_reaction",
|
||||
makeParams: (emoji: string) => ({ comment: name, emoji }),
|
||||
onSuccess() {
|
||||
fetchReactions.reload();
|
||||
},
|
||||
});
|
||||
|
||||
function handleReaction(emoji: string) {
|
||||
toggleReaction.submit(emoji);
|
||||
}
|
||||
|
||||
const commentBoxRef = ref(null);
|
||||
const editorRef = ref(null);
|
||||
const lastSavedContent = ref(content);
|
||||
const commentBoxState = ref(content);
|
||||
|
||||
function handleEditMode() {
|
||||
editable.value = true;
|
||||
commentBoxState.value = _content.value;
|
||||
editorRef.value.editor.chain().focus("start");
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
_content.value = content;
|
||||
_content.value = commentBoxState.value;
|
||||
editable.value = false;
|
||||
}
|
||||
|
||||
@@ -162,12 +290,13 @@ const deleteComment = createResource({
|
||||
}),
|
||||
onSuccess() {
|
||||
emit("update");
|
||||
showDialog.value = false;
|
||||
toast.success("Comment deleted");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSaveComment() {
|
||||
if (content === _content.value) {
|
||||
if (lastSavedContent.value === _content.value) {
|
||||
editable.value = false;
|
||||
return;
|
||||
}
|
||||
@@ -186,6 +315,7 @@ function handleSaveComment() {
|
||||
{
|
||||
onSuccess: () => {
|
||||
editable.value = false;
|
||||
lastSavedContent.value = _content.value;
|
||||
emit("update");
|
||||
toast.success("Comment updated");
|
||||
},
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
:mentions="agents"
|
||||
:mentions="dropdown"
|
||||
@change="editable ? (newComment = $event) : null"
|
||||
:extensions="[PreserveVideoControls]"
|
||||
:extensions="[ComponentUtils, HandleExcelPaste]"
|
||||
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
>
|
||||
<template #bottom>
|
||||
@@ -39,10 +39,6 @@
|
||||
<!-- Fixed Menu -->
|
||||
<div class="flex justify-between overflow-hidden border-t py-2.5">
|
||||
<div class="flex items-center overflow-x-auto w-[60%]">
|
||||
<TextEditorFixedMenu
|
||||
class="-ml-1"
|
||||
:buttons="textEditorMenuButtons"
|
||||
/>
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
@@ -51,7 +47,8 @@
|
||||
}"
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (loading = uploading) }}
|
||||
<Button
|
||||
theme="gray"
|
||||
variant="ghost"
|
||||
@@ -66,6 +63,10 @@
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<TextEditorFixedMenu
|
||||
class="-ml-0.5"
|
||||
:buttons="textEditorMenuButtons"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 w-[40%]">
|
||||
<Button
|
||||
@@ -81,7 +82,7 @@
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="label"
|
||||
:disabled="commentEmpty"
|
||||
:disabled="isDisabled"
|
||||
:loading="loading"
|
||||
@click="
|
||||
() => {
|
||||
@@ -112,7 +113,7 @@ import { AttachmentIcon } from "@/components/icons/";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
|
||||
import {
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
@@ -121,22 +122,12 @@ import {
|
||||
uploadFunction,
|
||||
} from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { agents: agentsList } = useAgentStore();
|
||||
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
|
||||
const { isManager } = useAuthStore();
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
agentsList.loading ||
|
||||
agentsList.data?.length ||
|
||||
agentsList.list.promise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
agentsList.fetch();
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
ticketId: {
|
||||
type: String,
|
||||
@@ -168,8 +159,8 @@ const newComment = useStorage("commentBoxContent" + props.ticketId, null);
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const attachments = ref([]);
|
||||
const commentEmpty = computed(() => {
|
||||
return isContentEmpty(newComment.value);
|
||||
const isDisabled = computed(() => {
|
||||
return isContentEmpty(newComment.value) || loading.value;
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -188,15 +179,6 @@ const label = computed(() => {
|
||||
return loading.value ? "Sending..." : props.label;
|
||||
});
|
||||
|
||||
const agents = computed(() => {
|
||||
return (
|
||||
agentsList.data?.map((agent) => ({
|
||||
label: agent.agent_name.trimEnd(),
|
||||
value: agent.name,
|
||||
})) || []
|
||||
);
|
||||
});
|
||||
|
||||
function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment);
|
||||
removeAttachmentFromServer(attachment.name);
|
||||
@@ -237,6 +219,17 @@ async function submitComment() {
|
||||
const editorRef = ref(null);
|
||||
const editor = computed(() => editorRef.value?.editor);
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
agentsList.value.loading ||
|
||||
agentsList.value.data?.length ||
|
||||
agentsList.value.list.promise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
agentsList.value.fetch();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
submitComment,
|
||||
editor,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="emailBoxRef"
|
||||
v-show="showEmailBox"
|
||||
class="flex gap-1.5 flex-1"
|
||||
@keydown.ctrl.enter.capture.stop="submitEmail"
|
||||
@@ -59,6 +60,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="commentBoxRef"
|
||||
v-show="showCommentBox"
|
||||
@keydown.ctrl.enter.capture.stop="submitComment"
|
||||
@keydown.meta.enter.capture.stop="submitComment"
|
||||
@@ -97,8 +99,10 @@ import { CommentTextEditor, EmailEditor, TypingIndicator } from "@/components";
|
||||
import { CommentIcon, EmailIcon } from "@/components/icons/";
|
||||
import { useDevice } from "@/composables";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { useShortcut } from "@/composables/shortcuts";
|
||||
import { showCommentBox, showEmailBox } from "@/pages/ticket/modalStates";
|
||||
import { ref, watch } from "vue";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
const content = defineModel("content");
|
||||
@@ -108,6 +112,8 @@ let doc = defineModel();
|
||||
// let doc = inject(TicketSymbol)?.value.doc
|
||||
const emailEditorRef = ref(null);
|
||||
const commentTextEditorRef = ref(null);
|
||||
const emailBoxRef = ref(null);
|
||||
const commentBoxRef = ref(null);
|
||||
|
||||
function toggleEmailBox() {
|
||||
if (showCommentBox.value) {
|
||||
@@ -135,13 +141,21 @@ function submitComment() {
|
||||
}
|
||||
}
|
||||
|
||||
function splitIfString(str: string | string[]) {
|
||||
if (typeof str === "string") {
|
||||
return str.split(",");
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function replyToEmail(data: object) {
|
||||
showEmailBox.value = true;
|
||||
|
||||
emailEditorRef.value.addToReply(
|
||||
data.content,
|
||||
data.to?.split(","),
|
||||
data.cc?.split(","),
|
||||
data.bcc?.split(",")
|
||||
splitIfString(data.to),
|
||||
splitIfString(data.cc),
|
||||
splitIfString(data.bcc)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -186,12 +200,43 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
useShortcut("r", () => {
|
||||
toggleEmailBox();
|
||||
});
|
||||
useShortcut("c", () => {
|
||||
toggleCommentBox();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
replyToEmail,
|
||||
toggleEmailBox,
|
||||
toggleCommentBox,
|
||||
editor: emailEditorRef,
|
||||
});
|
||||
|
||||
onClickOutside(
|
||||
emailBoxRef,
|
||||
() => {
|
||||
if (showEmailBox.value) {
|
||||
showEmailBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: [".tippy-box", ".tippy-content"],
|
||||
}
|
||||
);
|
||||
|
||||
onClickOutside(
|
||||
commentBoxRef,
|
||||
() => {
|
||||
if (showCommentBox.value) {
|
||||
showCommentBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: [".tippy-box", ".tippy-content"],
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
:key="action.label"
|
||||
:label="action.label"
|
||||
@click="action.onClick()"
|
||||
v-bind="action"
|
||||
>
|
||||
<template v-if="action.icon" #prefix>
|
||||
<FeatherIcon :name="action.icon" class="h-4 w-4" />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<Tooltip text="View documentation">
|
||||
<a :href="url" target="_blank">
|
||||
<lucide-info class="h-4 w-4 text-ink-gray-6" :class="color" />
|
||||
<lucide-circle-question-mark
|
||||
class="h-4 w-4 text-ink-gray-6"
|
||||
:class="color"
|
||||
/>
|
||||
</a>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:id="`communication-${name}`"
|
||||
v-bind="$attrs"
|
||||
class="grow cursor-pointer border-transparent bg-white rounded-md shadow text-base leading-6 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
@@ -10,7 +11,7 @@
|
||||
<!-- email design for mobile -->
|
||||
<div v-if="isMobileView" class="flex items-center gap-2 text-sm">
|
||||
<div class="leading-tight">
|
||||
<p>{{ sender.full_name || "No name found" }}</p>
|
||||
<p>{{ sender.full_name || "Guest" }}</p>
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<p class="text-xs md:text-sm text-gray-600">
|
||||
{{ timeAgo(creation) }}
|
||||
@@ -23,7 +24,7 @@
|
||||
</div>
|
||||
<!-- email design for desktop -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<span>{{ sender.full_name || "No name found" }}</span>
|
||||
<span>{{ sender.full_name || "Guest" }}</span>
|
||||
<span class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">{{
|
||||
"<" + sender.name + ">"
|
||||
}}</span>
|
||||
@@ -45,30 +46,10 @@
|
||||
{{ timeAgo(creation) }}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-gray-700"
|
||||
@click="
|
||||
emit('reply', {
|
||||
content: content,
|
||||
to: sender?.name ?? to,
|
||||
})
|
||||
"
|
||||
>
|
||||
<Button variant="ghost" class="text-gray-700" @click="reply">
|
||||
<ReplyIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-gray-700"
|
||||
@click="
|
||||
emit('reply', {
|
||||
content: content,
|
||||
to: to ?? sender.name,
|
||||
cc: cc ? cc : [],
|
||||
bcc: bcc ? bcc : [],
|
||||
})
|
||||
"
|
||||
>
|
||||
<Button variant="ghost" class="text-gray-700" @click="replyAll">
|
||||
<ReplyAllIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Dropdown
|
||||
@@ -132,6 +113,9 @@ import { computed, ref } from "vue";
|
||||
import LucideSplit from "~icons/lucide/split";
|
||||
import { ReplyAllIcon, ReplyIcon } from "./icons";
|
||||
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object,
|
||||
@@ -158,6 +142,8 @@ const {
|
||||
|
||||
const emit = defineEmits(["reply"]);
|
||||
|
||||
const auth = storeToRefs(useAuthStore());
|
||||
|
||||
const { isMobileView } = useScreenSize();
|
||||
|
||||
const showSplitModal = ref(false);
|
||||
@@ -177,6 +163,66 @@ const status = computed(() => {
|
||||
return { label: _status, color: indicator_color };
|
||||
});
|
||||
|
||||
const reply = () => {
|
||||
const user = auth.user.value;
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: user === sender.name ? to : sender.name,
|
||||
});
|
||||
};
|
||||
|
||||
const replyAll = () => {
|
||||
const user = auth.user.value;
|
||||
|
||||
const normalizeAndFilter = (field) => {
|
||||
let arr = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
if (typeof field === "string") {
|
||||
for (let char of field) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
arr.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current) arr.push(current.trim());
|
||||
} else {
|
||||
arr = field || [];
|
||||
}
|
||||
return arr.filter((item) => item !== user && item !== sender.name);
|
||||
};
|
||||
|
||||
const filteredTo = normalizeAndFilter(to);
|
||||
const filteredCc = normalizeAndFilter(cc);
|
||||
const filteredBcc = normalizeAndFilter(bcc);
|
||||
|
||||
let _to, _cc, _bcc;
|
||||
|
||||
if (user === sender.name) {
|
||||
// User is the sender, reply to all original recipients
|
||||
_to = filteredTo.join(", ");
|
||||
_cc = filteredCc;
|
||||
_bcc = filteredBcc;
|
||||
} else {
|
||||
// User is a recipient, reply to sender with all other recipients in cc
|
||||
_to = sender.name;
|
||||
_cc = [...filteredTo, ...filteredCc];
|
||||
_bcc = filteredBcc;
|
||||
}
|
||||
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: _to,
|
||||
cc: _cc.filter(Boolean),
|
||||
bcc: _bcc.filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Implement reply functionality using this way instead of emit drillup
|
||||
// function reply(email, reply_all = false) {
|
||||
// emailBox.toggleEmailBox();
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
<script setup lang="ts">
|
||||
import { getFontFamily } from "@/utils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
@@ -17,12 +17,26 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const files = import.meta.globEager("/src/index.css", { query: "?inline" });
|
||||
const css = files["/src/index.css"].default;
|
||||
|
||||
const iframeRef = ref(null);
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
const _content = ref(props.content);
|
||||
|
||||
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
|
||||
const cssHref = computed(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
return "/src/index.css";
|
||||
}
|
||||
// In production, find the built CSS file from the document
|
||||
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (const link of links) {
|
||||
const href = link.getAttribute("href");
|
||||
if (href?.includes("/assets/helpdesk/desk/") && href.endsWith(".css")) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
// Fallback to a reasonable path
|
||||
return "/assets/helpdesk/desk/index.css";
|
||||
});
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(_content.value, "text/html");
|
||||
|
||||
@@ -38,8 +52,12 @@ if (gmailReplyToContent.length) {
|
||||
_content.value = parseReplyToContent(doc, "p.reply-to-content");
|
||||
}
|
||||
|
||||
function parseReplyToContent(doc, selector, forGmail = false) {
|
||||
function handleAllInstances(doc) {
|
||||
function parseReplyToContent(
|
||||
doc: Document,
|
||||
selector: string,
|
||||
forGmail = false
|
||||
) {
|
||||
function handleAllInstances(doc: Document) {
|
||||
const replyToContentElements = doc.querySelectorAll(selector);
|
||||
if (replyToContentElements.length === 0) return;
|
||||
const replyToContentElement = replyToContentElements[0];
|
||||
@@ -48,13 +66,15 @@ function parseReplyToContent(doc, selector, forGmail = false) {
|
||||
}
|
||||
|
||||
handleAllInstances(doc);
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function replaceReplyToContent(replyToContentElement, forGmail) {
|
||||
function replaceReplyToContent(
|
||||
replyToContentElement: Element,
|
||||
forGmail: boolean
|
||||
) {
|
||||
if (!replyToContentElement) return;
|
||||
let randomId = Math.random().toString(36).substring(2, 7);
|
||||
const randomId = Math.random().toString(36).substring(2, 7);
|
||||
const wrapper = doc.createElement("div");
|
||||
wrapper.classList.add("replied-content");
|
||||
|
||||
@@ -75,166 +95,128 @@ function replaceReplyToContent(replyToContentElement, forGmail) {
|
||||
if (prevSibling && prevSibling.tagName === "BR") {
|
||||
prevSibling.remove();
|
||||
}
|
||||
let cloned = replyToContentElement.cloneNode(true);
|
||||
const cloned = replyToContentElement.cloneNode(true) as Element;
|
||||
cloned.classList.remove("gmail_quote");
|
||||
wrapper.appendChild(cloned);
|
||||
} else {
|
||||
const allSiblings = Array.from(
|
||||
replyToContentElement.parentElement.children
|
||||
replyToContentElement.parentElement?.children || []
|
||||
);
|
||||
const replyToContentIndex = allSiblings.indexOf(replyToContentElement);
|
||||
const followingSiblings = allSiblings.slice(replyToContentIndex + 1);
|
||||
|
||||
if (followingSiblings.length === 0) return;
|
||||
|
||||
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
|
||||
const clonedFollowingSiblings = followingSiblings.map((sibling) =>
|
||||
sibling.cloneNode(true)
|
||||
);
|
||||
|
||||
const div = doc.createElement("div");
|
||||
div.append(...clonedFollowingSiblings);
|
||||
|
||||
wrapper.append(div);
|
||||
|
||||
// Remove all siblings after the reply-to-content element
|
||||
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
|
||||
replyToContentElement.parentElement.removeChild(allSiblings[i]);
|
||||
replyToContentElement.parentElement?.removeChild(allSiblings[i]);
|
||||
}
|
||||
}
|
||||
|
||||
replyToContentElement.parentElement.replaceChild(
|
||||
replyToContentElement.parentElement?.replaceChild(
|
||||
wrapper,
|
||||
replyToContentElement
|
||||
);
|
||||
}
|
||||
|
||||
const htmlContent = `
|
||||
const htmlContent = computed(
|
||||
() => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
${css}
|
||||
|
||||
.replied-content .collapse {
|
||||
margin: 10px 0 10px 0;
|
||||
visibility: visible;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: larger;
|
||||
font-weight: 700;
|
||||
height: 12px;
|
||||
line-height: 0.1;
|
||||
background: #e8eaed;
|
||||
width: 23px;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.replied-content .collapse:hover {
|
||||
background: #dadce0;
|
||||
}
|
||||
|
||||
.replied-content .collapse + input {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input + div {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input:checked + div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
.email-content
|
||||
:is(:where(table):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))) {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
width: unset;
|
||||
table-layout: auto;
|
||||
text-align: unset;
|
||||
margin-top: unset;
|
||||
margin-bottom: unset;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
|
||||
/* tr */
|
||||
|
||||
.email-content
|
||||
:where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*)) {
|
||||
border-bottom-width: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* td */
|
||||
|
||||
.email-content
|
||||
:is(:where(td):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
|
||||
position: unset;
|
||||
border-width: 0;
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(tbody td):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*)) {
|
||||
vertical-align: revert;
|
||||
}
|
||||
|
||||
/* image */
|
||||
.email-content
|
||||
:is(:where(img):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))) {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* before & after */
|
||||
|
||||
.email-content
|
||||
:where(blockquote
|
||||
p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(blockquote
|
||||
p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))::after {
|
||||
content: none;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${cssHref.value}" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-surface-gray-3: #ededed;
|
||||
--bg-surface-gray-4: #e2e2e2;
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
--bg-surface-gray-3: #343434;
|
||||
--bg-surface-gray-4: #424242;
|
||||
}
|
||||
.replied-content .collapse {
|
||||
margin: 10px 0 10px 0;
|
||||
visibility: visible;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: larger;
|
||||
font-weight: 700;
|
||||
height: 12px;
|
||||
line-height: 0.1;
|
||||
background: #e8eaed;
|
||||
width: 23px;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.replied-content .collapse:hover {
|
||||
background: #dadce0;
|
||||
}
|
||||
.replied-content .collapse + input {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input + div {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input:checked + div {
|
||||
display: block;
|
||||
}
|
||||
.email-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
|
||||
border-width: 0;
|
||||
}
|
||||
.email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
margin: 0;
|
||||
}
|
||||
.email-content :where(blockquote p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::before {
|
||||
content: none;
|
||||
}
|
||||
.email-content :where(blockquote p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::after {
|
||||
content: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
|
||||
<div class="email-content prose-f">${_content.value}</div>
|
||||
</body>
|
||||
<base target="_blank" />
|
||||
</html>
|
||||
`;
|
||||
`
|
||||
);
|
||||
|
||||
watch(iframeRef, (iframe) => {
|
||||
if (iframe) {
|
||||
iframe.onload = () => {
|
||||
const emailContent =
|
||||
iframe.contentWindow.document.querySelector(".email-content");
|
||||
let parent = emailContent.closest("html");
|
||||
emailContent.classList.add(getFontFamily(_content.value));
|
||||
iframe.contentWindow?.document.querySelector(".email-content");
|
||||
if (!emailContent) return;
|
||||
|
||||
const parent = emailContent.closest("html");
|
||||
if (!parent) return;
|
||||
let theme = document.documentElement.getAttribute("data-theme");
|
||||
parent.setAttribute("data-theme", theme);
|
||||
|
||||
const font = getFontFamily(_content.value);
|
||||
if (font) emailContent.classList.add(font);
|
||||
|
||||
iframe.style.height = parent.offsetHeight + 1 + "px";
|
||||
|
||||
let replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
|
||||
// Clicks inside the iframe don't bubble to the parent document, popovers/dropdowns that close on outside-click never fire.
|
||||
iframe.contentDocument?.addEventListener("pointerdown", () => {
|
||||
document.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true })
|
||||
);
|
||||
});
|
||||
|
||||
const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
|
||||
if (replyCollapsers.length) {
|
||||
replyCollapsers.forEach((replyCollapser) => {
|
||||
replyCollapser.addEventListener("change", () => {
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
<TextEditor
|
||||
ref="editorRef"
|
||||
:editor-class="[
|
||||
'prose-sm max-w-full mx-6 md:mx-10 max-h-[50vh] py-3',
|
||||
'min-h-[7rem]',
|
||||
'prose-sm max-w-full mx-6 md:mx-10 py-3',
|
||||
getFontFamily(newEmail),
|
||||
editable && '!max-h-[35vh] overflow-y-auto',
|
||||
'[&_p.reply-to-content]:hidden',
|
||||
]"
|
||||
:content="newEmail"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
@change="editable ? (newEmail = $event) : null"
|
||||
:extensions="[PreserveVideoControls]"
|
||||
:extensions="[ComponentUtils, HandleExcelPaste]"
|
||||
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
@keydown.capture="handleKeydown"
|
||||
>
|
||||
<template #top>
|
||||
<div class="mx-6 md:mx-10 flex items-center gap-2 border-y py-2.5">
|
||||
@@ -21,7 +21,7 @@
|
||||
<MultiSelectInput
|
||||
v-model="toEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:validate="validateEmailWithZod"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
<Button
|
||||
@@ -45,7 +45,7 @@
|
||||
ref="ccInput"
|
||||
v-model="ccEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:validate="validateEmailWithZod"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
</div>
|
||||
@@ -59,17 +59,24 @@
|
||||
ref="bccInput"
|
||||
v-model="bccEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:validate="validateEmailWithZod"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <template v-slot:editor="{ _editor }">
|
||||
<EditorContent
|
||||
:class="[editable && 'max-h-[35vh] overflow-y-auto']"
|
||||
:editor="_editor"
|
||||
/>
|
||||
</template> -->
|
||||
|
||||
<template #editor>
|
||||
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh]">
|
||||
<EditorContent :editor="editor" />
|
||||
<div
|
||||
v-if="quotedContent"
|
||||
ref="quotedContentRef"
|
||||
contenteditable="true"
|
||||
class="prose !max-w-full mx-6 md:mx-10 my-2 border-l-4 border-gray-300 pl-4 text-sm focus:outline-none"
|
||||
@input="onQuotedInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #bottom>
|
||||
<!-- Attachments -->
|
||||
<div class="flex flex-wrap gap-2 px-10">
|
||||
@@ -106,8 +113,13 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button variant="ghost" @click="openFileSelector()">
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (isUploading = uploading) }}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="openFileSelector()"
|
||||
:loading="uploading"
|
||||
>
|
||||
<template #icon>
|
||||
<AttachmentIcon
|
||||
class="h-4"
|
||||
@@ -119,13 +131,10 @@
|
||||
</FileUploader>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="showCannedResponseSelectorModal = true"
|
||||
@click="showSavedRepliesSelectorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<EmailIcon
|
||||
class="h-4"
|
||||
style="color: #000000; stroke-width: 1.2"
|
||||
/>
|
||||
<SavedReplyIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -137,7 +146,7 @@
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
<Button
|
||||
variant="solid"
|
||||
:disabled="emailEmpty"
|
||||
:disabled="isDisabled"
|
||||
:loading="sendMail.loading"
|
||||
:label="label"
|
||||
@click="
|
||||
@@ -150,32 +159,34 @@
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
<CannedResponseSelectorModal
|
||||
v-model="showCannedResponseSelectorModal"
|
||||
<SavedRepliesSelectorModal
|
||||
v-if="showSavedRepliesSelectorModal"
|
||||
v-model="showSavedRepliesSelectorModal"
|
||||
:doctype="doctype"
|
||||
@apply="applyCannedResponse"
|
||||
@apply="applySavedReplies"
|
||||
:ticketId="ticketId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
AttachmentItem,
|
||||
CannedResponseSelectorModal,
|
||||
MultiSelectInput,
|
||||
SavedRepliesSelectorModal,
|
||||
} from "@/components";
|
||||
import { AttachmentIcon, EmailIcon } from "@/components/icons";
|
||||
import { EditorContent } from "@tiptap/vue-3";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||
import { ComponentUtils, HandleExcelPaste } from "@/tiptap-extensions";
|
||||
import {
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
removeAttachmentFromServer,
|
||||
textEditorMenuButtons,
|
||||
uploadFunction,
|
||||
validateEmail,
|
||||
validateEmailWithZod,
|
||||
} from "@/utils";
|
||||
// import { EditorContent } from "@tiptap/vue-3";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
FileUploader,
|
||||
@@ -185,10 +196,19 @@ import {
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import SavedReplyIcon from "./icons/SavedReplyIcon.vue";
|
||||
|
||||
const editorRef = ref(null);
|
||||
const showCannedResponseSelectorModal = ref(false);
|
||||
const showSavedRepliesSelectorModal = ref(false);
|
||||
const quotedContentRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const props = defineProps({
|
||||
ticketId: {
|
||||
@@ -231,7 +251,16 @@ const label = computed(() => {
|
||||
|
||||
const emit = defineEmits(["submit", "discard"]);
|
||||
|
||||
const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
|
||||
const newEmail = useStorage<null | string>(
|
||||
"emailBoxContent" + props.ticketId,
|
||||
null
|
||||
);
|
||||
|
||||
const quotedContent = useStorage<null | string>(
|
||||
"quotedEmailBoxContent" + props.ticketId,
|
||||
null
|
||||
);
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { isManager } = useAuthStore();
|
||||
|
||||
@@ -239,8 +268,13 @@ const { isManager } = useAuthStore();
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const attachments = ref([]);
|
||||
const emailEmpty = computed(() => {
|
||||
return isContentEmpty(newEmail.value);
|
||||
const isUploading = ref(false);
|
||||
const isDisabled = computed(() => {
|
||||
return (
|
||||
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
|
||||
sendMail.loading ||
|
||||
isUploading.value
|
||||
);
|
||||
});
|
||||
|
||||
// Watch for changes in email content to trigger typing events
|
||||
@@ -264,9 +298,11 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
|
||||
const ccInput = ref(null);
|
||||
const bccInput = ref(null);
|
||||
|
||||
function applyCannedResponse(template) {
|
||||
newEmail.value = template.message;
|
||||
showCannedResponseSelectorModal.value = false;
|
||||
function applySavedReplies(template: string) {
|
||||
isContentEmpty(newEmail.value)
|
||||
? (newEmail.value = template)
|
||||
: (newEmail.value = newEmail.value + "\n" + template);
|
||||
showSavedRepliesSelectorModal.value = false;
|
||||
}
|
||||
|
||||
const sendMail = createResource({
|
||||
@@ -280,7 +316,11 @@ const sendMail = createResource({
|
||||
to: toEmailsClone.value.join(","),
|
||||
cc: ccEmailsClone.value?.join(","),
|
||||
bcc: bccEmailsClone.value?.join(","),
|
||||
message: newEmail.value,
|
||||
message:
|
||||
newEmail.value +
|
||||
(quotedContentRef.value
|
||||
? `<p class="reply-to-content"><p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
|
||||
: ""),
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -295,7 +335,7 @@ const sendMail = createResource({
|
||||
});
|
||||
|
||||
function submitMail() {
|
||||
if (isContentEmpty(newEmail.value)) {
|
||||
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
|
||||
return false;
|
||||
}
|
||||
if (!toEmailsClone.value.length) {
|
||||
@@ -308,6 +348,21 @@ function submitMail() {
|
||||
sendMail.submit();
|
||||
}
|
||||
|
||||
watch(quotedContent, (newVal, oldVal) => {
|
||||
if (!oldVal && newVal) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = newVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
function onQuotedInput() {
|
||||
const el = quotedContentRef.value;
|
||||
if (!el) return;
|
||||
quotedContent.value = el.innerHTML || null;
|
||||
}
|
||||
|
||||
function toggleCC() {
|
||||
showCC.value = !showCC.value;
|
||||
|
||||
@@ -339,35 +394,112 @@ function addToReply(
|
||||
toEmailsClone.value = toEmails;
|
||||
ccEmailsClone.value = ccEmails;
|
||||
bccEmailsClone.value = bccEmails;
|
||||
editorRef.value.editor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.insertContent(body)
|
||||
.focus("all")
|
||||
.setBlockquote()
|
||||
.insertContentAt(0, { type: "paragraph" })
|
||||
.focus("start")
|
||||
.run();
|
||||
|
||||
if (body !== quotedContent.value) {
|
||||
//trigger change for watch when replied to body data is different from current quoted content
|
||||
quotedContent.value = null;
|
||||
nextTick(() => {
|
||||
quotedContent.value = body;
|
||||
});
|
||||
}
|
||||
|
||||
editorRef.value.editor.chain().clearContent().focus("start").run();
|
||||
nextTick(() => {
|
||||
newEmail.value = editorRef.value.editor.getHTML();
|
||||
});
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
newEmail.value = null;
|
||||
attachments.value = [];
|
||||
quotedContent.value = null;
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
attachments.value = [];
|
||||
newEmail.value = null;
|
||||
|
||||
quotedContent.value = null;
|
||||
ccEmailsClone.value = [];
|
||||
bccEmailsClone.value = [];
|
||||
ccEmailsClone.value = [];
|
||||
showCC.value = false;
|
||||
showBCC.value = false;
|
||||
|
||||
emit("discard");
|
||||
}
|
||||
|
||||
//on load set quoted content from storage
|
||||
onMounted(() => {
|
||||
if (quotedContent.value) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = quotedContent.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelectAll(e: KeyboardEvent) {
|
||||
const active = document.activeElement;
|
||||
const editorContext = editorRef.value?.editor;
|
||||
const editorDom = editorContext?.view?.dom as HTMLElement | undefined;
|
||||
const quotedEl = quotedContentRef.value;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !editorDom) return;
|
||||
if (!editorDom.contains(active) && !(quotedEl && quotedEl.contains(active))) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
editorContext?.commands.selectAll();
|
||||
sel.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
|
||||
if (quotedEl) {
|
||||
range.setStartBefore(editorDom);
|
||||
range.setEndAfter(quotedEl);
|
||||
} else {
|
||||
range.selectNodeContents(editorDom);
|
||||
}
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function handleDelete(e: KeyboardEvent) {
|
||||
const sel = window.getSelection();
|
||||
const quotedEl = quotedContentRef.value;
|
||||
const editorDom = editorRef.value?.editor?.view?.dom as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
|
||||
|
||||
const isSelectingEntireEditor = sel.containsNode(editorDom, true);
|
||||
|
||||
const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
|
||||
|
||||
if (isSelectingEntireEditor && isSelectingEntireQuote) {
|
||||
e.preventDefault();
|
||||
|
||||
editorRef.value?.editor?.commands?.clearContent();
|
||||
newEmail.value = null;
|
||||
quotedContent.value = null;
|
||||
|
||||
sel.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && key === "a") {
|
||||
handleSelectAll(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "backspace" || key === "delete") {
|
||||
handleDelete(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const editor = computed(() => {
|
||||
return editorRef.value.editor;
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-gray-600 w-4/6" v-else>
|
||||
<div class="text-gray-600 w-4/6 text-p-base" v-else>
|
||||
<span class="font-medium text-gray-800">
|
||||
{{ user }}
|
||||
</span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<QuickFilters v-if="!isMobileView" class="flex-1" />
|
||||
<div class="flex items-start gap-2 justify-end h-full" v-if="!isMobileView">
|
||||
<Button
|
||||
label="Save Changes"
|
||||
:label="__('Save Changes')"
|
||||
v-if="isViewUpdated && canSaveView"
|
||||
@click="handleViewUpdate"
|
||||
/>
|
||||
@@ -136,11 +136,12 @@ import { capture } from "@/telemetry";
|
||||
import { View, ViewType } from "@/types";
|
||||
import { formatTimeShort, getIcon } from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
import { useTicketStatusStore } from "@/stores/ticketStatus";
|
||||
import { __ } from "@/translation";
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjsLocal,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
ListFooter,
|
||||
@@ -163,6 +164,7 @@ import {
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EmptyState from "./EmptyState.vue";
|
||||
import ListRows from "./ListRows.vue";
|
||||
|
||||
@@ -221,15 +223,17 @@ const defaultOptions = reactive({
|
||||
},
|
||||
selectBannerActions: [
|
||||
{
|
||||
label: "Delete",
|
||||
label: __("Delete"),
|
||||
icon: "trash-2",
|
||||
onClick: (selections: Set<string>) => {
|
||||
$dialog({
|
||||
title: "Delete",
|
||||
message: `Are you sure you want to delete ${selections.size} item(s)?`,
|
||||
title: __("Delete"),
|
||||
message: __("Are you sure you want to delete {0} item(s)?", [
|
||||
selections.size,
|
||||
]),
|
||||
actions: [
|
||||
{
|
||||
label: "Confirm",
|
||||
label: __("Confirm"),
|
||||
variant: "solid",
|
||||
onClick({ close }) {
|
||||
handleBulkDelete(close, selections);
|
||||
@@ -249,7 +253,7 @@ function handleBulkDelete(hide: Function, selections: Set<string>) {
|
||||
items: JSON.stringify(Array.from(selections)),
|
||||
doctype: props.options.doctype,
|
||||
}).then(() => {
|
||||
toast.success("Item(s) deleted successfully");
|
||||
toast.success(__("Item(s) deleted successfully"));
|
||||
hide();
|
||||
reset();
|
||||
});
|
||||
@@ -271,16 +275,21 @@ const { isMobileView } = useScreenSize();
|
||||
|
||||
const defaultEmptyState = {
|
||||
icon: "",
|
||||
title: "No Data Found",
|
||||
title: __("No Data Found"),
|
||||
};
|
||||
|
||||
const pageLengthCount = useStorage(
|
||||
`list_page_length_count+${props.options.doctype}`,
|
||||
options.value.default_page_length
|
||||
);
|
||||
|
||||
const defaultParams = reactive({
|
||||
doctype: options.value.doctype,
|
||||
filters: {},
|
||||
default_filters: options.value.defaultFilters,
|
||||
order_by: "modified desc",
|
||||
page_length: options.value.default_page_length,
|
||||
page_length_count: options.value.default_page_length,
|
||||
page_length: pageLengthCount.value,
|
||||
page_length_count: pageLengthCount.value,
|
||||
view: options.value.view,
|
||||
columns: [],
|
||||
rows: [],
|
||||
@@ -567,7 +576,8 @@ function reload(reset: boolean = false) {
|
||||
defaultParams.filters = options.value.defaultFilters || {};
|
||||
defaultParams.order_by = "modified desc";
|
||||
defaultParams.page_length = options.value.default_page_length;
|
||||
defaultParams.page_length_count = options.value.default_page_length;
|
||||
pageLengthCount.value = options.value.default_page_length;
|
||||
defaultParams.page_length_count = pageLengthCount.value;
|
||||
defaultParams.columns = [];
|
||||
defaultParams.rows = [];
|
||||
defaultParams.is_default = true;
|
||||
@@ -576,7 +586,8 @@ function reload(reset: boolean = false) {
|
||||
}
|
||||
|
||||
function handlePageLength(count: number, loadMore: boolean = false) {
|
||||
defaultParams.page_length_count = count;
|
||||
pageLengthCount.value = count;
|
||||
defaultParams.page_length_count = pageLengthCount.value;
|
||||
if (loadMore) {
|
||||
defaultParams.page_length += count;
|
||||
} else {
|
||||
@@ -657,8 +668,8 @@ watch(
|
||||
defaultParams.view.name = val;
|
||||
handleViewChanges();
|
||||
if (!val) {
|
||||
headerView.value.label = "List";
|
||||
headerView.value.icon = "lucide:align-justify";
|
||||
headerView.value.label = __("List");
|
||||
headerView.value.icon = LucideAlignJustify;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -691,7 +702,7 @@ onMounted(async () => {
|
||||
if (route.query.view) {
|
||||
const currentView = findCurrentView();
|
||||
if (!currentView) return;
|
||||
headerView.value.label = currentView.label || "List";
|
||||
headerView.value.label = currentView.label || __("List");
|
||||
headerView.value.icon = getIcon(currentView.icon);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
class="rounded-full"
|
||||
:class="{
|
||||
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
}"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
@@ -60,12 +63,12 @@
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:name="option.value"
|
||||
:name="getUsernameLabel(option.value)"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-gray-800">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
{{ getUsernameLabel(option.label) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ option.value }}
|
||||
@@ -136,7 +139,7 @@ watchDebounced(
|
||||
text.value = val;
|
||||
reload(val);
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
{ debounce: 50, immediate: true }
|
||||
);
|
||||
|
||||
const filterOptions = createResource({
|
||||
@@ -174,10 +177,35 @@ function reload(val) {
|
||||
filterOptions.reload();
|
||||
}
|
||||
|
||||
const normalizeAndFilter = (field) => {
|
||||
let arr = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let char of field) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if (char === "," && !inQuotes) {
|
||||
if (current.trim()) {
|
||||
arr.push(current.trim());
|
||||
}
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) {
|
||||
arr.push(current.trim());
|
||||
}
|
||||
|
||||
return arr;
|
||||
};
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null;
|
||||
if (value) {
|
||||
const splitValues = value.split(",");
|
||||
const splitValues = normalizeAndFilter(value);
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim();
|
||||
if (value) {
|
||||
@@ -201,20 +229,28 @@ const addValue = (value) => {
|
||||
!error.value && (value = "");
|
||||
}
|
||||
};
|
||||
const getUsernameLabel = (value: string) => {
|
||||
if (!value) return "";
|
||||
|
||||
// Take everything before '<'
|
||||
const beforeAngle = value.split("<")[0];
|
||||
|
||||
// Remove quotes and trim
|
||||
return beforeAngle.replace(/"/g, "").trim();
|
||||
};
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value);
|
||||
};
|
||||
|
||||
const removeLastValue = () => {
|
||||
function removeLastValue() {
|
||||
if (query.value) return;
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el;
|
||||
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop();
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el;
|
||||
emailRef = emails.value[emails.value.length - 1].rootRef;
|
||||
emailRef?.focus();
|
||||
} else {
|
||||
setFocus();
|
||||
@@ -223,10 +259,10 @@ const removeLastValue = () => {
|
||||
} else {
|
||||
emailRef?.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus();
|
||||
search.value?.focus?.();
|
||||
}
|
||||
|
||||
defineExpose({ setFocus });
|
||||
|
||||
294
desk/src/components/SavedRepliesSelectorModal.vue
Normal file
294
desk/src/components/SavedRepliesSelectorModal.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: '4xl',
|
||||
}"
|
||||
@vue:unmounted="resetFilter"
|
||||
>
|
||||
<template #body>
|
||||
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
|
||||
<div class="flex items-center justify-between w-full p-4 pb-2">
|
||||
<div class="text-2xl font-semibold">{{ __("Saved Replies") }}</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
icon-left="plus"
|
||||
:label="__('New')"
|
||||
@click="onNewSavedReplyClick"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
ref="searchInput"
|
||||
:model-value="search"
|
||||
@input="search = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
<Button
|
||||
v-if="search"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="search = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="filters" placement="right">
|
||||
<Button :label="activeFilter" icon-left="filter" class="p-4">
|
||||
<template #suffix>
|
||||
<p
|
||||
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-white pt-px text-xs font-medium text-ink-gray-8 shadow-sm"
|
||||
v-if="savedReplyListResource?.data?.length"
|
||||
>
|
||||
{{ savedReplyListResource?.data?.length }}
|
||||
</p>
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 h-full overflow-y-auto">
|
||||
<div
|
||||
v-if="savedReplyListResource?.list?.loading"
|
||||
class="flex items-center justify-center mt-24"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedReplyListResource?.list?.loading &&
|
||||
savedReplyListResource?.data?.length
|
||||
"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-2 pb-36"
|
||||
>
|
||||
<div
|
||||
v-for="template in savedReplyListResource?.data"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100 relative"
|
||||
@click="onTemplateSelect(template)"
|
||||
>
|
||||
<div class="text-base font-semibold truncate border-b pb-2">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<TextEditor
|
||||
v-if="template.message"
|
||||
:content="template.message"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
class="flex-1 overflow-hidden pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
v-if="
|
||||
selectedTemplate.name === template.name &&
|
||||
selectedTemplate.isLoading
|
||||
"
|
||||
class="flex items-center justify-center absolute top-0 left-0 w-full h-full bg-black/20 rounded-lg"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedReplyListResource?.list?.loading &&
|
||||
!savedReplyListResource?.data?.length
|
||||
"
|
||||
class="mt-2"
|
||||
>
|
||||
<div class="flex h-56 flex-col items-center justify-center">
|
||||
<div class="text-p-sm text-gray-500">
|
||||
{{ __("No Saved Replies found") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReply } from "@/types";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
Input,
|
||||
LoadingIndicator,
|
||||
TextEditor,
|
||||
} from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from "vue";
|
||||
import { showEmailBox } from "../pages/ticket/modalStates";
|
||||
import {
|
||||
setActiveSettingsTab,
|
||||
showSettingsModal,
|
||||
} from "./Settings/settingsModal";
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
ticketId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = defineModel();
|
||||
const activeFilter = useStorage("saved-replies-filter", "Personal");
|
||||
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
||||
storeToRefs(useConfigStore());
|
||||
|
||||
const filters = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __("All"),
|
||||
value: "All",
|
||||
onClick: () => (activeFilter.value = "All"),
|
||||
},
|
||||
{
|
||||
label: __("Personal"),
|
||||
value: "Personal",
|
||||
onClick: () => (activeFilter.value = "Personal"),
|
||||
},
|
||||
{
|
||||
label: __("My Team"),
|
||||
value: "Team",
|
||||
onClick: () => (activeFilter.value = "My Team"),
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
value: "Global",
|
||||
onClick: () => (activeFilter.value = "Global"),
|
||||
},
|
||||
];
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
options.pop();
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
// Set default filter to Personal if Global is disabled
|
||||
if (
|
||||
teamRestrictionApplied.value &&
|
||||
disableGlobalScopeForSavedReplies.value &&
|
||||
activeFilter.value == "Global"
|
||||
) {
|
||||
activeFilter.value = "Personal";
|
||||
}
|
||||
|
||||
const emit = defineEmits(["apply"]);
|
||||
|
||||
const search = ref("");
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
const selectedTemplate = ref({
|
||||
name: "",
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const scope = computed(() => {
|
||||
return filters.value.find((f) => f.value === activeFilter.value)?.value;
|
||||
});
|
||||
|
||||
const savedReplyListResource = createListResource({
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["name", "title", "owner", "scope", "message"],
|
||||
filters: {
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
},
|
||||
cache: ["SavedReplyListForModal"],
|
||||
auto: true,
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
showEmailBox.value = true;
|
||||
});
|
||||
|
||||
const onTemplateSelect = (template: SavedReply) => {
|
||||
if (selectedTemplate.value.isLoading) return;
|
||||
selectedTemplate.value = {
|
||||
name: template.name,
|
||||
isLoading: true,
|
||||
};
|
||||
const renderResponse = createResource({
|
||||
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
|
||||
params: {
|
||||
saved_reply_id: template.name,
|
||||
ticket_id: props.ticketId,
|
||||
},
|
||||
onSuccess: (data: string) => {
|
||||
selectedTemplate.value = {
|
||||
name: "",
|
||||
isLoading: false,
|
||||
};
|
||||
emit("apply", data);
|
||||
capture("saved_reply_applied");
|
||||
},
|
||||
});
|
||||
renderResponse.submit().catch(() => {
|
||||
selectedTemplate.value = {
|
||||
name: "",
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const onNewSavedReplyClick = () => {
|
||||
show.value = false;
|
||||
showSettingsModal.value = true;
|
||||
setActiveSettingsTab("Saved Replies");
|
||||
};
|
||||
|
||||
const resetFilter = () => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource.filters,
|
||||
title: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
watch(search, (newValue) => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource.filters,
|
||||
title: ["like", `%${newValue}%`],
|
||||
};
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
watch(activeFilter, () => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource?.filters,
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
};
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
watch(
|
||||
show,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
const inputEl = searchInput.value?.$el?.querySelector("input");
|
||||
inputEl?.focus();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="!isEmpty(articles.data) && query.length > 2"
|
||||
v-if="Boolean(articles.data?.length) && query.length > 2"
|
||||
class="rounded border p-4 text-base"
|
||||
>
|
||||
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
|
||||
@@ -52,7 +52,7 @@
|
||||
"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
>
|
||||
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
<p class="font-base">No answers found</p>
|
||||
<span class="font-base text-p-sm text-gray-600 text-center"
|
||||
@@ -64,7 +64,7 @@
|
||||
v-else-if="articles.loading"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
>
|
||||
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
<p class="font-base">Searching...</p>
|
||||
<span class="font-base text-p-sm text-gray-600 text-center"
|
||||
@@ -75,11 +75,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from "vue";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { isEmpty } from "lodash";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { capture } from "@/telemetry";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { watch } from "vue";
|
||||
interface P {
|
||||
query: string;
|
||||
hideViewAll?: boolean;
|
||||
|
||||
409
desk/src/components/SearchMultiSelect.vue
Normal file
409
desk/src/components/SearchMultiSelect.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<div class="relative inline-block text-left">
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="{ 'ring-2 ring-outline-gray-2': isOpen }"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<div
|
||||
class="flex items-center min-h-[20px]"
|
||||
:class="{ 'gap-2': hasSelectedImages && selectedCount > 0 }"
|
||||
>
|
||||
<template v-if="selectedCount > 0">
|
||||
<!-- Stacked Avatars -->
|
||||
<div class="flex -space-x-2 isolate">
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions.slice(0, 3)"
|
||||
:key="`avatar-${option.value}`"
|
||||
class="relative flex"
|
||||
:style="{ zIndex: 10 - index }"
|
||||
>
|
||||
<Avatar
|
||||
v-if="option.image"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="border-2 border-white flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Count Text -->
|
||||
<span class="text-ink-gray-7">
|
||||
{{
|
||||
selectedCount === 1
|
||||
? selectedOptions[0]?.label
|
||||
: `${selectedCount} ${selectionText}`
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-ink-gray-6">{{ placeholder }}</span>
|
||||
</div>
|
||||
<template #suffix>
|
||||
<LucideChevronDown
|
||||
class="ml-2 h-4 w-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-2 w-64 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none left-0 origin-top-left"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="py-1.5 px-1.5">
|
||||
<div
|
||||
class="flex h-7 items-center text-sm font-medium text-ink-gray-6 justify-between"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="filterText"
|
||||
:placeholder="label"
|
||||
class="px-2 flex-1 bg-transparent border-none outline-none text-sm focus:border-none focus:ring-0 text-ink-gray-6 placeholder-ink-gray-4"
|
||||
@click.stop
|
||||
@keydown="handleInputKeydown"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
v-if="selectedCount > 0"
|
||||
@click="clearAll"
|
||||
>
|
||||
<template #icon>
|
||||
<LucideX class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options List -->
|
||||
<div class="px-1.5 pb-1.5 max-h-64 overflow-y-auto">
|
||||
<!-- Individual Options (ungrouped) -->
|
||||
<div class="pt-1.5" v-if="filteredIndividualOptions.length > 0">
|
||||
<button
|
||||
v-for="option in filteredIndividualOptions"
|
||||
:key="`individual-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="mr-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
|
||||
<Avatar
|
||||
v-else-if="hasAnyVisualElements"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="mr-2 flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<span class="text-ink-gray-7 flex-1 text-left truncate">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="(option.count ?? 0) > 0"
|
||||
class="text-xs text-ink-gray-5 ml-auto"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grouped Options -->
|
||||
<div v-for="group in filteredGroups" :key="`group-${group.group}`">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
class="flex h-7 pt-1.5 sticky top-0 bg-surface-white items-center px-2 text-sm font-medium text-ink-gray-6"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
|
||||
<!-- Group Options -->
|
||||
<button
|
||||
v-for="option in group.items"
|
||||
:key="`grouped-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="mr-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
|
||||
<Avatar
|
||||
v-else-if="option.image"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="mr-2 flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Spacer for alignment when other options have visual elements -->
|
||||
<div
|
||||
v-else-if="hasAnyVisualElements"
|
||||
class="mr-2 w-6 h-6 flex-shrink-0"
|
||||
></div>
|
||||
|
||||
<span class="text-ink-gray-7 flex-1 text-left truncate">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="(option.count ?? 0) > 0"
|
||||
class="text-xs text-ink-gray-5 ml-auto"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- No options message -->
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-2 py-4 text-center text-sm text-ink-gray-5"
|
||||
>
|
||||
No options found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay to close dropdown -->
|
||||
<div v-if="isOpen" class="fixed inset-0 z-40" @click="closeDropdown"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, Checkbox } from "frappe-ui";
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
nextTick,
|
||||
ref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
type Component,
|
||||
} from "vue";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
// Type Definitions
|
||||
interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count?: number;
|
||||
image?: string;
|
||||
icon?: string | Component;
|
||||
}
|
||||
|
||||
interface MultiSelectGroup {
|
||||
group: string;
|
||||
items: MultiSelectOption[];
|
||||
}
|
||||
|
||||
// Support both flat options and grouped options in the same prop
|
||||
type MultiSelectData = MultiSelectOption[] | MultiSelectGroup[];
|
||||
|
||||
interface Props {
|
||||
options: MultiSelectData;
|
||||
modelValue: string[];
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
selectionText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: "Select options...",
|
||||
label: "Options",
|
||||
selectionText: "items",
|
||||
options: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: string[]];
|
||||
}>();
|
||||
|
||||
// Reactive State
|
||||
const isOpen = ref(false);
|
||||
const filterText = ref("");
|
||||
const highlightedIndex = ref(0);
|
||||
const inputRef = useTemplateRef<HTMLInputElement>("inputRef");
|
||||
|
||||
// Core Computed Properties
|
||||
const selectedCount = computed(() => props.modelValue.length);
|
||||
|
||||
// Detect if the options are grouped or flat
|
||||
const isGrouped = computed(() => {
|
||||
return (
|
||||
props.options.length > 0 &&
|
||||
"group" in props.options[0] &&
|
||||
"items" in props.options[0]
|
||||
);
|
||||
});
|
||||
|
||||
// Extract individual options and groups from the unified options prop
|
||||
const individualOptions = computed(() => {
|
||||
if (isGrouped.value) return [];
|
||||
return props.options as MultiSelectOption[];
|
||||
});
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!isGrouped.value) return [];
|
||||
return props.options as MultiSelectGroup[];
|
||||
});
|
||||
|
||||
// Flatten all options for easier processing
|
||||
const allOptions = computed(() => {
|
||||
if (isGrouped.value) {
|
||||
return (props.options as MultiSelectGroup[]).flatMap(
|
||||
(group) => group.items
|
||||
);
|
||||
}
|
||||
return props.options as MultiSelectOption[];
|
||||
});
|
||||
|
||||
const selectedOptions = computed(() =>
|
||||
allOptions.value.filter((option) => props.modelValue.includes(option.value))
|
||||
);
|
||||
|
||||
// UI Helper Computed Properties
|
||||
const hasSelectedImages = computed(() =>
|
||||
selectedOptions.value.some((option) => option.image)
|
||||
);
|
||||
const hasAnyVisualElements = computed(() => {
|
||||
return allOptions.value.some((option) => option.image || option.icon);
|
||||
});
|
||||
|
||||
// Filtering Logic
|
||||
const filteredGroups = computed(() => {
|
||||
if (!isGrouped.value) return [];
|
||||
|
||||
const searchText = filterText.value.toLowerCase();
|
||||
|
||||
return groups.value
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((option) => {
|
||||
// Show all items if group name matches the search
|
||||
const groupMatches = group.group.toLowerCase().includes(searchText);
|
||||
// Show individual items if their label matches the search
|
||||
const itemMatches = option.label.toLowerCase().includes(searchText);
|
||||
|
||||
return groupMatches || itemMatches;
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
});
|
||||
|
||||
const filteredIndividualOptions = computed(() =>
|
||||
individualOptions.value.filter((option) =>
|
||||
option.label.toLowerCase().includes(filterText.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
// All filtered options in a flat array for keyboard navigation
|
||||
const filteredOptions = computed(() => {
|
||||
const individual = filteredIndividualOptions.value;
|
||||
const grouped = filteredGroups.value.flatMap((group) => group.items);
|
||||
return [...individual, ...grouped];
|
||||
});
|
||||
|
||||
const highlightedOption = computed(
|
||||
() => filteredOptions.value[highlightedIndex.value] || null
|
||||
);
|
||||
|
||||
// Main Interaction Methods
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value;
|
||||
if (isOpen.value) {
|
||||
highlightedIndex.value = 0; // Reset to first item when opening
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleOption(option: MultiSelectOption) {
|
||||
const newValue = [...props.modelValue];
|
||||
const index = newValue.indexOf(option.value);
|
||||
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1);
|
||||
} else {
|
||||
newValue.push(option.value);
|
||||
}
|
||||
|
||||
emit("update:modelValue", newValue);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
filterText.value = "";
|
||||
emit("update:modelValue", []);
|
||||
}
|
||||
|
||||
// Keyboard Navigation
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
} else if (event.key === "Enter" && highlightedOption.value) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleOption(highlightedOption.value);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
highlightedIndex.value = Math.min(
|
||||
highlightedIndex.value + 1,
|
||||
filteredOptions.value.length - 1
|
||||
);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
function renderIcon(icon: string | any) {
|
||||
if (!icon) return null;
|
||||
|
||||
const iconContent =
|
||||
typeof icon === "string"
|
||||
? h("span", { class: "text-base" }, icon)
|
||||
: h(icon, { class: "w-4 h-4" });
|
||||
|
||||
return h(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center mr-2",
|
||||
},
|
||||
[iconContent]
|
||||
);
|
||||
}
|
||||
|
||||
function isHighlighted(option: MultiSelectOption) {
|
||||
return highlightedOption.value?.value === option.value;
|
||||
}
|
||||
|
||||
// Watchers
|
||||
// Reset highlighted index when filter changes
|
||||
watch(filterText, () => {
|
||||
highlightedIndex.value = 0;
|
||||
});
|
||||
</script>
|
||||
@@ -11,16 +11,18 @@
|
||||
class="w-full focus:outline-none outline-none border-inherit shadow-none"
|
||||
v-bind="$attrs"
|
||||
v-model="query"
|
||||
@update:model-value="(e:string)=>{
|
||||
if (e.length >= 3) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
@update:model-value="
|
||||
(e: string) => {
|
||||
if (e.length >= 3) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
}"
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="lucide:search" class="h-4 w-4 text-gray-500" />
|
||||
<LucideSearch class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
@@ -40,9 +42,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormControl, Popover } from "frappe-ui";
|
||||
import SearchArticles from "./SearchArticles.vue";
|
||||
import { ModelRef } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import SearchArticles from "./SearchArticles.vue";
|
||||
|
||||
interface P {
|
||||
popoverClass: Array<string>;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@@ -81,6 +81,13 @@ const props = defineProps({
|
||||
const hide = ref(props.hideLabel);
|
||||
const opened = ref(props.opened);
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(val) => {
|
||||
opened.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
opened.value = !opened.value;
|
||||
}
|
||||
|
||||
81
desk/src/components/SelectDropdown.vue
Normal file
81
desk/src/components/SelectDropdown.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<Popover :placement="props.placement">
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="flex items-center justify-between min-w-36"
|
||||
@click="togglePopover()"
|
||||
:class="targetClass"
|
||||
icon-right="chevron-down"
|
||||
>
|
||||
<div class="w-full truncate">
|
||||
{{
|
||||
options?.find((option) => option.value == model)?.label || "Select"
|
||||
}}
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute w-[--reka-popper-anchor-width] bg-white shadow-2xl rounded"
|
||||
:class="bodyClass"
|
||||
>
|
||||
<div class="max-h-52 overflow-y-auto">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded"
|
||||
@click="
|
||||
() => {
|
||||
onChange(option.value);
|
||||
togglePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="w-full truncate">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<FeatherIcon
|
||||
v-if="model == option.value"
|
||||
name="check"
|
||||
class="size-4 ml-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
:label="__('Reset')"
|
||||
icon-left="refresh-ccw"
|
||||
class="w-full focus-visible:ring-0"
|
||||
@click="onReset(togglePopover)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button, FeatherIcon, Popover } from "frappe-ui";
|
||||
|
||||
const model = defineModel();
|
||||
|
||||
interface Props {
|
||||
options: Array<{ value: string; label: string }>;
|
||||
targetClass?: string;
|
||||
bodyClass?: string;
|
||||
placement?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
const onReset = (togglePopover: () => void) => {
|
||||
model.value = null;
|
||||
togglePopover();
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
model.value = value;
|
||||
};
|
||||
</script>
|
||||
@@ -1,158 +1,210 @@
|
||||
<template>
|
||||
<div v-bind:class="$attrs.class" class="px-10 py-8">
|
||||
<!-- Header -->
|
||||
<SettingsLayoutHeader
|
||||
:title="__('Agents')"
|
||||
:description="__('Add, manage agents and assign roles to them.')"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@click="() => (showNewAgentsDialog = !showNewAgentsDialog)"
|
||||
label="New"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #bottom-section>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="'Search'"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
class="w-60"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<Dropdown :options="dropdownOptions" placement="right">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-[90px]"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item="{ item, active }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded px-2 text-base"
|
||||
:class="{ 'bg-surface-gray-3': active }"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="activeFilter === item.label"
|
||||
name="check"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
|
||||
<!-- loading state -->
|
||||
<div v-if="agents.loading" class="flex mt-28 justify-between w-full h-full">
|
||||
<SettingsLayoutBase
|
||||
:title="__('Agents')"
|
||||
:description="__('Add, manage agents and assign roles to them.')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:loading="agents.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!agents.loading && !agents.data?.length"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<p class="text-sm text-gray-500 w-full flex justify-center">
|
||||
No agents found
|
||||
</p>
|
||||
</div>
|
||||
<!-- Agent List -->
|
||||
<div
|
||||
class="overflow-y-auto w-full hide-scrollbar mt-4"
|
||||
v-if="!agents.loading && Boolean(agents.data?.length)"
|
||||
>
|
||||
<div v-for="(agent, idx) in agents.data" :key="agent.agent_name">
|
||||
<AgentCard
|
||||
:agent="agent"
|
||||
:show-status="true"
|
||||
:class="idx !== agents.data.length - 1 && 'border-b '"
|
||||
>
|
||||
<template #right>
|
||||
<Dropdown
|
||||
v-if="isManager"
|
||||
class="w-1/5 flex justify-end items-center"
|
||||
:options="getRoles(agent.name)"
|
||||
:label="getUserRole(agent.name)"
|
||||
:button="{
|
||||
label: getUserRole(agent.name),
|
||||
iconRight: 'chevron-down',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="getOptions(agent)"
|
||||
placement="right"
|
||||
:key="agent"
|
||||
class="ml-2"
|
||||
@click="() => setActiveSettingsTab('Invite Agents')"
|
||||
label="New"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #header-bottom>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<div class="relative grow">
|
||||
<Input
|
||||
:model-value="search"
|
||||
@input="search = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
<Button
|
||||
v-if="search"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="search = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="dropdownOptions" placement="right">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-fit p-4"
|
||||
>
|
||||
<Button>
|
||||
<template #icon>
|
||||
<IconMoreHorizontal class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</AgentCard>
|
||||
<template #item="{ item }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="activeFilter === item.label"
|
||||
name="check"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
v-if="!agents.loading && agents.hasNextPage"
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => agents.next()"
|
||||
:loading="agents.loading"
|
||||
label="Load More"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grow">
|
||||
<!-- loading state -->
|
||||
<div
|
||||
v-if="agents.loading"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<Button
|
||||
:loading="agents.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!agents.loading && !agents.data?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex items-center justify-center"
|
||||
>
|
||||
<AgentIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No agent found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{
|
||||
activeFilter.length
|
||||
? __("Change your search terms or filters")
|
||||
: __("Add one to get started.")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="setActiveSettingsTab('Invite Agents')"
|
||||
/>
|
||||
</div>
|
||||
<!-- Agent List -->
|
||||
<div
|
||||
class="w-full"
|
||||
v-if="!agents.loading && Boolean(agents.data?.length)"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600"
|
||||
>
|
||||
<div class="col-span-6 text-p-sm">{{ __("Agent Name") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2" />
|
||||
<div v-for="(agent, index) in agents.data" :key="agent.agent_name">
|
||||
<div class="flex items-center justify-between h-14 group rounded">
|
||||
<div class="flex items-center space-x-3 grow">
|
||||
<Avatar
|
||||
:image="agent.user_image"
|
||||
:label="agent.agent_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base">
|
||||
{{ agent.agent_name }}
|
||||
</p>
|
||||
<Badge
|
||||
:label="__('Inactive')"
|
||||
:theme="'gray'"
|
||||
:class="!agent.is_active ? 'opacity-100' : 'opacity-0'"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-base text-ink-gray-6 mt-1">
|
||||
{{ agent.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown
|
||||
v-if="isManager"
|
||||
class="flex justify-end items-center"
|
||||
:options="getRoles(agent.name)"
|
||||
:label="getUserRole(agent.name)"
|
||||
:button="{
|
||||
label: getUserRole(agent.name),
|
||||
iconRight: 'chevron-down',
|
||||
iconLeft:
|
||||
getUserRole(agent.name) === 'Agent'
|
||||
? 'user'
|
||||
: getUserRole(agent.name) === 'Manager'
|
||||
? 'briefcase'
|
||||
: null,
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="getOptions(agent)"
|
||||
:key="agent"
|
||||
class="ml-2"
|
||||
placement="right"
|
||||
>
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="index !== agents.data.length - 1" />
|
||||
</div>
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
v-if="!agents.loading && agents.hasNextPage"
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => agents.next()"
|
||||
:loading="agents.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddNewAgentsDialog
|
||||
:title="__('Add Agents')"
|
||||
@close="showNewAgentsDialog = false"
|
||||
:modelValue="showNewAgentsDialog"
|
||||
:show="showNewAgentsDialog"
|
||||
@update:modelValue="showNewAgentsDialog = $event"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { call, FormControl, toast } from "frappe-ui";
|
||||
import { h } from "vue";
|
||||
import { Avatar, Button, call, Dropdown, FeatherIcon, toast } from "frappe-ui";
|
||||
import { h, onUnmounted } from "vue";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import IconMoreHorizontal from "~icons/lucide/more-horizontal";
|
||||
import AgentCard from "./AgentCard.vue";
|
||||
import { activeFilter, showNewAgentsDialog, useAgents } from "./agents";
|
||||
import { activeFilter, useAgents } from "./agents";
|
||||
import AgentIcon from "../icons/AgentIcon.vue";
|
||||
import { setActiveSettingsTab } from "./settingsModal";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const { getUserRole, updateUserRoleCache } = useUserStore();
|
||||
const { isManager } = useAuthStore();
|
||||
@@ -171,6 +223,7 @@ function getRoles(agent: string) {
|
||||
role: "Agent",
|
||||
active: props.active,
|
||||
selected: agentRole === "Agent",
|
||||
icon: "user",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Agent");
|
||||
},
|
||||
@@ -185,6 +238,7 @@ function getRoles(agent: string) {
|
||||
role: "Manager",
|
||||
active: props.active,
|
||||
selected: agentRole === "Manager",
|
||||
icon: "briefcase",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Manager");
|
||||
},
|
||||
@@ -194,18 +248,29 @@ function getRoles(agent: string) {
|
||||
|
||||
return roles;
|
||||
}
|
||||
function RoleOption({ active, role, onClick, selected }) {
|
||||
|
||||
function RoleOption({ active, role, onClick, selected, icon = null }) {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: [
|
||||
active ? "bg-surface-gray-2" : "text-ink-gray-7",
|
||||
"group flex w-full justify-between items-center rounded-md px-2 py-2 text-base",
|
||||
|
||||
"group flex w-full text-ink-gray-8 justify-between items-center rounded-md px-2 py-2 text-sm hover:bg-surface-gray-3",
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h("span", { class: "whitespace-nowrap" }, role),
|
||||
h("div", { class: "flex gap-2" }, [
|
||||
icon
|
||||
? h(FeatherIcon, {
|
||||
name: icon,
|
||||
class: ["h-4 w-4 shrink-0"],
|
||||
"aria-hidden": true,
|
||||
})
|
||||
: null,
|
||||
h("span", { class: "whitespace-nowrap" }, role),
|
||||
]),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ["h-4 w-4 shrink-0 text-ink-gray-7"],
|
||||
@@ -277,6 +342,8 @@ const dropdownOptions = [
|
||||
},
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
onUnmounted(() => {
|
||||
agents.filters = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
@@ -42,12 +42,12 @@
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
|
||||
class="p-1 text-ink-gray-7 mt-1 bg-white shadow-xl rounded w-[--reka-popper-anchor-width]"
|
||||
>
|
||||
<div
|
||||
v-for="option in ticketRoutingOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded"
|
||||
@click="
|
||||
() => {
|
||||
assignmentRuleData.rule = option.value;
|
||||
@@ -115,7 +115,7 @@ import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesErrors,
|
||||
validateAssignmentRule,
|
||||
} from "../../../stores/assignmentRules";
|
||||
} from "@/stores/assignmentRules";
|
||||
import AssigneeSearch from "./AssigneeSearch.vue";
|
||||
import { computed } from "vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<ComboboxOption
|
||||
v-show="users.length > 0"
|
||||
v-for="user in users"
|
||||
:key="user.username"
|
||||
:key="user.name"
|
||||
:value="user"
|
||||
as="template"
|
||||
v-slot="{ active }"
|
||||
@@ -55,14 +55,16 @@
|
||||
<Avatar
|
||||
:shape="'circle'"
|
||||
:image="user.user_image"
|
||||
:label="user.full_name"
|
||||
:label="user.agent_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-semibold text-ink-gray-7">
|
||||
{{ user.full_name }}
|
||||
<div class="flex flex-col gap-1 truncate">
|
||||
<div class="font-semibold text-ink-gray-7 truncate">
|
||||
{{ user.agent_name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 truncate">
|
||||
{{ user.user }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -80,31 +82,22 @@
|
||||
class="w-full"
|
||||
icon-left="plus"
|
||||
:label="__('Invite agent')"
|
||||
@click="
|
||||
() => {
|
||||
showNewAgentsDialog = true;
|
||||
togglePopover();
|
||||
}
|
||||
"
|
||||
@click="inviteAgents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
|
||||
<AddNewAgentsDialog
|
||||
:title="__('Add Agents')"
|
||||
@close="showNewAgentsDialog = false"
|
||||
:modelValue="showNewAgentsDialog"
|
||||
:show="showNewAgentsDialog"
|
||||
@update:modelValue="showNewAgentsDialog = $event"
|
||||
@onAgentsInvited="addInvitedAgents"
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AddNewAgentsDialog from "@/components/desk/global/AddNewAgentsDialog.vue";
|
||||
import { assignmentRuleData } from "@/stores/assignmentRules";
|
||||
import {
|
||||
Combobox,
|
||||
@@ -112,87 +105,76 @@ import {
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { Avatar, createListResource, Popover } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import { Avatar, Popover } from "frappe-ui";
|
||||
import { computed, inject, Ref, ref } from "vue";
|
||||
import { setActiveSettingsTab } from "../settingsModal";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { onMounted } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const emit = defineEmits(["addAssignee"]);
|
||||
const query = ref("");
|
||||
const showNewAgentsDialog = ref(false);
|
||||
|
||||
watchDebounced(
|
||||
() => query.value,
|
||||
(val) => {
|
||||
const filters = {
|
||||
full_name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
const isEmail = val.includes("@") || val.includes(".");
|
||||
if (isEmail) {
|
||||
filters.email = ["like", `%${val}%`];
|
||||
} else {
|
||||
filters.full_name = ["like", `%${val}%`];
|
||||
}
|
||||
usersList.update({
|
||||
filters,
|
||||
});
|
||||
usersList.reload();
|
||||
},
|
||||
{ debounce: 500 }
|
||||
const { agents } = useAgentStore();
|
||||
const isAssignmentRuleFormDirty = inject<Ref<boolean>>(
|
||||
"isAssignmentRuleFormDirty"
|
||||
);
|
||||
|
||||
const usersList = createListResource({
|
||||
doctype: "User",
|
||||
fields: ["name", "email", "full_name", "user_image"],
|
||||
start: 0,
|
||||
pageLength: 5,
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
return data
|
||||
.map((user) => {
|
||||
if (user.full_name == "Administrator") {
|
||||
return {
|
||||
...user,
|
||||
email: "Administrator",
|
||||
user: "Administrator",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
user: user.email,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.full_name != "Guest");
|
||||
},
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const users = computed(() => {
|
||||
return (
|
||||
usersList.data?.filter(
|
||||
let filteredAgents =
|
||||
agents.data?.filter(
|
||||
(user) =>
|
||||
!assignmentRuleData.value.users.some((u) => u.user === user.email)
|
||||
) || []
|
||||
);
|
||||
!assignmentRuleData.value.users.some((u) => u.user === user.user)
|
||||
) || [];
|
||||
|
||||
if (query.value) {
|
||||
const val = query.value.toLowerCase();
|
||||
const isEmail = val.includes("@") || val.includes(".");
|
||||
filteredAgents = filteredAgents.filter((user) => {
|
||||
if (isEmail) {
|
||||
return user.user?.toLowerCase().includes(val);
|
||||
} else {
|
||||
return user.agent_name?.toLowerCase().includes(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
return filteredAgents;
|
||||
});
|
||||
|
||||
const addInvitedAgents = (users) => {
|
||||
users.forEach((user) => {
|
||||
addAssignee({ user });
|
||||
const addAssignee = (user) => {
|
||||
assignmentRuleData.value.users.push({
|
||||
user: user.user,
|
||||
});
|
||||
emit("addAssignee", user);
|
||||
};
|
||||
|
||||
const addAssignee = (user) => {
|
||||
const userExists = assignmentRuleData.value.users.some(
|
||||
(u) => u.user === user.user
|
||||
);
|
||||
if (!userExists) {
|
||||
assignmentRuleData.value.users.push({
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
user_image: user.user_image,
|
||||
user: user.email,
|
||||
});
|
||||
emit("addAssignee", user);
|
||||
const inviteAgents = () => {
|
||||
if (isAssignmentRuleFormDirty.value) {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to leave? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: () => {
|
||||
setActiveSettingsTab("Invite Agents");
|
||||
},
|
||||
};
|
||||
} else {
|
||||
setActiveSettingsTab("Invite Agents");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (agents.loading || agents.data?.length || agents.list.promise) {
|
||||
return;
|
||||
}
|
||||
agents.fetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
|
||||
>
|
||||
<div
|
||||
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
|
||||
class="w-full py-3 pl-2 col-span-7"
|
||||
class="w-full pl-2 col-span-7 h-14 flex flex-col justify-center"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
|
||||
<div
|
||||
v-if="data.description && data.description.length > 0"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
|
||||
>
|
||||
{{ data.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<Select
|
||||
class="w-max bg-transparent -ml-2 border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
|
||||
:options="priorityOptions"
|
||||
<div class="col-span-3">
|
||||
<select
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
v-model="data.priority"
|
||||
@update:modelValue="onPriorityChange"
|
||||
/>
|
||||
@change="onPriorityChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full pr-2 col-span-2">
|
||||
<div>
|
||||
@@ -50,7 +58,7 @@
|
||||
<FormControl
|
||||
:label="__('New Assignment Rule Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.name"
|
||||
v-model="duplicateDialog.newName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,19 +77,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import { __ } from "@/translation";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
import { AssignmentRule } from "@/types/doctypes";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import {
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { inject, ref } from "vue";
|
||||
|
||||
const assignmentRulesList = inject<any>("assignmentRulesList");
|
||||
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -100,6 +111,7 @@ const priorityOptions = [
|
||||
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
@@ -113,9 +125,9 @@ const deleteAssignmentRule = () => {
|
||||
name: props.data.name,
|
||||
},
|
||||
onSuccess: () => {
|
||||
assignmentRulesList.reload();
|
||||
assignmentRulesListData?.reload();
|
||||
isConfirmingDelete.value = false;
|
||||
toast.success("Assignment rule deleted");
|
||||
toast.success(__("Assignment rule deleted"));
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -123,11 +135,12 @@ const deleteAssignmentRule = () => {
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: "Duplicate",
|
||||
label: __("Duplicate"),
|
||||
onClick: () => {
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
name: props.data.name + " (Copy)",
|
||||
newName: props.data.name + " (Copy)",
|
||||
name: props.data.name,
|
||||
};
|
||||
},
|
||||
icon: "copy",
|
||||
@@ -140,22 +153,37 @@ const dropdownOptions = [
|
||||
|
||||
const duplicate = () => {
|
||||
createResource({
|
||||
url: "helpdesk.api.assignment_rule.duplicate_assignment_rule",
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
docname: props.data.name,
|
||||
new_name: duplicateDialog.value.name,
|
||||
doctype: "Assignment Rule",
|
||||
name: duplicateDialog.value.name,
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
assignmentRulesList.reload();
|
||||
toast.success("Assignment rule duplicated");
|
||||
duplicateDialog.value.show = false;
|
||||
duplicateDialog.value.name = "";
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: {
|
||||
name: data.name,
|
||||
onSuccess: (data: AssignmentRule) => {
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
...data,
|
||||
name: duplicateDialog.value.newName,
|
||||
},
|
||||
},
|
||||
};
|
||||
auto: true,
|
||||
onSuccess(newAssignmentRuleData: AssignmentRule) {
|
||||
assignmentRulesListData?.reload();
|
||||
toast.success(__("Assignment rule duplicated"));
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: newAssignmentRuleData,
|
||||
};
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -167,13 +195,17 @@ const onPriorityChange = () => {
|
||||
|
||||
const onToggle = () => {
|
||||
if (!props.data.users_exists && props.data.disabled) {
|
||||
toast.error("Cannot enable rule without adding users in it");
|
||||
toast.error(__("Cannot enable rule without adding users in it"));
|
||||
return;
|
||||
}
|
||||
setAssignmentRuleValue("disabled", !props.data.disabled, "status");
|
||||
};
|
||||
|
||||
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
|
||||
const setAssignmentRuleValue = (
|
||||
key: string,
|
||||
value: any,
|
||||
fieldName?: string
|
||||
) => {
|
||||
createResource({
|
||||
url: "frappe.client.set_value",
|
||||
params: {
|
||||
@@ -183,8 +215,8 @@ const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
|
||||
value: value,
|
||||
},
|
||||
onSuccess: () => {
|
||||
assignmentRulesList.reload();
|
||||
toast.success(`Assignment rule ${fieldName || key} updated`);
|
||||
assignmentRulesListData?.reload();
|
||||
toast.success(__("Assignment rule {0} updated", fieldName || key));
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="getAssignmentRuleData.loading"
|
||||
class="flex items-center h-full justify-center"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!getAssignmentRuleData.loading"
|
||||
class="sticky top-0 z-10 bg-white pb-6 px-10 py-8"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="
|
||||
assignmentRuleData.assignmentRuleName || 'New Assignment Rule'
|
||||
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
|
||||
"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-lg hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
@@ -43,259 +36,292 @@
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="saveAssignmentRule()"
|
||||
:loading="isLoading || getAssignmentRuleData.loading"
|
||||
:loading="
|
||||
isLoading ||
|
||||
getAssignmentRuleData.loading ||
|
||||
createAssignmentRuleResource.loading
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!getAssignmentRuleData.loading" class="overflow-y-auto px-10 pb-8">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Name"
|
||||
label="Name"
|
||||
v-model="assignmentRuleData.assignmentRuleName"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateAssignmentRule('assignmentRuleName')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignmentRuleName"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="getAssignmentRuleData.loading"
|
||||
class="flex items-center h-full justify-center"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel label="Priority" />
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
<div v-if="!getAssignmentRuleData.loading">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Name')"
|
||||
:label="__('Name')"
|
||||
v-model="assignmentRuleData.assignmentRuleName"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateAssignmentRule('assignmentRuleName')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignmentRuleName"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Priority')" />
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
priorityOptions.find(
|
||||
(option) => option.value == assignmentRuleData.priority
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute bg-white shadow-2xl rounded w-[--reka-popper-anchor-width]"
|
||||
>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
|
||||
@click="
|
||||
assignmentRuleData.priority = option.value;
|
||||
togglePopover();
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Description')"
|
||||
:label="__('Description')"
|
||||
required
|
||||
maxlength="140"
|
||||
@change="validateAssignmentRule('description')"
|
||||
v-model="assignmentRuleData.description"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.description"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
priorityOptions.find(
|
||||
(option) => option.value == assignmentRuleData.priority
|
||||
)?.label
|
||||
__(
|
||||
"Choose which tickets are affected by this assignment rule."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
|
||||
>
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
|
||||
@click="
|
||||
assignmentRuleData.priority = option.value;
|
||||
togglePopover();
|
||||
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span>{{ __("Old Condition") }}</span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForAssignCondition &&
|
||||
assignmentRuleData.assignCondition
|
||||
"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to recreate
|
||||
the conditions here if you want to manage and add new conditions
|
||||
from this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForAssignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.assignConditionJson"
|
||||
name="assignCondition"
|
||||
:errors="assignmentRulesErrors.assignConditionError"
|
||||
v-else
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignCondition"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Unassignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose which tickets are affected by this un-assignment rule."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
isUnassignConditionOld &&
|
||||
assignmentRulesActiveScreen.data &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span> {{ __("Old Condition") }} </span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Description"
|
||||
label="Description"
|
||||
required
|
||||
maxlength="250"
|
||||
@change="validateAssignmentRule('description')"
|
||||
v-model="assignmentRuleData.description"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.description"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__("Choose which tickets are affected by this assignment rule.")
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForUnassignCondition &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
</span>
|
||||
<div v-if="isOldSla && assignmentRulesActiveScreen.data">
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span>{{ __("Old Condition") }}</span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to recreate
|
||||
the conditions here if you want to manage and add new conditions
|
||||
from this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForUnassignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.unassignConditionJson"
|
||||
name="unassignCondition"
|
||||
:errors="assignmentRulesErrors.unassignConditionError"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="!useNewUI && assignmentRuleData.assignCondition"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
|
||||
are not compatible with this UI, you will need to recreate the
|
||||
conditions here if you want to manage and add new conditions from
|
||||
this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.assignConditionJson"
|
||||
name="assignCondition"
|
||||
:errors="assignmentRulesErrors.assignConditionError"
|
||||
v-else
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignCondition"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Unassignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose which tickets are affected by this un-assignment rule."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
isOldSla &&
|
||||
assignmentRulesActiveScreen.data &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span> {{ __("Old Condition") }} </span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment Schedule")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose the days of the week when this rule should be active."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<AssignmentSchedule />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<AssigneeRules />
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="!useNewUI && assignmentRuleData.unassignCondition"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
|
||||
are not compatible with this UI, you will need to recreate the
|
||||
conditions here if you want to manage and add new conditions from
|
||||
this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.unassignConditionJson"
|
||||
name="unassignCondition"
|
||||
:errors="assignmentRulesErrors.unassignConditionError"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment Schedule")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__("Choose the days of the week when this rule should be active.")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<AssignmentSchedule />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<AssigneeRules />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
:onCancel="() => (showConfirmDialog.show = false)"
|
||||
:onCancel="
|
||||
() => {
|
||||
if (showConfirmDialog.onCancel) {
|
||||
showConfirmDialog.onCancel();
|
||||
} else {
|
||||
showConfirmDialog.show = false;
|
||||
}
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -314,7 +340,7 @@ import {
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { onMounted, onUnmounted, provide, ref, watch } from "vue";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesActiveScreen,
|
||||
@@ -322,25 +348,37 @@ import {
|
||||
resetAssignmentRuleData,
|
||||
resetAssignmentRuleErrors,
|
||||
validateAssignmentRule,
|
||||
} from "../../../stores/assignmentRules";
|
||||
} from "@/stores/assignmentRules";
|
||||
import AssigneeRules from "./AssigneeRules.vue";
|
||||
import AssignmentRulesSection from "./AssignmentRulesSection.vue";
|
||||
import AssignmentSchedule from "./AssignmentSchedule.vue";
|
||||
import { convertToConditions } from "@/utils";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const isDirty = ref(false);
|
||||
const initialData = ref(null);
|
||||
const isLoading = ref(false);
|
||||
provide("isAssignmentRuleFormDirty", isDirty);
|
||||
|
||||
const showConfirmDialog = ref({
|
||||
const showConfirmDialog = ref<{
|
||||
show: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}>({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
const useNewUI = ref(true);
|
||||
const isOldSla = ref(false);
|
||||
|
||||
const useNewUIForAssignCondition = ref(true);
|
||||
const useNewUIForUnassignCondition = ref(true);
|
||||
const isAssignConditionOld = ref(false);
|
||||
const isUnassignConditionOld = ref(false);
|
||||
const deskUrl = `${window.location.origin}/app/assignment-rule/${assignmentRulesActiveScreen.value.data?.name}`;
|
||||
|
||||
const getAssignmentRuleData = createResource({
|
||||
@@ -351,12 +389,30 @@ const getAssignmentRuleData = createResource({
|
||||
},
|
||||
auto: Boolean(assignmentRulesActiveScreen.value.data),
|
||||
onSuccess(data) {
|
||||
let assignConditionJson;
|
||||
let unassignConditionJson;
|
||||
try {
|
||||
assignConditionJson = JSON.parse(data.assign_condition_json || "[]");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
"Assignment conditions are invalid or corrupt, recreate the conditions."
|
||||
);
|
||||
assignConditionJson = [];
|
||||
}
|
||||
try {
|
||||
unassignConditionJson = JSON.parse(data.unassign_condition_json || "[]");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
"Unassignment conditions are invalid or corrupt, recreate the conditions."
|
||||
);
|
||||
unassignConditionJson = [];
|
||||
}
|
||||
assignmentRuleData.value = {
|
||||
loading: false,
|
||||
assignCondition: data.assign_condition,
|
||||
unassignCondition: data.unassign_condition,
|
||||
assignConditionJson: JSON.parse(data.assign_condition_json || "[]"),
|
||||
unassignConditionJson: JSON.parse(data.unassign_condition_json || "[]"),
|
||||
assignConditionJson: assignConditionJson,
|
||||
unassignConditionJson: unassignConditionJson,
|
||||
rule: data.rule,
|
||||
priority: data.priority,
|
||||
users: data.users,
|
||||
@@ -369,43 +425,52 @@ const getAssignmentRuleData = createResource({
|
||||
|
||||
initialData.value = JSON.stringify(assignmentRuleData.value);
|
||||
|
||||
const conditionsAvailable =
|
||||
const assignConditionsAvailable =
|
||||
assignmentRuleData.value.assignCondition?.length > 0;
|
||||
const conditionsJsonAvailable =
|
||||
const assignConditionsJsonAvailable =
|
||||
assignmentRuleData.value.assignConditionJson?.length > 0;
|
||||
|
||||
if (conditionsAvailable && !conditionsJsonAvailable) {
|
||||
useNewUI.value = false;
|
||||
isOldSla.value = true;
|
||||
if (assignConditionsAvailable && !assignConditionsJsonAvailable) {
|
||||
useNewUIForAssignCondition.value = false;
|
||||
isAssignConditionOld.value = true;
|
||||
} else {
|
||||
useNewUI.value = true;
|
||||
isOldSla.value = false;
|
||||
useNewUIForAssignCondition.value = true;
|
||||
isAssignConditionOld.value = false;
|
||||
}
|
||||
|
||||
const unassignConditionsAvailable =
|
||||
assignmentRuleData.value.unassignCondition?.length > 0;
|
||||
const unassignConditionsJsonAvailable =
|
||||
assignmentRuleData.value.unassignConditionJson?.length > 0;
|
||||
|
||||
if (unassignConditionsAvailable && !unassignConditionsJsonAvailable) {
|
||||
useNewUIForUnassignCondition.value = false;
|
||||
isUnassignConditionOld.value = true;
|
||||
} else {
|
||||
useNewUIForUnassignCondition.value = true;
|
||||
isUnassignConditionOld.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!assignmentRulesActiveScreen.value.data) {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
initialData.value = JSON.stringify(assignmentRuleData.value);
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const confirmDialogInfo = {
|
||||
show: true,
|
||||
title: "Unsaved changes",
|
||||
message: "Are you sure you want to go back? Unsaved changes will be lost.",
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to go back? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: goBack,
|
||||
};
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!assignmentRulesActiveScreen.value.data &&
|
||||
!showConfirmDialog.value.show
|
||||
) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
// Workaround fix for settings modal not closing after going back
|
||||
setTimeout(() => {
|
||||
assignmentRulesActiveScreen.value = {
|
||||
@@ -417,27 +482,26 @@ const goBack = () => {
|
||||
};
|
||||
|
||||
const saveAssignmentRule = () => {
|
||||
const validationErrors = validateAssignmentRule(undefined, !useNewUI.value);
|
||||
const validationErrors = validateAssignmentRule(
|
||||
undefined,
|
||||
!useNewUIForAssignCondition.value,
|
||||
!useNewUIForUnassignCondition.value
|
||||
);
|
||||
|
||||
if (Object.values(validationErrors).some((error) => error)) {
|
||||
toast.error(
|
||||
"Invalid fields, check if all are filled in and values are correct."
|
||||
__("Invalid fields, check if all are filled in and values are correct.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (assignmentRulesActiveScreen.value.data) {
|
||||
if (isOldSla.value && useNewUI.value) {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: "Confirm overwrite",
|
||||
message:
|
||||
"Your old condition will be overwritten. Are you sure you want to save?",
|
||||
onConfirm: () => {
|
||||
updateAssignmentRule();
|
||||
showConfirmDialog.value.show = false;
|
||||
},
|
||||
};
|
||||
if (isAssignConditionOld.value && useNewUIForAssignCondition.value) {
|
||||
showOverwriteConfirm();
|
||||
return;
|
||||
}
|
||||
if (isUnassignConditionOld.value && useNewUIForUnassignCondition.value) {
|
||||
showOverwriteConfirm();
|
||||
return;
|
||||
}
|
||||
updateAssignmentRule();
|
||||
@@ -446,60 +510,65 @@ const saveAssignmentRule = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const showOverwriteConfirm = () => {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: "Confirm overwrite",
|
||||
message:
|
||||
"Your old condition will be overwritten. Are you sure you want to save?",
|
||||
onConfirm: () => {
|
||||
updateAssignmentRule();
|
||||
showConfirmDialog.value.show = false;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createAssignmentRule = () => {
|
||||
isLoading.value = true;
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
doctype: "Assignment Rule",
|
||||
document_type: "HD Ticket",
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
|
||||
assign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
}),
|
||||
unassign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
}),
|
||||
assign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.assignConditionJson
|
||||
),
|
||||
unassign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.unassignConditionJson
|
||||
),
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
getAssignmentRuleData
|
||||
.submit({
|
||||
doctype: "Assignment Rule",
|
||||
name: data.name,
|
||||
})
|
||||
.then(() => {
|
||||
isLoading.value = false;
|
||||
toast.success("Assignment rule created");
|
||||
});
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
isLoading.value = false;
|
||||
createAssignmentRuleResource.submit({
|
||||
doc: {
|
||||
doctype: "Assignment Rule",
|
||||
document_type: "HD Ticket",
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
|
||||
assign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
}),
|
||||
unassign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
}),
|
||||
assign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.assignConditionJson
|
||||
),
|
||||
unassign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.unassignConditionJson
|
||||
),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createAssignmentRuleResource = createResource({
|
||||
url: "frappe.client.insert",
|
||||
onSuccess(data) {
|
||||
toast.success(__("Assignment rule created"));
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
};
|
||||
getAssignmentRuleData.submit({
|
||||
doctype: "Assignment Rule",
|
||||
name: data.name,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: "Low", value: "0" },
|
||||
{ label: "Low-Medium", value: "1" },
|
||||
@@ -524,26 +593,27 @@ const updateAssignmentRule = async () => {
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
assign_condition: useNewUI.value
|
||||
assign_condition: useNewUIForAssignCondition.value
|
||||
? convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
})
|
||||
: assignmentRuleData.value.assignCondition,
|
||||
unassign_condition: useNewUI.value
|
||||
unassign_condition: useNewUIForUnassignCondition.value
|
||||
? convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
})
|
||||
: assignmentRuleData.value.unassignCondition,
|
||||
assign_condition_json: useNewUI.value
|
||||
assign_condition_json: useNewUIForAssignCondition.value
|
||||
? JSON.stringify(assignmentRuleData.value.assignConditionJson)
|
||||
: null,
|
||||
unassign_condition_json: useNewUI.value
|
||||
unassign_condition_json: useNewUIForUnassignCondition.value
|
||||
? JSON.stringify(assignmentRuleData.value.unassignConditionJson)
|
||||
: null,
|
||||
},
|
||||
}).catch((er) => {
|
||||
const error =
|
||||
er?.messages?.[0] || "Some error occurred while updating assignment rule";
|
||||
er?.messages?.[0] ||
|
||||
__("Some error occurred while updating assignment rule");
|
||||
toast.error(error);
|
||||
isLoading.value = false;
|
||||
});
|
||||
@@ -559,7 +629,7 @@ const updateAssignmentRule = async () => {
|
||||
}).catch(async (er) => {
|
||||
const error =
|
||||
er?.messages?.[0] ||
|
||||
"Some error occurred while renaming assignment rule";
|
||||
__("Some error occurred while renaming assignment rule");
|
||||
toast.error(error);
|
||||
// Reset assignment rule to previous state
|
||||
await getAssignmentRuleData.reload();
|
||||
@@ -574,7 +644,7 @@ const updateAssignmentRule = async () => {
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
toast.success("Assignment rule updated");
|
||||
toast.success(__("Assignment rule updated"));
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="pb-8">
|
||||
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
|
||||
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
|
||||
</div>
|
||||
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
|
||||
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { assignmentRulesActiveScreen } from "../../../stores/assignmentRules";
|
||||
import { provide, ref } from "vue";
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import AssignmentRulesList from "./AssignmentRulesList.vue";
|
||||
import AssignmentRuleView from "./AssignmentRuleView.vue";
|
||||
|
||||
const assignmentRuleSearchQuery = ref("");
|
||||
|
||||
provide("assignmentRuleSearchQuery", assignmentRuleSearchQuery);
|
||||
</script>
|
||||
|
||||
@@ -1,48 +1,80 @@
|
||||
<template>
|
||||
<div class="px-10 py-8 sticky top-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Assignment rules") }}
|
||||
</h1>
|
||||
<p class="text-p-sm text-ink-gray-6 max-w-md">
|
||||
{{
|
||||
__(
|
||||
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Assignment Rules") }}
|
||||
</h1>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('Create new')"
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto px-10 pb-8">
|
||||
<AssignmentRulesListView />
|
||||
</div>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
assignmentRulesListData.data?.length > 9 ||
|
||||
assignmentRuleSearchQuery.length
|
||||
"
|
||||
#header-bottom
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="assignmentRuleSearchQuery"
|
||||
@input="assignmentRuleSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
<Button
|
||||
v-if="assignmentRuleSearchQuery"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="assignmentRuleSearchQuery = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<AssignmentRulesListView />
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button, createResource } from "frappe-ui";
|
||||
import { provide } from "vue";
|
||||
import { inject, provide, Ref } from "vue";
|
||||
import {
|
||||
assignmentRulesActiveScreen,
|
||||
resetAssignmentRuleData,
|
||||
} from "../../../stores/assignmentRules";
|
||||
} from "@/stores/assignmentRules";
|
||||
import AssignmentRulesListView from "./AssignmentRulesListView.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
|
||||
const assignmentRulesListData = createResource({
|
||||
url: "helpdesk.api.assignment_rule.get_assignment_rules_list",
|
||||
cache: ["assignmentRules", "get_assignment_rules_list"],
|
||||
auto: true,
|
||||
});
|
||||
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
|
||||
|
||||
provide("assignmentRulesList", assignmentRulesListData);
|
||||
provide(AssignmentRuleListResourceSymbol, assignmentRulesListData);
|
||||
|
||||
const goToNew = () => {
|
||||
resetAssignmentRuleData();
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
|
||||
v-if="assignmentRulesListData.loading && !assignmentRulesListData.data"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="-ml-2 grow">
|
||||
<div
|
||||
v-if="assignmentRulesList.data?.length === 0"
|
||||
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
|
||||
v-if="!assignmentRulesListData.loading && !assignmentRulesList?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __("No items in the list") }}
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<Settings class="size-6 text-ink-gray-6 rotate-90" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No assignment rule found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="goToNew()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600">
|
||||
<div class="grid grid-cols-12 items-center gap-4 text-sm text-gray-600">
|
||||
<div class="col-span-7 ml-2">{{ __("Assignment rule") }}</div>
|
||||
<div class="col-span-2">{{ __("Priority") }}</div>
|
||||
<div class="col-span-3">{{ __("Priority") }}</div>
|
||||
<div class="col-span-2">{{ __("Enabled") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2 mx-2" />
|
||||
<div
|
||||
v-for="assignmentRule in assignmentRulesList.data"
|
||||
v-for="(assignmentRule, index) in assignmentRulesList"
|
||||
:key="assignmentRule.name"
|
||||
>
|
||||
<AssignmentRuleListItem :data="assignmentRule" />
|
||||
<hr class="mx-2" />
|
||||
<hr v-if="index !== assignmentRulesList.length - 1" class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,8 +50,43 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoadingIndicator } from "frappe-ui";
|
||||
import { inject } from "vue";
|
||||
import { inject, Ref, ref, watch } from "vue";
|
||||
import AssignmentRuleListItem from "./AssignmentRuleListItem.vue";
|
||||
import {
|
||||
assignmentRulesActiveScreen,
|
||||
resetAssignmentRuleData,
|
||||
} from "@/stores/assignmentRules";
|
||||
import Settings from "~icons/lucide/settings-2";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
|
||||
const assignmentRulesList = inject<any>("assignmentRulesList");
|
||||
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
|
||||
|
||||
const assignmentRulesList = ref(assignmentRulesListData.data || []);
|
||||
|
||||
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
|
||||
|
||||
const goToNew = () => {
|
||||
resetAssignmentRuleData();
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [assignmentRuleSearchQuery.value, assignmentRulesListData.data],
|
||||
([query, data]) => {
|
||||
if (!query) {
|
||||
assignmentRulesList.value = data || [];
|
||||
return;
|
||||
}
|
||||
assignmentRulesList.value =
|
||||
data?.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}) || [];
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -42,7 +42,8 @@ import CFConditions from "@/components/conditions-filter/CFConditions.vue";
|
||||
import { validateConditions } from "@/utils";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { Button, Dropdown, ErrorMessage, FeatherIcon } from "frappe-ui";
|
||||
import { validateAssignmentRule } from "../../../stores/assignmentRules";
|
||||
import { validateAssignmentRule } from "@/stores/assignmentRules";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const props = defineProps({
|
||||
conditions: Array<any>,
|
||||
@@ -62,13 +63,13 @@ const getConjunction = () => {
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: "Add condition",
|
||||
label: __("Add condition"),
|
||||
onClick: () => {
|
||||
addCondition();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Add condition group",
|
||||
label: __("Add condition group"),
|
||||
onClick: () => {
|
||||
const conjunction = getConjunction();
|
||||
props.conditions.push(conjunction, [[]]);
|
||||
|
||||
@@ -29,16 +29,17 @@ import { onMounted, ref } from "vue";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesErrors,
|
||||
} from "../../../stores/assignmentRules";
|
||||
} from "@/stores/assignmentRules";
|
||||
import AssignmentScheduleItem from "./AssignmentScheduleItem.vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: "Days",
|
||||
label: __("Days"),
|
||||
key: "day",
|
||||
},
|
||||
{
|
||||
label: "Active",
|
||||
label: __("Active"),
|
||||
key: "active",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-10 py-8">
|
||||
<SettingsLayoutHeader
|
||||
title="Customise your Helpdesk"
|
||||
description="Customise Helpdesk with your own branding."
|
||||
/>
|
||||
|
||||
<!-- Brand Logo & Favicon -->
|
||||
<div v-for="config in brandingConfig" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-gray-600">{{ config.title }}</p>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Avatar
|
||||
v-if="config.image && !config.loading"
|
||||
size="3xl"
|
||||
:image="config.image"
|
||||
/>
|
||||
<FileUploader
|
||||
v-if="!config.image"
|
||||
:fileTypes="['image/*']"
|
||||
@success="
|
||||
(file) => {
|
||||
update(file.file_url, config.doctype, config.fieldname);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button
|
||||
@click="openFileSelector()"
|
||||
iconLeft="upload"
|
||||
label="Upload Image"
|
||||
:loading="config.loading"
|
||||
/>
|
||||
</template>
|
||||
</FileUploader>
|
||||
|
||||
<div v-else>
|
||||
<Button
|
||||
label="Remove"
|
||||
@click="update('', config.doctype, config.fieldname)"
|
||||
iconLeft="trash"
|
||||
:loading="config.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { Avatar, createResource, FileUploader, toast } from "frappe-ui";
|
||||
import { computed, reactive } from "vue";
|
||||
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
const websiteSettings = createResource({
|
||||
url: "frappe.client.get_value",
|
||||
cache: true,
|
||||
params: {
|
||||
doctype: "Website Settings",
|
||||
fieldname: "favicon",
|
||||
},
|
||||
onSuccess(data) {
|
||||
state.brandFavicon = data.favicon;
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
brandLogo: config.brandLogo,
|
||||
brandFavicon: websiteSettings.data?.favicon || "",
|
||||
});
|
||||
|
||||
const loadingState = reactive({
|
||||
logoLoading: false,
|
||||
faviconLoading: false,
|
||||
});
|
||||
|
||||
const brandingConfig = computed(() => [
|
||||
{
|
||||
title: "Brand Logo",
|
||||
image: state.brandLogo,
|
||||
doctype: "HD Settings",
|
||||
fieldname: "brand_logo",
|
||||
loading: loadingState.logoLoading,
|
||||
},
|
||||
{
|
||||
title: "Brand Favicon",
|
||||
image: state.brandFavicon,
|
||||
doctype: "Website Settings",
|
||||
fieldname: "favicon",
|
||||
loading: loadingState.faviconLoading,
|
||||
},
|
||||
]);
|
||||
|
||||
const settingsResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
debounce: 1000,
|
||||
onSuccess(data) {
|
||||
if (data.doctype === "HD Settings") {
|
||||
handleLogoChange(data.brand_logo);
|
||||
} else {
|
||||
handleFaviconChange(data.favicon);
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
toast.error("Failed to update, please try again");
|
||||
loadingState.logoLoading = false;
|
||||
loadingState.faviconLoading = false;
|
||||
},
|
||||
});
|
||||
|
||||
function update(file: string, doctype: string, fieldname: string) {
|
||||
settingsResource.submit({
|
||||
doctype: doctype,
|
||||
name: doctype,
|
||||
fieldname: fieldname,
|
||||
value: file,
|
||||
});
|
||||
doctype === "HD Settings"
|
||||
? (loadingState.logoLoading = true)
|
||||
: (loadingState.faviconLoading = true);
|
||||
}
|
||||
|
||||
function handleLogoChange(url: string) {
|
||||
state.brandLogo = url;
|
||||
loadingState.logoLoading = false;
|
||||
|
||||
toast.success("Brand logo updated");
|
||||
}
|
||||
|
||||
function handleFaviconChange(url: string) {
|
||||
state.brandFavicon = url;
|
||||
loadingState.faviconLoading = false;
|
||||
toast.success("Favicon updated, please refresh the page to see the changes");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,15 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center border-gray-200 p-2 pl-0 cursor-pointer"
|
||||
class="flex justify-between items-center border-gray-200 p-2 cursor-pointer hover:bg-gray-50 rounded h-14"
|
||||
>
|
||||
<!-- avatar and name -->
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
|
||||
<div>
|
||||
<p class="text-gray-700 font-semibold">
|
||||
<p class="text-base text-ink-gray-7 font-medium">
|
||||
{{ emailAccount.email_account_name }}
|
||||
</p>
|
||||
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
|
||||
<div class="text-sm w-full text-ink-gray-5 mt-1">
|
||||
{{ emailAccount.email_id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -28,6 +30,7 @@ import { EmailAccount } from "@/types";
|
||||
import { emailIcon } from "./emailConfig";
|
||||
import EmailProviderIcon from "./EmailProviderIcon.vue";
|
||||
import { computed } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
interface P {
|
||||
emailAccount: EmailAccount;
|
||||
@@ -44,16 +47,16 @@ const badgeTitleColor = computed(() => {
|
||||
props.emailAccount.enable_incoming && props.emailAccount.enable_outgoing
|
||||
? "blue"
|
||||
: "gray";
|
||||
return ["Default Sending and Inbox", color];
|
||||
return [__("Default Sending and Inbox"), color];
|
||||
} else if (props.emailAccount.default_incoming) {
|
||||
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
|
||||
return ["Default Inbox", color];
|
||||
return [__("Default Inbox"), color];
|
||||
} else if (props.emailAccount.default_outgoing) {
|
||||
const color = props.emailAccount.enable_outgoing ? "blue" : "gray";
|
||||
return ["Default Sending", color];
|
||||
return [__("Default Sending"), color];
|
||||
} else {
|
||||
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
|
||||
return ["Inbox", color];
|
||||
return [__("Inbox"), color];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,44 +1,76 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- header -->
|
||||
<SettingsLayoutHeader
|
||||
title="Email Accounts"
|
||||
description="Manage your email accounts and configure incoming and outgoing settings."
|
||||
>
|
||||
<template #actions>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Email Accounts')"
|
||||
:description="
|
||||
__(
|
||||
'Manage your email accounts and configure incoming and outgoing settings.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- list accounts -->
|
||||
<div
|
||||
class="-ml-2 grow"
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<div class="ml-2">{{ __("Email Account name") }}</div>
|
||||
</div>
|
||||
<hr class="mx-2 mt-2" />
|
||||
<div
|
||||
v-for="emailAccount in emailAccounts.data"
|
||||
:key="emailAccount.name"
|
||||
>
|
||||
<EmailAccountCard
|
||||
:emailAccount="emailAccount"
|
||||
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||
/>
|
||||
<hr class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<EmailIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No email account found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Add Account"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
<!-- list accounts -->
|
||||
<div
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
class="mt-4 divide-y"
|
||||
>
|
||||
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
|
||||
<EmailAccountCard
|
||||
:emailAccount="emailAccount"
|
||||
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<div v-else class="flex items-center justify-center h-64 text-gray-500">
|
||||
Please add an email account to continue.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EmailAccount } from "@/types";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import EmailAccountCard from "./EmailAccountCard.vue";
|
||||
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const emit = defineEmits(["update:step"]);
|
||||
|
||||
|
||||
@@ -1,122 +1,229 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<!-- title and desc -->
|
||||
<div role="heading" aria-level="1" class="flex flex-col gap-1">
|
||||
<h5 class="text-lg font-semibold pt-[5px]">Setup Email</h5>
|
||||
<p class="text-sm text-gray-600">
|
||||
Choose the email service provider you want to configure.
|
||||
</p>
|
||||
</div>
|
||||
<!-- email service provider selection -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div
|
||||
v-for="s in services"
|
||||
:key="s.name"
|
||||
class="min-w-3 flex flex-col items-center gap-1"
|
||||
@click="handleSelect(s)"
|
||||
>
|
||||
<EmailProviderIcon
|
||||
:service-name="s.name"
|
||||
:logo="s.icon"
|
||||
:selected="selectedService?.name === s?.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||
<!-- email service provider info -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200">
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-gray-700">
|
||||
{{ selectedService.info }}
|
||||
<a
|
||||
:href="selectedService.link"
|
||||
target="_blank"
|
||||
class="text-blue-500 underline"
|
||||
>here</a
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<!-- service provider fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Setup Email')"
|
||||
:description="
|
||||
__('Choose the email service provider you want to configure.')
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 overflow-y-auto p-0.5">
|
||||
<!-- email service provider selection -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div
|
||||
v-for="s in services"
|
||||
:key="s.name"
|
||||
class="min-w-3 flex flex-col items-center gap-1"
|
||||
@click="handleSelect(s)"
|
||||
>
|
||||
<EmailProviderIcon
|
||||
:service-name="s.name"
|
||||
:logo="s.icon"
|
||||
:selected="selectedService?.name === s?.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||
<!-- email service provider info -->
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
|
||||
>
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-gray-700">
|
||||
{{ selectedService.info }}
|
||||
<a
|
||||
:href="selectedService.link"
|
||||
target="_blank"
|
||||
class="text-blue-500 underline"
|
||||
>here</a
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- service provider fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="field.name === 'domain'"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Link
|
||||
v-model="customState.domain"
|
||||
:label="field.label"
|
||||
:placeholder="field.placeholder"
|
||||
doctype="Email Domain"
|
||||
:onCreate="handleCreateDomainClick"
|
||||
/>
|
||||
<div v-if="customState.domain" class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
:label="__('Edit Domain')"
|
||||
@click="handleEditDomainClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isCustomSelected"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customIncomingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- action button -->
|
||||
<div class="mt-auto flex justify-between -mb-8">
|
||||
<Button
|
||||
:label="__('Back')"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="addEmailRes.loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Create')"
|
||||
variant="solid"
|
||||
:loading="addEmailRes.loading"
|
||||
@click="createEmailAccount"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- action button -->
|
||||
<div v-if="selectedService" class="mt-auto flex justify-between">
|
||||
<Button
|
||||
label="Back"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="addEmailRes.loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<Button
|
||||
label="Create"
|
||||
variant="solid"
|
||||
:loading="addEmailRes.loading"
|
||||
@click="createEmailAccount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Link } from "@/components";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { capture } from "@/telemetry";
|
||||
import { EmailAccount, EmailService, EmailStep } from "@/types";
|
||||
import { createResource, toast } from "frappe-ui";
|
||||
import { call, createResource, toast } from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, Reactive, reactive, Ref, ref } from "vue";
|
||||
import { computed, reactive, Ref, ref, watch } from "vue";
|
||||
import CircleAlert from "~icons/lucide/circle-alert";
|
||||
import {
|
||||
customProviderFields,
|
||||
customIncomingFields,
|
||||
customOutgoingFields,
|
||||
customProviderTopFields,
|
||||
frappeMailFields,
|
||||
incomingOutgoingFields,
|
||||
popularProviderFields,
|
||||
services,
|
||||
validateInputs,
|
||||
} from "./emailConfig";
|
||||
import EmailProviderIcon from "./EmailProviderIcon.vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
interface E {
|
||||
(event: "update:step", value: EmailStep): void;
|
||||
}
|
||||
|
||||
interface EmailAccountBaseState {
|
||||
email_account_name: string;
|
||||
email_id: string;
|
||||
service: string;
|
||||
enable_incoming: boolean;
|
||||
enable_outgoing: boolean;
|
||||
default_incoming: boolean;
|
||||
default_outgoing: boolean;
|
||||
}
|
||||
|
||||
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
|
||||
password?: string;
|
||||
api_key?: string;
|
||||
api_secret?: string;
|
||||
frappe_mail_site?: string;
|
||||
}
|
||||
|
||||
type CustomEmailAccountState = {
|
||||
domain?: string;
|
||||
email_server?: string;
|
||||
incoming_port?: string | number;
|
||||
smtp_server?: string;
|
||||
smtp_port?: string | number;
|
||||
use_ssl?: boolean | number;
|
||||
use_starttls?: boolean | number;
|
||||
use_tls?: boolean | number;
|
||||
use_ssl_for_outgoing?: boolean | number;
|
||||
validate_ssl_certificate?: boolean | number;
|
||||
validate_ssl_certificate_for_outgoing?: boolean | number;
|
||||
attachment_limit?: string | number;
|
||||
append_emails_to_sent_folder?: boolean | number;
|
||||
sent_folder_name?: string;
|
||||
};
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
|
||||
const state: Reactive<EmailAccount> = reactive({
|
||||
const state = reactive<EmailAccountProviderAuthState>({
|
||||
service: "",
|
||||
email_account_name: "",
|
||||
email_id: "",
|
||||
@@ -130,39 +237,175 @@ const state: Reactive<EmailAccount> = reactive({
|
||||
default_outgoing: false,
|
||||
});
|
||||
|
||||
const selectedService: Ref<EmailService> = ref(null);
|
||||
const fields = computed(() =>
|
||||
selectedService.value.custom ? customProviderFields : popularProviderFields
|
||||
const getDefaultCustomState = (): CustomEmailAccountState => ({
|
||||
domain: "",
|
||||
email_server: "",
|
||||
smtp_server: "",
|
||||
incoming_port: "",
|
||||
smtp_port: "",
|
||||
use_ssl: false,
|
||||
use_starttls: false,
|
||||
use_tls: false,
|
||||
use_ssl_for_outgoing: false,
|
||||
validate_ssl_certificate: true,
|
||||
validate_ssl_certificate_for_outgoing: true,
|
||||
attachment_limit: "",
|
||||
append_emails_to_sent_folder: false,
|
||||
sent_folder_name: "",
|
||||
});
|
||||
|
||||
const customState = reactive<CustomEmailAccountState>(getDefaultCustomState());
|
||||
|
||||
function resetCustomState() {
|
||||
Object.assign(customState, getDefaultCustomState());
|
||||
}
|
||||
|
||||
const selectedService: Ref<EmailService | null> = ref(null);
|
||||
const fields = computed(() => {
|
||||
if (!selectedService.value) return [];
|
||||
if (selectedService.value.name === "Frappe Mail") {
|
||||
return frappeMailFields;
|
||||
}
|
||||
if (selectedService.value.custom) {
|
||||
return customProviderTopFields;
|
||||
}
|
||||
return popularProviderFields;
|
||||
});
|
||||
|
||||
const isCustomSelected = computed(
|
||||
() => selectedService.value?.name === "Custom"
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customState.domain,
|
||||
async (val) => {
|
||||
if (state.service !== "Custom") return;
|
||||
if (!isCustomSelected.value || !val) return;
|
||||
try {
|
||||
const doc = await call("frappe.client.get", {
|
||||
doctype: "Email Domain",
|
||||
name: val,
|
||||
});
|
||||
syncAccountFieldsFromDomain(doc);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load Email Domain", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleSelect(service: EmailService) {
|
||||
selectedService.value = service;
|
||||
state.service = service.name;
|
||||
if (service.name !== "Custom") {
|
||||
resetCustomState();
|
||||
}
|
||||
}
|
||||
|
||||
const addEmailRes = createResource({
|
||||
url: "helpdesk.api.settings.email.create_email_account",
|
||||
makeParams: (val: EmailAccount) => {
|
||||
return {
|
||||
...val,
|
||||
};
|
||||
},
|
||||
makeParams: (val: EmailAccount) => ({ ...val }),
|
||||
|
||||
onSuccess: () => {
|
||||
toast.success("Email account created");
|
||||
toast.success(__("Email account created"));
|
||||
emit("update:step", "email-list");
|
||||
updateOnboardingStep("setup_email_account");
|
||||
capture("email_account_created", { data: { service: state.service } });
|
||||
},
|
||||
onError: () => {
|
||||
error.value = "Failed to create email account, Invalid credentials";
|
||||
error.value = __("Failed to create email account, Invalid credentials");
|
||||
},
|
||||
});
|
||||
|
||||
const error = ref<string | undefined>();
|
||||
function createEmailAccount() {
|
||||
error.value = validateInputs(state, selectedService.value.custom);
|
||||
const validationState = { ...state, ...customState };
|
||||
error.value = validateInputs(validationState, state.service);
|
||||
if (error.value) return;
|
||||
|
||||
addEmailRes.submit({ data: state });
|
||||
addEmailRes.submit({ data: buildCreatePayload() });
|
||||
}
|
||||
|
||||
function buildCreatePayload() {
|
||||
const commonPayload = {
|
||||
email_account_name: state.email_account_name,
|
||||
email_id: state.email_id,
|
||||
service: state.service,
|
||||
enable_incoming: state.enable_incoming,
|
||||
enable_outgoing: state.enable_outgoing,
|
||||
default_incoming: state.default_incoming,
|
||||
default_outgoing: state.default_outgoing,
|
||||
};
|
||||
|
||||
if (state.service === "Frappe Mail") {
|
||||
return {
|
||||
...commonPayload,
|
||||
frappe_mail_site: state.frappe_mail_site,
|
||||
api_key: state.api_key,
|
||||
api_secret: state.api_secret,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.service === "Custom") {
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
...customState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
};
|
||||
}
|
||||
|
||||
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
|
||||
customState.email_server = payload.email_server ?? customState.email_server;
|
||||
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
|
||||
customState.incoming_port =
|
||||
payload.incoming_port ?? customState.incoming_port;
|
||||
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
|
||||
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
|
||||
customState.use_starttls = Boolean(
|
||||
payload.use_starttls ?? customState.use_starttls
|
||||
);
|
||||
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
|
||||
customState.use_ssl_for_outgoing = Boolean(
|
||||
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
|
||||
);
|
||||
customState.validate_ssl_certificate = Boolean(
|
||||
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
|
||||
);
|
||||
customState.validate_ssl_certificate_for_outgoing = Boolean(
|
||||
payload.validate_ssl_certificate_for_outgoing ??
|
||||
customState.validate_ssl_certificate_for_outgoing
|
||||
);
|
||||
customState.append_emails_to_sent_folder = Boolean(
|
||||
payload.append_emails_to_sent_folder ??
|
||||
customState.append_emails_to_sent_folder
|
||||
);
|
||||
customState.sent_folder_name =
|
||||
payload.sent_folder_name || customState.sent_folder_name;
|
||||
customState.attachment_limit =
|
||||
payload.attachment_limit || customState.attachment_limit;
|
||||
}
|
||||
|
||||
function handleCreateDomainClick(value: any, close?: () => void) {
|
||||
close?.();
|
||||
window.open(
|
||||
"/desk/email-domain/new-email-domain",
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
}
|
||||
|
||||
function handleEditDomainClick() {
|
||||
if (!customState.domain) return;
|
||||
window.open(
|
||||
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<div class="flex-1 px-10 py-8">
|
||||
<div v-if="step === 'email-add'" class="h-full">
|
||||
<EmailAdd @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-else-if="step === 'email-list'" class="h-full">
|
||||
<EmailAccountList @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-else-if="step === 'email-edit'" class="h-full">
|
||||
<EmailEdit :account-data="accountData" @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-if="step === 'email-add'" class="h-full">
|
||||
<EmailAdd @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-else-if="step === 'email-list'" class="h-full">
|
||||
<EmailAccountList @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-else-if="step === 'email-edit'" class="h-full">
|
||||
<EmailEdit :account-data="accountData" @update:step="updateStep" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,110 +1,222 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<!-- title and desc -->
|
||||
<div
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
class="flex gap-1 justify-between pt-[5px]"
|
||||
>
|
||||
<h5 class="text-lg font-semibold">Edit Email</h5>
|
||||
</div>
|
||||
<div class="w-fit">
|
||||
<EmailProviderIcon
|
||||
:logo="emailIcon[accountData.service]"
|
||||
:service-name="accountData.service"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner for setting up email account -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200">
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-gray-700">
|
||||
{{ info.description }}
|
||||
<a :href="info.link" target="_blank" class="text-ink-blue-2 underline"
|
||||
>here</a
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
<!-- fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
<SettingsLayoutBase
|
||||
:title="__('Edit Email')"
|
||||
:description="__('Edit your email account')"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div class="overflow-y-auto flex flex-col gap-4 p-0.5">
|
||||
<div class="w-fit">
|
||||
<EmailProviderIcon
|
||||
:logo="emailIcon[state.service || accountData.service]"
|
||||
:service-name="state.service || accountData.service"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner for setting up email account -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200"
|
||||
>
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-2"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-gray-700 flex flex-col gap-1">
|
||||
<span>
|
||||
{{ info.description }}
|
||||
<a
|
||||
:href="info.link"
|
||||
target="_blank"
|
||||
class="text-ink-blue-2 underline"
|
||||
>here</a
|
||||
>.
|
||||
</span>
|
||||
<span v-if="deskEditUrl" class="flex items-center gap-1">
|
||||
<a
|
||||
:href="deskEditUrl"
|
||||
target="_blank"
|
||||
class="text-ink-blue-2 underline"
|
||||
>
|
||||
{{ __("Open in Desk") }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div v-if="field.name === 'domain'" class="flex flex-col gap-2">
|
||||
<Link
|
||||
v-model="customState.domain"
|
||||
:label="field.label"
|
||||
:placeholder="field.placeholder"
|
||||
doctype="Email Domain"
|
||||
:onCreate="handleCreateDomainClick"
|
||||
/>
|
||||
<div v-if="customState.domain" class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
:label="__('Edit Domain')"
|
||||
@click="handleEditDomainClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isCustomProvider"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customIncomingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="mt-auto flex justify-between -mb-8">
|
||||
<Button
|
||||
:label="__('Back')"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:label="__('Update Account')"
|
||||
variant="solid"
|
||||
@click="updateAccount"
|
||||
:loading="loading"
|
||||
/>
|
||||
<Button
|
||||
v-if="accountData.enable_incoming"
|
||||
:label="__('Pull Emails')"
|
||||
variant="subtle"
|
||||
@click="pullEmails"
|
||||
:loading="loadingPull"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-p-sm text-gray-500">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
</div>
|
||||
<!-- action buttons -->
|
||||
<div class="mt-auto flex justify-between">
|
||||
<Button
|
||||
label="Back"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
label="Update Account"
|
||||
variant="solid"
|
||||
@click="updateAccount"
|
||||
:loading="loading"
|
||||
/>
|
||||
<Button
|
||||
v-if="accountData.enable_incoming"
|
||||
label="Pull Emails"
|
||||
variant="subtle"
|
||||
@click="pullEmails"
|
||||
:loading="loadingPull"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EmailAccount, EmailStep } from "@/types";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { call, toast } from "frappe-ui";
|
||||
import { computed, h, reactive, ref } from "vue";
|
||||
import { computed, h, reactive, ref, watch } from "vue";
|
||||
import CircleAlert from "~icons/lucide/circle-alert";
|
||||
import EmailProviderIcon from "./EmailProviderIcon.vue";
|
||||
import { Link } from "@/components";
|
||||
import {
|
||||
customProviderFields,
|
||||
customIncomingFields,
|
||||
customOutgoingFields,
|
||||
customProviderTopFields,
|
||||
emailIcon,
|
||||
frappeMailFields,
|
||||
incomingOutgoingFields,
|
||||
popularProviderFields,
|
||||
services,
|
||||
validateInputs,
|
||||
} from "./emailConfig";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
interface EmailAccountBaseState {
|
||||
email_account_name: string;
|
||||
service: string;
|
||||
email_id: string;
|
||||
enable_incoming: boolean;
|
||||
enable_outgoing: boolean;
|
||||
default_outgoing: boolean;
|
||||
default_incoming: boolean;
|
||||
}
|
||||
|
||||
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
|
||||
api_key?: string | null;
|
||||
api_secret?: string | null;
|
||||
password?: string | null;
|
||||
frappe_mail_site?: string;
|
||||
}
|
||||
|
||||
type CustomEmailAccountState = {
|
||||
[key: string]: string | number | boolean | undefined;
|
||||
domain?: string;
|
||||
email_server?: string;
|
||||
smtp_server?: string;
|
||||
incoming_port?: string | number;
|
||||
smtp_port?: string | number;
|
||||
use_ssl?: boolean | number;
|
||||
use_starttls?: boolean | number;
|
||||
use_tls?: boolean | number;
|
||||
use_ssl_for_outgoing?: boolean | number;
|
||||
validate_ssl_certificate?: boolean | number;
|
||||
validate_ssl_certificate_for_outgoing?: boolean | number;
|
||||
attachment_limit?: string | number;
|
||||
append_emails_to_sent_folder?: boolean | number;
|
||||
sent_folder_name?: string;
|
||||
};
|
||||
|
||||
interface P {
|
||||
accountData: EmailAccount;
|
||||
@@ -120,7 +232,7 @@ const props = withDefaults(defineProps<P>(), {
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const state = reactive({
|
||||
const state = reactive<EmailAccountProviderAuthState>({
|
||||
email_account_name: props.accountData.email_account_name || "",
|
||||
service: props.accountData.service || "",
|
||||
email_id: props.accountData.email_id || "",
|
||||
@@ -134,18 +246,73 @@ const state = reactive({
|
||||
default_incoming: props.accountData.default_incoming || false,
|
||||
});
|
||||
|
||||
const getInitialCustomState = (): CustomEmailAccountState => ({
|
||||
domain: props.accountData?.domain || "",
|
||||
email_server: props.accountData?.email_server || "",
|
||||
smtp_server: props.accountData?.smtp_server || "",
|
||||
incoming_port: props.accountData?.incoming_port || "",
|
||||
smtp_port: props.accountData?.smtp_port || "",
|
||||
use_ssl: props.accountData?.use_ssl || false,
|
||||
use_starttls: props.accountData?.use_starttls || false,
|
||||
use_tls: props.accountData?.use_tls || false,
|
||||
use_ssl_for_outgoing: props.accountData?.use_ssl_for_outgoing || false,
|
||||
validate_ssl_certificate: props.accountData?.validate_ssl_certificate ?? true,
|
||||
validate_ssl_certificate_for_outgoing:
|
||||
props.accountData?.validate_ssl_certificate_for_outgoing ?? true,
|
||||
attachment_limit: (props.accountData as any)?.attachment_limit || "",
|
||||
append_emails_to_sent_folder:
|
||||
(props.accountData as any)?.append_emails_to_sent_folder || false,
|
||||
sent_folder_name: (props.accountData as any)?.sent_folder_name || "",
|
||||
});
|
||||
|
||||
const customState = reactive<CustomEmailAccountState>(getInitialCustomState());
|
||||
|
||||
const info = {
|
||||
description: "To know more about setting up email accounts, click",
|
||||
link: "https://docs.erpnext.com/docs/user/manual/en/email-account",
|
||||
description: __("To know more about setting up email accounts, click"),
|
||||
link: "https://docs.frappe.io/erpnext/user/manual/en/email-domain",
|
||||
};
|
||||
|
||||
const isCustomService = computed(() => {
|
||||
return services.find((s) => s.name === props.accountData.service).custom;
|
||||
const deskEditUrl = computed(() => {
|
||||
const name = state.email_account_name || props.accountData.email_account_name;
|
||||
if (!name) return "";
|
||||
return `/desk/email-account/${encodeURIComponent(name)}`;
|
||||
});
|
||||
|
||||
const currentServiceName = computed(
|
||||
() => state.service || props.accountData.service || ""
|
||||
);
|
||||
|
||||
const serviceDef = computed(() => {
|
||||
const currentService = currentServiceName.value;
|
||||
return services.find((s) => s.name === currentService);
|
||||
});
|
||||
|
||||
const isCustomProvider = computed(() => {
|
||||
const currentService = currentServiceName.value;
|
||||
if (currentService === "Custom") {
|
||||
return true;
|
||||
}
|
||||
if (currentService) {
|
||||
return false;
|
||||
}
|
||||
// Many existing custom accounts store service as blank; detect by server fields.
|
||||
return Boolean(
|
||||
customState.email_server ||
|
||||
customState.smtp_server ||
|
||||
props.accountData.email_server ||
|
||||
props.accountData.smtp_server
|
||||
);
|
||||
});
|
||||
|
||||
const fields = computed(() => {
|
||||
if (isCustomService.value) {
|
||||
return customProviderFields;
|
||||
if (isCustomProvider.value) {
|
||||
return customProviderTopFields;
|
||||
}
|
||||
if (!serviceDef.value) {
|
||||
return popularProviderFields;
|
||||
}
|
||||
if (serviceDef.value.name === "Frappe Mail") {
|
||||
return frappeMailFields;
|
||||
}
|
||||
return popularProviderFields;
|
||||
});
|
||||
@@ -157,7 +324,11 @@ const loadingPull = useStorage(
|
||||
false
|
||||
);
|
||||
async function updateAccount() {
|
||||
error.value = validateInputs(state, isCustomService.value);
|
||||
const validationState = { ...state, ...customState };
|
||||
const validationService = isCustomProvider.value
|
||||
? "Custom"
|
||||
: currentServiceName.value;
|
||||
error.value = validateInputs(validationState, validationService, true);
|
||||
if (error.value) return;
|
||||
const old = { ...props.accountData };
|
||||
const updatedEmailAccount = { ...state };
|
||||
@@ -168,11 +339,10 @@ async function updateAccount() {
|
||||
delete updatedEmailAccount.email_account_name;
|
||||
|
||||
const otherFieldsChanged = isDirty.value;
|
||||
const values = updatedEmailAccount;
|
||||
|
||||
const values = buildUpdatePayload();
|
||||
if (!nameChanged && !otherFieldsChanged) {
|
||||
toast.create({
|
||||
message: "No changes made",
|
||||
message: __("No changes made"),
|
||||
icon: h(CircleAlert, { class: "text-ink-blue-2" }),
|
||||
});
|
||||
return;
|
||||
@@ -198,11 +368,44 @@ async function updateAccount() {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdatePayload() {
|
||||
const commonPayload = {
|
||||
email_id: state.email_id,
|
||||
service: state.service,
|
||||
enable_incoming: state.enable_incoming,
|
||||
enable_outgoing: state.enable_outgoing,
|
||||
default_incoming: state.default_incoming,
|
||||
default_outgoing: state.default_outgoing,
|
||||
};
|
||||
|
||||
if (currentServiceName.value === "Frappe Mail") {
|
||||
return {
|
||||
...commonPayload,
|
||||
frappe_mail_site: state.frappe_mail_site,
|
||||
api_key: state.api_key,
|
||||
api_secret: state.api_secret,
|
||||
};
|
||||
}
|
||||
|
||||
if (isCustomProvider.value) {
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
...customState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
};
|
||||
}
|
||||
|
||||
function pullEmails() {
|
||||
loadingPull.value = true;
|
||||
|
||||
toast.create({
|
||||
message: "Pulling emails, this may take a few minutes.",
|
||||
message: __("Pulling emails, this may take a few minutes."),
|
||||
icon: h(CircleAlert, { class: "text-blue-500" }),
|
||||
});
|
||||
|
||||
@@ -212,17 +415,28 @@ function pullEmails() {
|
||||
.then(() => {
|
||||
localStorage.removeItem(`loading-emails-${state.email_account_name}`);
|
||||
loadingPull.value = null;
|
||||
toast.success("Emails pulled successfully");
|
||||
toast.success(__("Emails pulled successfully"));
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem(`loading-emails-${state.email_account_name}`);
|
||||
loadingPull.value = null;
|
||||
error.value = "Failed to pull emails";
|
||||
error.value = __("Failed to pull emails");
|
||||
});
|
||||
}
|
||||
|
||||
const isDirty = computed(() => {
|
||||
const customDirty = isCustomProvider.value
|
||||
? customState.domain !== props.accountData.domain ||
|
||||
customState.email_server !== props.accountData.email_server ||
|
||||
customState.smtp_server !== props.accountData.smtp_server ||
|
||||
customState.incoming_port !== props.accountData.incoming_port ||
|
||||
customState.smtp_port !== props.accountData.smtp_port ||
|
||||
customState.use_ssl !== props.accountData.use_ssl ||
|
||||
customState.use_starttls !== props.accountData.use_starttls
|
||||
: false;
|
||||
return (
|
||||
customDirty ||
|
||||
state.service !== props.accountData.service ||
|
||||
state.email_id !== props.accountData.email_id ||
|
||||
state.api_key !== props.accountData.api_key ||
|
||||
state.api_secret !== props.accountData.api_secret ||
|
||||
@@ -236,15 +450,14 @@ const isDirty = computed(() => {
|
||||
});
|
||||
|
||||
async function callRenameDoc() {
|
||||
const d = await call("frappe.client.rename_doc", {
|
||||
return call("frappe.client.rename_doc", {
|
||||
doctype: "Email Account",
|
||||
old_name: props.accountData.email_account_name,
|
||||
new_name: state.email_account_name,
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
async function callSetValue(values) {
|
||||
async function callSetValue(values: Record<string, any>) {
|
||||
const d = await call("frappe.client.set_value", {
|
||||
doctype: "Email Account",
|
||||
name: state.email_account_name,
|
||||
@@ -255,11 +468,91 @@ async function callSetValue(values) {
|
||||
|
||||
function succesHandler() {
|
||||
emit("update:step", "email-list");
|
||||
toast.success("Email account updated successfully");
|
||||
toast.success(__("Email account updated successfully"));
|
||||
}
|
||||
|
||||
function errorHandler() {
|
||||
loading.value = false;
|
||||
error.value = "Failed to update email account, Invalid credentials";
|
||||
error.value = __("Failed to update email account, Invalid credentials");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.accountData,
|
||||
(val) => {
|
||||
if (!val) return;
|
||||
if (val.email_id && state.email_id !== val.email_id) {
|
||||
state.email_id = val.email_id;
|
||||
}
|
||||
if (!customState.domain && val.domain) {
|
||||
customState.domain = val.domain;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customState.domain,
|
||||
async (val) => {
|
||||
if (!isCustomProvider.value) return;
|
||||
if (!val) return;
|
||||
try {
|
||||
const doc = await call("frappe.client.get", {
|
||||
doctype: "Email Domain",
|
||||
name: val,
|
||||
});
|
||||
syncAccountFieldsFromDomain(doc);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load Email Domain", err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
|
||||
customState.email_server = payload.email_server ?? customState.email_server;
|
||||
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
|
||||
customState.incoming_port =
|
||||
payload.incoming_port ?? customState.incoming_port;
|
||||
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
|
||||
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
|
||||
customState.use_starttls = Boolean(
|
||||
payload.use_starttls ?? customState.use_starttls
|
||||
);
|
||||
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
|
||||
customState.use_ssl_for_outgoing = Boolean(
|
||||
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
|
||||
);
|
||||
customState.validate_ssl_certificate = Boolean(
|
||||
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
|
||||
);
|
||||
customState.validate_ssl_certificate_for_outgoing = Boolean(
|
||||
payload.validate_ssl_certificate_for_outgoing ??
|
||||
customState.validate_ssl_certificate_for_outgoing
|
||||
);
|
||||
customState.append_emails_to_sent_folder = Boolean(
|
||||
payload.append_emails_to_sent_folder ??
|
||||
customState.append_emails_to_sent_folder
|
||||
);
|
||||
customState.sent_folder_name =
|
||||
payload.sent_folder_name || customState.sent_folder_name;
|
||||
customState.attachment_limit =
|
||||
payload.attachment_limit || customState.attachment_limit;
|
||||
}
|
||||
|
||||
function handleCreateDomainClick(value: any, close?: () => void) {
|
||||
close?.();
|
||||
window.open(
|
||||
"/desk/email-domain/new-email-domain",
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
}
|
||||
|
||||
function handleEditDomainClick() {
|
||||
if (!customState.domain) return;
|
||||
window.open(
|
||||
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="px-10 h-full overflow-y-auto flex flex-col isolate">
|
||||
<div class="h-full overflow-y-auto flex flex-col isolate">
|
||||
<NotificationList v-if="curNotification === null" :onSelect="onSelect" />
|
||||
<component
|
||||
v-else
|
||||
|
||||
@@ -1,112 +1,107 @@
|
||||
<template>
|
||||
<div class="flex-grow flex flex-col isolate">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 bg-white py-8">
|
||||
<SettingsLayoutHeader :description="props.description">
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="pl-5 pr-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="internalOnBack"
|
||||
class="absolute top-0 -left-[0.375rem] w-full h-full"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
__("back to email event list")
|
||||
}}</span>
|
||||
<LucideChevronLeft class="w-4.5 h-4.5" />
|
||||
</button>
|
||||
<h1 class="font-semibold text-xl">
|
||||
{{ props.title }}
|
||||
</h1>
|
||||
</div>
|
||||
<Badge
|
||||
v-if="unsavedChanges"
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div
|
||||
:inert="notificationDataResource.loading"
|
||||
class="flex items-center gap-x-4 pt-[0.125rem]"
|
||||
:class="{ invisible: notificationDataResource.loading }"
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="pl-5 pr-2 relative text-ink-gray-7 hover:opacity-70 min-h-8 flex items-center"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="internalOnBack"
|
||||
class="absolute top-0 -left-[0.375rem] w-full h-full"
|
||||
>
|
||||
<Switch
|
||||
<span class="sr-only">{{ __("back to email event list") }}</span>
|
||||
<LucideChevronLeft class="w-4.5 h-4.5" />
|
||||
</button>
|
||||
<h1 class="font-semibold text-xl">
|
||||
{{ props.title }}
|
||||
</h1>
|
||||
</div>
|
||||
<Badge
|
||||
v-if="unsavedChanges"
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div
|
||||
:inert="notificationDataResource.loading"
|
||||
class="flex items-center gap-x-4 pt-[0.125rem]"
|
||||
:class="{ invisible: notificationDataResource.loading }"
|
||||
>
|
||||
<Switch
|
||||
size="sm"
|
||||
:label="__('Enabled')"
|
||||
v-model="enabled"
|
||||
@update:model-value="setUnsavedChanges"
|
||||
:style="{ background: 'transparent', padding: '0px' }"
|
||||
class="flex-row-reverse gap-x-2 pl-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
:label="__('Save')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
:disabled="!unsavedChanges"
|
||||
:loading="props.submitting"
|
||||
@click="props.onSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
class="flex flex-col gap-8 flex-grow pb-8"
|
||||
:class="{
|
||||
'items-center justify-center': notificationDataResource.loading,
|
||||
}"
|
||||
>
|
||||
<template v-if="!notificationDataResource.loading">
|
||||
<slot name="formFields"></slot>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormControl
|
||||
type="textarea"
|
||||
size="sm"
|
||||
:label="__('Enabled')"
|
||||
v-model="enabled"
|
||||
@update:model-value="setUnsavedChanges"
|
||||
:style="{ background: 'transparent', padding: '0px' }"
|
||||
class="flex-row-reverse gap-x-2 pl-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
:label="__('Save')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
:disabled="!unsavedChanges"
|
||||
:loading="props.submitting"
|
||||
@click="props.onSubmit"
|
||||
:label="__('Email Content')"
|
||||
:required="true"
|
||||
:rows="10"
|
||||
v-model="content"
|
||||
:oninput="setUnsavedChanges"
|
||||
/>
|
||||
<div class="flex gap-x-1 items-start justify-between">
|
||||
<p class="text-sm text-gray-700 leading-5">
|
||||
{{
|
||||
__(
|
||||
"Find out all of the variables that can be used in the content"
|
||||
)
|
||||
}}
|
||||
<a
|
||||
:href="props.documentationLink"
|
||||
target="_blank"
|
||||
class="underline font-semibold"
|
||||
>{{ __("here") }}</a
|
||||
>
|
||||
</p>
|
||||
<Button
|
||||
:disabled="content === defaultContent"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
@click="onResetContent"
|
||||
class="w-fit"
|
||||
>
|
||||
{{ __("Reset Content") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div
|
||||
class="flex flex-col gap-8 flex-grow pb-8"
|
||||
:class="{
|
||||
'items-center justify-center': notificationDataResource.loading,
|
||||
}"
|
||||
>
|
||||
<template v-if="!notificationDataResource.loading">
|
||||
<slot name="formFields"></slot>
|
||||
<div class="flex flex-col gap-2">
|
||||
<FormControl
|
||||
type="textarea"
|
||||
size="sm"
|
||||
:label="__('Email Content')"
|
||||
:required="true"
|
||||
:rows="10"
|
||||
v-model="content"
|
||||
:oninput="setUnsavedChanges"
|
||||
/>
|
||||
<div class="flex gap-x-1 items-start justify-between">
|
||||
<p class="text-sm text-gray-700 leading-5">
|
||||
{{
|
||||
__(
|
||||
"Find out all of the variables that can be used in the content"
|
||||
)
|
||||
}}
|
||||
<a
|
||||
:href="props.documentationLink"
|
||||
target="_blank"
|
||||
class="underline font-semibold"
|
||||
>{{ __("here") }}</a
|
||||
>
|
||||
</p>
|
||||
<Button
|
||||
:disabled="content === defaultContent"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
@click="onResetContent"
|
||||
class="w-fit"
|
||||
>
|
||||
{{ __("Reset Content") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<LoadingIndicator v-else class="w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<LoadingIndicator v-else class="w-4" />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="showUnsavedConfirm"
|
||||
:title="__('Unsaved changes')"
|
||||
@@ -141,9 +136,9 @@
|
||||
import { ref, watch } from "vue";
|
||||
import type { NotificationName } from "./types";
|
||||
import { createResource, Switch, LoadingIndicator } from "frappe-ui";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
<template>
|
||||
<!-- Header -->
|
||||
<div class="pt-8 pb-2 bg-white sticky top-0 z-10">
|
||||
<SettingsLayoutHeader
|
||||
:title="__('Email Notifications')"
|
||||
:description="
|
||||
__(
|
||||
'Customize your email notification preferences to stay informed about important updates and activities.'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<ul class="mt-4 pb-8 isolate">
|
||||
<li
|
||||
v-for="notification in notifications"
|
||||
:key="notification.name"
|
||||
class="flex items-center justify-between p-3 rounded relative"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2
|
||||
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
|
||||
<SettingsLayoutBase
|
||||
:title="__('Email Notifications')"
|
||||
:description="
|
||||
__(
|
||||
'Customize your email notification preferences to stay informed about important updates and activities.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<ul class="isolate -ml-3">
|
||||
<li
|
||||
v-for="notification in notifications"
|
||||
:key="notification.name"
|
||||
class="flex items-center justify-between p-3 rounded relative"
|
||||
>
|
||||
{{ notification.label }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
|
||||
>
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
|
||||
@click="
|
||||
() => {
|
||||
props.onSelect(notification);
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
__("customize {0}", notification.name)
|
||||
}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2
|
||||
class="text-base font-medium text-ink-gray-7 relative z-10 pointer-events-none"
|
||||
>
|
||||
{{ notification.label }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm text-ink-gray-5 truncate relative z-10 pointer-events-none"
|
||||
>
|
||||
{{ notification.description }}
|
||||
</p>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="chevron-right"
|
||||
class="text-ink-gray-7 size-4 relative z-10 pointer-events-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full h-full absolute top-0 left-0 hover:bg-gray-50 rounded-[inherit]"
|
||||
@click="
|
||||
() => {
|
||||
props.onSelect(notification);
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="sr-only">{{
|
||||
__("customize {0}", notification.name)
|
||||
}}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import type { AtLeastOneNotifcation, Notification } from "./types";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
onSelect: (notification: Notification) => void;
|
||||
@@ -62,7 +61,7 @@ const props = defineProps<{
|
||||
const notifications: AtLeastOneNotifcation = [
|
||||
{
|
||||
name: "share_feedback",
|
||||
label: __("Share Feedback"),
|
||||
label: __("Share feedback"),
|
||||
description: __(
|
||||
"Sent to the user who has raised the ticket after the ticket is closed or resolved"
|
||||
),
|
||||
@@ -74,14 +73,14 @@ const notifications: AtLeastOneNotifcation = [
|
||||
},
|
||||
{
|
||||
name: "reply_to_agents",
|
||||
label: __("Reply From Contact"),
|
||||
label: __("Reply from contact"),
|
||||
description: __(
|
||||
"Sent to all of the assigned agents after a reply from one of the contacts"
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "reply_via_agent",
|
||||
label: __("Reply From Agent"),
|
||||
label: __("Reply from agent"),
|
||||
description: __(
|
||||
"Sent to all of the recipients associated with an agent's reply"
|
||||
),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
export type NotificationName = "share_feedback" | "acknowledgement" | "reply_to_agents" | "reply_via_agent";
|
||||
export type NotificationName =
|
||||
| "share_feedback"
|
||||
| "acknowledgement"
|
||||
| "reply_to_agents"
|
||||
| "reply_via_agent";
|
||||
|
||||
export type Notification = {
|
||||
name: NotificationName;
|
||||
label: string;
|
||||
description: string;
|
||||
name: NotificationName;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type AtLeastOneNotifcation = [Notification, ...Notification[]];
|
||||
|
||||
export type BaseSettings = {
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
enabled: boolean;
|
||||
content: string;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-10 w-10 cursor-pointer items-center justify-center rounded-xl bg-gray-100 hover:bg-gray-200"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-full bg-gray-100 hover:bg-gray-200"
|
||||
:class="{ 'ring-2 ring-blue-500': selected }"
|
||||
>
|
||||
<img :src="logo" class="h-6 w-6" />
|
||||
<img v-if="logoValue" :src="logoValue" class="size-4.5" />
|
||||
<LucideMail v-else class="size-4.5 text-gray-700" />
|
||||
</div>
|
||||
<p v-if="serviceName" class="text-center text-p-xs text-gray-700">
|
||||
{{ serviceName }}
|
||||
@@ -11,17 +12,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import LucideMail from "~icons/lucide/mail";
|
||||
interface P {
|
||||
serviceName?: string;
|
||||
logo?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<P>(), {
|
||||
const props = withDefaults(defineProps<P>(), {
|
||||
serviceName: "",
|
||||
selected: false,
|
||||
logo: "",
|
||||
});
|
||||
|
||||
const logoValue = computed(() => props.logo);
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,74 +1,80 @@
|
||||
<template>
|
||||
<div class="flex flex-col py-8 gap-8 overflow-y-hidden">
|
||||
<SettingsLayoutHeader>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-10">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="dependencyLabel"
|
||||
size="md"
|
||||
@click="handleBackNavigation"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 pl-0 -ml-[5px] pr-0"
|
||||
/>
|
||||
<Badge v-if="isDirty" theme="orange"> Unsaved </Badge>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="dependencyLabel"
|
||||
size="md"
|
||||
@click="handleBackNavigation"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge v-if="isDirty" theme="orange"> {{ __("Unsaved") }} </Badge>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex gap-4">
|
||||
<!-- Switch -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<Switch v-model="state.enabled" class="!w-fit" />
|
||||
<span class="text-p-base text-ink-gray-6">
|
||||
{{ __("Enabled") }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-4 pr-10">
|
||||
<!-- Switch -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<Switch v-model="state.enabled" class="!w-fit" />
|
||||
<span class="text-p-base text-ink-gray-6">Enabled</span>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
label="Save"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!state.selectedParentField ||
|
||||
!state.selectedChildField ||
|
||||
Object.keys(state.childSelections).length === 0
|
||||
"
|
||||
:loading="createUpdateFieldDependency.loading"
|
||||
@click="handleSubmit"
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!state.selectedParentField ||
|
||||
!state.selectedChildField ||
|
||||
Object.keys(state.childSelections).length === 0
|
||||
"
|
||||
:loading="createUpdateFieldDependency.loading"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div>
|
||||
<!-- Body -->
|
||||
<div class="w-full flex-1 flex flex-col gap-8">
|
||||
<!-- Field Selection -->
|
||||
<FieldDependencyFieldsSelection
|
||||
v-model="state"
|
||||
:is-new="isNew"
|
||||
:parent-fields="parentFields"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- Value Selection -->
|
||||
<FieldDependencyValueSelection
|
||||
v-model="state"
|
||||
:is-new="isNew"
|
||||
:parent-fields="parentFields"
|
||||
/>
|
||||
|
||||
<!-- Criteria selection -->
|
||||
<FieldDependencyCriteria
|
||||
:parent-field-values="state.parentFieldValues"
|
||||
v-model="fieldCriteriaState"
|
||||
v-model:selections="state"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
<!-- Body -->
|
||||
<div class="w-full flex-1 flex flex-col gap-8 overflow-y-hidden px-10">
|
||||
<!-- Field Selection -->
|
||||
<FieldDependencyFieldsSelection
|
||||
v-model="state"
|
||||
:is-new="isNew"
|
||||
:parent-fields="parentFields"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-8 overflow-y-scroll">
|
||||
<!-- Value Selection -->
|
||||
<FieldDependencyValueSelection
|
||||
v-model="state"
|
||||
:is-new="isNew"
|
||||
:parent-fields="parentFields"
|
||||
/>
|
||||
|
||||
<!-- Criteria selection -->
|
||||
<FieldDependencyCriteria
|
||||
:parent-field-values="state.parentFieldValues"
|
||||
v-model="fieldCriteriaState"
|
||||
v-model:selections="state"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog"
|
||||
title="Unsaved changes"
|
||||
message="Are you sure you want to go back? Unsaved changes will be lost."
|
||||
:title="__('Unsaved changes')"
|
||||
:message="
|
||||
__('Are you sure you want to go back? Unsaved changes will be lost.')
|
||||
"
|
||||
:onConfirm="() => $emit('update:step', 'fd-list')"
|
||||
:onCancel="() => (showConfirmDialog = false)"
|
||||
/>
|
||||
@@ -79,13 +85,14 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { getMeta } from "@/stores/meta";
|
||||
import { getFieldDependencyLabel } from "@/utils";
|
||||
import { createResource, Switch, toast } from "frappe-ui";
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import { computed, onUnmounted, reactive, ref, watch } from "vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { getFieldOptions, hiddenChildFields } from "./fieldDependency";
|
||||
import FieldDependencyCriteria from "./FieldDependencyCriteria.vue";
|
||||
import FieldDependencyFieldsSelection from "./FieldDependencyFieldsSelection.vue";
|
||||
import FieldDependencyValueSelection from "./FieldDependencyValueSelection.vue";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const props = defineProps({
|
||||
fieldDependencyName: {
|
||||
@@ -98,7 +105,7 @@ const emit = defineEmits(["update:step"]);
|
||||
const isNew = computed(() => !props.fieldDependencyName);
|
||||
|
||||
const dependencyLabel = computed(() => {
|
||||
if (isNew.value) return "New Field Dependency";
|
||||
if (isNew.value) return __("New Field Dependency");
|
||||
return getFieldDependencyLabel(props.fieldDependencyName);
|
||||
});
|
||||
|
||||
@@ -297,8 +304,8 @@ function handleSubmit() {
|
||||
if (!isDirty.value) return;
|
||||
createUpdateFieldDependency.submit();
|
||||
let successMessage = isNew.value
|
||||
? "Field Dependency created successfully"
|
||||
: "Field Dependency updated successfully";
|
||||
? __("Field Dependency created successfully")
|
||||
: __("Field Dependency updated successfully");
|
||||
toast.success(successMessage);
|
||||
}
|
||||
|
||||
@@ -329,4 +336,8 @@ watch(
|
||||
state.childSearch = ""; // Reset child search when parent selection changes
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -28,5 +28,3 @@ function updateStep(
|
||||
fieldDependencyName.value = fieldDependency;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,136 +1,166 @@
|
||||
<template>
|
||||
<div class="px-10 py-8">
|
||||
<SettingsLayoutHeader>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
Field Dependencies
|
||||
</h1>
|
||||
<DocumentationButton
|
||||
url="https://docs.frappe.io/helpdesk/field-dependency"
|
||||
color="!text-ink-gray-6"
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Field Dependencies") }}
|
||||
</h1>
|
||||
<DocumentationButton
|
||||
url="https://docs.frappe.io/helpdesk/field-dependency"
|
||||
color="!text-ink-gray-6"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Create dependencies between fields to dynamically control options based on user selections."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="$emit('update:step', 'fd')"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grow">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="fieldDependenciesList.loading"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<LoadingIndicator :scale="5" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="
|
||||
!fieldDependenciesList.loading &&
|
||||
!fieldDependenciesList.data?.length
|
||||
"
|
||||
class="flex flex-col items-center justify-center gap-4 p-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<FieldDependencyIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No field dependency found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="$emit('update:step', 'fd')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
Create dependencies between fields to dynamically control options
|
||||
based on user selections.
|
||||
</p>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="New"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="$emit('update:step', 'fd')"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
</div>
|
||||
<div class="px-10 pb-8">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="fieldDependenciesList.loading"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<LoadingIndicator :scale="5" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="
|
||||
!fieldDependenciesList.loading &&
|
||||
fieldDependenciesList.data?.length === 0
|
||||
"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<p class="text-sm text-gray-500 w-full flex justify-center">
|
||||
No field dependencies found.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full"
|
||||
v-if="
|
||||
!fieldDependenciesList.loading && fieldDependenciesList.data?.length > 0
|
||||
"
|
||||
>
|
||||
<!-- table heading -->
|
||||
<div class="flex w-full p-2">
|
||||
<p class="w-7/12 text-p-sm text-ink-gray-5">Name</p>
|
||||
<p class="w-3/12 text-p-sm text-ink-gray-5">Created By</p>
|
||||
<p class="w-1/12 text-p-sm text-ink-gray-5">Enabled</p>
|
||||
<p class="w-1/12 text-p-sm text-ink-gray-5"></p>
|
||||
</div>
|
||||
<div class="h-px border-t mx-1 border-outline-gray-modals" />
|
||||
|
||||
<!-- Table content -->
|
||||
<ul>
|
||||
<!-- Each row -->
|
||||
<li
|
||||
class="w-full flex flex-col items-center cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
v-for="(row, idx) in fieldDependenciesList.data"
|
||||
@click.stop="$emit('update:step', 'fd', row.name)"
|
||||
:key="row.name"
|
||||
<div
|
||||
class="w-full -ml-2"
|
||||
v-if="
|
||||
!fieldDependenciesList.loading &&
|
||||
fieldDependenciesList.data?.length > 0
|
||||
"
|
||||
>
|
||||
<div class="w-full flex items-center p-2">
|
||||
<!-- Parent to Child -->
|
||||
<div class="flex gap-2 w-7/12 text-base text-ink-gray-7 pr-3">
|
||||
<span>{{ getFieldDependencyLabel(row.name) }}</span>
|
||||
<div>
|
||||
<div
|
||||
class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600"
|
||||
>
|
||||
<div class="col-span-7 ml-2">{{ __("Name") }}</div>
|
||||
<div class="col-span-2">{{ __("Created By") }}</div>
|
||||
<div class="col-span-2">{{ __("Enabled") }}</div>
|
||||
</div>
|
||||
<!-- Owner -->
|
||||
<p class="w-3/12 flex items-center gap-2 pr-3">
|
||||
<Avatar size="sm" :image="row.owner" :label="row.owner" />
|
||||
<span class="text-base text-ink-gray-7 truncate">{{
|
||||
row.owner
|
||||
}}</span>
|
||||
</p>
|
||||
<!-- Enabled -->
|
||||
<p class="w-1/12 flex items-center">
|
||||
<Switch
|
||||
:model-value="row.enabled"
|
||||
@update:modelValue="(e) => handleSwitchToggle(row.name, e)"
|
||||
@click.stop
|
||||
/>
|
||||
</p>
|
||||
<!-- More Options -->
|
||||
<p class="w-1/12 flex items-center justify-end">
|
||||
<Dropdown placement="right" :options="getOptions(row.name)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.stop="
|
||||
() => {
|
||||
isConfirmingDelete = false;
|
||||
}
|
||||
"
|
||||
<hr class="mt-2 mx-2" />
|
||||
<div
|
||||
v-for="(row, index) in fieldDependenciesList.data"
|
||||
:key="row.name"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded h-12.5"
|
||||
>
|
||||
<div
|
||||
@click.stop="$emit('update:step', 'fd', row.name)"
|
||||
class="w-full py-3 pl-2 col-span-7 text-base text-ink-gray-7"
|
||||
>
|
||||
<template #icon>
|
||||
<LucideMoreHorizontal class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</p>
|
||||
<span>{{ getFieldDependencyLabel(row.name) }}</span>
|
||||
</div>
|
||||
<div class="col-span-2 flex items-center gap-1">
|
||||
<Avatar size="sm" :image="row.owner" :label="row.owner" />
|
||||
<span class="text-base text-ink-gray-7 truncate">{{
|
||||
row.owner
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center w-full pr-2 col-span-2"
|
||||
>
|
||||
<div>
|
||||
<Switch
|
||||
:model-value="row.enabled"
|
||||
@update:modelValue="
|
||||
(e) => handleSwitchToggle(row.name, e)
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown placement="right" :options="getOptions(row.name)">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.stop="
|
||||
() => {
|
||||
isConfirmingDelete = false;
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #icon>
|
||||
<LucideMoreHorizontal class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr
|
||||
v-if="index !== fieldDependenciesList.data.length - 1"
|
||||
class="mx-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Separator -->
|
||||
<div
|
||||
class="h-px border-b mx-1 border-outline-gray-modals w-[99%]"
|
||||
v-if="idx < fieldDependenciesList.data?.length - 1"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar, LoadingIndicator, Switch, toast } from "frappe-ui";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
LoadingIndicator,
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { getFieldDependencyLabel, ConfirmDelete } from "@/utils";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { fieldDependenciesList } from "./fieldDependency";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import DocumentationButton from "@/components/DocumentationButton.vue";
|
||||
import FieldDependencyIcon from "@/components/icons/FieldDependencyIcon.vue";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
onMounted(() => {
|
||||
fieldDependenciesList.reload();
|
||||
@@ -144,7 +174,7 @@ function getOptions(rowName: string) {
|
||||
onConfirmDelete: () => {
|
||||
fieldDependenciesList.delete.submit(rowName, {
|
||||
onSuccess: () => {
|
||||
toast.success("Field dependency deleted successfully");
|
||||
toast.success(__("Field dependency deleted successfully"));
|
||||
fieldDependenciesList.reload();
|
||||
},
|
||||
});
|
||||
@@ -160,7 +190,7 @@ function handleSwitchToggle(rowName: string, value: boolean) {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Field dependency updated successfully.");
|
||||
toast.success(__("Field dependency updated successfully."));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -5,9 +5,10 @@ export const fieldDependenciesList = createListResource({
|
||||
doctype: "HD Form Script",
|
||||
filters: { is_standard: 1, name: ["like", "%Field Dependency%"] },
|
||||
fields: ["name", "enabled", "owner"],
|
||||
auto: true,
|
||||
cache: ["FD", "List"],
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
});
|
||||
|
||||
export const hiddenChildFields = computed(() => {
|
||||
|
||||
255
desk/src/components/Settings/General/General.vue
Normal file
255
desk/src/components/Settings/General/General.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<SettingsLayoutBase :description="__('Manage general settings of your app.')">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("General") }}
|
||||
</h1>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty || isWebsiteSettingsChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveSettings"
|
||||
:loading="
|
||||
saveSettingsResource.loading || saveWebsiteSettingsResource.loading
|
||||
"
|
||||
:disabled="!isDirty && !isWebsiteSettingsChanged"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="settingsDataResource.loading && !settingsDataResource.data"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<Branding />
|
||||
<hr class="my-8" />
|
||||
<TicketSettings />
|
||||
<hr class="my-8" />
|
||||
<WorkflowKnowledgebaseSettings />
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ __("User signup") }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Disable signup")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"New users will have to be manually registered by system managers."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch v-model="disableSignup" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
LoadingIndicator,
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import Branding from "./components/Branding.vue";
|
||||
import TicketSettings from "./components/TicketSettings.vue";
|
||||
import WorkflowKnowledgebaseSettings from "./components/WorkflowKnowledgebaseSettings.vue";
|
||||
import { computed, provide, ref, watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { HDSettings, HDSettingsSymbol } from "@/types";
|
||||
|
||||
const isDirty = ref(false);
|
||||
const initialData = ref<null | string>(null);
|
||||
const settingsData = ref({
|
||||
brandName: "",
|
||||
brandLogo: "",
|
||||
favicon: "",
|
||||
autoCloseAfterDays: "",
|
||||
autoCloseStatus: "",
|
||||
autoCloseTickets: "",
|
||||
assignWithinTeam: false,
|
||||
doNotRestrictTicketsWithoutAnAgentGroup: false,
|
||||
restrictTicketsByAgentGroup: false,
|
||||
updateStatusTo: "",
|
||||
autoUpdateStatus: false,
|
||||
isFeedbackMandatory: false,
|
||||
enableCommentReactions: false,
|
||||
allowAnyoneToCreateTickets: false,
|
||||
defaultTicketType: "",
|
||||
preferKnowledgeBase: false,
|
||||
skipEmailWorkflow: false,
|
||||
disableSavedRepliesGlobalScope: false,
|
||||
enableOutsideHoursBanner: false,
|
||||
outsideWorkingHoursBannerMessage: "",
|
||||
});
|
||||
const disableSignup = ref(false);
|
||||
|
||||
provide(HDSettingsSymbol, settingsData);
|
||||
|
||||
const settingsDataResource = createResource({
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
doctype: "HD Settings",
|
||||
name: "HD Settings",
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data: HDSettings) {
|
||||
settingsData.value = transformData(data);
|
||||
initialData.value = JSON.stringify(settingsData.value);
|
||||
},
|
||||
});
|
||||
|
||||
const isWebsiteSettingsChanged = computed(() => {
|
||||
return (
|
||||
disableSignup.value !==
|
||||
Boolean(websiteSettingsResource.data?.disable_signup)
|
||||
);
|
||||
});
|
||||
|
||||
const saveSettingsResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Settings",
|
||||
name: "HD Settings",
|
||||
fieldname: {
|
||||
brand_name: settingsData.value.brandName,
|
||||
auto_close_after_days: Number(settingsData.value.autoCloseAfterDays),
|
||||
auto_close_status: settingsData.value.autoCloseStatus,
|
||||
auto_close_tickets: settingsData.value.autoCloseTickets,
|
||||
assign_within_team: settingsData.value.assignWithinTeam,
|
||||
do_not_restrict_tickets_without_an_agent_group:
|
||||
settingsData.value.doNotRestrictTicketsWithoutAnAgentGroup,
|
||||
restrict_tickets_by_agent_group:
|
||||
settingsData.value.restrictTicketsByAgentGroup,
|
||||
update_status_to: settingsData.value.updateStatusTo,
|
||||
auto_update_status: settingsData.value.autoUpdateStatus,
|
||||
is_feedback_mandatory: settingsData.value.isFeedbackMandatory,
|
||||
enable_comment_reactions: settingsData.value.enableCommentReactions,
|
||||
allow_anyone_to_create_tickets:
|
||||
settingsData.value.allowAnyoneToCreateTickets,
|
||||
default_ticket_type: settingsData.value.defaultTicketType,
|
||||
prefer_knowledge_base: settingsData.value.preferKnowledgeBase,
|
||||
skip_email_workflow: settingsData.value.skipEmailWorkflow,
|
||||
disable_saved_replies_global_scope:
|
||||
settingsData.value.disableSavedRepliesGlobalScope,
|
||||
enable_outside_hours_banner:
|
||||
settingsData.value.enableOutsideHoursBanner,
|
||||
outside_working_hours_message:
|
||||
settingsData.value.outsideWorkingHoursBannerMessage,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess(data: HDSettings) {
|
||||
settingsData.value = transformData(data);
|
||||
initialData.value = JSON.stringify(settingsData.value);
|
||||
},
|
||||
});
|
||||
|
||||
const transformData = (data: any) => {
|
||||
return {
|
||||
brandName: data.brand_name,
|
||||
brandLogo: data.brand_logo,
|
||||
favicon: data.favicon,
|
||||
autoCloseAfterDays: data.auto_close_after_days,
|
||||
autoCloseStatus: data.auto_close_status,
|
||||
autoCloseTickets: data.auto_close_tickets,
|
||||
assignWithinTeam: Boolean(data.assign_within_team),
|
||||
doNotRestrictTicketsWithoutAnAgentGroup: Boolean(
|
||||
data.do_not_restrict_tickets_without_an_agent_group
|
||||
),
|
||||
restrictTicketsByAgentGroup: Boolean(data.restrict_tickets_by_agent_group),
|
||||
updateStatusTo: data.update_status_to,
|
||||
autoUpdateStatus: data.auto_update_status,
|
||||
isFeedbackMandatory: Boolean(data.is_feedback_mandatory),
|
||||
enableCommentReactions: Boolean(data.enable_comment_reactions),
|
||||
allowAnyoneToCreateTickets: Boolean(data.allow_anyone_to_create_tickets),
|
||||
defaultTicketType: data.default_ticket_type,
|
||||
preferKnowledgeBase: Boolean(data.prefer_knowledge_base),
|
||||
skipEmailWorkflow: Boolean(data.skip_email_workflow),
|
||||
disableSavedRepliesGlobalScope: Boolean(
|
||||
data.disable_saved_replies_global_scope
|
||||
),
|
||||
enableOutsideHoursBanner: Boolean(data.enable_outside_hours_banner),
|
||||
outsideWorkingHoursBannerMessage: data.outside_working_hours_message || "",
|
||||
};
|
||||
};
|
||||
|
||||
const websiteSettingsResource = createResource({
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
doctype: "Website Settings",
|
||||
name: "Website Settings",
|
||||
fields: ["disable_signup"],
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data: any) {
|
||||
disableSignup.value = Boolean(data.disable_signup);
|
||||
},
|
||||
});
|
||||
|
||||
const saveWebsiteSettingsResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "Website Settings",
|
||||
name: "Website Settings",
|
||||
fieldname: {
|
||||
disable_signup: disableSignup.value,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess() {
|
||||
websiteSettingsResource.reload();
|
||||
},
|
||||
});
|
||||
|
||||
const saveSettings = async () => {
|
||||
const promises = [];
|
||||
if (isDirty.value) {
|
||||
promises.push(saveSettingsResource.submit());
|
||||
}
|
||||
if (isWebsiteSettingsChanged.value) {
|
||||
promises.push(saveWebsiteSettingsResource.submit());
|
||||
}
|
||||
await Promise.allSettled(promises).then(() => {
|
||||
toast.success(__("Settings updated"));
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
settingsData,
|
||||
(data) => {
|
||||
if (!initialData.value) return;
|
||||
isDirty.value = JSON.stringify(data) !== initialData.value;
|
||||
if (isDirty.value) {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
} else {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
109
desk/src/components/Settings/General/components/Branding.vue
Normal file
109
desk/src/components/Settings/General/components/Branding.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div v-if="settingsData">
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ __("Branding") }}
|
||||
</div>
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-full md:w-1/2 mt-6"
|
||||
v-model="settingsData.brandName"
|
||||
:label="__('Brand name')"
|
||||
:placeholder="__('Enter brand name')"
|
||||
maxlength="30"
|
||||
/>
|
||||
<LogoUpload
|
||||
:title="__('Logo')"
|
||||
:description="
|
||||
__(
|
||||
'Appears in the left sidebar. Recommended size is minimum 32x32 px in PNG or SVG'
|
||||
)
|
||||
"
|
||||
:image="brandLogo"
|
||||
@onUpload="update($event, 'HD Settings', 'brand_logo')"
|
||||
@onRemove="onRemove('HD Settings', 'brand_logo')"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
<LogoUpload
|
||||
:title="__('Favicon')"
|
||||
:description="
|
||||
__(
|
||||
'Appears next to the title in your browser tab. Recommended size is minimum 32x32 px in PNG or ICO'
|
||||
)
|
||||
"
|
||||
:image="favicon"
|
||||
@onUpload="update($event, 'HD Settings', 'favicon')"
|
||||
@onRemove="onRemove('HD Settings', 'favicon')"
|
||||
:isLoading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
:onCancel="() => (showConfirmDialog.show = false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { inject, ref, watch } from "vue";
|
||||
import LogoUpload from "./LogoUpload.vue";
|
||||
import { createResource, toast } from "frappe-ui";
|
||||
import { __ } from "@/translation";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { HDSettingsSymbol } from "@/types";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const settingsData = inject(HDSettingsSymbol);
|
||||
const isLoading = ref(false);
|
||||
const brandLogo = ref(settingsData.value?.brandLogo);
|
||||
const favicon = ref(settingsData.value?.favicon);
|
||||
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const settingsResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
onSuccess(data) {
|
||||
brandLogo.value = data.brand_logo;
|
||||
favicon.value = data.favicon;
|
||||
isLoading.value = false;
|
||||
configStore.configResource.reload();
|
||||
toast.success(__("Updated successfully"));
|
||||
},
|
||||
onError() {
|
||||
isLoading.value = false;
|
||||
},
|
||||
});
|
||||
|
||||
function update(file: string, doctype: string, fieldname: string) {
|
||||
isLoading.value = true;
|
||||
settingsResource.submit({
|
||||
doctype: doctype,
|
||||
name: doctype,
|
||||
fieldname: fieldname,
|
||||
value: file,
|
||||
});
|
||||
}
|
||||
|
||||
const onRemove = (doctype: string, fieldname: string) => {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: __("Remove Logo"),
|
||||
message: __("Are you sure you want to remove the logo?"),
|
||||
onConfirm: () => {
|
||||
update("", doctype, fieldname);
|
||||
showConfirmDialog.value.show = false;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
watch(settingsData, (newData) => {
|
||||
brandLogo.value = newData.brandLogo;
|
||||
favicon.value = newData.favicon;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4 w-full"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-3.5">
|
||||
<div
|
||||
class="flex items-center justify-center min-w-16 min-h-16 rounded-lg overflow-hidden border border-gray-100"
|
||||
>
|
||||
<Avatar
|
||||
v-if="props.image"
|
||||
size="3xl"
|
||||
:image="props.image"
|
||||
:label="props.title"
|
||||
/>
|
||||
<FeatherIcon v-else name="image" class="size-6 text-ink-gray-4" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-sm items-start">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{ title }}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{ description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FileUploader
|
||||
:fileTypes="['image/*']"
|
||||
@success="
|
||||
(file) => {
|
||||
emit('onUpload', file.file_url);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-slot="{ progress, uploading, openFileSelector }">
|
||||
<div class="flex items-end space-x-2">
|
||||
<Button
|
||||
@click="openFileSelector"
|
||||
:iconLeft="ImageUpIcon"
|
||||
:label="
|
||||
uploading
|
||||
? __('Uploading {0}%', progress)
|
||||
: props.image
|
||||
? __('Change')
|
||||
: __('Upload')
|
||||
"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
<Button
|
||||
v-if="props.image"
|
||||
iconLeft="trash-2"
|
||||
:label="__('Remove')"
|
||||
@click="emit('onRemove')"
|
||||
:loading="isLoading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar, Button, FeatherIcon, FileUploader } from "frappe-ui";
|
||||
import ImageUpIcon from "~icons/lucide/image-up";
|
||||
|
||||
const emit = defineEmits(["onUpload", "onRemove"]);
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,349 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ __("Ticket settings") }}
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Make feedback mandatory")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"The feedback dialog will be shown, when a user tries to close a ticket from the customer portal."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch v-model="settingsData.isFeedbackMandatory" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Enable comment reactions")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Allow users to react to comments with emojis")
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch v-model="settingsData.enableCommentReactions" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Restrict tickets by team")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"Restrict tickets to be viewed and managed by team members only."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch v-model="settingsData.restrictTicketsByAgentGroup" />
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-2 gap-4 mt-3"
|
||||
v-if="settingsData.restrictTicketsByAgentGroup"
|
||||
>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
v-model="settingsData.doNotRestrictTicketsWithoutAnAgentGroup"
|
||||
:label="__('Do not restrict tickets without a team')"
|
||||
/>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
v-model="settingsData.assignWithinTeam"
|
||||
:label="__('Restrict agent assignment to selected team')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between"
|
||||
v-if="settingsData.restrictTicketsByAgentGroup"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Disable global saved replies")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"Agents will no longer be able to view and create saved replies with global scope."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch v-model="settingsData.disableSavedRepliesGlobalScope" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Auto update status")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"The ticket status will automatically change whenever the agent respond to a ticket."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
:options="autoUpdateTicketStatusList"
|
||||
:model-value="settingsData.updateStatusTo"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (value) {
|
||||
settingsData.updateStatusTo = value;
|
||||
settingsData.autoUpdateStatus = true;
|
||||
} else {
|
||||
settingsData.updateStatusTo = null;
|
||||
settingsData.autoUpdateStatus = false;
|
||||
}
|
||||
}
|
||||
"
|
||||
target-class="max-w-40"
|
||||
placement="bottom-start"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Allow anyone to create tickets")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6"
|
||||
>{{
|
||||
__(
|
||||
"Anyone will be able to create tickets without any permission. e.g. from webform."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="settingsData.allowAnyoneToCreateTickets"
|
||||
@update:model-value="
|
||||
(value) => (settingsData.allowAnyoneToCreateTickets = value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Default ticket type")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Select what type all tickets get by default.")
|
||||
}}</span>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
:options="ticketTypeList.data"
|
||||
v-model="settingsData.defaultTicketType"
|
||||
target-class="max-w-40"
|
||||
placement="bottom-start"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Automatically close stale tickets")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"Auto-close tickets that remain in a status for the specified number of days."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mt-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Ticket status')" />
|
||||
<SelectDropdown
|
||||
:options="autoCloseTicketStatusList"
|
||||
:model-value="settingsData.autoCloseStatus"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
if (value) {
|
||||
settingsData.autoCloseStatus = value;
|
||||
settingsData.autoCloseTickets = true;
|
||||
} else {
|
||||
settingsData.autoCloseStatus = null;
|
||||
settingsData.autoCloseTickets = false;
|
||||
}
|
||||
}
|
||||
"
|
||||
target-class="w-full"
|
||||
placement="bottom-start"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormControl
|
||||
:label="__('Auto-close after (Days)')"
|
||||
placeholder="e.g. 30"
|
||||
v-model="settingsData.autoCloseAfterDays"
|
||||
type="number"
|
||||
:debounce="300"
|
||||
:disabled="!settingsData.autoCloseStatus"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="
|
||||
settingsData.autoCloseStatus &&
|
||||
settingsData.autoCloseAfterDays < 1
|
||||
? __('The number of days must be 1 or more')
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Outside working hours notice")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6"
|
||||
>{{
|
||||
__(
|
||||
"Display a customizable banner message when customers raise tickets outside your working hours."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="settingsData.enableOutsideHoursBanner"
|
||||
@update:model-value="handleShowBannerToggle"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-if="settingsData.enableOutsideHoursBanner"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
placeholder="Enter Notification Message"
|
||||
:required="true"
|
||||
v-model="settingsData.outsideWorkingHoursBannerMessage"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="settingsData.enableOutsideHoursBanner"
|
||||
class="flex gap-x-1 items-start justify-between"
|
||||
>
|
||||
<p class="text-sm text-gray-700 leading-5">
|
||||
{{
|
||||
__(
|
||||
"Find out all of the variables that can be used in the content"
|
||||
)
|
||||
}}
|
||||
<a
|
||||
href="https://docs.frappe.io/helpdesk/helpdesk/customization/outside-working-hours-banner"
|
||||
target="_blank"
|
||||
class="underline font-semibold"
|
||||
>{{ __("here") }}</a
|
||||
>
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
class="w-fit"
|
||||
:disabled="!hasBannerMessageChanged"
|
||||
@click="resetBannerContent"
|
||||
:tooltip="
|
||||
hasBannerMessageChanged &&
|
||||
__('This will reset the content to the default message.')
|
||||
"
|
||||
>
|
||||
{{ __("Reset Content") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SelectDropdown from "@/components/SelectDropdown.vue";
|
||||
import { useTicketStatusStore } from "@/stores/ticketStatus";
|
||||
import { HDSettingsSymbol } from "@/types";
|
||||
import { HDTicketStatus } from "@/types/doctypes";
|
||||
import {
|
||||
Checkbox,
|
||||
createListResource,
|
||||
createResource,
|
||||
ErrorMessage,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from "frappe-ui";
|
||||
import { computed, inject } from "vue";
|
||||
|
||||
const settingsData = inject(HDSettingsSymbol);
|
||||
|
||||
const { statuses } = useTicketStatusStore();
|
||||
|
||||
const bannerMsg = createResource({
|
||||
url: "helpdesk.helpdesk.doctype.hd_settings.helpers.get_banner_msg",
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const hasBannerMessageChanged = computed(() => {
|
||||
return (
|
||||
settingsData.value.outsideWorkingHoursBannerMessage !==
|
||||
bannerMsg.data?.default
|
||||
);
|
||||
});
|
||||
|
||||
function resetBannerContent() {
|
||||
settingsData.value.outsideWorkingHoursBannerMessage = bannerMsg.data?.default;
|
||||
}
|
||||
|
||||
function handleShowBannerToggle(value: boolean) {
|
||||
if (!bannerMsg.data.current) {
|
||||
if (value) {
|
||||
settingsData.value.outsideWorkingHoursBannerMessage =
|
||||
bannerMsg.data?.default;
|
||||
} else {
|
||||
settingsData.value.outsideWorkingHoursBannerMessage = "";
|
||||
}
|
||||
}
|
||||
settingsData.value.enableOutsideHoursBanner = value;
|
||||
}
|
||||
|
||||
const ticketTypeList = createListResource({
|
||||
doctype: "HD Ticket Type",
|
||||
name: "HD Ticket Type",
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
return data.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const autoUpdateTicketStatusList = computed(() => {
|
||||
return (
|
||||
statuses.data?.map((s: HDTicketStatus) => {
|
||||
return {
|
||||
label: s.label_agent,
|
||||
value: s.label_agent,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
});
|
||||
|
||||
const autoCloseTicketStatusList = computed(() => {
|
||||
return (
|
||||
statuses.data
|
||||
?.filter(
|
||||
(s: HDTicketStatus) =>
|
||||
s.category === "Resolved" || s.category === "Paused"
|
||||
)
|
||||
?.map((s: HDTicketStatus) => {
|
||||
return {
|
||||
label: s.label_agent,
|
||||
value: s.label_agent,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-base font-semibold text-gray-900">
|
||||
{{ __("Workflow & knowledge base settings") }}
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Prefer knowledge base")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Guide users to articles before tickets.")
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="settingsData.preferKnowledgeBase"
|
||||
@update:model-value="
|
||||
(value) => (settingsData.preferKnowledgeBase = value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">{{
|
||||
__("Skip email workflow")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__(
|
||||
"Disables all email notifications for the ticket - including creation, updates, and alerts."
|
||||
)
|
||||
}}</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="settingsData.skipEmailWorkflow"
|
||||
@update:model-value="
|
||||
(value) => (settingsData.skipEmailWorkflow = value)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HDSettingsSymbol } from "@/types";
|
||||
import { Switch } from "frappe-ui";
|
||||
import { inject } from "vue";
|
||||
|
||||
const settingsData = inject(HDSettingsSymbol);
|
||||
</script>
|
||||
@@ -153,12 +153,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getFormattedDate, htmlToText } from "@/utils";
|
||||
import { Popover } from "frappe-ui";
|
||||
import { useDatePicker } from "frappe-ui/src/components/DatePicker/useDatePicker";
|
||||
import { ref, watch } from "vue";
|
||||
import { holidayData } from "@/stores/holidayList";
|
||||
import { getFormattedDate, htmlToText } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { Popover, useDatePicker } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
|
||||
|
||||
const dialog = ref({
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-8">
|
||||
<Holidays v-if="holidayListActiveScreen.screen == 'list'" />
|
||||
<HolidayView v-else-if="holidayListActiveScreen.screen == 'view'" />
|
||||
</div>
|
||||
<Holidays v-if="holidayListActiveScreen.screen == 'list'" />
|
||||
<HolidayView v-else-if="holidayListActiveScreen.screen == 'view'" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -10,16 +8,25 @@ import { holidayListActiveScreen } from "@/stores/holidayList";
|
||||
import Holidays from "./Holidays.vue";
|
||||
import HolidayView from "./HolidayView.vue";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { provide } from "vue";
|
||||
import { onUnmounted, provide, ref } from "vue";
|
||||
import { HolidayListResourceSymbol } from "@/types";
|
||||
|
||||
const holidayListData = createListResource({
|
||||
doctype: "HD Service Holiday List",
|
||||
fields: ["name", "description"],
|
||||
orderBy: "creation desc",
|
||||
cache: ["HolidayList"],
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
auto: true,
|
||||
});
|
||||
const holidaySearchRef = ref("");
|
||||
|
||||
provide("holidayList", holidayListData);
|
||||
provide("holidaySearchRef", holidaySearchRef);
|
||||
provide(HolidayListResourceSymbol, holidayListData);
|
||||
|
||||
onUnmounted(() => {
|
||||
holidaySearchRef.value = "";
|
||||
holidayListData.filters = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,22 +5,43 @@
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="-ml-2 grow">
|
||||
<div
|
||||
v-if="holidayList.list.data?.length === 0"
|
||||
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
|
||||
v-if="!holidayList.list.loading && !holidayList.list.data?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-7">No items in the list</div>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<Briefcase class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No Holiday list found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="goToNew()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<div class="ml-2">Schedule name</div>
|
||||
<div class="ml-2">{{ __("Schedule name") }}</div>
|
||||
</div>
|
||||
<hr class="mx-2 mt-2" />
|
||||
<div>
|
||||
<div v-for="holiday in holidayList.list.data" :key="holiday.name">
|
||||
<div
|
||||
v-for="(holiday, index) in holidayList.list.data"
|
||||
:key="holiday.name"
|
||||
>
|
||||
<HolidayListItem :data="holiday" />
|
||||
<hr class="mx-2" />
|
||||
<hr v-if="index !== holidayList.list.data.length - 1" class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,9 +50,23 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import HolidayListItem from "./HolidayListItem.vue";
|
||||
import { LoadingIndicator } from "frappe-ui";
|
||||
import { Button, LoadingIndicator } from "frappe-ui";
|
||||
import Briefcase from "~icons/lucide/briefcase";
|
||||
|
||||
import { inject } from "vue";
|
||||
import {
|
||||
holidayListActiveScreen,
|
||||
resetHolidayData,
|
||||
} from "@/stores/holidayList";
|
||||
import { HolidayListResourceSymbol } from "@/types";
|
||||
|
||||
const holidayList = inject<any>("holidayList");
|
||||
const holidayList = inject(HolidayListResourceSymbol);
|
||||
|
||||
const goToNew = () => {
|
||||
resetHolidayData();
|
||||
holidayListActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="flex items-center cursor-pointer hover:bg-gray-50 rounded">
|
||||
<div
|
||||
class="w-full py-3 pl-2"
|
||||
class="w-full pl-2 flex flex-col justify-center h-14"
|
||||
@click="holidayListActiveScreen = { screen: 'view', data: data }"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
|
||||
<div
|
||||
v-if="data.description && data.description.length > 0"
|
||||
class="text-sm text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
class="text-sm text-ink-gray-5 mt-1 truncate"
|
||||
>
|
||||
{{ data.description }}
|
||||
</div>
|
||||
@@ -25,15 +25,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
:options="{ title: `Duplicate Holiday List` }"
|
||||
:options="{ title: __('Duplicate Holiday List') }"
|
||||
v-model="duplicateDialog.show"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
label="New Holiday List Name"
|
||||
:label="__('New Holiday List Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.name"
|
||||
v-model="duplicateDialog.newName"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,19 +42,29 @@
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
label="Close"
|
||||
:label="__('Close')"
|
||||
@click="duplicateDialog.show = false"
|
||||
/>
|
||||
<Button variant="solid" label="Duplicate" @click="duplicate()" />
|
||||
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Button, createResource, toast } from "frappe-ui";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
createResource,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { inject, ref } from "vue";
|
||||
import { holidayListActiveScreen } from "@/stores/holidayList";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import { __ } from "@/translation";
|
||||
import { HolidayListResourceSymbol } from "@/types";
|
||||
import { HDServiceHolidayList } from "@/types/doctypes";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -62,10 +73,11 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const holidayList = inject<any>("holidayList");
|
||||
const holidayList = inject(HolidayListResourceSymbol);
|
||||
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
@@ -73,10 +85,13 @@ const isConfirmingDelete = ref(false);
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: "Duplicate",
|
||||
label: __("Duplicate"),
|
||||
onClick: () => {
|
||||
duplicateDialog.value.show = true;
|
||||
duplicateDialog.value.name = props.data.name + " (Copy)";
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
newName: props.data.name + " (Copy)",
|
||||
name: props.data.name,
|
||||
};
|
||||
},
|
||||
icon: "copy",
|
||||
},
|
||||
@@ -88,24 +103,38 @@ const dropdownOptions = [
|
||||
|
||||
const duplicate = () => {
|
||||
createResource({
|
||||
url: "helpdesk.api.holiday_list.duplicate_holiday_list",
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
docname: props.data.name,
|
||||
new_name: duplicateDialog.value.name,
|
||||
doctype: "HD Service Holiday List",
|
||||
name: duplicateDialog.value.name,
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
holidayList.reload();
|
||||
toast.success("Holiday list duplicated");
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
holidayListActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
};
|
||||
}, 250);
|
||||
onSuccess: (data: HDServiceHolidayList) => {
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
...data,
|
||||
holiday_list_name: duplicateDialog.value.newName,
|
||||
name: duplicateDialog.value.newName,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(newHolidayListData: HDServiceHolidayList) {
|
||||
holidayList?.reload();
|
||||
toast.success(__("Holiday list duplicated"));
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
holidayListActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: newHolidayListData,
|
||||
};
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -117,9 +146,9 @@ const deleteHolidayList = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
holidayList.delete.submit(props.data.name, {
|
||||
holidayList?.delete.submit(props.data.name, {
|
||||
onSuccess: () => {
|
||||
toast.success("Holiday list deleted");
|
||||
toast.success(__("Holiday list deleted"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,205 +1,213 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="holidayData.loading"
|
||||
class="flex items-center h-full justify-center"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!holidayData.loading"
|
||||
class="flex items-center justify-between sticky top-0 z-10 bg-white px-10 pt-8 pb-4"
|
||||
>
|
||||
<div>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="holidayData?.holiday_list_name || 'New Business Holiday'"
|
||||
:label="holidayData?.holiday_list_name || __('New Business Holiday')"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-xl hover:opacity-70 !pr-0"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
size="sm"
|
||||
label="Unsaved changes"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center">
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
label="Save"
|
||||
:label="__('Save')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="saveHoliday()"
|
||||
:disabled="Boolean(!isDirty && holidayListActiveScreen.data)"
|
||||
:loading="
|
||||
holidayList.loading ||
|
||||
updateHolidayResource.loading ||
|
||||
holidayList?.list.loading ||
|
||||
holidayList?.setValue.loading ||
|
||||
renameHolidayResource.loading ||
|
||||
getHolidayData.loading
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!holidayData.loading" class="px-10 pb-8 overflow-y-scroll h-full">
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-sm">
|
||||
There are in total <b>{{ holidayData.holidays.length }}</b> holidays in
|
||||
this list</span
|
||||
>
|
||||
</div>
|
||||
<hr class="mb-8 mt-2" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Name"
|
||||
label="Name"
|
||||
v-model="holidayData.holiday_list_name"
|
||||
required
|
||||
@change="validateHoliday('holiday_list_name')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="holidayDataErrors.holiday_list_name"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Description"
|
||||
label="Description"
|
||||
v-model="holidayData.description"
|
||||
/>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">Valid from</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
Choose the duration of this holiday list.
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel label="From date" for="from_date" required />
|
||||
<DatePicker
|
||||
v-model="holidayData.from_date"
|
||||
variant="subtle"
|
||||
placeholder="11/01/2025"
|
||||
class="w-full"
|
||||
id="from_date"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
:debounce="300"
|
||||
@update:model-value="updateDuration('from_date')"
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="!holidayData.loading" class="h-full">
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="text-sm">
|
||||
There are in total <b>{{ holidayData.holidays.length }}</b> holidays
|
||||
in this list</span
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage
|
||||
:message="
|
||||
holidayDataErrors.from_date || holidayDataErrors.dateRange
|
||||
"
|
||||
</div>
|
||||
<hr class="mb-8 mt-2" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
type="text"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Name')"
|
||||
:label="__('Name')"
|
||||
v-model="holidayData.holiday_list_name"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateHoliday('holiday_list_name')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="holidayDataErrors.holiday_list_name"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
type="textarea"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Description')"
|
||||
:label="__('Description')"
|
||||
v-model="holidayData.description"
|
||||
maxlength="140"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel label="To date" for="to_date" required />
|
||||
<DatePicker
|
||||
v-model="holidayData.to_date"
|
||||
variant="subtle"
|
||||
placeholder="25/12/2025"
|
||||
class="w-full"
|
||||
id="to_date"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
:debounce="300"
|
||||
@update:model-value="updateDuration('to_date')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="holidayDataErrors.to_date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-lg font-semibold text-ink-gray-8">
|
||||
Recurring holidays
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6">
|
||||
Add recurring holidays such as weekends.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<RecurringHolidaysList
|
||||
:holidayData="holidayData"
|
||||
:holidays="holidayData.recurring_holidays"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">Holidays</span>
|
||||
<div class="text-p-sm text-ink-gray-6">
|
||||
Add holidays here to make sure they’re excluded from SLA
|
||||
calculations.
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Valid from")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{ __("Choose the duration of this holiday list.") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel :label="__('From date')" for="from_date" required />
|
||||
<DatePicker
|
||||
v-model="holidayData.from_date"
|
||||
variant="subtle"
|
||||
placeholder="11/01/2025"
|
||||
class="w-full"
|
||||
id="from_date"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
:debounce="300"
|
||||
@update:model-value="updateDuration('from_date')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage
|
||||
:message="
|
||||
holidayDataErrors.from_date || holidayDataErrors.dateRange
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel :label="__('To date')" for="to_date" required />
|
||||
<DatePicker
|
||||
v-model="holidayData.to_date"
|
||||
variant="subtle"
|
||||
placeholder="25/12/2025"
|
||||
class="w-full"
|
||||
id="to_date"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
:debounce="300"
|
||||
@update:model-value="updateDuration('to_date')"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="holidayDataErrors.to_date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TabButtons
|
||||
:buttons="[
|
||||
{
|
||||
value: 'calendar',
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
value: 'list',
|
||||
icon: 'list',
|
||||
},
|
||||
]"
|
||||
v-model="holidayListView"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<HolidaysTableView v-if="holidayListView === 'list'" />
|
||||
<HolidaysCalendarView v-else />
|
||||
</div>
|
||||
<div class="mt-2.5 flex justify-between items-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
label="Add Holiday"
|
||||
@click="dialog.show = true"
|
||||
icon-left="plus"
|
||||
/>
|
||||
<!-- Indicators -->
|
||||
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-yellow-100 size-4 rounded-sm" />
|
||||
<span class="text-sm text-ink-gray-6">Holidays</span>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Recurring holidays") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6">
|
||||
{{ __("Add recurring holidays such as weekends.") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-gray-100 size-4 rounded-sm" />
|
||||
<span class="text-sm text-ink-gray-6">Recurring holidays</span>
|
||||
<div class="mt-5">
|
||||
<RecurringHolidaysList
|
||||
:holidayData="holidayData"
|
||||
:holidays="holidayData.recurring_holidays"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Holidays") }}
|
||||
</span>
|
||||
<div class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Add holidays here to make sure they’re excluded from SLA calculations."
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<TabButtons
|
||||
:buttons="[
|
||||
{
|
||||
value: 'calendar',
|
||||
icon: 'calendar',
|
||||
},
|
||||
{
|
||||
value: 'list',
|
||||
icon: 'list',
|
||||
},
|
||||
]"
|
||||
v-model="holidayListView"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<HolidaysTableView v-if="holidayListView === 'list'" />
|
||||
<HolidaysCalendarView v-else />
|
||||
</div>
|
||||
<div class="mt-2.5 flex justify-between items-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
:label="__('Add Holiday')"
|
||||
@click="dialog.show = true"
|
||||
icon-left="plus"
|
||||
/>
|
||||
<!-- Indicators -->
|
||||
<div class="flex gap-4" v-if="holidayListView === 'calendar'">
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-yellow-100 size-4 rounded-sm" />
|
||||
<span class="text-sm text-ink-gray-6">{{
|
||||
__("Holidays")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="gap-1 flex items-center">
|
||||
<span class="bg-gray-100 size-4 rounded-sm" />
|
||||
<span class="text-sm text-ink-gray-6">{{
|
||||
__("Recurring holidays")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<AddHolidayModal v-model="dialog" />
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog"
|
||||
title="Unsaved changes"
|
||||
message="Are you sure you want to go back? Unsaved changes will be lost."
|
||||
:title="__('Unsaved changes')"
|
||||
:message="
|
||||
__('Are you sure you want to go back? Unsaved changes will be lost.')
|
||||
"
|
||||
:onConfirm="goBack"
|
||||
:onCancel="() => (showConfirmDialog = false)"
|
||||
/>
|
||||
@@ -230,13 +238,17 @@ import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { slaActiveScreen } from "@/stores/sla";
|
||||
import { getFormattedDate, htmlToText } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import FormLabel from "frappe-ui/src/components/FormLabel.vue";
|
||||
import { FormLabel } from "frappe-ui";
|
||||
import {
|
||||
disableSettingModalOutsideClick,
|
||||
setActiveSettingsTab,
|
||||
} from "../settingsModal";
|
||||
import HolidaysCalendarView from "./HolidaysCalendarView.vue";
|
||||
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { HolidayListResourceSymbol } from "@/types";
|
||||
import { HDServiceHolidayList } from "@/types/doctypes";
|
||||
|
||||
const dialog = ref({
|
||||
show: false,
|
||||
@@ -251,18 +263,19 @@ const holidayListView = ref("calendar");
|
||||
|
||||
const showConfirmDialog = ref(false);
|
||||
|
||||
const holidayList = inject<any>("holidayList");
|
||||
const holidayList = inject(HolidayListResourceSymbol);
|
||||
|
||||
const getHolidayData = createResource({
|
||||
url: "helpdesk.api.holiday_list.get_holiday_list",
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
docname: holidayListActiveScreen.value.data?.name,
|
||||
doctype: "HD Service Holiday List",
|
||||
name: holidayListActiveScreen.value.data?.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
onSuccess(data: HDServiceHolidayList) {
|
||||
holidayData.value = data;
|
||||
initialData.value = JSON.stringify(data);
|
||||
},
|
||||
transform(data) {
|
||||
transform(data: HDServiceHolidayList) {
|
||||
for (let holiday of data.holidays) {
|
||||
holiday.description = htmlToText(holiday.description);
|
||||
}
|
||||
@@ -271,11 +284,22 @@ const getHolidayData = createResource({
|
||||
},
|
||||
});
|
||||
|
||||
const renameHolidayResource = createResource({
|
||||
url: "frappe.client.rename_doc",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Service Holiday List",
|
||||
old_name: holidayData.value.name,
|
||||
new_name: holidayData.value.holiday_list_name,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
if (holidayListActiveScreen.value.data?.name) {
|
||||
holidayData.value.loading = true;
|
||||
getHolidayData.fetch();
|
||||
} else {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
initialData.value = JSON.stringify(holidayData.value);
|
||||
}
|
||||
|
||||
const updateDuration = (key) => {
|
||||
@@ -293,10 +317,6 @@ const goBack = () => {
|
||||
showConfirmDialog.value = true;
|
||||
return;
|
||||
}
|
||||
if (!holidayListActiveScreen.value.data && !showConfirmDialog.value) {
|
||||
showConfirmDialog.value = true;
|
||||
return;
|
||||
}
|
||||
if (holidayListActiveScreen.value.previousScreen) {
|
||||
setActiveSettingsTab("SLA Policies");
|
||||
|
||||
@@ -320,7 +340,7 @@ const saveHoliday = () => {
|
||||
const validationErrors = validateHoliday();
|
||||
if (Object.values(validationErrors).some((error) => error)) {
|
||||
toast.error(
|
||||
"Invalid fields, check if all are filled in and values are correct."
|
||||
__("Invalid fields, check if all are filled in and values are correct.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -339,7 +359,7 @@ const createHoliday = () => {
|
||||
holiday_date: dayjs(holiday.holiday_date).format("YYYY-MM-DD"),
|
||||
};
|
||||
});
|
||||
holidayList.insert.submit(
|
||||
holidayList?.insert.submit(
|
||||
{
|
||||
holiday_list_name: holidayData.value.holiday_list_name,
|
||||
description: holidayData.value.description,
|
||||
@@ -350,29 +370,19 @@ const createHoliday = () => {
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success("Holiday list created");
|
||||
toast.success(__("Holiday list created"));
|
||||
holidayListActiveScreen.value.data = data;
|
||||
holidayListActiveScreen.value.screen = "view";
|
||||
getHolidayData.submit({
|
||||
docname: data.name,
|
||||
doctype: "HD Service Holiday List",
|
||||
name: data.name,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const updateHolidayResource = createResource({
|
||||
url: "helpdesk.api.holiday_list.update_holiday_list",
|
||||
onSuccess(data) {
|
||||
holidayListActiveScreen.value.data = data;
|
||||
getHolidayData.submit({
|
||||
docname: data.name,
|
||||
});
|
||||
toast.success("Holiday list updated");
|
||||
},
|
||||
});
|
||||
|
||||
const updateHoliday = () => {
|
||||
const updateHoliday = async () => {
|
||||
const holidays = holidayData.value.holidays.map((holiday) => {
|
||||
return {
|
||||
...holiday,
|
||||
@@ -380,9 +390,9 @@ const updateHoliday = () => {
|
||||
};
|
||||
});
|
||||
|
||||
updateHolidayResource.submit({
|
||||
docname: holidayListActiveScreen.value.data.name,
|
||||
doc: {
|
||||
await holidayList?.setValue.submit(
|
||||
{
|
||||
name: holidayListActiveScreen.value?.data?.name,
|
||||
holiday_list_name: holidayData.value.holiday_list_name,
|
||||
description: holidayData.value.description,
|
||||
from_date: holidayData.value.from_date,
|
||||
@@ -390,7 +400,40 @@ const updateHoliday = () => {
|
||||
holidays: holidays,
|
||||
recurring_holidays: JSON.stringify(holidayData.value.recurring_holidays),
|
||||
},
|
||||
});
|
||||
{
|
||||
onError(err) {
|
||||
const message = err?.messages?.[0];
|
||||
toast.error(
|
||||
message || __("Some error occurred while updating holiday list")
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (holidayData.value.name !== holidayData.value.holiday_list_name) {
|
||||
await renameHolidayResource.submit().catch(async (err) => {
|
||||
const error =
|
||||
err?.messages?.[0] ||
|
||||
__("Some error occurred while renaming holiday list");
|
||||
toast.error(error);
|
||||
// Reset holiday data to previous state
|
||||
await getHolidayData.reload();
|
||||
});
|
||||
|
||||
// Update the active screen data to reflect the new name
|
||||
holidayListActiveScreen.value.data.name =
|
||||
holidayData.value.holiday_list_name;
|
||||
|
||||
getHolidayData.submit({
|
||||
doctype: "HD Service Holiday List",
|
||||
name: holidayData.value.holiday_list_name,
|
||||
});
|
||||
} else {
|
||||
await getHolidayData.reload();
|
||||
}
|
||||
|
||||
toast.success(__("Holiday list updated"));
|
||||
holidayList?.reload();
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
<template>
|
||||
<div class="px-10 py-8">
|
||||
<SettingsLayoutHeader
|
||||
title="Business Holidays"
|
||||
description="Set your team’s working days, hours, and holidays using a template or custom schedule."
|
||||
<SettingsLayoutBase
|
||||
:title="__('Business Holidays')"
|
||||
:description="
|
||||
__(
|
||||
'Set your team’s working days, hours, and holidays using a template or custom schedule.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="holidayList.data?.length > 9 || holidaySearchRef.length"
|
||||
#header-bottom
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="New"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="holidaySearchRef"
|
||||
@input="holidaySearchRef = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
</div>
|
||||
<div class="overflow-y-auto pb-8 px-10">
|
||||
<HolidayList />
|
||||
</div>
|
||||
<Button
|
||||
v-if="holidaySearchRef"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="holidaySearchRef = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<HolidayList />
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,7 +52,12 @@ import {
|
||||
resetHolidayData,
|
||||
} from "@/stores/holidayList";
|
||||
import HolidayList from "./HolidayList.vue";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import { inject, Ref, ref, watch } from "vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { HolidayListResourceSymbol } from "@/types";
|
||||
|
||||
const holidayList = inject(HolidayListResourceSymbol);
|
||||
const holidaySearchRef = inject<Ref<string>>("holidaySearchRef");
|
||||
|
||||
const goToNew = () => {
|
||||
resetHolidayData();
|
||||
@@ -35,4 +66,15 @@ const goToNew = () => {
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
|
||||
watch(holidaySearchRef, (newValue) => {
|
||||
holidayList.filters = {
|
||||
name: ["like", `%${newValue}%`],
|
||||
};
|
||||
if (!newValue) {
|
||||
holidayList.start = 0;
|
||||
holidayList.pageLength = 10;
|
||||
}
|
||||
holidayList.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -61,6 +61,7 @@ import dayjs from "dayjs";
|
||||
import { ConfirmDelete, getFormattedDate } from "@/utils";
|
||||
import { holidayData } from "@/stores/holidayList";
|
||||
import AddHolidayModal from "./Modals/AddHolidayModal.vue";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
|
||||
const isConfirmingDelete = ref(false);
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ import { ConfirmDelete } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
import isSameOrBefore from "dayjs/plugin/isSameOrBefore";
|
||||
import weekday from "dayjs/plugin/weekday";
|
||||
import { Checkbox, FormLabel, Select, toast } from "frappe-ui";
|
||||
import { Checkbox, Dropdown, FormLabel, Select, toast } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import { getRepetitionText } from "./utils";
|
||||
|
||||
|
||||
@@ -1,98 +1,99 @@
|
||||
<template>
|
||||
<div class="px-10 pb-8 h-full overflow-y-auto">
|
||||
<form @submit.prevent="onSubmit" class="flex flex-col gap-5">
|
||||
<div class="sticky top-0 z-10 bg-white pt-8 pb-1">
|
||||
<SettingsLayoutHeader
|
||||
title="Invite Agents"
|
||||
description="Easily invite new agents, managers, or admins"
|
||||
<SettingsLayoutBase
|
||||
:title="__('Invite Agents')"
|
||||
:description="__('Easily invite new agents, managers, or admins.')"
|
||||
>
|
||||
<template #content>
|
||||
<form @submit.prevent="onSubmit" class="flex flex-col gap-5">
|
||||
<FormControl
|
||||
type="textarea"
|
||||
:required="true"
|
||||
:label="__('Invite by email')"
|
||||
placeholder="user1@example.com, user2@example.com, ..."
|
||||
v-model="emails"
|
||||
:debounce="100"
|
||||
:description="__('Comma separated emails to invite')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
type="textarea"
|
||||
:required="true"
|
||||
label="Invite by email"
|
||||
placeholder="user1@example.com, user2@example.com, ..."
|
||||
v-model="emails"
|
||||
:debounce="100"
|
||||
description="Comma separated emails to invite"
|
||||
/>
|
||||
<FormControl
|
||||
label="Role"
|
||||
type="select"
|
||||
:required="true"
|
||||
:options="roleOptions"
|
||||
v-model="role"
|
||||
:description="roleDescription"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="solid"
|
||||
class="w-fit mt-1"
|
||||
:disabled="cancelInviteResource.loading"
|
||||
:loading="inviteByEmailResource.loading"
|
||||
>Send Invites</Button
|
||||
>
|
||||
</form>
|
||||
<template v-if="pendingInvitesResource.data?.length">
|
||||
<h2 class="mt-8 text-base font-semibold">Pending Invites</h2>
|
||||
<ul class="flex flex-col gap-[0.375rem] mt-3">
|
||||
<li
|
||||
v-for="invite in pendingInvitesResource.data"
|
||||
:key="invite.name"
|
||||
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
|
||||
<FormControl
|
||||
:label="__('Role')"
|
||||
type="select"
|
||||
:required="true"
|
||||
:options="roleOptions"
|
||||
v-model="role"
|
||||
:description="roleDescription"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="solid"
|
||||
class="w-fit mt-1"
|
||||
:disabled="cancelInviteResource.loading"
|
||||
:loading="inviteByEmailResource.loading"
|
||||
>{{ __("Send Invites") }}</Button
|
||||
>
|
||||
<div class="text-base">
|
||||
<span class="text-ink-gray-8">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
<span class="text-ink-gray-5">
|
||||
({{ rolesToLabel(invite.roles) }})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
:disabled="
|
||||
inviteByEmailResource.loading || cancelInviteResource.loading
|
||||
"
|
||||
text="Cancel Invitation"
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
:loading="
|
||||
cancelInviteResource.loading &&
|
||||
invite.name === cancelInviteResource.params.name
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
if (
|
||||
inviteByEmailResource.loading ||
|
||||
cancelInviteResource.loading
|
||||
) {
|
||||
return;
|
||||
</form>
|
||||
<template v-if="pendingInvitesResource.data?.length">
|
||||
<h2 class="mt-8 text-base font-semibold">
|
||||
{{ __("Pending Invites") }}
|
||||
</h2>
|
||||
<ul class="flex flex-col gap-[0.375rem] mt-3">
|
||||
<li
|
||||
v-for="invite in pendingInvitesResource.data"
|
||||
:key="invite.name"
|
||||
class="flex items-center justify-between px-3 py-1 rounded-lg bg-surface-gray-2"
|
||||
>
|
||||
<div class="text-base">
|
||||
<span class="text-ink-gray-8">
|
||||
{{ invite.email }}
|
||||
</span>
|
||||
<span class="text-ink-gray-5">
|
||||
({{ rolesToLabel(invite.roles) }})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
:disabled="
|
||||
inviteByEmailResource.loading || cancelInviteResource.loading
|
||||
"
|
||||
:text="__('Cancel Invitation')"
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
:loading="
|
||||
cancelInviteResource.loading &&
|
||||
invite.name === cancelInviteResource.params.name
|
||||
"
|
||||
@click="
|
||||
() => {
|
||||
if (
|
||||
inviteByEmailResource.loading ||
|
||||
cancelInviteResource.loading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
cancelInviteResource.submit({
|
||||
name: invite.name,
|
||||
app_name: 'helpdesk',
|
||||
});
|
||||
}
|
||||
cancelInviteResource.submit({
|
||||
name: invite.name,
|
||||
app_name: 'helpdesk',
|
||||
});
|
||||
}
|
||||
"
|
||||
v-bind="{
|
||||
...((cancelInviteResource.loading &&
|
||||
cancelInviteResource.params.name !== invite.name) ||
|
||||
inviteByEmailResource.loading
|
||||
? { 'aria-disabled': true, class: 'opacity-25' }
|
||||
: {}),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
"
|
||||
v-bind="{
|
||||
...((cancelInviteResource.loading &&
|
||||
cancelInviteResource.params.name !== invite.name) ||
|
||||
inviteByEmailResource.loading
|
||||
? { 'aria-disabled': true, class: 'opacity-25' }
|
||||
: {}),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -100,7 +101,9 @@ import { useAuthStore } from "@/stores/auth";
|
||||
import { FormControl, Button, Tooltip, createResource, toast } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { isAdmin, isManager } = authStore;
|
||||
@@ -134,21 +137,23 @@ const roleOptions: [RoleOption, ...RoleOption[]] = [
|
||||
{
|
||||
label: roleToLabel("Agent"),
|
||||
value: "Agent",
|
||||
description:
|
||||
"Can work on tickets, create custom views and manage private views.",
|
||||
description: __(
|
||||
"Can work on tickets, create custom views and manage private views."
|
||||
),
|
||||
},
|
||||
];
|
||||
const managerRoleOption: RoleOption = {
|
||||
label: roleToLabel("Agent Manager"),
|
||||
value: "Agent Manager",
|
||||
description:
|
||||
"Can invite new agents, manage tickets, create custom views and manage public views.",
|
||||
description: __(
|
||||
"Can invite new agents, manage tickets, create custom views and manage public views."
|
||||
),
|
||||
};
|
||||
if (isAdmin) {
|
||||
roleOptions.push(managerRoleOption, {
|
||||
label: roleToLabel("System Manager"),
|
||||
value: "System Manager",
|
||||
description: "Can manage all aspects of Helpdesk.",
|
||||
description: __("Can manage all aspects of Helpdesk."),
|
||||
});
|
||||
} else if (isManager) {
|
||||
roleOptions.push(managerRoleOption);
|
||||
@@ -163,7 +168,7 @@ const roleDescription = computed(
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (emails.value.trim() === "") {
|
||||
toast.error("At least one email required");
|
||||
toast.error(__("At least one email required"));
|
||||
}
|
||||
await inviteByEmailResource.submit({
|
||||
emails: emails.value,
|
||||
@@ -211,6 +216,11 @@ const inviteByEmailResource = createResource({
|
||||
}
|
||||
pendingInvitesResource.reload();
|
||||
updateOnboardingStep("invite_your_team");
|
||||
capture("agents_invited", {
|
||||
data: {
|
||||
role: role.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: 'New team',
|
||||
title: __('New team'),
|
||||
actions: [
|
||||
{
|
||||
label: 'Create',
|
||||
label: __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => {
|
||||
newTeam.submit();
|
||||
@@ -18,8 +18,8 @@
|
||||
<form class="space-y-2" @submit.prevent="newTeam.submit">
|
||||
<FormControl
|
||||
v-model="newTeamTitle"
|
||||
label="Title"
|
||||
placeholder="Product experts"
|
||||
:label="__('Title')"
|
||||
:placeholder="__('Product experts')"
|
||||
type="text"
|
||||
/>
|
||||
</form>
|
||||
@@ -29,8 +29,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createResource, toast } from "frappe-ui";
|
||||
import { isEmpty } from "lodash";
|
||||
import { ref } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const emit = defineEmits(["create"]);
|
||||
const show = defineModel();
|
||||
@@ -47,11 +47,11 @@ const newTeam = createResource({
|
||||
};
|
||||
},
|
||||
validate(params) {
|
||||
if (isEmpty(params.doc.team_name)) return "Title is required";
|
||||
if (!params.doc.team_name) return __("Title is required");
|
||||
},
|
||||
auto: false,
|
||||
onSuccess() {
|
||||
toast.success("Team created");
|
||||
toast.success(__("Team created"));
|
||||
newTeamTitle.value = null;
|
||||
show.value = false;
|
||||
emit("create");
|
||||
|
||||
342
desk/src/components/Settings/Profile/Profile.vue
Normal file
342
desk/src/components/Settings/Profile/Profile.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Profile')"
|
||||
:description="__('Manage your profile information.')"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<FileUploader
|
||||
:fileTypes="['image/*']"
|
||||
@success="
|
||||
(file) => {
|
||||
updateImage(file.file_url);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector, error: _error, uploading }">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="group relative !size-14">
|
||||
<Avatar
|
||||
class="!size-14"
|
||||
:image="profile.userImage"
|
||||
:label="profile.fullName"
|
||||
/>
|
||||
<component
|
||||
:is="profile.userImage ? Dropdown : 'div'"
|
||||
v-bind="
|
||||
profile.userImage
|
||||
? {
|
||||
options: [
|
||||
{
|
||||
icon: 'upload',
|
||||
label: profile.userImage
|
||||
? __('Change image')
|
||||
: __('Upload image'),
|
||||
onClick: openFileSelector,
|
||||
},
|
||||
{
|
||||
icon: 'trash-2',
|
||||
label: __('Remove image'),
|
||||
onClick: () => updateImage(null),
|
||||
},
|
||||
],
|
||||
}
|
||||
: { onClick: openFileSelector }
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="z-1 absolute top-0 left-0 flex h-9 cursor-pointer items-center justify-center rounded-full bg-black bg-opacity-40 opacity-0 duration-300 ease-in-out group-hover:opacity-100 !size-14"
|
||||
>
|
||||
<CameraIcon class="size-4 cursor-pointer text-white" />
|
||||
</div>
|
||||
</component>
|
||||
<div
|
||||
v-if="uploading"
|
||||
class="w-full h-full top-0 left-0 absolute bg-black bg-opacity-20 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-lg sm:text-xl !font-semibold text-ink-gray-8"
|
||||
>{{ auth?.userName }}</span
|
||||
>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
auth?.user
|
||||
}}</span>
|
||||
</div>
|
||||
<ErrorMessage :message="__(_error)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<hr class="my-6" />
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="text-base font-semibold text-ink-gray-9">
|
||||
{{ __("Account info & security") }}
|
||||
</div>
|
||||
<Badge
|
||||
v-if="isAccountInfoDirty || isLanguageChanged"
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
@click="onSave"
|
||||
:loading="
|
||||
setAgent.loading ||
|
||||
saveLanguageResource.loading ||
|
||||
saveTimezoneResource.loading
|
||||
"
|
||||
:disabled="
|
||||
!isAccountInfoDirty && !isLanguageChanged && !isTimezoneChanged
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 mt-6">
|
||||
<FormControl
|
||||
class="w-full"
|
||||
:label="__('First Name')"
|
||||
maxlength="40"
|
||||
v-model="profile.firstName"
|
||||
/>
|
||||
<FormControl
|
||||
class="w-full"
|
||||
:label="__('Last Name')"
|
||||
maxlength="40"
|
||||
v-model="profile.lastName"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">
|
||||
{{ __("Password") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Change your account password for security.")
|
||||
}}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon-left="lock"
|
||||
:label="__('Change Password')"
|
||||
@click="showChangePasswordModal = true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">
|
||||
{{ __("Language") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Change language of the application.")
|
||||
}}</span>
|
||||
</div>
|
||||
<Link
|
||||
:model-value="language"
|
||||
@update:modelValue="language = $event || auth.language"
|
||||
doctype="Language"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base font-medium text-ink-gray-8">
|
||||
{{ __("Timezone") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">{{
|
||||
__("Change timezone of the application.")
|
||||
}}</span>
|
||||
</div>
|
||||
<Autocomplete
|
||||
:options="timezoneOptions"
|
||||
:model-value="timezone"
|
||||
@update:modelValue="timezone = $event?.value"
|
||||
placeholder="Select Timezone"
|
||||
class="w-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<ChangePasswordModal
|
||||
v-if="showChangePasswordModal"
|
||||
v-model="showChangePasswordModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
createResource,
|
||||
Dropdown,
|
||||
FileUploader,
|
||||
LoadingIndicator,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { Autocomplete } from "@/components";
|
||||
import { __ } from "@/translation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import CameraIcon from "~icons/lucide/camera";
|
||||
import ChangePasswordModal from "./components/ChangePasswordModal.vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import Link from "@/components/frappe-ui/Link.vue";
|
||||
import { HDAgent } from "@/types/doctypes";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const profile = ref({
|
||||
fullName: auth.userName,
|
||||
userImage: auth.userImage,
|
||||
firstName: auth.userFirstName,
|
||||
lastName: auth.userLastName,
|
||||
});
|
||||
const showChangePasswordModal = ref(false);
|
||||
const language = ref(auth.language);
|
||||
const timezone = ref(auth.timezone);
|
||||
const timezoneOptions = ref([]);
|
||||
|
||||
const isLanguageChanged = computed(() => {
|
||||
return language.value !== auth?.language;
|
||||
});
|
||||
|
||||
const isTimezoneChanged = computed(() => {
|
||||
return timezone.value !== auth?.timezone;
|
||||
});
|
||||
|
||||
const isAccountInfoDirty = computed(() => {
|
||||
const agentName = agentData.data?.agent_name?.split(" ");
|
||||
if (!agentName) return false;
|
||||
const isDirty =
|
||||
profile.value.firstName !== agentName[0] ||
|
||||
profile.value.lastName !== (agentName[1] || "");
|
||||
if (isDirty) {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
} else {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
}
|
||||
return isDirty;
|
||||
});
|
||||
|
||||
const agentData = createResource({
|
||||
url: "frappe.client.get",
|
||||
auto: true,
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Agent",
|
||||
name: auth.userId,
|
||||
};
|
||||
},
|
||||
onSuccess: (data: HDAgent) => {
|
||||
const fullName = data.agent_name.split(" ");
|
||||
profile.value = {
|
||||
fullName: data.agent_name,
|
||||
firstName: fullName[0],
|
||||
lastName: fullName[1] || "",
|
||||
userImage: data.user_image,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const timezoneData = createResource({
|
||||
url: "frappe.core.doctype.user.user.get_timezones",
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
timezoneOptions.value = data.timezones.map((tz: any) => ({
|
||||
label: tz,
|
||||
value: tz,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const setAgent = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
validate: () => {
|
||||
if (!profile.value.firstName.trim()) {
|
||||
return __("Please enter first name at least");
|
||||
}
|
||||
},
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Agent",
|
||||
name: agentData.data?.name,
|
||||
fieldname: {
|
||||
agent_name: `${profile.value.firstName} ${profile.value.lastName}`,
|
||||
user_image: profile.value.userImage,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
auth.reloadUser();
|
||||
agentData.reload();
|
||||
toast.success(__("Profile updated"));
|
||||
},
|
||||
});
|
||||
|
||||
const saveLanguageResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "User",
|
||||
name: auth.userId,
|
||||
fieldname: {
|
||||
language: language.value,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success(__("Language updated"));
|
||||
setTimeout(() => {
|
||||
window.location.reload(true);
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
const saveTimezoneResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "User",
|
||||
name: auth.userId,
|
||||
fieldname: {
|
||||
time_zone: timezone.value,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success(__("Timezone updated"));
|
||||
setTimeout(() => {
|
||||
window.location.reload(true);
|
||||
}, 500);
|
||||
},
|
||||
});
|
||||
|
||||
const onSave = () => {
|
||||
if (isAccountInfoDirty.value) {
|
||||
setAgent.submit();
|
||||
}
|
||||
|
||||
if (isLanguageChanged.value) {
|
||||
saveLanguageResource.submit();
|
||||
}
|
||||
|
||||
if (isTimezoneChanged.value) {
|
||||
saveTimezoneResource.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const updateImage = (file: string | null) => {
|
||||
profile.value.userImage = file;
|
||||
setAgent.submit();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ title: __('Change Password') }">
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<Password
|
||||
v-model="newPassword"
|
||||
:placeholder="__('New Password')"
|
||||
maxLength="50"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockKeyhole class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</Password>
|
||||
</div>
|
||||
<div>
|
||||
<Password
|
||||
v-model="confirmPassword"
|
||||
:placeholder="__('Confirm Password')"
|
||||
maxLength="50"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockKeyhole class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</Password>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p
|
||||
v-if="confirmPasswordMessage"
|
||||
class="text-sm text-ink-gray-5"
|
||||
:class="
|
||||
confirmPasswordMessage === __('Passwords match')
|
||||
? 'text-ink-green-3'
|
||||
: 'text-ink-red-3'
|
||||
"
|
||||
>
|
||||
{{ confirmPasswordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Update')"
|
||||
:disabled="
|
||||
!newPassword ||
|
||||
!confirmPassword ||
|
||||
newPassword !== confirmPassword ||
|
||||
!isStrongPassword(newPassword)
|
||||
"
|
||||
:loading="updatePassword.loading"
|
||||
@click="updatePassword.submit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import LockKeyhole from "~icons/lucide/lock-keyhole";
|
||||
import { Dialog, toast, createResource } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const show = defineModel<boolean>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const newPassword = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const confirmPasswordMessage = ref("");
|
||||
|
||||
const updatePassword = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "User",
|
||||
name: auth?.user,
|
||||
fieldname: "new_password",
|
||||
value: newPassword.value,
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__("Password updated successfully"));
|
||||
show.value = false;
|
||||
newPassword.value = "";
|
||||
confirmPassword.value = "";
|
||||
confirmPasswordMessage.value = "";
|
||||
},
|
||||
});
|
||||
|
||||
function isStrongPassword(password) {
|
||||
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d\s]).{8,}$/;
|
||||
return regex.test(password);
|
||||
}
|
||||
|
||||
watch([newPassword, confirmPassword], () => {
|
||||
confirmPasswordMessage.value = "";
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
confirmPasswordMessage.value = __("Password must be at least 8 characters");
|
||||
return;
|
||||
} else if (!isStrongPassword(newPassword.value)) {
|
||||
confirmPasswordMessage.value = __(
|
||||
"Password must contain lowercase, uppercase, number, and symbol"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
confirmPassword.value.length &&
|
||||
newPassword.value !== confirmPassword.value
|
||||
) {
|
||||
confirmPasswordMessage.value = __("Passwords do not match");
|
||||
} else if (
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length &&
|
||||
confirmPassword.value.length
|
||||
) {
|
||||
confirmPasswordMessage.value = __("Passwords match");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
43
desk/src/components/Settings/SavedReplies/SavedReplies.vue
Normal file
43
desk/src/components/Settings/SavedReplies/SavedReplies.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<SavedRepliesList v-if="savedRepliesActiveScreen.screen == 'list'" />
|
||||
<SavedReplyView v-else-if="savedRepliesActiveScreen.screen == 'view'" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { createListResource } from "frappe-ui";
|
||||
import SavedRepliesList from "./SavedRepliesList.vue";
|
||||
import SavedReplyView from "./SavedReplyView.vue";
|
||||
import { onUnmounted, provide, ref } from "vue";
|
||||
import { SavedReplyListResourceSymbol } from "../../../types";
|
||||
import { activeFilter } from "./savedReplies";
|
||||
|
||||
const savedRepliesActiveScreen = ref({
|
||||
screen: "list",
|
||||
data: null,
|
||||
});
|
||||
const savedRepliesSearchQuery = ref("");
|
||||
|
||||
const savedReplyListResource = createListResource({
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["name", "title", "owner", "scope"],
|
||||
filters: {
|
||||
scope: activeFilter.value == "All" ? undefined : ["=", activeFilter.value],
|
||||
},
|
||||
cache: ["SavedReplyList"],
|
||||
auto: true,
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
});
|
||||
|
||||
provide(SavedReplyListResourceSymbol, savedReplyListResource);
|
||||
provide("savedRepliesActiveScreen", savedRepliesActiveScreen);
|
||||
provide("savedRepliesSearchQuery", savedRepliesSearchQuery);
|
||||
|
||||
onUnmounted(() => {
|
||||
savedRepliesSearchQuery.value = "";
|
||||
savedReplyListResource.filters = {
|
||||
scope: activeFilter.value == "All" ? undefined : ["=", activeFilter.value],
|
||||
};
|
||||
});
|
||||
</script>
|
||||
399
desk/src/components/Settings/SavedReplies/SavedRepliesList.vue
Normal file
399
desk/src/components/Settings/SavedReplies/SavedRepliesList.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Saved Replies')"
|
||||
:description="
|
||||
__(
|
||||
'Manage pre-defined responses that can be used to respond to customer queries.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template #header-bottom>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<div class="relative w-full">
|
||||
<Input
|
||||
:model-value="savedRepliesSearchQuery"
|
||||
@input="savedRepliesSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
<Button
|
||||
v-if="savedRepliesSearchQuery"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="savedRepliesSearchQuery = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="filterOptions" placement="right">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-fit p-4"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded p-2 text-base hover:bg-surface-gray-3"
|
||||
@click="item.onSelect"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="activeFilter === item.value"
|
||||
name="check"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="savedRepliesListResource?.list?.loading"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedRepliesListResource?.list?.loading &&
|
||||
!savedRepliesListResource?.data?.length
|
||||
"
|
||||
class="flex flex-col items-center justify-center gap-4 grow"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<SavedReplyIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No Saved reply found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add your first Saved reply to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('Add Saved reply')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="goToNew()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedRepliesListResource?.list?.loading &&
|
||||
savedRepliesListResource?.data?.length
|
||||
"
|
||||
class="-ml-2"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-12 items-center gap-3 text-sm text-gray-600 ml-2"
|
||||
>
|
||||
<div class="col-span-7">{{ __("Title") }}</div>
|
||||
<div class="col-span-2">{{ __("Owner") }}</div>
|
||||
<div class="col-span-3">{{ __("Scope") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2 mx-2" />
|
||||
<div
|
||||
v-for="(savedReply, index) in savedRepliesListResource?.data"
|
||||
:key="savedReply.name"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
|
||||
>
|
||||
<div
|
||||
@click="
|
||||
savedRepliesActiveScreen = {
|
||||
screen: 'view',
|
||||
data: savedReply,
|
||||
}
|
||||
"
|
||||
class="w-full px-2 flex flex-col justify-center h-12.5 col-span-7"
|
||||
>
|
||||
<div
|
||||
class="text-base text-ink-gray-7 font-medium w-full truncate"
|
||||
>
|
||||
{{ savedReply.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-sm text-ink-gray-7 truncate col-span-2"
|
||||
>
|
||||
<Avatar
|
||||
:label="
|
||||
getUser(savedReply.owner)?.full_name || savedReply.owner
|
||||
"
|
||||
:image="getUser(savedReply.owner)?.user_image"
|
||||
size="xs"
|
||||
/>
|
||||
{{ getUser(savedReply.owner)?.full_name }}
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between items-center w-full pr-2 col-span-3"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-sm text-ink-gray-7">
|
||||
<component
|
||||
:is="getScopeIcon(savedReply.scope)"
|
||||
class="size-4"
|
||||
/>
|
||||
{{ savedReply.scope }}
|
||||
</div>
|
||||
<Dropdown
|
||||
placement="right"
|
||||
:options="dropdownOptions(savedReply)"
|
||||
>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
class="mr-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="index !== savedRepliesList.length - 1" class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<Dialog
|
||||
:options="{ title: __('Duplicate Saved reply') }"
|
||||
v-model="duplicateDialog.show"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
:label="__('New Saved reply Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.newTitle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
:label="__('Close')"
|
||||
@click="duplicateDialog.show = false"
|
||||
/>
|
||||
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
call,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
Input,
|
||||
LoadingIndicator,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { computed, inject, ref, Ref, watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import { activeFilter } from "./savedReplies";
|
||||
import { useUserStore } from "../../../stores/user";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
import UsersIcon from "~icons/lucide/users";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import { SavedReply, SavedReplyListResourceSymbol } from "../../../types";
|
||||
import SavedReplyIcon from "../../icons/SavedReplyIcon.vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
||||
storeToRefs(useConfigStore());
|
||||
|
||||
const savedRepliesSearchQuery = inject<Ref<string>>("savedRepliesSearchQuery");
|
||||
const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
name: "",
|
||||
title: "",
|
||||
newTitle: "",
|
||||
});
|
||||
const savedRepliesList = ref([]);
|
||||
|
||||
const goToNew = () => {
|
||||
savedRepliesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
|
||||
const isConfirmingDelete = ref(false);
|
||||
|
||||
const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
|
||||
|
||||
const dropdownOptions = (savedReply: SavedReply) => [
|
||||
{
|
||||
label: __("Duplicate"),
|
||||
onClick: () => {
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
name: savedReply.name,
|
||||
title: savedReply.title,
|
||||
newTitle: `${savedReply.title} (Copy)`,
|
||||
};
|
||||
},
|
||||
icon: "copy",
|
||||
},
|
||||
...ConfirmDelete({
|
||||
onConfirmDelete: () => deleteSavedReply(savedReply),
|
||||
isConfirmingDelete,
|
||||
}),
|
||||
];
|
||||
|
||||
const deleteSavedReply = (savedReply: SavedReply) => {
|
||||
if (!isConfirmingDelete.value) {
|
||||
isConfirmingDelete.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
savedRepliesListResource?.delete.submit(savedReply.name, {
|
||||
onSuccess: () => {
|
||||
toast.success(__("Saved reply deleted"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const duplicate = async () => {
|
||||
await call("frappe.client.get", {
|
||||
doctype: "HD Saved Reply",
|
||||
name: duplicateDialog.value.name,
|
||||
}).then((data: SavedReply) => {
|
||||
savedRepliesListResource?.insert.submit(
|
||||
{
|
||||
...data,
|
||||
title: duplicateDialog.value.newTitle,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(__("Saved reply duplicated"));
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
name: "",
|
||||
title: "",
|
||||
newTitle: "",
|
||||
};
|
||||
savedRepliesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: { name: data.name },
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const filterOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __("All"),
|
||||
value: "All",
|
||||
onSelect: () => {
|
||||
applyFilter("All");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Personal"),
|
||||
value: "Personal",
|
||||
onSelect: () => {
|
||||
applyFilter("Personal");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("My Team"),
|
||||
value: "Team",
|
||||
onSelect: () => {
|
||||
applyFilter("Team");
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
value: "Global",
|
||||
onSelect: () => {
|
||||
applyFilter("Global");
|
||||
},
|
||||
},
|
||||
];
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
options.pop();
|
||||
}
|
||||
return options;
|
||||
});
|
||||
|
||||
const applyFilter = (scope: string) => {
|
||||
if (!savedRepliesListResource) return;
|
||||
activeFilter.value = scope;
|
||||
savedRepliesListResource.filters = {
|
||||
...savedRepliesListResource?.filters,
|
||||
scope: scope == "All" ? undefined : ["=", scope],
|
||||
};
|
||||
savedRepliesListResource.list.reload();
|
||||
};
|
||||
|
||||
const getScopeIcon = (scope: string) => {
|
||||
const icons = [
|
||||
{
|
||||
label: __("Personal"),
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
label: __("Team"),
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
];
|
||||
return icons.find((x) => x.label === scope)?.icon;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => savedRepliesSearchQuery?.value,
|
||||
(newValue) => {
|
||||
if (!savedRepliesListResource) return;
|
||||
|
||||
savedRepliesListResource.filters = {
|
||||
...savedRepliesListResource.filters,
|
||||
title: ["like", `%${newValue}%`],
|
||||
};
|
||||
savedRepliesListResource.list.reload();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
482
desk/src/components/Settings/SavedReplies/SavedReplyView.vue
Normal file
482
desk/src/components/Settings/SavedReplies/SavedReplyView.vue
Normal file
@@ -0,0 +1,482 @@
|
||||
<template>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="savedReplyData.title || __('New Saved Reply')"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
size="sm"
|
||||
:label="__('Unsaved changes')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="savedReplyData.name"
|
||||
:label="__('Preview')"
|
||||
size="sm"
|
||||
@click="onShowPreview()"
|
||||
icon-left="eye"
|
||||
:disabled="
|
||||
Boolean(!content?.editor?.state?.doc?.textContent?.trim()?.length)
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
theme="gray"
|
||||
@click="onSave"
|
||||
:loading="
|
||||
savedRepliesListResource?.insert.loading ||
|
||||
savedRepliesListResource?.setValue.loading ||
|
||||
renameSavedReplyResource.loading ||
|
||||
getSavedReplyData.loading
|
||||
"
|
||||
:disabled="Boolean(!isDirty)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="getSavedReplyData.loading"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div class="space-y-1.5">
|
||||
<FormControl
|
||||
:label="__('Name')"
|
||||
type="text"
|
||||
v-model="savedReplyData.title"
|
||||
required
|
||||
maxLength="50"
|
||||
@change="validateData('title')"
|
||||
/>
|
||||
<ErrorMessage class="text-p-sm" :message="errors.title" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<FormLabel :label="__('Scope')" />
|
||||
<Select
|
||||
v-model="savedReplyData.scope"
|
||||
:options="scopeDropdownOptions"
|
||||
required
|
||||
>
|
||||
<template #prefix>
|
||||
<component
|
||||
:is="getScopeIcon(savedReplyData.scope)"
|
||||
class="size-4 text-ink-gray-9"
|
||||
/>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<div class="flex gap-2 items-center cursor-pointer">
|
||||
<component :is="option.icon" class="size-4 text-ink-gray-9" />
|
||||
<span>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Select>
|
||||
<FormLabel
|
||||
:label="__('Choose who can view and use this response')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="savedReplyData.scope === 'Team'" class="space-y-1.5">
|
||||
<FormLabel :label="__('Teams')" required />
|
||||
<MultiSelect
|
||||
:options="teamsList"
|
||||
v-model="savedReplyData.teams"
|
||||
:placeholder="__('Select teams')"
|
||||
@update:modelValue="validateData('teams')"
|
||||
/>
|
||||
<div class="text-xs text-ink-gray-5 cursor-default">
|
||||
{{ __("Restrict visibility to these teams") }}
|
||||
</div>
|
||||
<ErrorMessage class="text-p-sm" :message="errors.teams" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<FormLabel :label="__('Response')" required />
|
||||
<DocumentationButton
|
||||
url="https://docs.frappe.io/helpdesk/saved-replies"
|
||||
/>
|
||||
</div>
|
||||
<PreviewDialog v-model="previewDialog" />
|
||||
<TextEditor
|
||||
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded-b border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors -mt-0.5"
|
||||
ref="content"
|
||||
:bubble-menu="false"
|
||||
:content="savedReplyData.message"
|
||||
@change="
|
||||
(val) => {
|
||||
savedReplyData.message = val;
|
||||
validateData('message');
|
||||
}
|
||||
"
|
||||
:fixed-menu="menuButtons"
|
||||
:extensions="[FieldAutocomplete]"
|
||||
:placeholder="
|
||||
__(
|
||||
'Hello {{ contact }}, \n\nWe are sorry for the inconvenience, we will get back to you soon. \n\nRegards, \n{{ full_name }}'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ErrorMessage class="text-p-sm" :message="errors.message" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
:onCancel="() => (showConfirmDialog.show = false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
ErrorMessage,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
LoadingIndicator,
|
||||
MultiSelect,
|
||||
Select,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { computed, inject, onUnmounted, ref, watch } from "vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { __ } from "@/translation";
|
||||
import PreviewDialog from "./components/PreviewDialog.vue";
|
||||
import { menuButtons } from "./savedReplies";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import DocumentationButton from "@/components/DocumentationButton.vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { FieldAutocomplete } from "../../../tiptap-extensions";
|
||||
import SettingsLayoutBase from "../../layouts/SettingsLayoutBase.vue";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
import UsersIcon from "~icons/lucide/users";
|
||||
import GlobeIcon from "~icons/lucide/globe";
|
||||
import { SavedReply, SavedReplyListResourceSymbol, Team } from "../../../types";
|
||||
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const savedRepliesActiveScreen = inject<any>("savedRepliesActiveScreen");
|
||||
const savedRepliesListResource = inject(SavedReplyListResourceSymbol);
|
||||
|
||||
const previewDialog = ref({
|
||||
show: false,
|
||||
ticketId: "",
|
||||
savedReply: "",
|
||||
preview: null,
|
||||
});
|
||||
const content = ref();
|
||||
|
||||
const { teamRestrictionApplied, disableGlobalScopeForSavedReplies } =
|
||||
storeToRefs(useConfigStore());
|
||||
const { userTeams, isAdmin } = storeToRefs(useAuthStore());
|
||||
|
||||
const savedReplyData = ref({
|
||||
name: "",
|
||||
title: "",
|
||||
scope: "Personal",
|
||||
message: "",
|
||||
teams: [],
|
||||
});
|
||||
const initialData = ref("");
|
||||
const errors = ref({
|
||||
title: "",
|
||||
message: "",
|
||||
teams: "",
|
||||
});
|
||||
|
||||
const scopeDropdownOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __("Personal"),
|
||||
value: "Personal",
|
||||
icon: UserIcon,
|
||||
},
|
||||
{
|
||||
label: __("Team"),
|
||||
value: "Team",
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
label: __("Global"),
|
||||
value: "Global",
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
options.pop();
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const getSavedReplyData = createResource({
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
doctype: "HD Saved Reply",
|
||||
name: savedRepliesActiveScreen.value.data?.name,
|
||||
},
|
||||
auto: false,
|
||||
onSuccess: (data: SavedReply) => {
|
||||
savedReplyData.value = {
|
||||
name: data.name,
|
||||
title: data.title,
|
||||
scope: data.scope,
|
||||
message: data.message,
|
||||
teams: data.teams?.map((team) => team.team) || [],
|
||||
};
|
||||
initialData.value = JSON.stringify(savedReplyData.value);
|
||||
},
|
||||
});
|
||||
|
||||
const getTeamsListResource = createListResource({
|
||||
doctype: "HD Team",
|
||||
auto: true,
|
||||
fields: ["name"],
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
transform: (data: Array<Team>) => {
|
||||
return data.map((item) => ({
|
||||
value: item.name,
|
||||
label: item.name,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const teamsList = computed(() => {
|
||||
if (!isAdmin.value && teamRestrictionApplied.value) {
|
||||
return (
|
||||
userTeams.value?.map((team: string) => ({
|
||||
value: team,
|
||||
label: team,
|
||||
})) || []
|
||||
);
|
||||
}
|
||||
return getTeamsListResource.data || [];
|
||||
});
|
||||
|
||||
const onShowPreview = () => {
|
||||
previewDialog.value.show = true;
|
||||
previewDialog.value.savedReply = savedReplyData.value.name;
|
||||
};
|
||||
|
||||
if (savedRepliesActiveScreen.value.data?.name) {
|
||||
getSavedReplyData.submit();
|
||||
} else {
|
||||
initialData.value = JSON.stringify(savedReplyData.value);
|
||||
}
|
||||
|
||||
const isDirty = ref(false);
|
||||
|
||||
const goBack = () => {
|
||||
const confirmDialogInfo = {
|
||||
show: true,
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to go back? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: goBack,
|
||||
};
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
// Workaround fix for settings modal not closing after going back
|
||||
setTimeout(() => {
|
||||
savedRepliesActiveScreen.value = {
|
||||
screen: "list",
|
||||
data: null,
|
||||
};
|
||||
}, 250);
|
||||
showConfirmDialog.value.show = false;
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
validateData();
|
||||
|
||||
if (Object.values(errors.value).some((e) => e)) {
|
||||
toast.error(__("Please fill all the required fields"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedRepliesActiveScreen.value.data?.name) {
|
||||
updateSavedReply();
|
||||
} else {
|
||||
createSavedReply();
|
||||
}
|
||||
};
|
||||
|
||||
const createSavedReply = () => {
|
||||
savedRepliesListResource?.insert.submit(
|
||||
{
|
||||
title: savedReplyData.value.title,
|
||||
message: savedReplyData.value.message,
|
||||
scope: savedReplyData.value.scope,
|
||||
teams: savedReplyData.value.teams.map((team) => ({
|
||||
team: team,
|
||||
})),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(__("Saved reply saved"));
|
||||
savedReplyData.value = {
|
||||
...savedReplyData.value,
|
||||
name: data.name,
|
||||
};
|
||||
initialData.value = JSON.stringify(savedReplyData.value);
|
||||
savedRepliesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: { name: data.name },
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const renameSavedReplyResource = createResource({
|
||||
url: "frappe.client.rename_doc",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Saved Reply",
|
||||
old_name: savedRepliesActiveScreen.value.data.name,
|
||||
new_name: savedReplyData.value.title,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const updateSavedReply = async () => {
|
||||
await savedRepliesListResource?.setValue.submit({
|
||||
name: savedReplyData.value.name,
|
||||
title: savedReplyData.value.title,
|
||||
subject: savedReplyData.value.title,
|
||||
message: savedReplyData.value.message,
|
||||
scope: savedReplyData.value.scope,
|
||||
teams: savedReplyData.value.teams.map((team) => ({
|
||||
team: team,
|
||||
})),
|
||||
});
|
||||
|
||||
if (savedReplyData.value.name !== savedReplyData.value.title) {
|
||||
await renameSavedReplyResource.submit().catch(async (err: any) => {
|
||||
const error =
|
||||
err?.messages?.[0] ||
|
||||
__("Some error occurred while renaming saved reply");
|
||||
toast.error(error);
|
||||
// Reset saved reply to previous state
|
||||
await getSavedReplyData.reload();
|
||||
});
|
||||
|
||||
await getSavedReplyData.submit({
|
||||
doctype: "HD Saved Reply",
|
||||
name: savedReplyData.value.title,
|
||||
});
|
||||
|
||||
savedRepliesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: { name: savedReplyData.value.title },
|
||||
};
|
||||
} else {
|
||||
await getSavedReplyData.reload();
|
||||
}
|
||||
|
||||
savedRepliesListResource?.reload();
|
||||
isDirty.value = false;
|
||||
toast.success(__("Saved reply updated"));
|
||||
};
|
||||
|
||||
const getScopeIcon = (scope: string) => {
|
||||
return scopeDropdownOptions.value.find((x) => x.value === scope)?.icon;
|
||||
};
|
||||
|
||||
const validateData = (key?: string) => {
|
||||
const validateField = (key: string) => {
|
||||
switch (key) {
|
||||
case "title":
|
||||
if (!savedReplyData.value.title) {
|
||||
errors.value.title = __("Title is required");
|
||||
} else {
|
||||
errors.value.title = "";
|
||||
}
|
||||
break;
|
||||
|
||||
case "message":
|
||||
if (!content.value?.editor?.state?.doc?.textContent?.trim()?.length) {
|
||||
errors.value.message = __("Response is required");
|
||||
} else {
|
||||
errors.value.message = "";
|
||||
}
|
||||
break;
|
||||
|
||||
case "teams":
|
||||
if (
|
||||
savedReplyData.value.scope === "Team" &&
|
||||
!savedReplyData.value.teams.length
|
||||
) {
|
||||
errors.value.teams = __("At least one team is required");
|
||||
} else {
|
||||
errors.value.teams = "";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
if (key) {
|
||||
validateField(key);
|
||||
} else {
|
||||
(Object.keys(errors.value) as string[]).forEach(validateField);
|
||||
}
|
||||
|
||||
return errors.value;
|
||||
};
|
||||
|
||||
watch(
|
||||
savedReplyData,
|
||||
(newVal) => {
|
||||
if (!initialData.value) return;
|
||||
isDirty.value = JSON.stringify(newVal) != initialData.value;
|
||||
if (isDirty.value) {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
} else {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
disableSettingModalOutsideClick.value = false;
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="props.items.length > 0"
|
||||
class="max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-white p-1 text-base shadow-lg pointer-events-auto hide-scrollbar"
|
||||
>
|
||||
<button
|
||||
v-for="(item, index) in props.items"
|
||||
:key="item.value"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) itemRefs[index] = el as HTMLButtonElement
|
||||
}
|
||||
"
|
||||
:class="[
|
||||
'flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9',
|
||||
index === selectedIndex ? 'bg-surface-gray-2' : '',
|
||||
]"
|
||||
@click.stop.prevent="selectItem(index)"
|
||||
@mouseover="selectedIndex = index"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUpdate, ref, watch, type PropType } from "vue";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { FieldItem } from "../../../../tiptap-extensions";
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array as PropType<any[]>,
|
||||
required: true,
|
||||
},
|
||||
editor: {
|
||||
type: Object as PropType<Editor>,
|
||||
required: true,
|
||||
},
|
||||
range: {
|
||||
type: Object as PropType<Range>,
|
||||
required: true,
|
||||
},
|
||||
command: {
|
||||
type: Function as PropType<(item: FieldItem) => void>,
|
||||
required: true,
|
||||
},
|
||||
query: String,
|
||||
});
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index];
|
||||
if (item) {
|
||||
props.command(item);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
|
||||
if (!props.items.length) return false;
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const selectedIndex = ref(0);
|
||||
const itemRefs = ref<HTMLButtonElement[]>([]);
|
||||
|
||||
onBeforeUpdate(() => {
|
||||
itemRefs.value = [];
|
||||
});
|
||||
|
||||
const scrollIntoView = () => {
|
||||
nextTick(() => {
|
||||
const selectedElement = itemRefs.value[selectedIndex.value];
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value + props.items.length - 1) % props.items.length;
|
||||
scrollIntoView();
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % props.items.length;
|
||||
scrollIntoView();
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex.value);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
onKeyDown,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="dialogModel.show"
|
||||
:options="{ title: __('Preview'), size: '2xl' }"
|
||||
@after-leave="
|
||||
() => {
|
||||
dialogModel.ticketId = '';
|
||||
dialogModel.preview = null;
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<Link
|
||||
:value="dialogModel.ticketId"
|
||||
:label="__('Select ticket to preview')"
|
||||
doctype="HD Ticket"
|
||||
class="form-control flex-1"
|
||||
:placeholder="__('Search ticket')"
|
||||
:show-description="true"
|
||||
@change="getResponsePreview"
|
||||
/>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<FormLabel :label="__('Preview')" />
|
||||
<div class="relative">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm max-w-full overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||
:bubble-menu="menuButtons"
|
||||
:content="dialogModel.preview"
|
||||
:editable="false"
|
||||
class="pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
v-if="getResponsePreviewResource.loading"
|
||||
class="absolute top-0 right-0 flex items-center justify-center size-full rounded-md bg-black/20"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
FormLabel,
|
||||
TextEditor,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { LoadingIndicator } from "frappe-ui";
|
||||
import { menuButtons } from "../savedReplies";
|
||||
import { Link } from "@/components";
|
||||
import { watch } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const dialogModel = defineModel<{
|
||||
show: boolean;
|
||||
ticketId: string;
|
||||
savedReply: string;
|
||||
preview: string;
|
||||
}>();
|
||||
|
||||
const getResponsePreviewResource = createResource({
|
||||
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
|
||||
onSuccess: (data) => {
|
||||
dialogModel.value.preview = data;
|
||||
},
|
||||
});
|
||||
|
||||
const getResponsePreview = (ticketId: string) => {
|
||||
if (!ticketId) return;
|
||||
dialogModel.value.ticketId = ticketId;
|
||||
dialogModel.value.preview = null;
|
||||
getResponsePreviewResource.submit({
|
||||
ticket_id: ticketId,
|
||||
saved_reply_id: dialogModel.value.savedReply,
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => dialogModel.value.show,
|
||||
(newShowValue) => {
|
||||
if (newShowValue) {
|
||||
createListResource({
|
||||
doctype: "HD Ticket",
|
||||
fields: ["name"],
|
||||
start: 0,
|
||||
pageLength: 1,
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
if (data.length === 0) {
|
||||
toast.error(__("No tickets found to preview."));
|
||||
return;
|
||||
}
|
||||
dialogModel.value.ticketId = data[0].name;
|
||||
getResponsePreview(data[0].name);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
99
desk/src/components/Settings/SavedReplies/savedReplies.ts
Normal file
99
desk/src/components/Settings/SavedReplies/savedReplies.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { __ } from "@/translation";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const menuButtons = [
|
||||
[
|
||||
"Heading 1",
|
||||
"Heading 2",
|
||||
"Heading 3",
|
||||
"Heading 4",
|
||||
"Heading 5",
|
||||
"Heading 6",
|
||||
],
|
||||
"Paragraph",
|
||||
"Separator",
|
||||
"Bold",
|
||||
"Italic",
|
||||
"Separator",
|
||||
"Bullet List",
|
||||
"Numbered List",
|
||||
"Task List",
|
||||
"Separator",
|
||||
"Align Left",
|
||||
"Align Center",
|
||||
"Align Right",
|
||||
"FontColor",
|
||||
"Separator",
|
||||
"Image",
|
||||
"Link",
|
||||
"Blockquote",
|
||||
"Code",
|
||||
"Horizontal Rule",
|
||||
[
|
||||
"InsertTable",
|
||||
"AddColumnBefore",
|
||||
"AddColumnAfter",
|
||||
"DeleteColumn",
|
||||
"AddRowBefore",
|
||||
"AddRowAfter",
|
||||
"DeleteRow",
|
||||
"MergeCells",
|
||||
"SplitCell",
|
||||
"ToggleHeaderColumn",
|
||||
"ToggleHeaderRow",
|
||||
"ToggleHeaderCell",
|
||||
"DeleteTable",
|
||||
],
|
||||
"Separator",
|
||||
"Undo",
|
||||
"Redo",
|
||||
];
|
||||
|
||||
export const userFields = [
|
||||
{
|
||||
label: __("Email"),
|
||||
value: "email",
|
||||
},
|
||||
{
|
||||
label: __("First Name"),
|
||||
value: "first_name",
|
||||
},
|
||||
{
|
||||
label: __("Middle Name"),
|
||||
value: "middle_name",
|
||||
},
|
||||
{
|
||||
label: __("Last Name"),
|
||||
value: "last_name",
|
||||
},
|
||||
{
|
||||
label: __("Full Name"),
|
||||
value: "full_name",
|
||||
},
|
||||
{
|
||||
label: __("Username"),
|
||||
value: "username",
|
||||
},
|
||||
{
|
||||
label: __("User Image"),
|
||||
value: "user_image",
|
||||
},
|
||||
{
|
||||
label: __("Phone"),
|
||||
value: "phone",
|
||||
},
|
||||
{
|
||||
label: __("Location"),
|
||||
value: "location",
|
||||
},
|
||||
{
|
||||
label: __("Bio"),
|
||||
value: "bio",
|
||||
},
|
||||
{
|
||||
label: __("Mobile No"),
|
||||
value: "mobile_no",
|
||||
},
|
||||
];
|
||||
|
||||
export const activeFilter = ref("Personal");
|
||||
@@ -5,15 +5,23 @@
|
||||
:disableOutsideClickToClose="disableSettingModalOutsideClick"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex" :style="{ height: 'calc(100vh - 8rem)' }">
|
||||
<div class="flex w-52 shrink-0 flex-col bg-gray-50 p-2">
|
||||
<h1 class="px-2 pt-2 text-lg font-semibold mb-3">Settings</h1>
|
||||
<div class="flex z-50" :style="{ height: 'calc(100vh - 8rem)' }">
|
||||
<div
|
||||
class="flex w-52 shrink-0 flex-col bg-gray-50 p-2 overflow-y-auto hide-scrollbar"
|
||||
>
|
||||
<h1
|
||||
class="h-7.5 px-2 py-[7px] my-[3px] flex cursor-pointer gap-1.5 text-base text-ink-gray-5 transition-all duration-300 ease-in-out truncate"
|
||||
>
|
||||
{{ __("My Settings") }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs">
|
||||
<div
|
||||
v-if="!tab.hideLabel"
|
||||
class="mb-2 mt-3 flex gap-1.5 px-1 text-base font-medium text-ink-gray-5"
|
||||
>
|
||||
<span>{{ __(tab.label) }}</span>
|
||||
<Tooltip :text="__(tab.label)" placement="right">
|
||||
<span class="truncate">{{ __(tab.label) }}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<nav class="space-y-1">
|
||||
<button
|
||||
@@ -27,15 +35,20 @@
|
||||
]"
|
||||
@click="() => onTabChange(item)"
|
||||
>
|
||||
<component :is="item.icon" class="h-4 w-4 text-gray-700" />
|
||||
<span class="text-base text-gray-800">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 text-gray-700 shrink-0"
|
||||
/>
|
||||
<Tooltip :text="__(item.label)" placement="right">
|
||||
<span class="text-p-sm text-gray-800 truncate">
|
||||
{{ __(item.label) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col bg-surface-modal">
|
||||
<div class="flex flex-1 flex-col bg-surface-modal max-w-[816px]">
|
||||
<component
|
||||
:is="activeTab.component"
|
||||
v-if="activeTab"
|
||||
@@ -69,7 +82,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog } from "frappe-ui";
|
||||
import { Dialog, Tooltip } from "frappe-ui";
|
||||
import { ModelRef, ref, watch } from "vue";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<div class="pb-8">
|
||||
<SlaPolicies v-if="slaActiveScreen.screen == 'list'" />
|
||||
<SlaPolicyView v-else-if="slaActiveScreen.screen == 'view'" />
|
||||
</div>
|
||||
<SlaPolicies v-if="slaActiveScreen.screen == 'list'" />
|
||||
<SlaPolicyView v-else-if="slaActiveScreen.screen == 'view'" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -10,16 +8,25 @@ import { slaActiveScreen } from "@/stores/sla";
|
||||
import SlaPolicies from "./SlaPolicies.vue";
|
||||
import SlaPolicyView from "./SlaPolicyView.vue";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { provide } from "vue";
|
||||
import { onUnmounted, provide, ref } from "vue";
|
||||
import { SlaPolicyListResourceSymbol } from "@/types";
|
||||
|
||||
const slaPolicyListData = createListResource({
|
||||
doctype: "HD Service Level Agreement",
|
||||
fields: ["name", "default_sla", "enabled", "description"],
|
||||
orderBy: "creation desc",
|
||||
cache: ["SLAPolicyList"],
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 999,
|
||||
auto: true,
|
||||
});
|
||||
const slaSearchQuery = ref("");
|
||||
|
||||
provide("slaPolicyList", slaPolicyListData);
|
||||
provide("slaSearchQuery", slaSearchQuery);
|
||||
provide(SlaPolicyListResourceSymbol, slaPolicyListData);
|
||||
|
||||
onUnmounted(() => {
|
||||
slaSearchQuery.value = "";
|
||||
slaPolicyListData.filters = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-lg font-semibold text-ink-gray-8">
|
||||
Work schedule and holidays
|
||||
{{ __("Work schedule and holidays") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6 max-w-lg">
|
||||
Set working days, hours, and holidays by selecting a predefined schedule
|
||||
or creating a new one.
|
||||
{{
|
||||
__(
|
||||
"Set working days, hours, and holidays by selecting a predefined schedule or creating a new one"
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<NestedPopover>
|
||||
@@ -19,13 +22,13 @@
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="my-2 p-1 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
class="my-2 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div>
|
||||
<div class="max-h-52 overflow-y-auto p-1">
|
||||
<div
|
||||
v-for="holiday in holidayListData.data"
|
||||
:key="holiday.name"
|
||||
class="flex items-center justify-between gap-4 rounded px-2 py-1.5 text-base text-ink-gray-8 cursor-pointer hover:bg-surface-gray-2"
|
||||
class="flex items-center justify-between gap-4 rounded px-2 py-1.5 text-base text-ink-gray-8 cursor-pointer hover:bg-surface-gray-3"
|
||||
@click="slaData.holiday_list = holiday.name"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
@@ -44,17 +47,17 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1.5 flex flex-col gap-1 border-t border-outline-gray-modals pt-1.5"
|
||||
>
|
||||
<Button
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
label="Create new business holiday"
|
||||
@click="createNewHolidayList()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-1.5 flex flex-col gap-1 border-t border-outline-gray-modals pt-1.5 p-1"
|
||||
>
|
||||
<Button
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
:label="__('Create new business holiday')"
|
||||
@click="createNewHolidayList()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,44 +1,74 @@
|
||||
<template>
|
||||
<div class="px-10 py-8">
|
||||
<SettingsLayoutHeader>
|
||||
<template #title>
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
Service Level Agreements (SLAs)
|
||||
</h1>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
SLAs align your team and customers with defined timelines for a
|
||||
reliable experience.
|
||||
<a
|
||||
href="https://docs.frappe.io/helpdesk/service-level-agreement"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>Learn more about SLA
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="New"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Service Level Agreements (SLAs)") }}
|
||||
</h1>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"SLAs align your team and customers with defined timelines for a reliable experience."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
href="https://docs.frappe.io/helpdesk/service-level-agreement"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>{{ __("Learn more about SLA") }}
|
||||
</a>
|
||||
</p>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="slaPolicyList.data?.length > 9 || slaSearchQuery.length"
|
||||
#header-bottom
|
||||
>
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="slaSearchQuery"
|
||||
@input="slaSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
</div>
|
||||
<div class="px-10 pb-8 overflow-y-auto">
|
||||
<SlaPolicyList />
|
||||
</div>
|
||||
<Button
|
||||
v-if="slaSearchQuery"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="slaSearchQuery = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<SlaPolicyList />
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { resetSlaData, slaActiveScreen } from "@/stores/sla";
|
||||
import { Button } from "frappe-ui";
|
||||
import SlaPolicyList from "./SlaPolicyList.vue";
|
||||
import SettingsLayoutHeader from "../SettingsLayoutHeader.vue";
|
||||
import { inject, Ref, watch } from "vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { SlaPolicyListResourceSymbol } from "@/types";
|
||||
|
||||
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
|
||||
const slaSearchQuery = inject<Ref>("slaSearchQuery");
|
||||
|
||||
const goToNew = () => {
|
||||
resetSlaData();
|
||||
@@ -48,4 +78,15 @@ const goToNew = () => {
|
||||
fetchData: true,
|
||||
};
|
||||
};
|
||||
|
||||
watch(slaSearchQuery, (newValue) => {
|
||||
slaPolicyList.filters = {
|
||||
name: ["like", `%${newValue}%`],
|
||||
};
|
||||
if (!newValue) {
|
||||
slaPolicyList.start = 0;
|
||||
slaPolicyList.pageLength = 10;
|
||||
}
|
||||
slaPolicyList.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,33 +5,65 @@
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="grow">
|
||||
<div
|
||||
v-if="slaPolicyList.list.data?.length === 0"
|
||||
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
|
||||
v-if="!slaPolicyList.list.loading && !slaPolicyList.list.data?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div class="text-sm text-ink-gray-7">No items in the list</div>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<ShieldCheck class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No SLA found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="goToNew()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-else class="-ml-2">
|
||||
<div
|
||||
class="grid grid-cols-6 items-center gap-3 text-sm text-gray-600 ml-2"
|
||||
>
|
||||
<div class="col-span-5">Policy Name</div>
|
||||
<div class="col-span-1">Enabled</div>
|
||||
<div class="col-span-5">
|
||||
{{ __("Policy Name") }}
|
||||
</div>
|
||||
<div class="col-span-1">{{ __("Enabled") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2 mx-2" />
|
||||
<div v-for="sla in slaPolicyList.list.data" :key="sla.name">
|
||||
<div v-for="(sla, index) in slaPolicyList.list.data" :key="sla.name">
|
||||
<SlaPolicyListItem :data="sla" />
|
||||
<hr class="mx-2" />
|
||||
<hr v-if="index !== slaPolicyList.list.data.length - 1" class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoadingIndicator } from "frappe-ui";
|
||||
import { Button, LoadingIndicator } from "frappe-ui";
|
||||
import SlaPolicyListItem from "./SlaPolicyListItem.vue";
|
||||
import { inject } from "vue";
|
||||
import { resetSlaData, slaActiveScreen } from "@/stores/sla";
|
||||
import ShieldCheck from "~icons/lucide/shield-check";
|
||||
import { SlaPolicyListResourceSymbol } from "@/types";
|
||||
|
||||
const slaPolicyList = inject<any>("slaPolicyList");
|
||||
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
|
||||
|
||||
const goToNew = () => {
|
||||
resetSlaData();
|
||||
slaActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: null,
|
||||
fetchData: true,
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
<div
|
||||
@click="slaActiveScreen = { screen: 'view', data: data, fetchData: true }"
|
||||
class="w-full py-3 pl-2 col-span-5"
|
||||
class="w-full pl-2 col-span-5 flex flex-col justify-center h-14"
|
||||
>
|
||||
<div
|
||||
class="text-base text-ink-gray-7 font-medium flex items-center gap-2"
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="data.description && data.description.length > 0"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
|
||||
>
|
||||
{{ data.description }}
|
||||
</div>
|
||||
@@ -39,15 +39,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
:options="{ title: `Duplicate SLA Policy` }"
|
||||
:options="{ title: __('Duplicate SLA Policy') }"
|
||||
v-model="duplicateDialog.show"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
label="New SLA Policy Name"
|
||||
:label="__('New SLA Policy Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.name"
|
||||
v-model="duplicateDialog.newName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,10 +55,10 @@
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
label="Close"
|
||||
:label="__('Close')"
|
||||
@click="duplicateDialog.show = false"
|
||||
/>
|
||||
<Button variant="solid" label="Duplicate" @click="duplicate()" />
|
||||
<Button variant="solid" :label="__('Duplicate')" @click="duplicate()" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@@ -71,15 +71,20 @@ import {
|
||||
toast,
|
||||
Dialog,
|
||||
Badge,
|
||||
Dropdown,
|
||||
} from "frappe-ui";
|
||||
import { ref, inject } from "vue";
|
||||
import { slaActiveScreen } from "@/stores/sla";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import { __ } from "@/translation";
|
||||
import { SlaPolicyListResourceSymbol } from "@/types";
|
||||
import { HDServiceLevelAgreement } from "@/types/doctypes";
|
||||
|
||||
const slaPolicyList = inject<any>("slaPolicyList");
|
||||
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
|
||||
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
@@ -94,11 +99,12 @@ const isConfirmingDelete = ref(false);
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: "Duplicate",
|
||||
label: __("Duplicate"),
|
||||
onClick: () => {
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
name: props.data.name + " (Copy)",
|
||||
newName: props.data.name + " (Copy)",
|
||||
name: props.data.name,
|
||||
};
|
||||
},
|
||||
icon: "copy",
|
||||
@@ -111,25 +117,40 @@ const dropdownOptions = [
|
||||
|
||||
const duplicate = () => {
|
||||
createResource({
|
||||
url: "helpdesk.api.sla.duplicate_sla",
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
docname: props.data.name,
|
||||
new_name: duplicateDialog.value.name,
|
||||
doctype: "HD Service Level Agreement",
|
||||
name: duplicateDialog.value.name,
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
slaPolicyList.reload();
|
||||
toast.success("SLA policy duplicated");
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
slaActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
fetchData: true,
|
||||
};
|
||||
}, 250);
|
||||
onSuccess: (data: HDServiceLevelAgreement) => {
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
...data,
|
||||
default_sla: false,
|
||||
service_level: duplicateDialog.value.newName,
|
||||
name: duplicateDialog.value.newName,
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(newSlaPolicyData: HDServiceLevelAgreement) {
|
||||
slaPolicyList?.reload();
|
||||
toast.success(__("SLA policy duplicated"));
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
slaActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: newSlaPolicyData,
|
||||
fetchData: true,
|
||||
};
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -141,26 +162,26 @@ const deleteSla = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
slaPolicyList.delete.submit(props.data.name, {
|
||||
slaPolicyList?.delete.submit(props.data.name, {
|
||||
onSuccess: () => {
|
||||
toast.success("SLA policy deleted");
|
||||
toast.success(__("SLA policy deleted"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onToggle = () => {
|
||||
if (props.data.default_sla) {
|
||||
toast.error("SLA set as default cannot be disabled");
|
||||
toast.error(__("SLA set as default cannot be disabled"));
|
||||
return;
|
||||
}
|
||||
slaPolicyList.setValue.submit(
|
||||
slaPolicyList?.setValue.submit(
|
||||
{
|
||||
name: props.data.name,
|
||||
enabled: !props.data.enabled,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("SLA policy status updated");
|
||||
toast.success(__("SLA policy status updated"));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
<template>
|
||||
<div v-if="slaData.loading" class="flex items-center h-full justify-center">
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!slaData.loading"
|
||||
class="sticky top-0 z-10 bg-white px-10 pt-8 pb-6"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="slaData.service_level || 'New SLA Policy'"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-xl hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
label="Unsaved changes"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="slaData.service_level || __('New SLA Policy')"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 cursor-pointer"
|
||||
@click="toggleEnabled"
|
||||
>
|
||||
<Switch size="sm" v-model="slaData.enabled" />
|
||||
<span class="text-sm text-ink-gray-7 font-medium">Enabled</span>
|
||||
<span class="text-sm text-ink-gray-7 font-medium">
|
||||
{{ __("Enabled") }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
label="Save"
|
||||
:label="__('Save')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="saveSla()"
|
||||
@@ -43,153 +39,170 @@
|
||||
:loading="slaData.loading || slaPolicyList.setValue.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!slaData.loading" class="overflow-y-auto px-10 pb-10">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Name"
|
||||
label="Name"
|
||||
v-model="slaData.service_level"
|
||||
required
|
||||
@change="validateSlaData('service_level')"
|
||||
:disabled="Boolean(slaActiveScreen.data)"
|
||||
/>
|
||||
<ErrorMessage :message="slaDataErrors.service_level" class="mt-2" />
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="slaData.loading"
|
||||
class="flex items-center h-full justify-center"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Description"
|
||||
label="Description"
|
||||
v-model="slaData.description"
|
||||
/>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8"
|
||||
>Assignment conditions</span
|
||||
>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
Choose which tickets are affected by this policy.
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Checkbox
|
||||
label="Set as default SLA"
|
||||
:model-value="slaData.default_sla"
|
||||
@update:model-value="toggleDefaultSla"
|
||||
class="text-ink-gray-6 text-base font-medium"
|
||||
/>
|
||||
<div v-if="isOldSla && slaActiveScreen.data && !slaData.default_sla">
|
||||
<Popover trigger="hover" hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div class="text-sm text-ink-gray-6 flex gap-1 cursor-default">
|
||||
Old Conditions
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ slaData.condition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5" v-if="!slaData.default_sla">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="!useNewUI"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this SLA were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to recreate
|
||||
the conditions here if you want to manage and add new conditions
|
||||
from this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
<div v-if="!slaData.loading">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
:placeholder="__('Name')"
|
||||
:label="__('Name')"
|
||||
v-model="slaData.service_level"
|
||||
required
|
||||
@change="validateSlaData('service_level')"
|
||||
:disabled="Boolean(slaActiveScreen.data)"
|
||||
maxlength="50"
|
||||
/>
|
||||
<ErrorMessage :message="slaDataErrors.service_level" class="mt-2" />
|
||||
</div>
|
||||
<SlaAssignmentConditions
|
||||
:conditions="slaData.condition_json"
|
||||
v-if="useNewUI"
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Description')"
|
||||
:label="__('Description')"
|
||||
v-model="slaData.description"
|
||||
maxlength="140"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">Valid from</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
Choose how long this SLA policy will be active.
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel label="From date" for="from_date" />
|
||||
<DatePicker
|
||||
v-model="slaData.start_date"
|
||||
variant="subtle"
|
||||
placeholder="11/01/2025"
|
||||
class="w-full"
|
||||
id="from_date"
|
||||
@change="validateSlaData('start_date')"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="slaDataErrors.start_date" />
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment conditions")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{ __("Choose which tickets are affected by this policy.") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Checkbox
|
||||
:label="__('Set as default SLA')"
|
||||
:model-value="slaData.default_sla"
|
||||
@update:model-value="toggleDefaultSla"
|
||||
class="text-ink-gray-6 text-base font-medium"
|
||||
/>
|
||||
<div
|
||||
v-if="isOldSla && slaActiveScreen.data && !slaData.default_sla"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default"
|
||||
>
|
||||
{{ __("Old Conditions") }}
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ slaData.condition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5" v-if="!slaData.default_sla">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="!useNewUI"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this SLA were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to
|
||||
recreate the conditions here if you want to manage and add new
|
||||
conditions from this UI.
|
||||
</span>
|
||||
<Button
|
||||
:label="__('I understand, add conditions')"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUI = true"
|
||||
/>
|
||||
</div>
|
||||
<SlaAssignmentConditions
|
||||
:conditions="slaData.condition_json"
|
||||
v-if="useNewUI"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel label="To date" for="to_date" />
|
||||
<DatePicker
|
||||
v-model="slaData.end_date"
|
||||
variant="subtle"
|
||||
placeholder="25/12/2025"
|
||||
class="w-full"
|
||||
id="to_date"
|
||||
@change="validateSlaData('end_date')"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="slaDataErrors.end_date" />
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Valid from") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{ __("Choose how long this SLA policy will be active.") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3.5 flex gap-5 flex-col md:flex-row">
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel :label="__('From date')" for="from_date" />
|
||||
<DatePicker
|
||||
v-model="slaData.start_date"
|
||||
variant="subtle"
|
||||
placeholder="11/01/2025"
|
||||
class="w-full"
|
||||
id="from_date"
|
||||
@change="validateSlaData('start_date')"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="slaDataErrors.start_date" />
|
||||
</div>
|
||||
<div class="w-full space-y-1.5">
|
||||
<FormLabel :label="__('To date')" for="to_date" />
|
||||
<DatePicker
|
||||
v-model="slaData.end_date"
|
||||
variant="subtle"
|
||||
placeholder="25/12/2025"
|
||||
class="w-full"
|
||||
id="to_date"
|
||||
@change="validateSlaData('end_date')"
|
||||
:formatter="(date) => getFormattedDate(date)"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideCalendar class="size-4" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
<ErrorMessage :message="slaDataErrors.end_date" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8"
|
||||
>Response and resolution</span
|
||||
>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
Add time targets around support milestones like first reply and
|
||||
resolution times
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<!-- <div class="flex gap-6">
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Response and resolution") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Add time targets around support milestones like first reply and resolution times"
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<!-- <div class="flex gap-6">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
@click="onApplySlaForChange(false)"
|
||||
@@ -217,30 +230,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="mt-5">
|
||||
<SlaPriorityList />
|
||||
<div class="mt-5">
|
||||
<SlaPriorityList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Status details") }}
|
||||
</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Set the default status assigned when a ticket is created, and the status to apply when a ticket is reopened. If not specified, the default status from HD Settings will be used."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<SlaStatusList />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<SlaHolidays />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8"
|
||||
>Status details</span
|
||||
>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
Set the default status assigned when a ticket is created, and the
|
||||
status to apply when a ticket is reopened. If not specified, the
|
||||
default status from HD Settings will be used.
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<SlaStatusList />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<SlaHolidays />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
@@ -280,6 +297,10 @@ import SlaPriorityList from "./SlaPriorityList.vue";
|
||||
import SlaStatusList from "./SlaStatusList.vue";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { SlaPolicyListResourceSymbol } from "@/types";
|
||||
import { HDServiceLevelAgreement } from "@/types/doctypes";
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
|
||||
@@ -295,16 +316,25 @@ const initialData = ref(null);
|
||||
const useNewUI = ref(true);
|
||||
const isOldSla = ref(false);
|
||||
|
||||
const slaPolicyList = inject<any>("slaPolicyList");
|
||||
const slaPolicyList = inject(SlaPolicyListResourceSymbol);
|
||||
const deskUrl = `${window.location.origin}/app/hd-service-level-agreement/${slaActiveScreen.value.data?.name}`;
|
||||
|
||||
const getSlaData = createResource({
|
||||
url: "helpdesk.api.sla.get_sla",
|
||||
url: "frappe.client.get",
|
||||
params: {
|
||||
docname: slaActiveScreen.value.data?.name,
|
||||
doctype: "HD Service Level Agreement",
|
||||
name: slaActiveScreen.value.data?.name,
|
||||
},
|
||||
onSuccess(data) {
|
||||
const condition_json = JSON.parse(data.condition_json || "[]");
|
||||
onSuccess(data: HDServiceLevelAgreement) {
|
||||
let condition_json;
|
||||
try {
|
||||
condition_json = JSON.parse(data.condition_json || "[]");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
"Assignment conditions are invalid or corrupt, recreate the conditions."
|
||||
);
|
||||
condition_json = [];
|
||||
}
|
||||
|
||||
const newData = {
|
||||
...data,
|
||||
@@ -336,8 +366,10 @@ if (slaActiveScreen.value.data && slaActiveScreen.value.fetchData) {
|
||||
const goBack = () => {
|
||||
const confirmDialogInfo = {
|
||||
show: true,
|
||||
title: "Unsaved changes",
|
||||
message: "Are you sure you want to go back? Unsaved changes will be lost.",
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to go back? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: goBack,
|
||||
};
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
@@ -364,7 +396,7 @@ const saveSla = () => {
|
||||
|
||||
if (Object.values(validationErrors).some((error) => error)) {
|
||||
toast.error(
|
||||
"Invalid fields, check if all are filled in and values are correct."
|
||||
__("Invalid fields, check if all are filled in and values are correct.")
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -373,9 +405,10 @@ const saveSla = () => {
|
||||
if (isOldSla.value && useNewUI.value) {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: "Confirm overwrite",
|
||||
message:
|
||||
"Your old conditions will be overwritten. Are you sure you want to save?",
|
||||
title: __("Confirm overwrite"),
|
||||
message: __(
|
||||
"Your old conditions will be overwritten. Are you sure you want to save?"
|
||||
),
|
||||
onConfirm: () => {
|
||||
updateSla();
|
||||
showConfirmDialog.value.show = false;
|
||||
@@ -396,7 +429,7 @@ const createSla = () => {
|
||||
const ticketReopenStatus = slaData.value.reopen_ticket_status
|
||||
? slaData.value.reopen_ticket_status?.value
|
||||
: null;
|
||||
slaPolicyList.insert.submit(
|
||||
slaPolicyList?.insert.submit(
|
||||
{
|
||||
...slaData.value,
|
||||
default_ticket_status: defaultTicketStatus,
|
||||
@@ -409,11 +442,12 @@ const createSla = () => {
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
toast.success("SLA policy created");
|
||||
toast.success(__("SLA policy created"));
|
||||
slaActiveScreen.value.data = data;
|
||||
slaActiveScreen.value.screen = "view";
|
||||
getSlaData.submit({
|
||||
docname: data.name,
|
||||
doctype: "HD Service Level Agreement",
|
||||
name: data.name,
|
||||
});
|
||||
updateOnboardingStep("setup_sla", true);
|
||||
},
|
||||
@@ -428,10 +462,10 @@ const updateSla = () => {
|
||||
const ticketReopenStatus = slaData.value.reopen_ticket_status
|
||||
? slaData.value.reopen_ticket_status?.value
|
||||
: null;
|
||||
slaPolicyList.setValue.submit(
|
||||
slaPolicyList?.setValue.submit(
|
||||
{
|
||||
...slaData.value,
|
||||
name: slaActiveScreen.value.data.name,
|
||||
name: slaActiveScreen.value.data?.name,
|
||||
default_ticket_status: defaultTicketStatus,
|
||||
ticket_reopen_status: ticketReopenStatus,
|
||||
condition: useNewUI.value
|
||||
@@ -447,7 +481,7 @@ const updateSla = () => {
|
||||
{
|
||||
onSuccess() {
|
||||
getSlaData.submit();
|
||||
toast.success("SLA policy updated");
|
||||
toast.success(__("SLA policy updated"));
|
||||
slaPolicyList.reload();
|
||||
},
|
||||
}
|
||||
@@ -456,7 +490,7 @@ const updateSla = () => {
|
||||
|
||||
const toggleEnabled = () => {
|
||||
if (slaData.value.default_sla) {
|
||||
toast.error("SLA set as default cannot be disabled");
|
||||
toast.error(__("SLA set as default cannot be disabled"));
|
||||
return;
|
||||
}
|
||||
slaData.value.enabled = !slaData.value.enabled;
|
||||
|
||||
@@ -37,12 +37,19 @@
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Select
|
||||
class="w-full bg-transparent cursor-pointer border-0 focus-visible:!ring-0 bg-none"
|
||||
:options="priorityOptions"
|
||||
<div v-else class="ml-2">
|
||||
<select
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
v-model="props.row[column.key]"
|
||||
/>
|
||||
>
|
||||
<option
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@@ -63,7 +70,7 @@
|
||||
import DurationPicker from "@/components/frappe-ui/DurationPicker.vue";
|
||||
import { slaData } from "@/stores/sla";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import { Button, Checkbox, Popover, Select } from "frappe-ui";
|
||||
import { Button, Checkbox, Dropdown, Popover } from "frappe-ui";
|
||||
import { inject, ref } from "vue";
|
||||
import EditResponseResolutionModal from "./Modals/EditResponseResolutionModal.vue";
|
||||
import { formatTimeHMS } from "./utils";
|
||||
|
||||
@@ -26,13 +26,15 @@ import { HDTicketStatus } from "@/types/doctypes";
|
||||
const { statuses } = useTicketStatusStore();
|
||||
|
||||
const openStatuses: ComputedRef<HDTicketStatus[]> = computed(() => {
|
||||
return statuses.data
|
||||
.filter((s: HDTicketStatus) => s.category === "Open")
|
||||
.map((s: HDTicketStatus) => {
|
||||
return {
|
||||
label: s.label_agent,
|
||||
value: s.label_agent,
|
||||
};
|
||||
});
|
||||
return (
|
||||
statuses?.data
|
||||
?.filter((s: HDTicketStatus) => s.category === "Open")
|
||||
?.map((s: HDTicketStatus) => {
|
||||
return {
|
||||
label: s.label_agent,
|
||||
value: s.label_agent,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -13,12 +13,19 @@
|
||||
<div v-if="column.key === 'start_time' || column.key === 'end_time'">
|
||||
{{ formatTime(props.row[column.key]) }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<Select
|
||||
class="bg-transparent cursor-pointer border-0 focus-visible:!ring-0 bg-none"
|
||||
:options="workDayOptions"
|
||||
<div v-else class="ml-2">
|
||||
<select
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-md p-0 pl-2 pr-5 bg-transparent -ml-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
v-model="props.row[column.key]"
|
||||
/>
|
||||
>
|
||||
<option
|
||||
v-for="option in workDayOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@@ -40,7 +47,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Button, Select } from "frappe-ui";
|
||||
import { Button, Dropdown } from "frappe-ui";
|
||||
import WorkDayModal from "./Modals/WorkDayModal.vue";
|
||||
import { ConfirmDelete, getGridTemplateColumnsForTable } from "@/utils";
|
||||
import { slaData } from "@/stores/sla";
|
||||
|
||||
178
desk/src/components/Settings/Teams/NewTeam.vue
Normal file
178
desk/src/components/Settings/Teams/NewTeam.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="teamData.name || __('New Team')"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
theme="orange"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('Save')"
|
||||
variant="solid"
|
||||
@click="saveTeam()"
|
||||
:disabled="!isDirty"
|
||||
:loading="teamsList.insert.loading"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<FormControl
|
||||
v-model="teamData.name"
|
||||
:label="__('Team Name')"
|
||||
:placeholder="__('Product Experts')"
|
||||
maxlength="50"
|
||||
required
|
||||
@change="validateData('name')"
|
||||
/>
|
||||
<ErrorMessage :message="errors.name" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Members')" required />
|
||||
<div class="flex">
|
||||
<AgentSelector
|
||||
v-model="teamData.agents"
|
||||
@change="validateData('agents')"
|
||||
/>
|
||||
</div>
|
||||
<ErrorMessage :message="errors.agents" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onMounted, ref } from "vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { Badge, ErrorMessage, FormControl, FormLabel, toast } from "frappe-ui";
|
||||
import { __ } from "@/translation";
|
||||
import AgentSelector from "./components/AgentSelector.vue";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { TeamListResourceSymbol } from "@/types";
|
||||
import ConfirmDialog from "../../ConfirmDialog.vue";
|
||||
|
||||
interface E {
|
||||
(event: "update:step", step: string, team?: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const teamsList = inject(TeamListResourceSymbol);
|
||||
const { agents } = useAgentStore();
|
||||
|
||||
const isDirty = computed(() => {
|
||||
return teamData.value.name || teamData.value.agents.length;
|
||||
});
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const teamData = ref({
|
||||
name: "",
|
||||
agents: [],
|
||||
});
|
||||
|
||||
const errors = ref({
|
||||
name: "",
|
||||
agents: "",
|
||||
});
|
||||
|
||||
const goBack = () => {
|
||||
const confirmDialogInfo = {
|
||||
show: true,
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to go back? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: goBack,
|
||||
};
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
|
||||
// Workaround fix for settings modal not closing after going back
|
||||
setTimeout(() => {
|
||||
emit("update:step", "team-list");
|
||||
}, 250);
|
||||
showConfirmDialog.value.show = false;
|
||||
};
|
||||
|
||||
const saveTeam = () => {
|
||||
validateData();
|
||||
if (Object.values(errors.value).some((error) => error)) {
|
||||
toast.error(__("Please fill all required fields"));
|
||||
return;
|
||||
}
|
||||
teamsList.insert.submit(
|
||||
{
|
||||
team_name: teamData.value.name,
|
||||
users: teamData.value.agents.map((agent) => ({ user: agent })),
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
toast.success(__("Team created"));
|
||||
emit("update:step", "team-edit", data.name);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const validateData = (key?: string) => {
|
||||
const validateField = (field: string) => {
|
||||
if (key && field !== key) return;
|
||||
|
||||
switch (field) {
|
||||
case "name":
|
||||
teamData.value.name?.length == 0
|
||||
? (errors.value.name = "Name is required")
|
||||
: (errors.value.name = "");
|
||||
break;
|
||||
case "agents":
|
||||
teamData.value.agents.length == 0
|
||||
? (errors.value.agents = "At least one team member is required")
|
||||
: (errors.value.agents = "");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (key) {
|
||||
validateField(key);
|
||||
} else {
|
||||
(Object.keys(errors.value) as string[]).forEach(validateField);
|
||||
}
|
||||
|
||||
return errors.value;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (agents.loading || agents.data?.length || agents.list.promise) {
|
||||
return;
|
||||
}
|
||||
agents.fetch();
|
||||
});
|
||||
</script>
|
||||
72
desk/src/components/Settings/Teams/RenameTeamModal.vue
Normal file
72
desk/src/components/Settings/Teams/RenameTeamModal.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<Dialog v-model="dialog.show" :options="renameDialogOptions">
|
||||
<template #body-content>
|
||||
<FormControl
|
||||
v-model="teamName"
|
||||
:label="__('Title')"
|
||||
:placeholder="__('Product Experts')"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
variant="solid"
|
||||
@click="renameTeam"
|
||||
:loading="renameTeamResource.loading"
|
||||
:disabled="teamName == dialog.teamName || teamName.trim() == ''"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { createResource, Dialog, toast } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const emit = defineEmits(["onRename"]);
|
||||
|
||||
const dialog = defineModel<{
|
||||
show: boolean;
|
||||
teamName: string;
|
||||
}>();
|
||||
const teamName = ref(dialog.value.teamName);
|
||||
|
||||
const renameTeamResource = createResource({
|
||||
url: "frappe.client.rename_doc",
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: "HD Team",
|
||||
old_name: dialog.value.teamName,
|
||||
new_name: teamName.value,
|
||||
};
|
||||
},
|
||||
validate(params) {
|
||||
if (!params.new_name) return __("New title is required");
|
||||
if (params.new_name === params.old_name)
|
||||
return __("New and old title cannot be same");
|
||||
},
|
||||
onSuccess(data) {
|
||||
toast.success(__("Team renamed"));
|
||||
dialog.value.show = false;
|
||||
emit("onRename", data);
|
||||
},
|
||||
});
|
||||
|
||||
const renameDialogOptions = computed(() => ({
|
||||
title: __("Rename team"),
|
||||
message: __("Enter the new name for the team"),
|
||||
}));
|
||||
|
||||
function renameTeam() {
|
||||
renameTeamResource.submit();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => dialog.value.show,
|
||||
() => {
|
||||
teamName.value = dialog.value.teamName;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,17 +1,20 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-1 justify-center -ml-[16px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="teamName"
|
||||
size="md"
|
||||
@click="() => emit('update:step', 'team-list')"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5"
|
||||
/>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-1 justify-center -ml-[16px]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="teamName"
|
||||
size="md"
|
||||
@click="() => emit('update:step', 'team-list')"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-ink-gray-7 text-lg hover:opacity-70 !pr-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<Dropdown placement="right" :options="options">
|
||||
<Button variant="ghost">
|
||||
<template #icon>
|
||||
@@ -19,99 +22,103 @@
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<!-- Add member -->
|
||||
<div class="flex flex-col gap-4 h-full">
|
||||
<div class="flex gap-2 items-end">
|
||||
</template>
|
||||
<template #header-bottom>
|
||||
<!-- Add member -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- Form control for search -->
|
||||
<Link
|
||||
doctype="HD Agent"
|
||||
class="form-control flex-1"
|
||||
placeholder="Search members"
|
||||
v-model="search"
|
||||
label="Members"
|
||||
:hide-me="true"
|
||||
:filters="agentFilters"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="h-4 w-4 text-gray-500 mr-2" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex items-center justify-between !w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<Avatar
|
||||
:label="option.label || option.value"
|
||||
:image="getUser(option.label)?.user_image"
|
||||
size="sm"
|
||||
/>
|
||||
<p>{{ getUser(option.label)?.full_name || "User" }}</p>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1.5 w-full">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-1">
|
||||
<AgentSelector
|
||||
v-model="invitees"
|
||||
:existing-agents="teamMembers.map((m) => m.name)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Link>
|
||||
<Button
|
||||
label="Add"
|
||||
variant="solid"
|
||||
:disabled="!search"
|
||||
@click="addMember(search)"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Member List -->
|
||||
<div
|
||||
class="flex flex-col divide-y divide-gray-200 overflow-y-scroll hide-scrollbar h-full"
|
||||
v-if="teamMembers.length"
|
||||
>
|
||||
<div
|
||||
v-for="(member, idx) in teamMembers"
|
||||
:key="member.user"
|
||||
class="group py-2"
|
||||
:class="idx == teamMembers.length - 1 && 'mb-12'"
|
||||
>
|
||||
<AgentCard :agent="member" class="!py-0">
|
||||
<template #right>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click.stop="() => removeMemberFromTeam(member.name)"
|
||||
theme="red"
|
||||
class="opacity-0 group-hover:opacity-100"
|
||||
:disabled="team.loading"
|
||||
>
|
||||
<template #icon>
|
||||
<LucideX class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</AgentCard>
|
||||
<Button
|
||||
label="Add Member"
|
||||
variant="solid"
|
||||
:disabled="!invitees.length"
|
||||
@click="addMember(invitees)"
|
||||
class="h-8"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-center h-full">
|
||||
<p class="text-p-base text-gray-500">No members found</p>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="w-full h-full" v-if="teamMembers?.length > 0">
|
||||
<div class="grid grid-cols-8 items-center gap-3 text-sm text-gray-600">
|
||||
<div class="col-span-6 text-p-sm">
|
||||
{{ __("Members ({0})", teamMembers.length) }}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="mt-2" />
|
||||
<div v-for="(member, idx) in teamMembers" :key="member.name">
|
||||
<div class="grid grid-cols-8 items-center gap-4 group">
|
||||
<div class="w-full p-2 pl-0 col-span-8">
|
||||
<AgentCard :agent="member" class="!py-0">
|
||||
<template #right>
|
||||
<Dropdown
|
||||
:options="memberDropdownOptions(member)"
|
||||
placement="right"
|
||||
>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
</Dropdown>
|
||||
</template>
|
||||
</AgentCard>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="member !== teamMembers.at(-1)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<UserIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No members found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add members to this team to get started.") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<Dialog v-model="showDelete" :options="{ title: 'Delete team' }">
|
||||
<template #body-content>
|
||||
<p class="text-p-base text-ink-gray-7">
|
||||
Are you sure you want to delete this team? This action cannot be
|
||||
reversed!
|
||||
{{
|
||||
__(
|
||||
"Are you sure you want to delete this team? This action cannot be reversed!"
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="mt-4 float-right"
|
||||
label="Confirm"
|
||||
:label="__('Confirm')"
|
||||
theme="red"
|
||||
@click="
|
||||
() => {
|
||||
team.delete.submit();
|
||||
showDelete = false;
|
||||
emit('update:step', 'team-list');
|
||||
}
|
||||
"
|
||||
/>
|
||||
@@ -121,19 +128,24 @@
|
||||
<template #body-content>
|
||||
<FormControl
|
||||
v-model="_teamName"
|
||||
label="Title"
|
||||
placeholder="Product Experts"
|
||||
:label="__('Title')"
|
||||
:placeholder="__('Product Experts')"
|
||||
maxlength="50"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Link from "@/components/frappe-ui/Link.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { TeamListResourceSymbol } from "@/types";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
createDocumentResource,
|
||||
createResource,
|
||||
@@ -142,25 +154,31 @@ import {
|
||||
toast,
|
||||
Tooltip,
|
||||
} from "frappe-ui";
|
||||
import { computed, h, markRaw, ref } from "vue";
|
||||
import { computed, h, inject, markRaw, onMounted, ref } from "vue";
|
||||
import LucideLock from "~icons/lucide/lock";
|
||||
import LucideUnlock from "~icons/lucide/unlock";
|
||||
import AgentCard from "../AgentCard.vue";
|
||||
import Settings from "~icons/lucide/settings-2";
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import LucideUnlock from "~icons/lucide/unlock";
|
||||
import UserIcon from "~icons/lucide/user";
|
||||
import AgentCard from "../AgentCard.vue";
|
||||
import { setActiveSettingsTab } from "../settingsModal";
|
||||
import AgentSelector from "./components/AgentSelector.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
teamName: string;
|
||||
}>();
|
||||
|
||||
interface E {
|
||||
(event: "update:step", value: string): void;
|
||||
(event: "update:step", step: string, team?: string): void;
|
||||
}
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
const { agents } = useAgentStore();
|
||||
const teamsList = inject(TeamListResourceSymbol);
|
||||
|
||||
const { teamRestrictionApplied } = useConfigStore();
|
||||
const invitees = ref<string[]>([]);
|
||||
|
||||
const _teamName = ref(props.teamName);
|
||||
const team = createDocumentResource({
|
||||
doctype: "HD Team",
|
||||
@@ -168,7 +186,7 @@ const team = createDocumentResource({
|
||||
auto: true,
|
||||
delete: {
|
||||
onSuccess() {
|
||||
toast.success("Team deleted");
|
||||
toast.success(__("Team deleted"));
|
||||
emit("update:step", "team-list");
|
||||
},
|
||||
},
|
||||
@@ -185,7 +203,6 @@ const ignoreRestrictions = computed({
|
||||
});
|
||||
},
|
||||
});
|
||||
const search = ref("");
|
||||
|
||||
const teamMembers = computed(() => {
|
||||
let users = team.doc?.users || [];
|
||||
@@ -199,12 +216,6 @@ const teamMembers = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const agentFilters = computed(() => {
|
||||
return {
|
||||
name: ["not in", teamMembers.value.map((user) => user.name)],
|
||||
};
|
||||
});
|
||||
|
||||
function removeMemberFromTeam(member: string) {
|
||||
const users = team.doc?.users?.filter((u) => u.user !== member);
|
||||
team.setValue.submit({
|
||||
@@ -212,22 +223,22 @@ function removeMemberFromTeam(member: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function addMember(user: string) {
|
||||
const users = team.doc.users.concat([{ user }]);
|
||||
function addMember(users: string[]) {
|
||||
const _users = team.doc.users.concat(users.map((user) => ({ user })));
|
||||
team.setValue.submit({
|
||||
users,
|
||||
users: _users,
|
||||
});
|
||||
search.value = "";
|
||||
invitees.value = [];
|
||||
}
|
||||
|
||||
const showRename = ref(false);
|
||||
|
||||
const renameDialogOptions = {
|
||||
title: "Rename team",
|
||||
message: "Enter the new name for the team",
|
||||
title: __("Rename team"),
|
||||
message: __("Enter the new name for the team"),
|
||||
actions: [
|
||||
{
|
||||
label: "Confirm",
|
||||
label: __("Confirm"),
|
||||
variant: "solid",
|
||||
loading: team.loading,
|
||||
onClick: ({ close }) => {
|
||||
@@ -248,12 +259,13 @@ function renameTeam(close) {
|
||||
};
|
||||
},
|
||||
validate(params) {
|
||||
if (!params.new_name) return "New title is required";
|
||||
if (!params.new_name) return __("New title is required");
|
||||
if (params.new_name === params.old_name)
|
||||
return "New and old title cannot be same";
|
||||
return __("New and old title cannot be same");
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Team renamed");
|
||||
teamsList.reload();
|
||||
toast.success(__("Team renamed"));
|
||||
close();
|
||||
emit("update:step", "team-list");
|
||||
},
|
||||
@@ -263,24 +275,10 @@ function renameTeam(close) {
|
||||
}
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteDialogOptions = {
|
||||
title: "Delete team",
|
||||
message: `Are you sure you want to delete this team? This action cannot be reversed!`,
|
||||
actions: [
|
||||
{
|
||||
label: "Confirm",
|
||||
variant: "solid",
|
||||
onClick: (ctx) => {
|
||||
team.delete.submit();
|
||||
ctx.close();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: "View Assignment rule",
|
||||
label: __("View Assignment rule"),
|
||||
icon: markRaw(h(Settings, { class: "rotate-90" })),
|
||||
onClick: () => {
|
||||
assignmentRulesActiveScreen.value = {
|
||||
@@ -291,22 +289,26 @@ const options = [
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Rename",
|
||||
label: __("Rename"),
|
||||
icon: "edit-3",
|
||||
onClick: () => (showRename.value = !showRename.value),
|
||||
},
|
||||
{
|
||||
condition: () => teamRestrictionApplied,
|
||||
label: team.doc?.ignore_restrictions
|
||||
? "Disable Bypass Restrictions"
|
||||
: "Enable Bypass Restrictions",
|
||||
? __("Disable Bypass Restrictions")
|
||||
: __("Enable Bypass Restrictions"),
|
||||
component: () =>
|
||||
h(
|
||||
Tooltip,
|
||||
{
|
||||
text: ignoreRestrictions.value
|
||||
? "Members of this team will see the tickets assigned to this team only"
|
||||
: "Members of this team will be able to see the tickets assigned to all the teams",
|
||||
? __(
|
||||
"Members of this team will see the tickets assigned to this team only"
|
||||
)
|
||||
: __(
|
||||
"Members of this team will be able to see the tickets assigned to all the teams"
|
||||
),
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
@@ -332,8 +334,8 @@ const options = [
|
||||
},
|
||||
[
|
||||
team.doc?.ignore_restrictions
|
||||
? "Access only this team's tickets"
|
||||
: "Access all team tickets",
|
||||
? __("Access only this team's tickets")
|
||||
: __("Access all team tickets"),
|
||||
]
|
||||
),
|
||||
]
|
||||
@@ -343,9 +345,9 @@ const options = [
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
label: __("Delete"),
|
||||
component: h(Button, {
|
||||
label: "Delete",
|
||||
label: __("Delete"),
|
||||
variant: "ghost",
|
||||
iconLeft: "trash-2",
|
||||
theme: "red",
|
||||
@@ -356,6 +358,20 @@ const options = [
|
||||
}),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
const isConfirmingDelete = ref(false);
|
||||
|
||||
const memberDropdownOptions = (member) => {
|
||||
return ConfirmDelete({
|
||||
onConfirmDelete: () => removeMemberFromTeam(member.name),
|
||||
isConfirmingDelete,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (agents.loading || agents.data?.length || agents.list.promise) {
|
||||
return;
|
||||
}
|
||||
agents.fetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<template>
|
||||
<div v-if="step === 'team-list'" class="h-full px-10 py-8">
|
||||
<TeamsList @update:step="updateStep" />
|
||||
</div>
|
||||
<div v-else-if="step === 'team-edit'" class="h-full px-10 py-8">
|
||||
<TeamEdit @update:step="updateStep" :team-name="teamName" />
|
||||
</div>
|
||||
<TeamsList v-if="step === 'team-list'" @update:step="updateStep" />
|
||||
<NewTeam v-else-if="step === 'new-team'" @update:step="updateStep" />
|
||||
<TeamEdit
|
||||
v-else-if="step === 'team-edit'"
|
||||
@update:step="updateStep"
|
||||
:team-name="teamName"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Ref, ref } from "vue";
|
||||
import { onUnmounted, provide, Ref, ref } from "vue";
|
||||
import TeamEdit from "./TeamEdit.vue";
|
||||
import TeamsList from "./TeamsList.vue";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import NewTeam from "./NewTeam.vue";
|
||||
import { TeamListResourceSymbol } from "@/types";
|
||||
|
||||
type TeamStep = "team-list" | "team-edit";
|
||||
type TeamStep = "team-list" | "team-edit" | "new-team";
|
||||
|
||||
const step: Ref<TeamStep> = ref("team-list");
|
||||
const teamName: Ref<string> = ref("");
|
||||
@@ -20,6 +24,25 @@ function updateStep(newStep: TeamStep, team?: string): void {
|
||||
step.value = newStep;
|
||||
teamName.value = team;
|
||||
}
|
||||
const teamsSearchQuery = ref("");
|
||||
|
||||
const teams = createListResource({
|
||||
doctype: "HD Team",
|
||||
cache: ["Teams"],
|
||||
fields: ["name"],
|
||||
auto: true,
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 20,
|
||||
});
|
||||
|
||||
provide("teamsSearchQuery", teamsSearchQuery);
|
||||
provide(TeamListResourceSymbol, teams);
|
||||
|
||||
onUnmounted(() => {
|
||||
teamsSearchQuery.value = "";
|
||||
teams.filters = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,71 +1,134 @@
|
||||
<template>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<SettingsLayoutHeader
|
||||
title="Teams"
|
||||
description="Create and manage teams and assign agents to specific teams."
|
||||
<SettingsLayoutBase
|
||||
:title="__('Teams')"
|
||||
:description="
|
||||
__('Create and manage teams and assign agents to specific teams.')
|
||||
"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'new-team', '')"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="teams.data?.length > 9 || teamsSearchQuery.length"
|
||||
#header-bottom
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="New"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="() => (showForm = !showForm)"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template #bottom-section>
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="'Search'"
|
||||
<div class="relative">
|
||||
<Input
|
||||
:model-value="teamsSearchQuery"
|
||||
@input="teamsSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
:debounce="300"
|
||||
class="w-60"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
<!-- List -->
|
||||
<div
|
||||
v-if="!teams.loading && teams.data?.length > 0"
|
||||
class="divide-y w-full h-full hide-scrollbar overflow-y-scroll mt-4"
|
||||
>
|
||||
class="bg-white hover:bg-white focus:ring-0 border-outline-gray-2"
|
||||
icon-left="search"
|
||||
debounce="300"
|
||||
inputClass="p-4 pr-12"
|
||||
/>
|
||||
<Button
|
||||
v-if="teamsSearchQuery"
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
@click="teamsSearchQuery = ''"
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- List -->
|
||||
<div
|
||||
v-for="team in teams.data"
|
||||
:key="team.name"
|
||||
class="flex items-center gap-2 py-2 group justify-between cursor-pointer"
|
||||
@click="() => emit('update:step', 'team-edit', team.name)"
|
||||
v-if="!teams.loading && teams.data?.length > 0"
|
||||
class="w-full h-full -ml-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar :label="team.name" size="sm" />
|
||||
<p :key="team.name" class="text-p-base text-gray-700">
|
||||
{{ team.name }}
|
||||
</p>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<div class="ml-2">{{ __("Team name") }}</div>
|
||||
</div>
|
||||
<hr class="mx-2 mt-2" />
|
||||
<div v-for="(team, index) in teams.data" :key="team.name">
|
||||
<div
|
||||
class="flex items-center cursor-pointer hover:bg-gray-50 rounded h-12.5"
|
||||
>
|
||||
<div
|
||||
class="w-full py-3 pl-2"
|
||||
@click="() => emit('update:step', 'team-edit', team.name)"
|
||||
>
|
||||
<div class="text-base text-ink-gray-7 font-medium">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center pr-2">
|
||||
<div>
|
||||
<Dropdown placement="right" :options="dropdownOptions(team)">
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="index !== teams.data.length - 1" class="mx-2" />
|
||||
</div>
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
v-if="!teams.loading && teams.hasNextPage"
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => teams.next()"
|
||||
:loading="teams.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="teams.loading" class="flex mt-28 justify-between w-full h-full">
|
||||
<Button
|
||||
:loading="teams.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!teams.data?.length"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<p class="text-sm text-gray-500 w-full flex justify-center">
|
||||
No teams found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="teams.loading"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<Button
|
||||
:loading="teams.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!teams.loading && !teams.data?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<AgentIcon class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base font-medium text-ink-gray-6">
|
||||
{{ __("No team found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{
|
||||
teamsSearchQuery.length
|
||||
? __("Change your search terms to find teams.")
|
||||
: __("Add one to get started.")
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
variant="outline"
|
||||
icon-left="plus"
|
||||
@click="showForm = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<NewTeamModal
|
||||
v-model="showForm"
|
||||
@create="
|
||||
@@ -74,40 +137,72 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<RenameTeamModal v-model="showRename" @onRename="() => teams.reload()" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Avatar, FormControl, createListResource } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import { Dropdown, Input, toast } from "frappe-ui";
|
||||
import { inject, markRaw, Ref, ref, watch } from "vue";
|
||||
import NewTeamModal from "../NewTeamModal.vue";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import EditIcon from "@/components/icons/EditIcon.vue";
|
||||
import RenameTeamModal from "./RenameTeamModal.vue";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { TeamListResourceSymbol } from "@/types";
|
||||
|
||||
interface E {
|
||||
(event: "update:step", step: string, team: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
const teamsSearchQuery = inject<Ref>("teamsSearchQuery");
|
||||
|
||||
const teams = createListResource({
|
||||
doctype: "HD Team",
|
||||
cache: ["Teams"],
|
||||
fields: ["name"],
|
||||
auto: true,
|
||||
orderBy: "creation desc",
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
const teams = inject(TeamListResourceSymbol);
|
||||
const showForm = ref(false);
|
||||
const showRename = ref({
|
||||
show: false,
|
||||
teamName: "",
|
||||
});
|
||||
const isConfirmingDelete = ref(false);
|
||||
|
||||
watch(search, (newValue) => {
|
||||
const dropdownOptions = (team: any) => {
|
||||
return [
|
||||
{
|
||||
label: __("Rename"),
|
||||
icon: markRaw(EditIcon),
|
||||
onClick: () => {
|
||||
showRename.value = {
|
||||
show: true,
|
||||
teamName: team.name,
|
||||
};
|
||||
},
|
||||
},
|
||||
...ConfirmDelete({
|
||||
onConfirmDelete: () => deleteTeam(team),
|
||||
isConfirmingDelete,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
const deleteTeam = (team: any) => {
|
||||
if (!isConfirmingDelete.value) {
|
||||
isConfirmingDelete.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
teams.delete.submit(team.name, {
|
||||
onSuccess: () => {
|
||||
toast.success(__("Team deleted"));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(teamsSearchQuery, (newValue) => {
|
||||
teams.filters = {
|
||||
...teams.filters,
|
||||
name: ["like", `%${newValue}%`],
|
||||
};
|
||||
if (!newValue) {
|
||||
teams.start = 0;
|
||||
teams.pageLength = 10;
|
||||
}
|
||||
teams.reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
182
desk/src/components/Settings/Teams/components/AgentSelector.vue
Normal file
182
desk/src/components/Settings/Teams/components/AgentSelector.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap gap-1 bg-surface-gray-2 p-0.5 border rounded w-full"
|
||||
>
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
class="active:bg-surface-gray-1 active:border-outline-gray-2 hover:border-outline-gray-2 hover:bg-surface-gray-1"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar :name="value" size="xs" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon class="h-3.5" name="x" @click.stop="removeValue(value)" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<ComboboxRoot
|
||||
:model-value="tempSelection"
|
||||
:open="showOptions"
|
||||
@update:open="(o) => (showOptions = o)"
|
||||
:ignore-filter="true"
|
||||
@update:modelValue="onSelect"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex h-7 w-full items-center gap-2 rounded px-2 py-1 border border-transparent bg-surface-gray-2 hover:bg-surface-gray-2"
|
||||
>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 focus:outline-none focus:ring-0 focus:border-0 w-full select-none"
|
||||
placeholder="Type agent name or email"
|
||||
@focus="showOptions = true"
|
||||
@click="showOptions = true"
|
||||
@input="onInput"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
position="popper"
|
||||
:align="'start'"
|
||||
@openAutoFocus.prevent
|
||||
@closeAutoFocus.prevent
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||
<ComboboxEmpty
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
Agent not found
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="agent in agentsList"
|
||||
:key="agent.name"
|
||||
:value="agent.name"
|
||||
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||
>
|
||||
<UserAvatar class="mr-1" :name="agent.agent_name" size="lg" />
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ agent.agent_name }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ agent.name }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UserAvatar } from "@/components";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { Button } from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import {
|
||||
ComboboxRoot,
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxContent,
|
||||
ComboboxViewport,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from "reka-ui";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
|
||||
const values = defineModel<any[]>();
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
const props = defineProps({
|
||||
existingAgents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emails = ref([]);
|
||||
const search = ref(null);
|
||||
const query = ref("");
|
||||
const showOptions = ref(false);
|
||||
const tempSelection = ref(null);
|
||||
|
||||
const { agents } = storeToRefs(useAgentStore());
|
||||
|
||||
const agentsList = computed(() => {
|
||||
let list = agents.value.data;
|
||||
if (query.value) {
|
||||
list = list.filter(
|
||||
(agent) =>
|
||||
agent.agent_name.toLowerCase().includes(query.value.toLowerCase()) ||
|
||||
agent.name.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (props.existingAgents.length) {
|
||||
list = list.filter((agent) => !props.existingAgents.includes(agent.name));
|
||||
}
|
||||
if (values.value.length) {
|
||||
list = list.filter((agent) => !values.value.includes(agent.name));
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function removeValue(value) {
|
||||
values.value = values.value.filter((v) => v !== value);
|
||||
}
|
||||
|
||||
function removeLastValue() {
|
||||
if (query.value) return;
|
||||
let emailRef = emails.value[emails.value.length - 1]?.rootRef;
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop();
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].rootRef;
|
||||
emailRef?.focus();
|
||||
} else {
|
||||
setFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
emailRef?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect(val) {
|
||||
if (!val) return;
|
||||
|
||||
if (!values.value) values.value.push(val);
|
||||
else values.value.push(val);
|
||||
query.value = "";
|
||||
emit("change", values.value);
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value;
|
||||
showOptions.value = true;
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (query.value) onSelect(query.value);
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value?.focus?.();
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user