Compare commits
1178 Commits
main
...
l10n_devel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
337f1c06f4 | ||
|
|
0cc8b38ad9 | ||
|
|
315e52522c | ||
|
|
c00964da0b | ||
|
|
e336d0ce38 | ||
|
|
b1b7c5d246 | ||
|
|
4b6d2fb9b6 | ||
|
|
171060df8a | ||
|
|
96b28cf11a | ||
|
|
dbcda4c548 | ||
|
|
9e92282e25 | ||
|
|
0138716e07 | ||
|
|
589c95e263 | ||
|
|
a4e2663b24 | ||
|
|
f91ac266ca | ||
|
|
415d5410ba | ||
|
|
9fc20a3078 | ||
|
|
43dadfe746 | ||
|
|
56071d5e0d | ||
|
|
b0e79d0aef | ||
|
|
5848649955 | ||
|
|
3c5ee979f8 | ||
|
|
2acd7476c8 | ||
|
|
66c5865828 | ||
|
|
103a137af1 | ||
|
|
52cc70d704 | ||
|
|
d72dcee7b6 | ||
|
|
b473b27f9a | ||
|
|
8805560144 | ||
|
|
3f0c4e9614 | ||
|
|
2b13c3f277 | ||
|
|
b6fa3bf32b | ||
|
|
ae9e59aa00 | ||
|
|
4533becc62 | ||
|
|
57bd9fe70a | ||
|
|
013c21a5d1 | ||
|
|
9a780039e5 | ||
|
|
1bd62289dc | ||
|
|
f7382f40ac | ||
|
|
3c870ce042 | ||
|
|
c05c2393f8 | ||
|
|
c16f2a286f | ||
|
|
07994ec071 | ||
|
|
96c0c99939 | ||
|
|
ce632c69c1 | ||
|
|
625e472303 | ||
|
|
93ed6fcddd | ||
|
|
e3eff7f78d | ||
|
|
394da5e002 | ||
|
|
887ae4ef57 | ||
|
|
536d657e5b | ||
|
|
d687a2eb56 | ||
|
|
1044adc494 | ||
|
|
9f95a3a2b2 | ||
|
|
fca831b92e | ||
|
|
69f8090311 | ||
|
|
ac34ac9b87 | ||
|
|
129f8a00b6 | ||
|
|
6328b6941b | ||
|
|
fbc9e37036 | ||
|
|
149901f605 | ||
|
|
7e21a5fee2 | ||
|
|
f4ff6bbdf3 | ||
|
|
9150233173 | ||
|
|
186584c1ac | ||
|
|
3752c61157 | ||
|
|
a6ecc5cfed | ||
|
|
84e0fe30a9 | ||
|
|
03acea69b1 | ||
|
|
e19f750831 | ||
|
|
a52bfee98d | ||
|
|
41ef219d0a | ||
|
|
0a2f9e31c0 | ||
|
|
69bcf0846c | ||
|
|
b6e3cdfc37 | ||
|
|
c0171c0555 | ||
|
|
6f154e191a | ||
|
|
552e500a31 | ||
|
|
bf6940a6ff | ||
|
|
dc9b07b02a | ||
|
|
0a45094c33 | ||
|
|
247d8e043e | ||
|
|
73a1ecd418 | ||
|
|
77e7bb011b | ||
|
|
9233e77ab8 | ||
|
|
32e5d56ef1 | ||
|
|
cea6b6c6b4 | ||
|
|
2d636d7ffb | ||
|
|
9d9caf2856 | ||
|
|
4ff4f3c5b5 | ||
|
|
8167e1388d | ||
|
|
8e3cf3846a | ||
|
|
2d8ada04c8 | ||
|
|
4fba2353cf | ||
|
|
f251d83e97 | ||
|
|
283b34662e | ||
|
|
9bcfcf4ac7 | ||
|
|
d80bbcd33d | ||
|
|
42ee5ea64d | ||
|
|
1472a7f33d | ||
|
|
aabcb9b7ce | ||
|
|
9e8a247dee | ||
|
|
2e5f4a9d22 | ||
|
|
100d01334a | ||
|
|
03ab96d94f | ||
|
|
6f640f5eee | ||
|
|
1627cf1e54 | ||
|
|
376917bc75 | ||
|
|
54753d3274 | ||
|
|
3c3108a9c1 | ||
|
|
e0cfae1eb3 | ||
|
|
fa03245eff | ||
|
|
f253392ba7 | ||
|
|
6e608d845b | ||
|
|
db577afc56 | ||
|
|
3b32b9a766 | ||
|
|
ffd2452675 | ||
|
|
b2949afd33 | ||
|
|
097c58b991 | ||
|
|
64bf702b62 | ||
|
|
7e27e9f45e | ||
|
|
ee19b344ec | ||
|
|
eb70553fea | ||
|
|
9e4d268b50 | ||
|
|
d92c25ab4e | ||
|
|
9975be4ff7 | ||
|
|
2442f6f2ad | ||
|
|
e39a20f652 | ||
|
|
dcca47f3ca | ||
|
|
df8aaea374 | ||
|
|
c3ac80afa8 | ||
|
|
8d59359ef5 | ||
|
|
53202f4a5b | ||
|
|
792db27252 | ||
|
|
c9bcbcf1d0 | ||
|
|
79a0c02f03 | ||
|
|
2a5dbfb75d | ||
|
|
b7443ff1bb | ||
|
|
5ee9d5787a | ||
|
|
426dc836ca | ||
|
|
d528710379 | ||
|
|
0a4cfa6055 | ||
|
|
1ca85157ab | ||
|
|
d8162a1dc4 | ||
|
|
9487edf05f | ||
|
|
30d95a6582 | ||
|
|
24b580150a | ||
|
|
1b7af2096f | ||
|
|
843f844e2c | ||
|
|
ed7d739291 | ||
|
|
591076bf27 | ||
|
|
891d78c3b6 | ||
|
|
4baee8351b | ||
|
|
0653c2293c | ||
|
|
509bdd08dd | ||
|
|
93db75b835 | ||
|
|
af6970569f | ||
|
|
50708ebe32 | ||
|
|
816bc700ed | ||
|
|
6e8228a82c | ||
|
|
54e4819c71 | ||
|
|
f573db2fe0 | ||
|
|
a45c150a3d | ||
|
|
46a7a9c495 | ||
|
|
09ff459751 | ||
|
|
1d249b8fff | ||
|
|
5eaf828758 | ||
|
|
0eb07f0242 | ||
|
|
023d949577 | ||
|
|
abec857dd2 | ||
|
|
58914d7053 | ||
|
|
c93909523d | ||
|
|
800f3f1453 | ||
|
|
9c1d0b3d56 | ||
|
|
f9f405cc00 | ||
|
|
181439be1d | ||
|
|
1d2328ced1 | ||
|
|
b9b073601b | ||
|
|
feec676632 | ||
|
|
2b0c43677e | ||
|
|
c51ee63008 | ||
|
|
0cd527f6ef | ||
|
|
fead8c3876 | ||
|
|
1e6270df44 | ||
|
|
7c9e9b954d | ||
|
|
6b2a1e7ff1 | ||
|
|
4aa506985f | ||
|
|
496659af0a | ||
|
|
9669b6d54c | ||
|
|
d406f7345c | ||
|
|
f838ef56a6 | ||
|
|
77c5cb40e9 | ||
|
|
0e1a4f0006 | ||
|
|
c4b5c56fe4 | ||
|
|
9066ed0688 | ||
|
|
9a189aa586 | ||
|
|
aa28206f57 | ||
|
|
628be7cb7c | ||
|
|
b74f92c86e | ||
|
|
9190974293 | ||
|
|
ffae4b10b9 | ||
|
|
b64baa2390 | ||
|
|
87c4d38c77 | ||
|
|
bf32cbd3e8 | ||
|
|
6d929d0e7e | ||
|
|
df31d8820c | ||
|
|
4bd5e5a9cc | ||
|
|
f38775bbe5 | ||
|
|
7a6caf2389 | ||
|
|
bbb2f8757e | ||
|
|
1effb6bc58 | ||
|
|
20318d0d13 | ||
|
|
610a5cd40b | ||
|
|
6c30596dd1 | ||
|
|
5eddfbe9b3 | ||
|
|
ed1c448fd7 | ||
|
|
32993af090 | ||
|
|
346643bc6d | ||
|
|
f2ce3165dd | ||
|
|
11d1b3a67a | ||
|
|
74f6f65210 | ||
|
|
557dc1f94c | ||
|
|
1e99192448 | ||
|
|
8031964d3d | ||
|
|
52d99ebf20 | ||
|
|
da7ee0926f | ||
|
|
ae5a1ceae5 | ||
|
|
2503dea30f | ||
|
|
92b79c2195 | ||
|
|
c69a468e35 | ||
|
|
4b4b188827 | ||
|
|
223cbf4020 | ||
|
|
0d1a4effdb | ||
|
|
369d9fcd63 | ||
|
|
8af4a4ecf2 | ||
|
|
b6a15ab96e | ||
|
|
0386df262e | ||
|
|
032b0d3723 | ||
|
|
4cb8789786 | ||
|
|
957aa4e2e2 | ||
|
|
65f11d7c8f | ||
|
|
2950e8c993 | ||
|
|
9976b9617f | ||
|
|
eada826503 | ||
|
|
bff1b6156f | ||
|
|
9cd6b142d7 | ||
|
|
ea644c22f1 | ||
|
|
b6e8d83c3b | ||
|
|
68ac2b80ff | ||
|
|
3f4601efa0 | ||
|
|
6ec2c1e805 | ||
|
|
202ba3c856 | ||
|
|
aa7d9affdb | ||
|
|
10e3adfd18 | ||
|
|
682e445288 | ||
|
|
38b838ec97 | ||
|
|
1cc972ea8b | ||
|
|
f9f1c2a437 | ||
|
|
1c432d8610 | ||
|
|
55c61cbd80 | ||
|
|
27c54461de | ||
|
|
2162ed588a | ||
|
|
593cf4ab5a | ||
|
|
b73bca354a | ||
|
|
60b5665981 | ||
|
|
e109b59a55 | ||
|
|
09c48d76ce | ||
|
|
f73fbcafdd | ||
|
|
02da09633d | ||
|
|
32d8dcf80a | ||
|
|
d520586e87 | ||
|
|
28217968e1 | ||
|
|
21bd24f614 | ||
|
|
8f8235e9d9 | ||
|
|
85b4f63bc7 | ||
|
|
743ffc0cf2 | ||
|
|
7ea8c60e5d | ||
|
|
19e699ea54 | ||
|
|
fa105079d7 | ||
|
|
8d79956f3c | ||
|
|
bfba0258ba | ||
|
|
15e8bf06fd | ||
|
|
0f0becd096 | ||
|
|
9040d50c3c | ||
|
|
899481d752 | ||
|
|
8a1ebeb52d | ||
|
|
4f08cb95e8 | ||
|
|
736c956ec2 | ||
|
|
eb787b2d6f | ||
|
|
8d0d234ac1 | ||
|
|
7fda0db51b | ||
|
|
a1d4853d1c | ||
|
|
6c41a4d0a0 | ||
|
|
cedafc82dc | ||
|
|
443070886e | ||
|
|
3d6111627b | ||
|
|
33a7f50fed | ||
|
|
6ca2f83c05 | ||
|
|
c4f76cea6c | ||
|
|
26d49d7ae0 | ||
|
|
22369825e8 | ||
|
|
27b286c6de | ||
|
|
0c5684905f | ||
|
|
3fb888561b | ||
|
|
f2f0a4ba2b | ||
|
|
50bf6be95f | ||
|
|
486a06baf0 | ||
|
|
214b91a64b | ||
|
|
84aacb0717 | ||
|
|
4c1a4f956e | ||
|
|
9f1d75817e | ||
|
|
3b61c0ba8f | ||
|
|
1706c05f93 | ||
|
|
f3bd3de81c | ||
|
|
91187f4db0 | ||
|
|
0d5ad337f6 | ||
|
|
d3fa9cf98a | ||
|
|
2251d46711 | ||
|
|
962418c37d | ||
|
|
1d633eabdc | ||
|
|
5a0e68b0e8 | ||
|
|
76f9843251 | ||
|
|
1a6a1854fc | ||
|
|
b55bf0918f | ||
|
|
5f9fb5a68f | ||
|
|
469177182a | ||
|
|
a72e580e1f | ||
|
|
9c88ba879d | ||
|
|
9d45dea8b3 | ||
|
|
f1732780cc | ||
|
|
ae70eee7e0 | ||
|
|
03277e240e | ||
|
|
510f7301fd | ||
|
|
32f42a4009 | ||
|
|
267016cff9 | ||
|
|
fc02800a35 | ||
|
|
b33f09c76b | ||
|
|
2d68d1d29a | ||
|
|
ec14798d9c | ||
|
|
a2e438ceea | ||
|
|
e606e56ce7 | ||
|
|
a8a75f6e97 | ||
|
|
35e07b321c | ||
|
|
28ce5762f8 | ||
|
|
97367afed1 | ||
|
|
d1b229c459 | ||
|
|
335f73a0e6 | ||
|
|
ef38c4a882 | ||
|
|
132b631a36 | ||
|
|
d1ccbc6541 | ||
|
|
7807c12114 | ||
|
|
779032be50 | ||
|
|
e78d08a6d4 | ||
|
|
4301cd4806 | ||
|
|
5ab41a5ebb | ||
|
|
e7c09b6f2f | ||
|
|
bf63bd54df | ||
|
|
f486a664ca | ||
|
|
41118eda97 | ||
|
|
cd96171a4c | ||
|
|
83c1abd707 | ||
|
|
574d3ec70e | ||
|
|
18c1bb1ad4 | ||
|
|
8ea94765ce | ||
|
|
8d7a5f22fd | ||
|
|
a31ff74999 | ||
|
|
c60979f1ab | ||
|
|
b5739efbbc | ||
|
|
96e014e6ca | ||
|
|
d0020b8a90 | ||
|
|
f32b86e7e7 | ||
|
|
7cab5d9815 | ||
|
|
a6a22aa393 | ||
|
|
fd098a0766 | ||
|
|
36326526d5 | ||
|
|
f20a903e78 | ||
|
|
0f0564066d | ||
|
|
7b6a4d3b30 | ||
|
|
175c450559 | ||
|
|
5b79141dd5 | ||
|
|
97de6543c2 | ||
|
|
bc424265e0 | ||
|
|
15d057b63c | ||
|
|
27a121c270 | ||
|
|
d99a4bc3ff | ||
|
|
d6251adae3 | ||
|
|
388d3e9369 | ||
|
|
f8f0800f97 | ||
|
|
9b9d87757c | ||
|
|
247a7c4da6 | ||
|
|
f2d87fa801 | ||
|
|
e7534c9b15 | ||
|
|
edd0ec5f68 | ||
|
|
a76bd2cab2 | ||
|
|
25d9d562e6 | ||
|
|
e8c331dfff | ||
|
|
afa96c330b | ||
|
|
4992cdda74 | ||
|
|
c2a1a1b1d2 | ||
|
|
89cce5160c | ||
|
|
5f0bb46bf4 | ||
|
|
f4551a92c5 | ||
|
|
e9c197f46e | ||
|
|
3a756630f3 | ||
|
|
a77bfd2aca | ||
|
|
1cebc1fed8 | ||
|
|
1a90876500 | ||
|
|
c4065b95b8 | ||
|
|
1a74d6a280 | ||
|
|
0523920fd0 | ||
|
|
a8ae2e551e | ||
|
|
aa0df7be79 | ||
|
|
49ddcda2b6 | ||
|
|
d173d5584a | ||
|
|
1679a67dc6 | ||
|
|
31163a1b2e | ||
|
|
5f32e46759 | ||
|
|
91bdb02867 | ||
|
|
f8aa6cab78 | ||
|
|
8a62ff38af | ||
|
|
8350752f56 | ||
|
|
a182bee57f | ||
|
|
c5a8df19ae | ||
|
|
38b6674cc1 | ||
|
|
73b2c36bbc | ||
|
|
672c5eb733 | ||
|
|
948ce99482 | ||
|
|
3791e2ae70 | ||
|
|
68d1172b8f | ||
|
|
c94e61bfce | ||
|
|
8b557a5963 | ||
|
|
d4f99a411c | ||
|
|
ea2e44a2be | ||
|
|
4884ca0bd6 | ||
|
|
2a7c9ef9e8 | ||
|
|
cb3f67f231 | ||
|
|
00e3bd12cc | ||
|
|
d539bc075f | ||
|
|
818fd6fcdd | ||
|
|
5dc3a364a4 | ||
|
|
840eb664ce | ||
|
|
51823d1b88 | ||
|
|
8e0536ee11 | ||
|
|
5648e5eb77 | ||
|
|
940e8d24c7 | ||
|
|
4813a861a6 | ||
|
|
afb30a256e | ||
|
|
499888b4a7 | ||
|
|
227359da62 | ||
|
|
022b14c830 | ||
|
|
58f970c6b1 | ||
|
|
d83ce2f276 | ||
|
|
0c8f36bc36 | ||
|
|
1286ecfc8f | ||
|
|
24ebe94730 | ||
|
|
90b0e0d7b6 | ||
|
|
ebd115c129 | ||
|
|
5f544416ef | ||
|
|
5a7a7a7257 | ||
|
|
8e8bf4ed5e | ||
|
|
7244f69c7f | ||
|
|
d5b992d736 | ||
|
|
38cb777458 | ||
|
|
1a569d45de | ||
|
|
93a710f8c5 | ||
|
|
5414bbb190 | ||
|
|
c70aac4f31 | ||
|
|
9471966bee | ||
|
|
055a39cc0d | ||
|
|
4f075d4b23 | ||
|
|
526c43c655 | ||
|
|
084b446ae1 | ||
|
|
de9c04ff18 | ||
|
|
2fb09126c0 | ||
|
|
8944295474 | ||
|
|
85348188d2 | ||
|
|
a68b405c61 | ||
|
|
2a18a556bf | ||
|
|
fa14d4ad15 | ||
|
|
44deb5878c | ||
|
|
2601c1d059 | ||
|
|
7e51d96379 | ||
|
|
4c1eaf507d | ||
|
|
76149b0c79 | ||
|
|
564d2b1ac5 | ||
|
|
faef5cb866 | ||
|
|
3ed2c4812a | ||
|
|
4c53dd6ea3 | ||
|
|
5010cccc71 | ||
|
|
aeb3f150c5 | ||
|
|
995f356419 | ||
|
|
c64dcb43b4 | ||
|
|
7dafba9fc3 | ||
|
|
abc501825a | ||
|
|
3ad2a56efb | ||
|
|
c53e486bf0 | ||
|
|
819a669922 | ||
|
|
d852fe1e9f | ||
|
|
d431d5b4b1 | ||
|
|
ba99d14f68 | ||
|
|
7e42599b49 | ||
|
|
c38c190d42 | ||
|
|
6e8d869afb | ||
|
|
63d6062673 | ||
|
|
af830b8782 | ||
|
|
0605cf5fd0 | ||
|
|
4acb4dd3a7 | ||
|
|
72c31b9f21 | ||
|
|
f2b1c24d19 | ||
|
|
f86bb0ada2 | ||
|
|
ec4866f39a | ||
|
|
7961b614f1 | ||
|
|
901d84d070 | ||
|
|
0144bc109a | ||
|
|
ca5d82f5be | ||
|
|
b6a6152a49 | ||
|
|
5e19a83f8a | ||
|
|
cd30e9d533 | ||
|
|
ea0011771b | ||
|
|
94b0077b2a | ||
|
|
ea815d0147 | ||
|
|
0ae048d396 | ||
|
|
972558396e | ||
|
|
c218241e80 | ||
|
|
e616e69aa4 | ||
|
|
2627471b23 | ||
|
|
a2c42ee5a7 | ||
|
|
8d89caeba7 | ||
|
|
b0393b532c | ||
|
|
2ea875f2cb | ||
|
|
d1d2900847 | ||
|
|
98d851d76d | ||
|
|
30fdc1db4c | ||
|
|
12df15154d | ||
|
|
0bf6c01f91 | ||
|
|
497263f367 | ||
|
|
cd458d6d22 | ||
|
|
a2b55166ed | ||
|
|
570d31b6d1 | ||
|
|
2483dd6828 | ||
|
|
1adef98c57 | ||
|
|
dfd3c8f2bf | ||
|
|
015c592978 | ||
|
|
8984cea367 | ||
|
|
c3cf63dfb7 | ||
|
|
c532b61ef6 | ||
|
|
491fbb7801 | ||
|
|
4c7a40d8bc | ||
|
|
c866d1e836 | ||
|
|
c72160b2a4 | ||
|
|
3143f14e0b | ||
|
|
e06a365029 | ||
|
|
36e79e49da | ||
|
|
5bee7022b2 | ||
|
|
e578513eaf | ||
|
|
8de2a69a99 | ||
|
|
890299c2f3 | ||
|
|
2a6f1c402b | ||
|
|
25d2b1889f | ||
|
|
ee843fae26 | ||
|
|
fea35205b7 | ||
|
|
ee28543180 | ||
|
|
4d7a66f1d8 | ||
|
|
3b9ff8d58f | ||
|
|
b46d5a4e5e | ||
|
|
23a823f2bb | ||
|
|
56ef00536a | ||
|
|
0081006a64 | ||
|
|
1c98d81c2c | ||
|
|
7e9d9a5fed | ||
|
|
45826e0a88 | ||
|
|
8815433962 | ||
|
|
e4261820d4 | ||
|
|
5bfcaf4809 | ||
|
|
7f4f6ff651 | ||
|
|
7d9b7e92fb | ||
|
|
63963414c1 | ||
|
|
f0ee245f75 | ||
|
|
a4710a8b85 | ||
|
|
201ad61e6d | ||
|
|
bbb9eefc9a | ||
|
|
d53b503805 | ||
|
|
b1d90952c4 | ||
|
|
742bd6e213 | ||
|
|
e0abb53d4c | ||
|
|
8e08e6f415 | ||
|
|
e883ea1346 | ||
|
|
0327b37d2f | ||
|
|
699feead15 | ||
|
|
a08fabaed9 | ||
|
|
61e276df37 | ||
|
|
076762e2f3 | ||
|
|
9761989ea4 | ||
|
|
5ae7698704 | ||
|
|
c84ac29332 | ||
|
|
cf8a1ce8a3 | ||
|
|
1507701981 | ||
|
|
b4796efed1 | ||
|
|
273f7d7f07 | ||
|
|
8474630f4c | ||
|
|
907ec5fa3c | ||
|
|
43001bee9f | ||
|
|
5479a6b885 | ||
|
|
594f7922f9 | ||
|
|
7aa9f768ea | ||
|
|
b796a30e1f | ||
|
|
5508eb013a | ||
|
|
a3c7af7c8c | ||
|
|
d689bf21d2 | ||
|
|
05ea067361 | ||
|
|
2af8710a6f | ||
|
|
6abc9f7f9f | ||
|
|
7de21b7015 | ||
|
|
f9bae5d8ff | ||
|
|
29322577aa | ||
|
|
a4f96c7c5b | ||
|
|
b3fe85b8a0 | ||
|
|
efc4213363 | ||
|
|
b983648f35 | ||
|
|
e62eac91d1 | ||
|
|
e6f1bd50db | ||
|
|
c5458fb29c | ||
|
|
8e3daf482d | ||
|
|
03edd1a011 | ||
|
|
246ed83a3d | ||
|
|
53440476c4 | ||
|
|
14f3fd3cd7 | ||
|
|
6c9d56808b | ||
|
|
08b3107d92 | ||
|
|
dc8fa27ea1 | ||
|
|
445d7050b7 | ||
|
|
bfe0df4df3 | ||
|
|
71bcca71cd | ||
|
|
53864ac12c | ||
|
|
f438500a57 | ||
|
|
cef8aacf2f | ||
|
|
1ab414c4bf | ||
|
|
fdffd7bf3a | ||
|
|
6fc52aba6d | ||
|
|
dfd4cc068a | ||
|
|
5ae21c8580 | ||
|
|
178a934fa3 | ||
|
|
1ce508a4b1 | ||
|
|
d0678ec7c2 | ||
|
|
8036ffeafc | ||
|
|
a5a37c6181 | ||
|
|
945cdc0ae3 | ||
|
|
8de2e89b68 | ||
|
|
0f49470bf6 | ||
|
|
7826565ce7 | ||
|
|
713571469b | ||
|
|
2f34fdd409 | ||
|
|
6fefa16ac3 | ||
|
|
b344f412c9 | ||
|
|
1e245e7719 | ||
|
|
28facd66c4 | ||
|
|
c8f01f08ed | ||
|
|
a89525f77e | ||
|
|
38a2fa87c3 | ||
|
|
46e6ed2e6f | ||
|
|
266952c404 | ||
|
|
b77e59589a | ||
|
|
1a5ae397dc | ||
|
|
7c4718ad02 | ||
|
|
d79341b6d9 | ||
|
|
84738ba00c | ||
|
|
3b0a8d8e4b | ||
|
|
2584cca128 | ||
|
|
05b8cea206 | ||
|
|
6e3d23a8e1 | ||
|
|
2a38d0fb5f | ||
|
|
97724c776b | ||
|
|
9b072058cc | ||
|
|
d2e65feaa6 | ||
|
|
37c2d3a2b0 | ||
|
|
0909423fe9 | ||
|
|
139bcb101c | ||
|
|
0f06715d0c | ||
|
|
4a783fcba8 | ||
|
|
fd38f0ac98 | ||
|
|
ed2208fe75 | ||
|
|
4320142132 | ||
|
|
160649bf97 | ||
|
|
e7a2efd14a | ||
|
|
81614418d4 | ||
|
|
bb08f3d377 | ||
|
|
5232da6ec3 | ||
|
|
e59547da30 | ||
|
|
de85ccfc51 | ||
|
|
f82019e510 | ||
|
|
7fc26a5202 | ||
|
|
dcb1e47564 | ||
|
|
61259f3d2e | ||
|
|
2dd2608c09 | ||
|
|
81dc4e1138 | ||
|
|
cb1f9f760c | ||
|
|
51530b7608 | ||
|
|
4e6d4a1d77 | ||
|
|
efc5dd93e9 | ||
|
|
210a9d8d06 | ||
|
|
9bd855ee2e | ||
|
|
970c215f40 | ||
|
|
7d157046ac | ||
|
|
1ae7018f79 | ||
|
|
6802567291 | ||
|
|
cbc127e947 | ||
|
|
d91d4765b5 | ||
|
|
328959cc39 | ||
|
|
36320f61ab | ||
|
|
faeacb9a7d | ||
|
|
5dcd416007 | ||
|
|
33e4072430 | ||
|
|
c7fbd6f8f1 | ||
|
|
a5d3694386 | ||
|
|
b3075416e2 | ||
|
|
743d97d690 | ||
|
|
2cb09dde4b | ||
|
|
d7ba5a5f62 | ||
|
|
a00bba35f8 | ||
|
|
71db65d21c | ||
|
|
37d820a67c | ||
|
|
4f02f0a4d7 | ||
|
|
f4b81b3761 | ||
|
|
0be737914a | ||
|
|
1b0d966db0 | ||
|
|
27f87883f7 | ||
|
|
f747e076ab | ||
|
|
4b12918ba5 | ||
|
|
c104b1b8b4 | ||
|
|
9d4106cd81 | ||
|
|
eddf8c9295 | ||
|
|
6b3e42a44e | ||
|
|
9b1d4832b6 | ||
|
|
4d2f054e40 | ||
|
|
6450b69ae7 | ||
|
|
223187c7ea | ||
|
|
3b34f73cb3 | ||
|
|
40c5c92230 | ||
|
|
6760798f18 | ||
|
|
42ea1ad16e | ||
|
|
96200aebe6 | ||
|
|
bcfe4b6a49 | ||
|
|
cb92e5e68d | ||
|
|
1fa6b5bb51 | ||
|
|
cafc4fb22f | ||
|
|
39eb5600d9 | ||
|
|
0b97462dc9 | ||
|
|
cab80edf60 | ||
|
|
6f3b58d1a5 | ||
|
|
fc89c7b93c | ||
|
|
4a57c4eb84 | ||
|
|
96cbdea820 | ||
|
|
a3a54aef94 | ||
|
|
c96e5ff6c5 | ||
|
|
144470877d | ||
|
|
391844512a | ||
|
|
d89c304b13 | ||
|
|
881126c7f1 | ||
|
|
5bbec00803 | ||
|
|
7730e46cfc | ||
|
|
97b2253e9d | ||
|
|
1afb2a783b | ||
|
|
0fdbfa3ad4 | ||
|
|
a7dc5e05b3 | ||
|
|
92d7280728 | ||
|
|
5d01b88a1e | ||
|
|
2b47e3f4c9 | ||
|
|
485360f291 | ||
|
|
17fdbb05ce | ||
|
|
adc22efcb1 | ||
|
|
4c70b1a06b | ||
|
|
4f58aa110a | ||
|
|
4d3fe722e8 | ||
|
|
6320e580ae | ||
|
|
611f4cde70 | ||
|
|
6d3268a61e | ||
|
|
bf0a1ecebd | ||
|
|
693c086930 | ||
|
|
7c307a9134 | ||
|
|
aae7e0e36c | ||
|
|
2014a3d6de | ||
|
|
2e5c1bc3b5 | ||
|
|
ac13b7a3bd | ||
|
|
6b7bdf5afb | ||
|
|
3eba628a8b | ||
|
|
9949478b36 | ||
|
|
ff657ec34c | ||
|
|
da4698d431 | ||
|
|
20d47ae323 | ||
|
|
f4f799f636 | ||
|
|
cc411f036d | ||
|
|
62d5c2a91f | ||
|
|
8350c5ee36 | ||
|
|
65435cf2b5 | ||
|
|
af4c64e633 | ||
|
|
41b913debe | ||
|
|
a3b9368953 | ||
|
|
28ece820ed | ||
|
|
cca420b1a0 | ||
|
|
05803c79b4 | ||
|
|
5932ccafec | ||
|
|
7cee017e20 | ||
|
|
b15a8d9c8a | ||
|
|
7e6d5c3e54 | ||
|
|
dd3d297dab | ||
|
|
e4f728d809 | ||
|
|
cd7bab9184 | ||
|
|
ec6b1558b1 | ||
|
|
1c3ee8b557 | ||
|
|
1db7f69f89 | ||
|
|
3c1ce1fe27 | ||
|
|
2d05b6a282 | ||
|
|
b5ed9692df | ||
|
|
9a326d791b | ||
|
|
7fbd240d97 | ||
|
|
58d4691354 | ||
|
|
594295b7c8 | ||
|
|
2c45673f54 | ||
|
|
7827afe606 | ||
|
|
bc7498e02b | ||
|
|
e5dd85aefb | ||
|
|
cf1fce3dc0 | ||
|
|
480cc07cd9 | ||
|
|
84d4327e80 | ||
|
|
34102ef6ef | ||
|
|
ca985a0b76 | ||
|
|
4b4a154261 | ||
|
|
e957327877 | ||
|
|
2fdea90ad4 | ||
|
|
ad1aee9c9e | ||
|
|
bd7451e86f | ||
|
|
0230360145 | ||
|
|
eee1190f10 | ||
|
|
96c8aae01e | ||
|
|
0ad65be961 | ||
|
|
0f8d484e28 | ||
|
|
364c369199 | ||
|
|
901bcb8460 | ||
|
|
d06ac91052 | ||
|
|
f818a4c1d6 | ||
|
|
85191e10c8 | ||
|
|
001a3231e1 | ||
|
|
d951dff5a9 | ||
|
|
dc82f837aa | ||
|
|
7f1db0b444 | ||
|
|
a317950567 | ||
|
|
4c7269e357 | ||
|
|
15fd763de8 | ||
|
|
0c314674fc | ||
|
|
efd03141f0 | ||
|
|
675bcb549d | ||
|
|
56425254a9 | ||
|
|
22856351fd | ||
|
|
dd1229309f | ||
|
|
fad7c5985c | ||
|
|
3234102e55 | ||
|
|
fb2f105520 | ||
|
|
03abe0b5cd | ||
|
|
6873c6db4e | ||
|
|
08bab927a2 | ||
|
|
d244567b30 | ||
|
|
2b1b21d2e2 | ||
|
|
12213de478 | ||
|
|
2a2c832e0b | ||
|
|
b534aae70b | ||
|
|
6d3e4406ae | ||
|
|
463d60b650 | ||
|
|
e9812495e9 | ||
|
|
0a836c78bb | ||
|
|
5c7f835e4c | ||
|
|
346849631e | ||
|
|
bf166bdaad | ||
|
|
123f183f68 | ||
|
|
bea1505c63 | ||
|
|
fac5ed5579 | ||
|
|
96fefbd8a3 | ||
|
|
548018997e | ||
|
|
9f3477e1cd | ||
|
|
baa03246e6 | ||
|
|
a17b1cd0e2 | ||
|
|
49d82870c4 | ||
|
|
6a72a4467a | ||
|
|
efbed6e0b6 | ||
|
|
5270670b65 | ||
|
|
b82f4ca02b | ||
|
|
0f451c7e3a | ||
|
|
824dc8dcdd | ||
|
|
20405be86c | ||
|
|
4edfa951dc | ||
|
|
029c16d1d0 | ||
|
|
b3acff8cba | ||
|
|
a98b0e3a00 | ||
|
|
b1cbcbd98d | ||
|
|
e079980598 | ||
|
|
2e27c0459c | ||
|
|
d87a237789 | ||
|
|
e9e0aa357b | ||
|
|
9af300bba8 | ||
|
|
7d79cbf5bd | ||
|
|
fdca27bb81 | ||
|
|
01f0213693 | ||
|
|
5d29a49120 | ||
|
|
33e6b80d5a | ||
|
|
100d931535 | ||
|
|
dba6dd1983 | ||
|
|
dc8898e1da | ||
|
|
ed79bf55eb | ||
|
|
fb644f5fbe | ||
|
|
ab409dfd2c | ||
|
|
42285dd911 | ||
|
|
db2a6c65b7 | ||
|
|
c6ad10857a | ||
|
|
f128a55f97 | ||
|
|
2a817e5861 | ||
|
|
5e616f1a50 | ||
|
|
c6e9d71e1f | ||
|
|
f58d44bf9c | ||
|
|
f72ab39c93 | ||
|
|
6c706e6162 | ||
|
|
8f81d207b8 | ||
|
|
b74c5f384d | ||
|
|
df412d51fe | ||
|
|
8942bb7e48 | ||
|
|
ca60679126 | ||
|
|
8db846ad5d | ||
|
|
bb6a90058b | ||
|
|
44df09fac2 | ||
|
|
e214ce8bfb | ||
|
|
6d281922e4 | ||
|
|
58f09331b0 | ||
|
|
9780a6b63e | ||
|
|
71f764c224 | ||
|
|
9362997246 | ||
|
|
28ea88f61e | ||
|
|
a25ff14dd4 | ||
|
|
d86caee7af | ||
|
|
c4caabe722 | ||
|
|
8dcb77634b | ||
|
|
c4feed116d | ||
|
|
e220767179 | ||
|
|
571126c36d | ||
|
|
832323f25e | ||
|
|
3b73432d8c | ||
|
|
3aa341370b | ||
|
|
895da1a812 | ||
|
|
d34ee6fe48 | ||
|
|
7298fe378c | ||
|
|
2da0b48c29 | ||
|
|
165509f5a0 | ||
|
|
c9b9dbb092 | ||
|
|
0cc1d5da8f | ||
|
|
c70dced268 | ||
|
|
df698387dc | ||
|
|
716dc056d6 | ||
|
|
cf91f3f72a | ||
|
|
51b87d0ac6 | ||
|
|
c83d7adddd | ||
|
|
549665bc61 | ||
|
|
7f5f43f0c2 | ||
|
|
af41469d58 | ||
|
|
43e1309bd8 | ||
|
|
91f7cf05fc | ||
|
|
875431a620 | ||
|
|
db0c0d98bc | ||
|
|
5406f4a11b | ||
|
|
bfdd3273fe | ||
|
|
8798103e7e | ||
|
|
203b5ab1ac | ||
|
|
ed1b26207b | ||
|
|
e0166a08e2 | ||
|
|
8af4e9b5e8 | ||
|
|
900c1d3570 | ||
|
|
b95a17a4e0 | ||
|
|
0f0b012a44 | ||
|
|
b291f82e4d | ||
|
|
86b7222916 | ||
|
|
7a12b80dd2 | ||
|
|
4a836a58ee | ||
|
|
b47fc5b93b | ||
|
|
f3b9103a51 | ||
|
|
dc3ccdddd4 | ||
|
|
807eb4a7d9 | ||
|
|
a24283eb5e | ||
|
|
fd7116b2e1 | ||
|
|
2e1289df28 | ||
|
|
6064ca5a4f | ||
|
|
3db1b3c0f3 | ||
|
|
06ffa203ef | ||
|
|
dd1db8f782 | ||
|
|
fe8e309399 | ||
|
|
e7a20374c7 | ||
|
|
4cfa0f512b | ||
|
|
64b4f6b759 | ||
|
|
2d421e6052 | ||
|
|
cd8dd683fa | ||
|
|
a2bdc7ab93 | ||
|
|
d4132c2411 | ||
|
|
4c6e273268 | ||
|
|
043f174e05 | ||
|
|
26e9fac1ed | ||
|
|
88f33db249 | ||
|
|
55a67bbc0c | ||
|
|
08f042589d | ||
|
|
52f540a014 | ||
|
|
e85ef93480 | ||
|
|
a757f80263 | ||
|
|
b9b8ff0e10 | ||
|
|
e0aad074ec | ||
|
|
ad88b4e046 | ||
|
|
5156814e7a | ||
|
|
f988d16215 | ||
|
|
f5a3fccad3 | ||
|
|
e3f0079578 | ||
|
|
b831ea3c47 | ||
|
|
a88545b8b9 | ||
|
|
44523a0392 | ||
|
|
dbc207a9a6 | ||
|
|
e68d861ee5 | ||
|
|
7851bbadfa | ||
|
|
9223d00af3 | ||
|
|
740c21532a | ||
|
|
9fdd8bbc17 | ||
|
|
0978fa58a2 | ||
|
|
1395a12d32 | ||
|
|
9aab0e7417 | ||
|
|
ddc5810c71 | ||
|
|
21c349e1d7 | ||
|
|
a7784c2985 | ||
|
|
0cc69d90f0 | ||
|
|
f125737d30 | ||
|
|
18aef2376a | ||
|
|
c8287ff107 | ||
|
|
baf344a697 | ||
|
|
8c94049e3c | ||
|
|
646c76c3cb | ||
|
|
adbb9f5765 | ||
|
|
d3a6cc968f | ||
|
|
d6ff40cc6a | ||
|
|
fdd6c46b5f | ||
|
|
26c892c2a0 | ||
|
|
3516e1ff44 | ||
|
|
0047077074 | ||
|
|
8459fac184 | ||
|
|
afe828f012 | ||
|
|
60ed0a2043 | ||
|
|
2c9bc07dec | ||
|
|
91ba11b565 | ||
|
|
8f79427720 | ||
|
|
32f3aaf38f | ||
|
|
76aaf7f37d | ||
|
|
7d37c606cc | ||
|
|
6bce89f277 | ||
|
|
5420fcfe29 | ||
|
|
8507c20481 | ||
|
|
914dd8bf93 | ||
|
|
960ebdc727 | ||
|
|
74ef956638 | ||
|
|
a6323f42af | ||
|
|
bc1c20c91f | ||
|
|
43297373ed | ||
|
|
5228755f7f | ||
|
|
7ded0a0742 | ||
|
|
d74ff9ab62 | ||
|
|
6ef27106df | ||
|
|
35a27101c1 | ||
|
|
6fbe75c8ad | ||
|
|
89fd754efc | ||
|
|
576763fe5b | ||
|
|
c67ec08e1a | ||
|
|
6f49573f2f | ||
|
|
12c3290f19 | ||
|
|
53c0706a3a | ||
|
|
556386e446 | ||
|
|
07b2d9f792 | ||
|
|
a2081da296 | ||
|
|
dde7db9489 | ||
|
|
f947f55fc6 | ||
|
|
7bbac6c703 | ||
|
|
420ecb6147 | ||
|
|
dcb2787498 | ||
|
|
336083a00f | ||
|
|
727d0a9acd | ||
|
|
29894ffcca | ||
|
|
e804fa39ba | ||
|
|
f866284240 | ||
|
|
9e3124d29e | ||
|
|
d7e0eb09b3 | ||
|
|
5fcd447bc8 | ||
|
|
6f04b85663 | ||
|
|
47262761fe | ||
|
|
b46e7a2185 | ||
|
|
2d484c1ad2 | ||
|
|
275fa90a4d | ||
|
|
f8956c70bf | ||
|
|
39fa9c78f8 | ||
|
|
d96a29543e | ||
|
|
d2d4abe91f | ||
|
|
5f567cf138 | ||
|
|
7bf7d94127 | ||
|
|
5b8d0d2aeb | ||
|
|
d37e585205 | ||
|
|
a30503ca5f | ||
|
|
e65899e384 | ||
|
|
16a3f3d66c | ||
|
|
1e2f325c55 | ||
|
|
ccd240f4e8 | ||
|
|
7b34c5eb66 | ||
|
|
6da3761e76 | ||
|
|
b03abdd2eb | ||
|
|
6ea4e985ef | ||
|
|
699d6cb08c | ||
|
|
ac70deaf19 | ||
|
|
4907db44eb | ||
|
|
81154d1f50 | ||
|
|
5eb46f6b6c | ||
|
|
001a6617f5 | ||
|
|
c009373a43 | ||
|
|
cef20e37c2 | ||
|
|
20d16c6a32 | ||
|
|
2fc3daee70 | ||
|
|
a7955ba9c5 | ||
|
|
84e773eab9 | ||
|
|
da4d3032be | ||
|
|
d89e71ac2f | ||
|
|
de806ee6d9 | ||
|
|
9c45877999 | ||
|
|
2059ecdb40 | ||
|
|
52d66b5de4 | ||
|
|
fb9b026ad6 | ||
|
|
8f1b6f6b67 | ||
|
|
0bd448a399 | ||
|
|
2b395a05ea | ||
|
|
dce17de000 | ||
|
|
3881179f72 | ||
|
|
da0a502756 | ||
|
|
cbf00e29ac | ||
|
|
a466766c5c | ||
|
|
a4781509c4 | ||
|
|
8a9361d822 | ||
|
|
e2522a492a | ||
|
|
bab551c511 | ||
|
|
c63bb16704 | ||
|
|
fa56dc4791 | ||
|
|
e92ee3b730 | ||
|
|
bb794f4887 | ||
|
|
a227389e3e | ||
|
|
d9f0b067ca | ||
|
|
c0b708462a | ||
|
|
adb0dfff47 | ||
|
|
6139cb5cb9 | ||
|
|
61d7924c54 | ||
|
|
899b09ac40 | ||
|
|
debc9fc1cb | ||
|
|
5c76adedf3 | ||
|
|
1ebb26e4c2 | ||
|
|
67378c1f52 | ||
|
|
469a22ef5f | ||
|
|
fdceb51fdc | ||
|
|
97a132e05f | ||
|
|
26fabddcbe | ||
|
|
40370067b2 | ||
|
|
f0bf6962e7 | ||
|
|
3b432a0209 | ||
|
|
c7a03922a0 | ||
|
|
e70b4c091e | ||
|
|
7e38d5e405 | ||
|
|
f810e82b45 | ||
|
|
dff9f93a6b | ||
|
|
c4109ad6ac | ||
|
|
7a6efb900e | ||
|
|
e080e47a35 | ||
|
|
82599f91d8 | ||
|
|
8fa156f625 | ||
|
|
55112cefa9 | ||
|
|
152c7c8a91 | ||
|
|
aa1c0da80e | ||
|
|
87174f207d | ||
|
|
400f879d29 |
45
.mergify.yml
Normal file
@ -0,0 +1,45 @@
|
||||
pull_request_rules:
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- and:
|
||||
- author!=shariquerik
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
- or:
|
||||
- base=main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
__version__ = "1.53.1"
|
||||
__version__ = "2.0.0-dev"
|
||||
__title__ = "Frappe CRM"
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import frappe
|
||||
from bs4 import BeautifulSoup
|
||||
from frappe.config import get_modules_from_all_apps_for_user
|
||||
from frappe.core.api.file import get_max_file_size
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import cstr, split_emails, validate_email_address
|
||||
from frappe.utils.modules import get_modules_from_all_apps_for_user
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
|
||||
|
||||
@ -106,6 +106,7 @@ def invite_by_email(emails: str, role: str):
|
||||
|
||||
if not emails:
|
||||
return
|
||||
|
||||
email_string = validate_email_address(emails, throw=False)
|
||||
email_list = split_emails(email_string)
|
||||
if not email_list:
|
||||
|
||||
@ -1008,7 +1008,7 @@ def get_deals_by_territory(from_date="", to_date="", user=""):
|
||||
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
|
||||
{deal_conds}
|
||||
GROUP BY d.territory
|
||||
ORDER BY value DESC
|
||||
ORDER BY deals DESC, value DESC
|
||||
""",
|
||||
{"from": from_date, "to": to_date},
|
||||
as_dict=True,
|
||||
@ -1065,7 +1065,7 @@ def get_deals_by_salesperson(from_date="", to_date="", user=""):
|
||||
WHERE DATE(d.creation) BETWEEN %(from)s AND %(to)s
|
||||
{deal_conds}
|
||||
GROUP BY d.deal_owner
|
||||
ORDER BY value DESC
|
||||
ORDER BY deals DESC, value DESC
|
||||
""",
|
||||
{"from": from_date, "to": to_date},
|
||||
as_dict=True,
|
||||
|
||||
@ -420,16 +420,23 @@ def get_data(
|
||||
rows.append(field)
|
||||
|
||||
for kc in kanban_columns:
|
||||
column_filters = {column_field: kc.get("name")}
|
||||
# Start with base filters
|
||||
column_filters = []
|
||||
|
||||
# Convert and add the main filters first
|
||||
if filters:
|
||||
base_filters = convert_filter_to_tuple(doctype, filters)
|
||||
column_filters.extend(base_filters)
|
||||
|
||||
# Add the column-specific filter
|
||||
if column_field and kc.get("name"):
|
||||
column_filters.append([doctype, column_field, "=", kc.get("name")])
|
||||
|
||||
order = kc.get("order")
|
||||
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"):
|
||||
if kc.get("delete"):
|
||||
column_data = []
|
||||
else:
|
||||
column_filters.update(filters.copy())
|
||||
page_length = 20
|
||||
|
||||
if kc.get("page_length"):
|
||||
page_length = kc.get("page_length")
|
||||
page_length = kc.get("page_length", 20)
|
||||
|
||||
if order:
|
||||
column_data = get_records_based_on_order(
|
||||
@ -439,26 +446,20 @@ def get_data(
|
||||
column_data = frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
filters=convert_filter_to_tuple(doctype, column_filters),
|
||||
filters=column_filters,
|
||||
order_by=order_by,
|
||||
page_length=page_length,
|
||||
)
|
||||
|
||||
new_filters = filters.copy()
|
||||
new_filters.update({column_field: kc.get("name")})
|
||||
|
||||
all_count = frappe.get_list(
|
||||
doctype,
|
||||
filters=convert_filter_to_tuple(doctype, new_filters),
|
||||
filters=column_filters,
|
||||
fields="count(*) as total_count",
|
||||
)[0].total_count
|
||||
|
||||
kc["all_count"] = all_count
|
||||
kc["count"] = len(column_data)
|
||||
|
||||
for d in column_data:
|
||||
getCounts(d, doctype)
|
||||
|
||||
if order:
|
||||
column_data = sorted(
|
||||
column_data,
|
||||
@ -807,6 +808,7 @@ def remove_doc_link(doctype, docname):
|
||||
"reference_doctype": "",
|
||||
"reference_name": "",
|
||||
}
|
||||
|
||||
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
|
||||
delete_references.update(delete_notification_type)
|
||||
|
||||
@ -854,6 +856,7 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||
remove_contact_link(item["doctype"], item["docname"])
|
||||
else:
|
||||
remove_doc_link(item["doctype"], item["docname"])
|
||||
|
||||
if delete:
|
||||
frappe.delete_doc(item["doctype"], item["docname"])
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
|
||||
@ -135,7 +135,7 @@ def get_quotation_url(crm_deal, organization):
|
||||
"party_name": crm_deal,
|
||||
"company": erpnext_crm_settings.erpnext_company,
|
||||
"contact_person": contact,
|
||||
"customer_address": address
|
||||
"customer_address": address,
|
||||
}
|
||||
else:
|
||||
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
||||
@ -147,14 +147,11 @@ def get_quotation_url(crm_deal, organization):
|
||||
"party_name": prospect,
|
||||
"company": erpnext_crm_settings.erpnext_company,
|
||||
"contact_person": contact,
|
||||
"customer_address": address
|
||||
"customer_address": address,
|
||||
}
|
||||
|
||||
|
||||
# Filter out None values and build query string
|
||||
query_string = "&".join(
|
||||
f"{key}={value}" for key, value in params.items()
|
||||
if value is not None
|
||||
)
|
||||
query_string = "&".join(f"{key}={value}" for key, value in params.items() if value is not None)
|
||||
|
||||
return f"{base_url}?{query_string}"
|
||||
|
||||
|
||||
0
crm/fcrm/doctype/helpdesk_crm_settings/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Helpdesk CRM Settings", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,102 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-08-18 17:25:49.638398",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enabled",
|
||||
"column_break_idaw",
|
||||
"is_helpdesk_in_different_site",
|
||||
"helpdesk_site_url",
|
||||
"helpdesk_site_apis_section",
|
||||
"api_key",
|
||||
"column_break_tqsm",
|
||||
"api_secret"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_idaw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "is_helpdesk_in_different_site",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Helpdesk installed on a different site?"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||
"fieldname": "helpdesk_site_url",
|
||||
"fieldtype": "Data",
|
||||
"label": "Helpdesk Site URL",
|
||||
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||
},
|
||||
{
|
||||
"depends_on": "enabled",
|
||||
"fieldname": "helpdesk_site_apis_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Helpdesk Site API's"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||
"fieldname": "api_key",
|
||||
"fieldtype": "Data",
|
||||
"label": "API Key",
|
||||
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_tqsm",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enabled && doc.is_helpdesk_in_different_site",
|
||||
"fieldname": "api_secret",
|
||||
"fieldtype": "Password",
|
||||
"label": "API Secret",
|
||||
"mandatory_depends_on": "is_helpdesk_in_different_site"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-18 17:33:38.616328",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "Helpdesk CRM Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
178
crm/fcrm/doctype/helpdesk_crm_settings/helpdesk_crm_settings.py
Normal file
@ -0,0 +1,178 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class HelpdeskCRMSettings(Document):
|
||||
def validate(self):
|
||||
if self.enabled:
|
||||
self.validate_if_helpdesk_installed()
|
||||
self.create_helpdesk_script()
|
||||
|
||||
def validate_if_helpdesk_installed(self):
|
||||
if not self.is_helpdesk_in_different_site:
|
||||
if "helpdesk" not in frappe.get_installed_apps():
|
||||
frappe.throw(_("Helpdesk is not installed in the current site"))
|
||||
|
||||
def create_helpdesk_script(self):
|
||||
if not frappe.db.exists("CRM Form Script", "Helpdesk Integration Script"):
|
||||
script = get_helpdesk_script()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Form Script",
|
||||
"name": "Helpdesk Integration Script",
|
||||
"dt": "CRM Deal",
|
||||
"view": "Form",
|
||||
"script": script,
|
||||
"enabled": 1,
|
||||
"is_standard": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_customer_in_helpdesk(name, email):
|
||||
helpdesk_crm_settings = frappe.get_single("Helpdesk CRM Settings")
|
||||
if not helpdesk_crm_settings.enabled:
|
||||
frappe.throw(_("Helpdesk is not integrated with the CRM"))
|
||||
|
||||
if not helpdesk_crm_settings.is_helpdesk_in_different_site:
|
||||
# from helpdesk.integrations.crm.api import create_customer
|
||||
return create_customer(name, email)
|
||||
|
||||
|
||||
def get_helpdesk_script():
|
||||
return """class CRMDeal {
|
||||
onLoad() {
|
||||
this.actions.push(
|
||||
{
|
||||
group: "Helpdesk",
|
||||
hideLabel: true,
|
||||
items: [
|
||||
{
|
||||
label: "Create customer in Helpdesk",
|
||||
onClick: () => {
|
||||
call('crm.fcrm.doctype.helpdesk_crm_settings.helpdesk_crm_settings.create_customer_in_helpdesk', {
|
||||
name: this.doc.organization,
|
||||
email: this.doc.email
|
||||
}).then((a) => {
|
||||
toast.success("Customer created successfully, " + a.customer)
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
}"""
|
||||
|
||||
# Helpdesk methods TODO: move to helpdesk.integrations.crm.api
|
||||
def create_customer(name, email):
|
||||
customer = frappe.db.exists("HD Customer", name)
|
||||
if not customer:
|
||||
customer = frappe.get_doc(
|
||||
{
|
||||
"doctype": "HD Customer",
|
||||
"customer_name": name,
|
||||
}
|
||||
)
|
||||
customer.insert(ignore_permissions=True, ignore_if_duplicate=True)
|
||||
else:
|
||||
customer = frappe.get_doc("HD Customer", customer)
|
||||
|
||||
contact = frappe.db.exists("Contact", {"email_id": email})
|
||||
if contact:
|
||||
contact = frappe.get_doc("Contact", contact)
|
||||
contact.append(
|
||||
"links", {"link_doctype": "HD Customer", "link_name": customer.name}
|
||||
)
|
||||
contact.save(ignore_permissions=True)
|
||||
else:
|
||||
contact = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contact",
|
||||
"first_name": email.split("@")[0],
|
||||
"email_ids": [{"email_id": email, "is_primary": 1}],
|
||||
"links": [{"link_doctype": "HD Customer", "link_name": customer.name}],
|
||||
}
|
||||
)
|
||||
contact.insert(ignore_permissions=True)
|
||||
|
||||
if not frappe.db.exists("User", contact.email_id):
|
||||
invite_user(contact.name)
|
||||
else:
|
||||
base_url = frappe.utils.get_url() + "/helpdesk"
|
||||
frappe.sendmail(
|
||||
recipients=[contact.email_id],
|
||||
subject="Welcome existing user to Helpdesk",
|
||||
message=f"""
|
||||
<h1>Hello,</h1>
|
||||
<button>{base_url}</button>
|
||||
""",
|
||||
now=True,
|
||||
)
|
||||
|
||||
return {"customer": customer.name, "contact": contact.name}
|
||||
|
||||
|
||||
def invite_user(contact: str):
|
||||
contact = frappe.get_doc("Contact", contact)
|
||||
contact.check_permission()
|
||||
|
||||
if not contact.email_id:
|
||||
frappe.throw(_("Please set Email Address"))
|
||||
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"first_name": contact.first_name,
|
||||
"last_name": contact.last_name,
|
||||
"email": contact.email_id,
|
||||
"user_type": "Website User",
|
||||
"send_welcome_email": 0
|
||||
}
|
||||
).insert()
|
||||
|
||||
contact.user = user.name
|
||||
contact.save(ignore_permissions=True)
|
||||
|
||||
send_welcome_mail_to_user(user)
|
||||
return user.name
|
||||
|
||||
|
||||
def send_welcome_mail_to_user(user):
|
||||
from frappe.utils import get_url
|
||||
from frappe.utils.user import get_user_fullname
|
||||
|
||||
link = user.reset_password()
|
||||
|
||||
frappe.cache.hset("redirect_after_login", user.name, "/helpdesk")
|
||||
|
||||
site_url = get_url()
|
||||
subject = _("Welcome to Helpdesk")
|
||||
|
||||
created_by = get_user_fullname(frappe.session["user"])
|
||||
if created_by == "Guest":
|
||||
created_by = "Administrator"
|
||||
|
||||
args = {
|
||||
"first_name": user.first_name or user.last_name or "user",
|
||||
"last_name": user.last_name,
|
||||
"user": user.name,
|
||||
"title": subject,
|
||||
"login_url": get_url(),
|
||||
"created_by": created_by,
|
||||
"site_url": site_url,
|
||||
"link": link
|
||||
}
|
||||
|
||||
frappe.sendmail(
|
||||
recipients=[user.email],
|
||||
subject=subject,
|
||||
template="helpdesk_invitation",
|
||||
args=args,
|
||||
now=True,
|
||||
)
|
||||
@ -0,0 +1,21 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
|
||||
class IntegrationTestHelpdeskCRMSettings(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for HelpdeskCRMSettings.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: shariq@frappe.io\n"
|
||||
"POT-Creation-Date: 2025-09-21 09:35+0000\n"
|
||||
"PO-Revision-Date: 2025-09-22 20:42\n"
|
||||
"PO-Revision-Date: 2025-09-27 22:24\n"
|
||||
"Last-Translator: shariq@frappe.io\n"
|
||||
"Language-Team: Hungarian\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@ -450,7 +450,7 @@ msgstr ""
|
||||
#. Description of the 'Favicon' (Attach) field in DocType 'FCRM Settings'
|
||||
#: crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
|
||||
msgid "An icon file with .ico extension. Should be 16 x 16 px. Generated using a favicon generator. [favicon-generator.org]"
|
||||
msgstr ""
|
||||
msgstr "Egy ikon fájlt .ico kiterjesztéssel. Legyen 16 x 16 px. favicon generátor felhasználásával előállított. [favicon-generator.org]"
|
||||
|
||||
#. Description of the 'Logo' (Attach) field in DocType 'FCRM Settings'
|
||||
#: crm/fcrm/doctype/fcrm_settings/fcrm_settings.json
|
||||
@ -5937,7 +5937,7 @@ msgstr "Ön"
|
||||
|
||||
#: crm/utils/__init__.py:262
|
||||
msgid "You are not permitted to access this resource."
|
||||
msgstr ""
|
||||
msgstr "Nincs hozzáférésed ehhez az erőforráshoz."
|
||||
|
||||
#: crm/templates/emails/helpdesk_invitation.html:22
|
||||
msgid "You can also copy-paste following link in your browser"
|
||||
|
||||
@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: shariq@frappe.io\n"
|
||||
"POT-Creation-Date: 2025-09-21 09:35+0000\n"
|
||||
"PO-Revision-Date: 2025-09-24 21:29\n"
|
||||
"PO-Revision-Date: 2025-09-27 22:24\n"
|
||||
"Last-Translator: shariq@frappe.io\n"
|
||||
"Language-Team: Norwegian Bokmal\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@ -300,7 +300,7 @@ msgstr ""
|
||||
#. 'CRM Holiday List'
|
||||
#: crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json
|
||||
msgid "Add Weekly Holidays"
|
||||
msgstr "Legg til ukentlige helligdager"
|
||||
msgstr "Legg til ukentlige fridager"
|
||||
|
||||
#: frontend/src/components/Settings/AssignmentRules/AssignmentRulesSection.vue:20
|
||||
msgid "Add a condition"
|
||||
@ -375,7 +375,7 @@ msgstr ""
|
||||
#. Label of the add_to_holidays (Button) field in DocType 'CRM Holiday List'
|
||||
#: crm/fcrm/doctype/crm_holiday_list/crm_holiday_list.json
|
||||
msgid "Add to Holidays"
|
||||
msgstr ""
|
||||
msgstr "Legg til i fridager"
|
||||
|
||||
#: frontend/src/components/Layouts/AppSidebar.vue:439
|
||||
msgid "Add your first comment"
|
||||
@ -597,12 +597,12 @@ msgstr ""
|
||||
#. Option for the 'Type' (Select) field in DocType 'CRM Notification'
|
||||
#: crm/fcrm/doctype/crm_notification/crm_notification.json
|
||||
msgid "Assignment"
|
||||
msgstr ""
|
||||
msgstr "Tildeling"
|
||||
|
||||
#. Label of a shortcut in the Frappe CRM Workspace
|
||||
#: crm/fcrm/workspace/frappe_crm/frappe_crm.json
|
||||
msgid "Assignment Rule"
|
||||
msgstr "Tilordningsregel"
|
||||
msgstr "Tildelingsregel"
|
||||
|
||||
#: frontend/src/components/Settings/AssignmentRules/AssignmentRuleView.vue:302
|
||||
msgid "Assignment Schedule"
|
||||
@ -1331,7 +1331,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/composables/frappecloud.js:29
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
msgstr "Bekreft"
|
||||
|
||||
#: frontend/src/components/Settings/AssignmentRules/AssignmentRuleListItem.vue:139
|
||||
#: frontend/src/components/Settings/EmailTemplate/EmailTemplates.vue:239
|
||||
@ -1340,7 +1340,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/components/Modals/ChangePasswordModal.vue:18
|
||||
msgid "Confirm Password"
|
||||
msgstr ""
|
||||
msgstr "Bekreft passord"
|
||||
|
||||
#: frontend/src/components/Settings/Users.vue:228
|
||||
msgid "Confirm Remove"
|
||||
@ -1362,7 +1362,7 @@ msgstr ""
|
||||
#: frontend/src/components/Modals/ConvertToDealModal.vue:51
|
||||
#: frontend/src/pages/MobileLead.vue:145
|
||||
msgid "Contact"
|
||||
msgstr ""
|
||||
msgstr "Kontakt"
|
||||
|
||||
#: crm/fcrm/doctype/crm_lead/crm_lead.py:212
|
||||
msgid "Contact Already Exists"
|
||||
@ -1614,7 +1614,7 @@ msgstr ""
|
||||
#: frontend/src/components/Layouts/AppSidebar.vue:587
|
||||
#: frontend/src/components/Settings/Settings.vue:170
|
||||
msgid "Customization"
|
||||
msgstr ""
|
||||
msgstr "Egendefinering"
|
||||
|
||||
#: frontend/src/components/ViewControls.vue:211
|
||||
msgid "Customize quick filters"
|
||||
@ -3234,7 +3234,7 @@ msgstr ""
|
||||
#: crm/fcrm/doctype/crm_deal/crm_deal.json
|
||||
#: crm/fcrm/doctype/crm_lead/crm_lead.json
|
||||
msgid "Last Name"
|
||||
msgstr ""
|
||||
msgstr "Etternavn"
|
||||
|
||||
#: frontend/src/components/Filter.vue:611
|
||||
msgid "Last Quarter"
|
||||
@ -4610,7 +4610,7 @@ msgstr "Fjernet seksjon"
|
||||
|
||||
#: frontend/src/components/FieldLayoutEditor.vue:318
|
||||
msgid "Remove tab"
|
||||
msgstr ""
|
||||
msgstr "Fjern fane"
|
||||
|
||||
#: frontend/src/components/Activities/EmailArea.vue:34
|
||||
#: frontend/src/components/CommunicationArea.vue:10
|
||||
@ -5981,7 +5981,7 @@ msgstr ""
|
||||
|
||||
#: crm/api/todo.py:37 crm/api/todo.py:78
|
||||
msgid "Your assignment on {0} {1} has been removed by {2}"
|
||||
msgstr ""
|
||||
msgstr "Oppgaven din på {0} {1} er fjernet av {2}"
|
||||
|
||||
#: crm/templates/emails/helpdesk_invitation.html:6
|
||||
msgid "Your login id is"
|
||||
@ -6168,7 +6168,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:140 frontend/src/utils/index.js:166
|
||||
msgid "just now"
|
||||
msgstr ""
|
||||
msgstr "akkurat nå"
|
||||
|
||||
#. Option for the 'Type' (Select) field in DocType 'CRM View Settings'
|
||||
#: crm/fcrm/doctype/crm_view_settings/crm_view_settings.json
|
||||
@ -6177,7 +6177,7 @@ msgstr ""
|
||||
|
||||
#: crm/api/doc.py:40 crm/api/doc.py:158 crm/api/doc.py:503
|
||||
msgid "label"
|
||||
msgstr ""
|
||||
msgstr "etikett"
|
||||
|
||||
#: frontend/src/components/Settings/AssignmentRules/AssigneeRules.vue:130
|
||||
#: frontend/src/components/Settings/AssignmentRules/AssignmentRuleView.vue:379
|
||||
@ -6200,7 +6200,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:97 frontend/src/utils/index.js:117
|
||||
msgid "now"
|
||||
msgstr ""
|
||||
msgstr "nå"
|
||||
|
||||
#: frontend/src/components/ConditionsFilter/CFCondition.vue:47
|
||||
msgid "operator"
|
||||
@ -6295,7 +6295,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:130
|
||||
msgid "{0} M"
|
||||
msgstr ""
|
||||
msgstr "{0} M"
|
||||
|
||||
#: crm/api/todo.py:41
|
||||
msgid "{0} assigned a {1} {2} to you"
|
||||
@ -6303,15 +6303,15 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:126
|
||||
msgid "{0} d"
|
||||
msgstr ""
|
||||
msgstr "{0} d"
|
||||
|
||||
#: frontend/src/utils/index.js:181
|
||||
msgid "{0} days ago"
|
||||
msgstr ""
|
||||
msgstr "{0} dager siden"
|
||||
|
||||
#: frontend/src/utils/index.js:121
|
||||
msgid "{0} h"
|
||||
msgstr ""
|
||||
msgstr "{0} h"
|
||||
|
||||
#: frontend/src/components/Settings/Users.vue:286
|
||||
msgid "{0} has been granted {1} access"
|
||||
@ -6319,7 +6319,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:174
|
||||
msgid "{0} hours ago"
|
||||
msgstr ""
|
||||
msgstr "{0} timer siden."
|
||||
|
||||
#: frontend/src/composables/event.js:163
|
||||
msgid "{0} hrs"
|
||||
@ -6340,7 +6340,7 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:119
|
||||
msgid "{0} m"
|
||||
msgstr ""
|
||||
msgstr "{0} m"
|
||||
|
||||
#: frontend/src/composables/event.js:157
|
||||
msgid "{0} mins"
|
||||
@ -6348,15 +6348,15 @@ msgstr ""
|
||||
|
||||
#: frontend/src/utils/index.js:170
|
||||
msgid "{0} minutes ago"
|
||||
msgstr ""
|
||||
msgstr "{0} minutter siden"
|
||||
|
||||
#: frontend/src/utils/index.js:189
|
||||
msgid "{0} months ago"
|
||||
msgstr ""
|
||||
msgstr "{0} måneder siden"
|
||||
|
||||
#: frontend/src/utils/index.js:128
|
||||
msgid "{0} w"
|
||||
msgstr ""
|
||||
msgstr "{0} w"
|
||||
|
||||
#: frontend/src/utils/index.js:185
|
||||
msgid "{0} weeks ago"
|
||||
@ -6364,11 +6364,11 @@ msgstr "{0} uker siden"
|
||||
|
||||
#: frontend/src/utils/index.js:132
|
||||
msgid "{0} y"
|
||||
msgstr ""
|
||||
msgstr "{0} y"
|
||||
|
||||
#: frontend/src/utils/index.js:193
|
||||
msgid "{0} years ago"
|
||||
msgstr ""
|
||||
msgstr "{0} uker siden"
|
||||
|
||||
#: frontend/src/data/script.js:326
|
||||
msgid "⚠️ Avoid using \"trigger\" as a field name — it conflicts with the built-in trigger() method."
|
||||
|
||||
@ -3,7 +3,7 @@ msgstr ""
|
||||
"Project-Id-Version: frappe\n"
|
||||
"Report-Msgid-Bugs-To: shariq@frappe.io\n"
|
||||
"POT-Creation-Date: 2025-09-21 09:35+0000\n"
|
||||
"PO-Revision-Date: 2025-09-22 20:42\n"
|
||||
"PO-Revision-Date: 2025-09-27 22:23\n"
|
||||
"Last-Translator: shariq@frappe.io\n"
|
||||
"Language-Team: Swedish\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@ -3030,7 +3030,7 @@ msgstr "Integration inte aktiverad"
|
||||
#: frontend/src/components/Settings/Settings.vue:181
|
||||
msgctxt "FCRM"
|
||||
msgid "Integrations"
|
||||
msgstr "System Integrationer"
|
||||
msgstr "Integrationer"
|
||||
|
||||
#: frontend/src/components/Layouts/AppSidebar.vue:529
|
||||
#: frontend/src/components/Layouts/AppSidebar.vue:532
|
||||
|
||||
24
crm/templates/emails/helpdesk_invitation.html
Normal file
@ -0,0 +1,24 @@
|
||||
<p>
|
||||
{{_("Hello")}} {{ first_name }}{% if last_name %} {{ last_name}}{% endif %},
|
||||
</p>
|
||||
{% set site_link = "<a href='" + site_url + "'>" + site_url + "</a>" %}
|
||||
<p>{{_("A new account has been created for you at {0}").format(site_link)}}.</p>
|
||||
<p>{{_("Your login id is")}}: <b>{{ user }}</b>
|
||||
<p>{{_("Click on the link below to complete your registration and set a new password")}}.</p>
|
||||
|
||||
<p style="margin: 15px 0px;">
|
||||
<a href="{{ link }}" rel="nofollow" class="btn btn-primary">{{ _("Complete Registration") }}</a>
|
||||
</p>
|
||||
|
||||
{% if created_by != "Administrator" %}
|
||||
<br>
|
||||
<p style="margin-top: 15px">
|
||||
{{_("Thanks")}},<br>
|
||||
{{ created_by }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<br>
|
||||
<p>
|
||||
{{_("You can also copy-paste following link in your browser")}}<br>
|
||||
<a href="{{ link }}">{{ link }}</a>
|
||||
</p>
|
||||
32
frontend/components.d.ts
vendored
@ -23,18 +23,30 @@ declare module 'vue' {
|
||||
AppSidebar: typeof import('./src/components/Layouts/AppSidebar.vue')['default']
|
||||
ArrowUpRightIcon: typeof import('./src/components/Icons/ArrowUpRightIcon.vue')['default']
|
||||
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
|
||||
AssigneeRules: typeof import('./src/components/Settings/AssignmentRules/AssigneeRules.vue')['default']
|
||||
AssigneeSearch: typeof import('./src/components/Settings/AssignmentRules/AssigneeSearch.vue')['default']
|
||||
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default']
|
||||
AssignmentRuleListItem: typeof import('./src/components/Settings/AssignmentRules/AssignmentRuleListItem.vue')['default']
|
||||
AssignmentRulePage: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulePage.vue')['default']
|
||||
AssignmentRules: typeof import('./src/components/Settings/AssignmentRules/AssignmentRules.vue')['default']
|
||||
AssignmentRulesList: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulesList.vue')['default']
|
||||
AssignmentRulesSection: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulesSection.vue')['default']
|
||||
AssignmentRuleView: typeof import('./src/components/Settings/AssignmentRules/AssignmentRuleView.vue')['default']
|
||||
AssignmentSchedule: typeof import('./src/components/Settings/AssignmentRules/AssignmentSchedule.vue')['default']
|
||||
AssignmentScheduleItem: typeof import('./src/components/Settings/AssignmentRules/AssignmentScheduleItem.vue')['default']
|
||||
AssignTo: typeof import('./src/components/AssignTo.vue')['default']
|
||||
AssignToBody: typeof import('./src/components/AssignToBody.vue')['default']
|
||||
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
|
||||
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
|
||||
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
|
||||
Attendee: typeof import('./src/components/Calendar/Attendee.vue')['default']
|
||||
AudioPlayer: typeof import('./src/components/Activities/AudioPlayer.vue')['default']
|
||||
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
|
||||
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
|
||||
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
|
||||
BrandSettings: typeof import('./src/components/Settings/BrandSettings.vue')['default']
|
||||
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
|
||||
CalendarEventPanel: typeof import('./src/components/Calendar/CalendarEventPanel.vue')['default']
|
||||
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
||||
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
|
||||
@ -43,6 +55,8 @@ declare module 'vue' {
|
||||
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
|
||||
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
|
||||
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
|
||||
CFCondition: typeof import('./src/components/ConditionsFilter/CFCondition.vue')['default']
|
||||
CFConditions: typeof import('./src/components/ConditionsFilter/CFConditions.vue')['default']
|
||||
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
|
||||
@ -75,6 +89,7 @@ declare module 'vue' {
|
||||
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
|
||||
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
|
||||
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
|
||||
DescriptionIcon: typeof import('./src/components/Icons/DescriptionIcon.vue')['default']
|
||||
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
|
||||
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
|
||||
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
|
||||
@ -84,7 +99,6 @@ declare module 'vue' {
|
||||
DoubleCheckIcon: typeof import('./src/components/Icons/DoubleCheckIcon.vue')['default']
|
||||
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
|
||||
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
|
||||
Dropdown: typeof import('./src/components/frappe-ui/Dropdown.vue')['default']
|
||||
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
|
||||
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
|
||||
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
|
||||
@ -101,6 +115,7 @@ declare module 'vue' {
|
||||
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
|
||||
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailMultiSelect: typeof import('./src/components/Controls/EmailMultiSelect.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
@ -109,6 +124,9 @@ declare module 'vue' {
|
||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||
EventArea: typeof import('./src/components/Activities/EventArea.vue')['default']
|
||||
EventIcon: typeof import('./src/components/Icons/EventIcon.vue')['default']
|
||||
EventModal: typeof import('./src/components/Modals/EventModal.vue')['default']
|
||||
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
|
||||
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
|
||||
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
|
||||
@ -140,6 +158,8 @@ declare module 'vue' {
|
||||
GroupBy: typeof import('./src/components/GroupBy.vue')['default']
|
||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
|
||||
HelpdeskIcon: typeof import('./src/components/Icons/HelpdeskIcon.vue')['default']
|
||||
HelpdeskSettings: typeof import('./src/components/Settings/HelpdeskSettings.vue')['default']
|
||||
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
|
||||
HomeActions: typeof import('./src/components/Settings/HomeActions.vue')['default']
|
||||
Icon: typeof import('./src/components/Icon.vue')['default']
|
||||
@ -168,6 +188,10 @@ declare module 'vue' {
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
|
||||
LucideCopy: typeof import('~icons/lucide/copy')['default']
|
||||
LucideTrash2: typeof import('~icons/lucide/trash2')['default']
|
||||
LucideX: typeof import('~icons/lucide/x')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -179,8 +203,6 @@ declare module 'vue' {
|
||||
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
|
||||
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
||||
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
|
||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
|
||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||
@ -194,12 +216,12 @@ declare module 'vue' {
|
||||
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
|
||||
Password: typeof import('./src/components/Controls/Password.vue')['default']
|
||||
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
|
||||
PeopleIcon: typeof import('./src/components/Icons/PeopleIcon.vue')['default']
|
||||
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
|
||||
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
|
||||
PlaybackSpeedIcon: typeof import('./src/components/Icons/PlaybackSpeedIcon.vue')['default']
|
||||
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
|
||||
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
|
||||
Popover: typeof import('./src/components/frappe-ui/Popover.vue')['default']
|
||||
PrimaryDropdown: typeof import('./src/components/PrimaryDropdown.vue')['default']
|
||||
PrimaryDropdownItem: typeof import('./src/components/PrimaryDropdownItem.vue')['default']
|
||||
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
|
||||
@ -219,7 +241,9 @@ declare module 'vue' {
|
||||
SelectIcon: typeof import('./src/components/Icons/SelectIcon.vue')['default']
|
||||
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
|
||||
SettingsIcon: typeof import('./src/components/Icons/SettingsIcon.vue')['default']
|
||||
SettingsIcon2: typeof import('./src/components/Icons/SettingsIcon2.vue')['default']
|
||||
SettingsPage: typeof import('./src/components/Settings/SettingsPage.vue')['default']
|
||||
ShortcutTooltip: typeof import('./src/components/ShortcutTooltip.vue')['default']
|
||||
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
|
||||
SidePanelLayout: typeof import('./src/components/SidePanelLayout.vue')['default']
|
||||
SidePanelLayoutEditor: typeof import('./src/components/SidePanelLayoutEditor.vue')['default']
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout v-if="session().isLoggedIn">
|
||||
<Layout class="isolate" v-if="session().isLoggedIn">
|
||||
<router-view :key="$route.fullPath"/>
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
@ -10,9 +10,8 @@
|
||||
<script setup>
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { sessionStore as session } from '@/stores/session'
|
||||
import { setTheme } from '@/stores/theme'
|
||||
import { FrappeUIProvider, setConfig } from 'frappe-ui'
|
||||
import { computed, defineAsyncComponent, onMounted } from 'vue'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
const MobileLayout = defineAsyncComponent(
|
||||
() => import('./components/Layouts/MobileLayout.vue'),
|
||||
@ -28,8 +27,6 @@ const Layout = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => setTheme())
|
||||
|
||||
setConfig('systemTimezone', window.timezone?.system || null)
|
||||
setConfig('localTimezone', window.timezone?.user || null)
|
||||
</script>
|
||||
|
||||
@ -21,6 +21,9 @@
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div v-else-if="title == 'Events'" class="h-full activity">
|
||||
<EventArea :doctype="doctype" :docname="docname" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
activities?.length ||
|
||||
@ -435,6 +438,7 @@
|
||||
<AllModals
|
||||
ref="modalRef"
|
||||
v-model="all_activities"
|
||||
v-model:events="events"
|
||||
:doctype="doctype"
|
||||
:doc="doc"
|
||||
/>
|
||||
@ -463,11 +467,13 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
|
||||
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import EventArea from '@/components/Activities/EventArea.vue'
|
||||
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
||||
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
@ -754,6 +760,9 @@ function timelineIcon(activity_type, is_lead) {
|
||||
case 'comment':
|
||||
icon = CommentIcon
|
||||
break
|
||||
case 'event':
|
||||
icon = CalendarIcon
|
||||
break
|
||||
case 'incoming_call':
|
||||
icon = InboundCallIcon
|
||||
break
|
||||
@ -783,7 +792,7 @@ watch([reload, reload_email], ([reload_value, reload_email_value]) => {
|
||||
})
|
||||
|
||||
function scroll(hash) {
|
||||
if (['tasks', 'notes'].includes(route.hash?.slice(1))) return
|
||||
if (['tasks', 'notes', 'events'].includes(route.hash?.slice(1))) return
|
||||
setTimeout(() => {
|
||||
let el
|
||||
if (!hash) {
|
||||
|
||||
@ -25,6 +25,16 @@
|
||||
variant="solid"
|
||||
:options="callActions"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Events'"
|
||||
variant="solid"
|
||||
@click="modalRef.showEvent()"
|
||||
>
|
||||
<template #prefix>
|
||||
<EventIcon class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('Schedule an event') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
variant="solid"
|
||||
@ -75,6 +85,7 @@
|
||||
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import EventIcon from '@/components/Icons/EventIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
@ -112,6 +123,11 @@ const defaultActions = computed(() => {
|
||||
label: __('New Comment'),
|
||||
onClick: () => (props.emailBox.showComment = true),
|
||||
},
|
||||
{
|
||||
icon: h(EventIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Schedule an event'),
|
||||
onClick: () => props.modalRef.showEvent(),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Log a Call'),
|
||||
|
||||
@ -22,21 +22,36 @@
|
||||
:referenceDoc="referenceDoc"
|
||||
:options="{ afterInsert: () => activities.reload() }"
|
||||
/>
|
||||
<EventModal
|
||||
v-if="showEventModal"
|
||||
v-model="showEventModal"
|
||||
:event="activeEvent"
|
||||
:doctype="doctype"
|
||||
:docname="doc?.name"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||
import NoteModal from '@/components/Modals/NoteModal.vue'
|
||||
import CallLogModal from '@/components/Modals/CallLogModal.vue'
|
||||
import EventModal from '@/components/Modals/EventModal.vue'
|
||||
import { showEventModal, activeEvent } from '@/composables/event'
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: String,
|
||||
doc: Object,
|
||||
})
|
||||
|
||||
const activities = defineModel()
|
||||
const doc = defineModel('doc')
|
||||
|
||||
// Event
|
||||
function showEvent(e) {
|
||||
showEventModal.value = true
|
||||
activeEvent.value = e
|
||||
}
|
||||
|
||||
// Tasks
|
||||
const showTaskModal = ref(false)
|
||||
@ -115,6 +130,7 @@ function redirect(tabName) {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showEvent,
|
||||
showTask,
|
||||
deleteTask,
|
||||
updateTaskStatus,
|
||||
|
||||
@ -89,7 +89,7 @@ import VolumnHighIcon from '@/components/Icons/VolumnHighIcon.vue'
|
||||
import MuteIcon from '@/components/Icons/MuteIcon.vue'
|
||||
import PlaybackSpeedIcon from '@/components/Icons/PlaybackSpeedIcon.vue'
|
||||
import PlaybackSpeedOption from '@/components/Activities/PlaybackSpeedOption.vue'
|
||||
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { computed, h, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
109
frontend/src/components/Activities/EventArea.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div v-if="events.length" v-for="(event, i) in events" :key="event.name">
|
||||
<div
|
||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
|
||||
>
|
||||
<div
|
||||
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-gray-modals"
|
||||
:class="i != events.length - 1 ? 'before:h-full' : 'before:h-4'"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
||||
>
|
||||
<CalendarIcon class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<div
|
||||
class="mb-1 flex items-center justify-stretch gap-2 py-1 text-base"
|
||||
>
|
||||
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
|
||||
<Avatar
|
||||
:image="event.owner.image"
|
||||
:label="event.owner.label"
|
||||
size="md"
|
||||
/>
|
||||
<span class="font-medium text-ink-gray-8 ml-1">
|
||||
{{ event.owner.label }}
|
||||
</span>
|
||||
<span>{{ 'has created an event' }}</span>
|
||||
</div>
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="formatDate(event.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(event.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-2 border cursor-pointer border-outline-gray-modals rounded-lg bg-surface-cards px-2.5 py-2.5 text-ink-gray-9"
|
||||
@click="showEvent(event)"
|
||||
>
|
||||
<div
|
||||
class="flex w-[2px] rounded-lg"
|
||||
:style="{ backgroundColor: event.color || '#30A66D' }"
|
||||
/>
|
||||
<div class="flex-1 flex flex-col gap-1 text-base">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 font-medium text-ink-gray-7"
|
||||
>
|
||||
<div>{{ event.subject }}</div>
|
||||
<MultipleAvatar
|
||||
v-if="event.participants?.length > 1"
|
||||
:avatars="event.participants"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-2 items-center text-ink-gray-6"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
startEndTime(event.starts_on, event.ends_on, event.all_day)
|
||||
}}
|
||||
</div>
|
||||
<div>{{ startDate(event.starts_on) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
|
||||
>
|
||||
<CalendarIcon class="h-10 w-10" />
|
||||
<span>{{ __('No Events Scheduled') }}</span>
|
||||
<Button :label="__('Schedule an Event')" @click="showEvent()" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import { useEvent, showEventModal, activeEvent } from '@/composables/event'
|
||||
import { formatDate, timeAgo } from '@/utils'
|
||||
import { Tooltip, Avatar } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
function showEvent(e = {}) {
|
||||
showEventModal.value = true
|
||||
activeEvent.value = e
|
||||
}
|
||||
|
||||
const { events, startEndTime, startDate } = useEvent(
|
||||
props.doctype,
|
||||
props.docname,
|
||||
)
|
||||
</script>
|
||||
334
frontend/src/components/Calendar/Attendee.vue
Normal file
@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Combobox Input -->
|
||||
<div class="flex items-center w-full text-ink-gray-8 [&>div]:w-full">
|
||||
<ComboboxRoot
|
||||
:model-value="tempSelection"
|
||||
:open="showOptions"
|
||||
@update:open="(o) => (showOptions = o)"
|
||||
@update:modelValue="onSelect"
|
||||
:ignore-filter="true"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex w-full text-base items-center gap-1 rounded border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 px-2 py-1"
|
||||
:class="[size === 'sm' ? 'h-7' : 'h-8 ', inputClass]"
|
||||
@click="showOptions = true"
|
||||
>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
autocomplete="off"
|
||||
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||
:placeholder="placeholder"
|
||||
:value="query"
|
||||
@input="onInput"
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
@keydown.escape.stop="showOptions = false"
|
||||
/>
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 text-ink-gray-5 cursor-pointer"
|
||||
@click.stop="showOptions = !showOptions"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
class="z-10 mt-1 min-w-48 w-full max-w-md 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"
|
||||
>
|
||||
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
|
||||
{{ emptyStateText }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
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"
|
||||
@mousedown.prevent="onSelect(option.value, option)"
|
||||
>
|
||||
<UserAvatar class="mr-2" :user="option.value" size="lg" />
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">{{ option.label }}</div>
|
||||
<div class="text-sm text-ink-gray-5">{{ option.value }}</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
|
||||
<!-- Selected Attendees -->
|
||||
<div
|
||||
v-if="values.length"
|
||||
class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
|
||||
ref="optionsRef"
|
||||
>
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="att in values"
|
||||
:key="att.email"
|
||||
:label="att.email"
|
||||
theme="gray"
|
||||
class="rounded-full w-fit"
|
||||
:tooltip="getTooltip(att.email)"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar :user="att.email" class="-ml-1 !size-5.5" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(att.email)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import {
|
||||
ComboboxRoot,
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxContent,
|
||||
ComboboxViewport,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from 'reka-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'sm',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Add attendee',
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchContacts: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
const optionsRef = ref(null)
|
||||
const tempSelection = ref(null)
|
||||
|
||||
const metaByEmail = computed(() => {
|
||||
const out = {}
|
||||
const source = values.value || []
|
||||
for (const a of source) {
|
||||
if (a?.email) out[a.email] = a
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
function getTooltip(email) {
|
||||
const m = metaByEmail.value[email]
|
||||
if (!m) return email
|
||||
const parts = []
|
||||
if (m.reference_doctype) parts.push(m.reference_doctype)
|
||||
if (m.reference_docname) parts.push(m.reference_docname)
|
||||
return parts.length ? parts.join(': ') : email
|
||||
}
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val && options.value?.length) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'crm.api.contact.search_emails',
|
||||
method: 'POST',
|
||||
cache: [text.value, 'Contact'],
|
||||
params: { txt: text.value },
|
||||
transform: (data) => {
|
||||
let allData = data.map((option) => {
|
||||
let fullName = option[0]
|
||||
let email = option[1]
|
||||
let name = option[2]
|
||||
return {
|
||||
label: fullName || name || email,
|
||||
name: name,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out existing emails
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let searchedContacts = props.fetchContacts ? filterOptions.data : []
|
||||
if (!searchedContacts?.length && query.value) {
|
||||
searchedContacts.push({
|
||||
name: 'new',
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
return searchedContacts || []
|
||||
})
|
||||
|
||||
const emptyStateText = computed(() =>
|
||||
props.fetchContacts
|
||||
? __('No results found')
|
||||
: __('Type an email address to add attendee'),
|
||||
)
|
||||
|
||||
function reload(val) {
|
||||
if (!props.fetchContacts) return
|
||||
|
||||
filterOptions.update({
|
||||
params: { txt: val },
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
function onSelect(val, fullOption = null) {
|
||||
if (!val) return
|
||||
const optionObj = fullOption ||
|
||||
options.value.find((o) => o.value === val) || {
|
||||
name: 'new',
|
||||
label: val,
|
||||
value: val,
|
||||
}
|
||||
addValue(optionObj)
|
||||
if (!error.value) {
|
||||
query.value = ''
|
||||
tempSelection.value = null
|
||||
showOptions.value = false
|
||||
nextTick(() => setFocus())
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (query.value) {
|
||||
onSelect(query.value, {
|
||||
name: 'new',
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value
|
||||
showOptions.value = true
|
||||
}
|
||||
|
||||
const addValue = (option) => {
|
||||
// Safeguard for falsy option
|
||||
if (!option || !option.value) return
|
||||
|
||||
error.value = null
|
||||
info.value = null
|
||||
|
||||
const current = Array.isArray(values.value) ? values.value.slice() : []
|
||||
const existing = new Set(current.map((a) => a.email))
|
||||
|
||||
const raw = option.value || ''
|
||||
const parts = raw.split(',')
|
||||
const hasMultiple = parts.length > 1
|
||||
|
||||
for (let p of parts) {
|
||||
p = p.trim()
|
||||
if (!p) continue
|
||||
if (existing.has(p)) {
|
||||
info.value = __('email already exists')
|
||||
continue
|
||||
}
|
||||
if (props.validate && !props.validate(p)) {
|
||||
error.value = props.errorMessage(p)
|
||||
query.value = p
|
||||
continue
|
||||
}
|
||||
existing.add(p)
|
||||
const entry = { email: p }
|
||||
|
||||
if (option.name && !hasMultiple) {
|
||||
entry.reference_docname = option.name
|
||||
}
|
||||
current.push(entry)
|
||||
}
|
||||
|
||||
values.value = current
|
||||
// Scroll to the bottom so the last added value is visible
|
||||
nextTick(() => {
|
||||
// use requestAnimationFrame to ensure DOM paint
|
||||
requestAnimationFrame(() => {
|
||||
const el = optionsRef.value
|
||||
if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const removeValue = (email) => {
|
||||
values.value = (values.value || []).filter((a) => a.email !== email)
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value?.focus?.()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
</script>
|
||||
719
frontend/src/components/Calendar/CalendarEventPanel.vue
Normal file
@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div v-if="show" class="flex flex-col w-[352px] text-base">
|
||||
<!-- Event Header -->
|
||||
<div
|
||||
class="flex items-center justify-between p-4.5 text-ink-gray-7 text-lg font-medium"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-x-2"
|
||||
:class="mode == 'edit' && 'cursor-pointer hover:text-ink-gray-8'"
|
||||
@click="mode == 'edit' && details()"
|
||||
>
|
||||
<LucideChevronLeft v-if="mode == 'edit'" class="size-4" />
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<ShortcutTooltip
|
||||
v-if="mode == 'details'"
|
||||
:label="__('Edit event')"
|
||||
combo="Enter"
|
||||
>
|
||||
<Button :icon="EditIcon" variant="ghost" @click="editDetails" />
|
||||
</ShortcutTooltip>
|
||||
<ShortcutTooltip
|
||||
v-if="mode === 'edit' || mode === 'details'"
|
||||
:label="__('Delete event')"
|
||||
combo="Delete"
|
||||
:alt-combos="['Backspace']"
|
||||
>
|
||||
<Button icon="trash-2" variant="ghost" @click="deleteEvent" />
|
||||
</ShortcutTooltip>
|
||||
<ShortcutTooltip
|
||||
v-if="mode === 'edit' || mode === 'details'"
|
||||
:label="__('Duplicate event')"
|
||||
combo="Mod+D"
|
||||
>
|
||||
<Button icon="copy" variant="ghost" @click="duplicateEvent" />
|
||||
</ShortcutTooltip>
|
||||
<ShortcutTooltip :label="__('Close panel')" combo="Esc">
|
||||
<Button icon="x" variant="ghost" @click="close" />
|
||||
</ShortcutTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div v-if="mode == 'details'" class="flex flex-col overflow-y-auto">
|
||||
<div
|
||||
class="flex items-start gap-2 px-4.5 py-3 pb-0"
|
||||
@dblclick="editDetails"
|
||||
>
|
||||
<div
|
||||
class="mx-0.5 my-[5px] size-2.5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: _event.color || '#30A66D',
|
||||
}"
|
||||
/>
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
<div class="text-ink-gray-8 font-semibold text-xl">
|
||||
{{ _event.title || __('(No title)') }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 text-p-base">{{ formattedDateTime }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="_event.referenceDocname"
|
||||
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
|
||||
/>
|
||||
<div
|
||||
v-if="_event.referenceDocname"
|
||||
class="flex items-center px-4.5 py-1 text-ink-gray-7"
|
||||
>
|
||||
<component
|
||||
:is="_event.referenceDoctype == 'CRM Lead' ? LeadsIcon : DealsIcon"
|
||||
class="size-4"
|
||||
/>
|
||||
<Link
|
||||
class="[&_button]:bg-surface-white [&_button]:select-text [&_button]:text-ink-gray-7 [&_button]:cursor-text"
|
||||
v-model="_event.referenceDocname"
|
||||
:doctype="_event.referenceDoctype"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Button variant="ghost" @click="redirect">
|
||||
<template #icon>
|
||||
<ArrowUpRightIcon class="size-4 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="peoples.length"
|
||||
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
|
||||
/>
|
||||
<div v-if="peoples.length" class="px-4.5 py-2">
|
||||
<div class="flex gap-3 text-ink-gray-7 mb-3">
|
||||
<PeopleIcon class="size-4" />
|
||||
<div>{{ __('{0} Attendees', [peoples.length + 1]) }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 -ml-1">
|
||||
<Button
|
||||
:key="_event.owner"
|
||||
variant="ghost"
|
||||
theme="gray"
|
||||
class="rounded-full w-fit !h-8.5 !pr-3"
|
||||
:tooltip="__('Owner: {0}', [_event.owner?.label])"
|
||||
>
|
||||
<template #default>
|
||||
<div class="flex flex-col justify-start items-start text-sm">
|
||||
<div>{{ _event.owner?.label }}</div>
|
||||
<div class="text-ink-gray-5">{{ __('Organizer') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #prefix>
|
||||
<UserAvatar :user="_event.owner?.value" class="-ml-1 !size-5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-for="att in displayedPeoples"
|
||||
:key="att.email"
|
||||
:label="att.email"
|
||||
variant="ghost"
|
||||
theme="gray"
|
||||
class="rounded-full w-fit !text-sm"
|
||||
:tooltip="getTooltip(att)"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar :user="att.email" class="-ml-1 !size-5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!showAllParticipants && peoples.length > 2"
|
||||
variant="ghost"
|
||||
:label="__('See all participants')"
|
||||
iconLeft="more-horizontal"
|
||||
class="!justify-start w-fit"
|
||||
@click="showAllParticipants = true"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="showAllParticipants"
|
||||
variant="ghost"
|
||||
:label="__('Show less')"
|
||||
iconLeft="chevron-up"
|
||||
class="!justify-start w-fit"
|
||||
@click="showAllParticipants = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="_event.description && _event.description !== '<p></p>'"
|
||||
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
|
||||
/>
|
||||
<div v-if="_event.description && _event.description !== '<p></p>'">
|
||||
<div class="flex gap-2 items-center text-ink-gray-7 px-4.5 py-1">
|
||||
<DescriptionIcon class="size-4" />
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<div
|
||||
class="px-4.5 py-2 text-ink-gray-7 text-p-base"
|
||||
v-html="_event.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event new, duplicate & edit -->
|
||||
<div v-else class="flex flex-col overflow-y-auto">
|
||||
<div class="flex gap-2 items-center px-4.5 py-3">
|
||||
<Dropdown class="ml-1" :options="colors">
|
||||
<div
|
||||
class="flex items-center justify-center size-7 shrink-0 border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm rounded cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="size-2.5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: _event.color || '#30A66D',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<TextInput
|
||||
ref="eventTitle"
|
||||
class="w-full"
|
||||
variant="outline"
|
||||
v-model="_event.title"
|
||||
:debounce="500"
|
||||
:placeholder="__('Event title')"
|
||||
@change="sync"
|
||||
@keyup.enter="saveEvent"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between py-2.5 px-4.5 text-ink-gray-6">
|
||||
<div class="flex items-center">
|
||||
<Switch v-model="_event.isFullDay" @update:model-value="sync" />
|
||||
<div class="ml-2">
|
||||
{{ __('All day') }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex items-center gap-1.5 text-ink-gray-5">
|
||||
<LucideEarth class="size-4" />
|
||||
{{ __('GMT+5:30') }}
|
||||
</div> -->
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
||||
>
|
||||
<div class="">{{ __('Date') }}</div>
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<DatePicker
|
||||
:class="['[&_input]:w-[216px]']"
|
||||
variant="outline"
|
||||
:value="_event.fromDate"
|
||||
:format="'MMM D, YYYY'"
|
||||
:placeholder="__('May 1, 2025')"
|
||||
:clearable="false"
|
||||
@update:modelValue="(date) => updateDate(date, true)"
|
||||
>
|
||||
<template #suffix="{ togglePopover }">
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!_event.isFullDay"
|
||||
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
||||
>
|
||||
<div class="w-20">{{ __('Time') }}</div>
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<TimePicker
|
||||
v-if="!_event.isFullDay"
|
||||
class="max-w-[105px]"
|
||||
variant="outline"
|
||||
:modelValue="_event.fromTime"
|
||||
:placeholder="__('Start Time')"
|
||||
@update:modelValue="(time) => updateTime(time, true)"
|
||||
/>
|
||||
<TimePicker
|
||||
class="max-w-[105px]"
|
||||
variant="outline"
|
||||
:modelValue="_event.toTime"
|
||||
:options="toOptions"
|
||||
:placeholder="__('End Time')"
|
||||
placement="bottom-end"
|
||||
@update:modelValue="(time) => updateTime(time)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
|
||||
<div
|
||||
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
||||
>
|
||||
<div class="">{{ __('Link') }}</div>
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
class="w-[216px]"
|
||||
type="select"
|
||||
:options="[
|
||||
{
|
||||
label: '',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: __('Lead'),
|
||||
value: 'CRM Lead',
|
||||
},
|
||||
{
|
||||
label: __('Deal'),
|
||||
value: 'CRM Deal',
|
||||
},
|
||||
]"
|
||||
v-model="_event.referenceDoctype"
|
||||
variant="outline"
|
||||
:placeholder="__('Add Lead or Deal')"
|
||||
@change="
|
||||
() => {
|
||||
_event.referenceDocname = ''
|
||||
sync()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="_event.referenceDoctype"
|
||||
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
|
||||
>
|
||||
<div class="">
|
||||
{{ _event.referenceDoctype == 'CRM Lead' ? __('Lead') : __('Deal') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-x-1.5">
|
||||
<Link
|
||||
class="w-[220px]"
|
||||
v-model="_event.referenceDocname"
|
||||
:doctype="_event.referenceDoctype"
|
||||
:filters="
|
||||
_event.referenceDoctype === 'CRM Lead' ? { converted: 0 } : {}
|
||||
"
|
||||
variant="outline"
|
||||
@update:model-value="sync"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
|
||||
<Attendee
|
||||
class="px-4.5 py-[7px]"
|
||||
v-model="peoples"
|
||||
:validate="validateEmail"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
/>
|
||||
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
|
||||
<div class="px-4.5 py-3">
|
||||
<div class="flex items-center gap-x-2 border rounded py-1">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm overflow-auto min-h-[22px] max-h-32 px-2.5 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors"
|
||||
:bubbleMenu="true"
|
||||
:content="_event.description"
|
||||
@change="
|
||||
(val) => {
|
||||
_event.description = val
|
||||
sync()
|
||||
}
|
||||
"
|
||||
:placeholder="__('Add description')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode != 'details'" class="px-4.5 py-3">
|
||||
<ErrorMessage class="my-2" :message="error" />
|
||||
<div class="w-full">
|
||||
<Button
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
:disabled="!dirty"
|
||||
:loading="
|
||||
mode === 'edit' ? events.setValue.loading : events.insert.loading
|
||||
"
|
||||
@click="saveEvent"
|
||||
>
|
||||
{{
|
||||
mode === 'edit'
|
||||
? __('Save')
|
||||
: mode === 'duplicate'
|
||||
? __('Duplicate event')
|
||||
: __('Create event')
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PeopleIcon from '@/components/Icons/PeopleIcon.vue'
|
||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { validateEmail } from '@/utils'
|
||||
import {
|
||||
normalizeParticipants,
|
||||
buildEndTimeOptions,
|
||||
computeAutoToTime,
|
||||
validateTimeRange,
|
||||
parseEventDoc,
|
||||
} from '@/composables/event'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import {
|
||||
TextInput,
|
||||
Switch,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
TextEditor,
|
||||
ErrorMessage,
|
||||
Dropdown,
|
||||
dayjs,
|
||||
CalendarColorMap as colorMap,
|
||||
CalendarActiveEvent as activeEvent,
|
||||
createDocumentResource,
|
||||
} from 'frappe-ui'
|
||||
import ShortcutTooltip from '@/components/ShortcutTooltip.vue'
|
||||
import { ref, computed, watch, h, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'details',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'save',
|
||||
'edit',
|
||||
'delete',
|
||||
'details',
|
||||
'close',
|
||||
'sync',
|
||||
'duplicate',
|
||||
])
|
||||
|
||||
const router = useRouter()
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const show = defineModel()
|
||||
const event = defineModel('event')
|
||||
|
||||
const events = inject('events')
|
||||
|
||||
const _event = ref({})
|
||||
|
||||
const peoples = computed({
|
||||
get() {
|
||||
return _event.value.event_participants || []
|
||||
},
|
||||
set(list) {
|
||||
_event.value.event_participants = normalizeParticipants(list)
|
||||
sync()
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.mode === 'details') return __('Event details')
|
||||
if (props.mode === 'edit') return __('Editing event')
|
||||
if (props.mode === 'new') return __('New event')
|
||||
return __('Duplicate event')
|
||||
})
|
||||
|
||||
const eventTitle = ref(null)
|
||||
const error = ref(null)
|
||||
const showAllParticipants = ref(false)
|
||||
|
||||
const eventResource = ref({})
|
||||
|
||||
const oldEvent = ref(null)
|
||||
const dirty = computed(() => {
|
||||
return JSON.stringify(oldEvent.value) !== JSON.stringify(_event.value)
|
||||
})
|
||||
|
||||
const displayedPeoples = computed(() => {
|
||||
if (showAllParticipants.value) return peoples.value
|
||||
return peoples.value.slice(0, 2)
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.mode, () => event.value],
|
||||
([mode, event], [oldMode, oldEvent]) => {
|
||||
error.value = null
|
||||
focusOnTitle()
|
||||
fetchEvent(oldMode)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function fetchEvent(oldMode) {
|
||||
if (
|
||||
event.value.id &&
|
||||
event.value.id !== 'new-event' &&
|
||||
event.value.id !== 'duplicate-event'
|
||||
) {
|
||||
eventResource.value = createDocumentResource({
|
||||
doctype: 'Event',
|
||||
name: event.value.id,
|
||||
fields: ['*'],
|
||||
onSuccess: (data) => {
|
||||
_event.value = parseEventDoc(data)
|
||||
oldEvent.value = { ..._event.value }
|
||||
},
|
||||
})
|
||||
if (eventResource.value.doc && !event.value.reloadEvent) {
|
||||
_event.value = parseEventDoc(eventResource.value.doc)
|
||||
oldEvent.value = { ..._event.value }
|
||||
} else {
|
||||
eventResource.value.reload()
|
||||
}
|
||||
} else {
|
||||
_event.value = event.value
|
||||
|
||||
if (oldMode !== props.mode) {
|
||||
oldEvent.value = { ...event.value }
|
||||
}
|
||||
|
||||
if (event.value.id === 'duplicate-event' && oldMode !== 'duplicate') {
|
||||
_event.value.title = _event.value.title + ' (Copy)'
|
||||
}
|
||||
}
|
||||
showAllParticipants.value = false
|
||||
}
|
||||
|
||||
function focusOnTitle() {
|
||||
setTimeout(() => {
|
||||
if (['edit', 'new', 'duplicate'].includes(props.mode)) {
|
||||
eventTitle.value?.el?.focus()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function sync() {
|
||||
emit('sync', _event.value.id, _event.value)
|
||||
}
|
||||
|
||||
function updateDate(d) {
|
||||
_event.value.fromDate = d
|
||||
_event.value.toDate = d
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
function updateTime(t, fromTime = false) {
|
||||
error.value = null
|
||||
const prevTo = _event.value.toTime
|
||||
if (fromTime) {
|
||||
_event.value.fromTime = t
|
||||
if (!_event.value.toTime || _event.value.toTime <= t) {
|
||||
_event.value.toTime = computeAutoToTime(t)
|
||||
}
|
||||
} else {
|
||||
_event.value.toTime = t
|
||||
}
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
_event.value.toTime = prevTo
|
||||
} else {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
|
||||
function saveEvent() {
|
||||
if (!dirty.value) return
|
||||
|
||||
error.value = null
|
||||
if (!_event.value.title) {
|
||||
error.value = __('Title is required')
|
||||
eventTitle.value.el.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
oldEvent.value = { ..._event.value }
|
||||
sync()
|
||||
emit('save', _event.value)
|
||||
}
|
||||
|
||||
function editDetails() {
|
||||
emit('edit', _event.value)
|
||||
}
|
||||
|
||||
function duplicateEvent() {
|
||||
if (dirty.value) {
|
||||
showDiscardChangesModal(() => reset())
|
||||
} else {
|
||||
emit('duplicate', _event.value)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEvent() {
|
||||
emit('delete', _event.value.id)
|
||||
}
|
||||
|
||||
function details() {
|
||||
if (dirty.value) {
|
||||
showDiscardChangesModal(() => reset())
|
||||
} else {
|
||||
emit('details', _event.value)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const _close = () => {
|
||||
show.value = false
|
||||
activeEvent.value = ''
|
||||
emit('close', _event.value)
|
||||
}
|
||||
|
||||
if (dirty.value) {
|
||||
showDiscardChangesModal(() => {
|
||||
reset()
|
||||
if (['new-event', 'duplicate-event'].includes(_event.value.id)) _close()
|
||||
})
|
||||
} else {
|
||||
_close()
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(_event.value, oldEvent.value)
|
||||
sync()
|
||||
}
|
||||
|
||||
function showDiscardChangesModal(action) {
|
||||
$dialog({
|
||||
title: __('Discard unsaved changes?'),
|
||||
message: __(
|
||||
'Are you sure you want to discard unsaved changes to this event?',
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __('Cancel'),
|
||||
onClick: (close) => {
|
||||
close()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Discard'),
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
action()
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const formattedDateTime = computed(() => {
|
||||
const date = dayjs(_event.value.fromDate)
|
||||
|
||||
if (_event.value.isFullDay) {
|
||||
return `${__('All day')} - ${date.format('ddd, D MMM YYYY')}`
|
||||
}
|
||||
|
||||
const start = dayjs(_event.value.fromDate + ' ' + _event.value.fromTime)
|
||||
const end = dayjs(_event.value.toDate + ' ' + _event.value.toTime)
|
||||
|
||||
return `${start.format('h:mm a')} - ${end.format('h:mm a')} ${date.format('ddd, D MMM YYYY')}`
|
||||
})
|
||||
|
||||
const colors = Object.keys(colorMap).map((color) => ({
|
||||
label: color.charAt(0).toUpperCase() + color.slice(1),
|
||||
value: colorMap[color].color,
|
||||
icon: h('div', {
|
||||
class: '!size-2.5 rounded-full',
|
||||
style: { backgroundColor: colorMap[color].color },
|
||||
}),
|
||||
onClick: () => {
|
||||
_event.value.color = colorMap[color].color
|
||||
sync()
|
||||
},
|
||||
}))
|
||||
|
||||
function redirect() {
|
||||
if (_event.value.referenceDocname) {
|
||||
let name = _event.value.referenceDoctype === 'CRM Lead' ? 'Lead' : 'Deal'
|
||||
|
||||
let params =
|
||||
_event.value.referenceDoctype == 'CRM Lead'
|
||||
? { leadId: _event.value.referenceDocname }
|
||||
: { dealId: _event.value.referenceDocname }
|
||||
|
||||
router.push({ name, params })
|
||||
}
|
||||
}
|
||||
|
||||
function getTooltip(m) {
|
||||
if (!m) return email
|
||||
const parts = []
|
||||
if (m.reference_doctype) parts.push(m.reference_doctype)
|
||||
if (m.reference_docname) parts.push(m.reference_docname)
|
||||
return parts.length ? parts.join(': ') : email
|
||||
}
|
||||
|
||||
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
|
||||
|
||||
function updateEvent(_e) {
|
||||
Object.assign(_event.value, _e)
|
||||
}
|
||||
|
||||
defineExpose({ updateEvent })
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
active: () => show.value,
|
||||
shortcuts: [
|
||||
{ keys: 'Escape', action: () => close() },
|
||||
{
|
||||
keys: 'Enter',
|
||||
guard: () =>
|
||||
['details', 'edit'].includes(props.mode) && props.mode === 'details',
|
||||
action: () => editDetails(),
|
||||
},
|
||||
{
|
||||
keys: ['Delete', 'Backspace'],
|
||||
guard: () => ['details', 'edit'].includes(props.mode),
|
||||
action: () => deleteEvent(),
|
||||
},
|
||||
{
|
||||
match: (e) =>
|
||||
['details', 'edit'].includes(props.mode) &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
e.key.toLowerCase() === 'd',
|
||||
action: () => duplicateEvent(),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
16
frontend/src/components/Calendar/utils.js
Normal file
@ -0,0 +1,16 @@
|
||||
export function allTimeSlots() {
|
||||
const out = []
|
||||
for (let h = 0; h < 24; h++) {
|
||||
for (const m of [0, 15, 30, 45]) {
|
||||
const hh = String(h).padStart(2, '0')
|
||||
const mm = String(m).padStart(2, '0')
|
||||
const ampm = h >= 12 ? 'pm' : 'am'
|
||||
const hour12 = h % 12 === 0 ? 12 : h % 12
|
||||
out.push({
|
||||
value: `${hh}:${mm}`,
|
||||
label: `${hour12}:${mm} ${ampm}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
314
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
: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>
|
||||
<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)"
|
||||
@update:modelValue="onSelect"
|
||||
:ignore-filter="true"
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-white hover:bg-surface-white'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
>
|
||||
<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 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||
:placeholder="placeholder"
|
||||
@focus="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"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="showSearchIcon"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{ emptyStateText }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
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"
|
||||
@mousedown.prevent="onSelect(option.value)"
|
||||
>
|
||||
<UserAvatar class="mr-2" :user="option.value" size="lg" />
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">{{ option.label }}</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Generic multi-source (users / contacts / free) multi-select email-like input
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import {
|
||||
ComboboxRoot,
|
||||
ComboboxAnchor,
|
||||
ComboboxInput,
|
||||
ComboboxPortal,
|
||||
ComboboxContent,
|
||||
ComboboxViewport,
|
||||
ComboboxItem,
|
||||
ComboboxEmpty,
|
||||
} from 'reka-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
// Behaviour
|
||||
mode: { type: String, default: null }, // 'users' | 'contacts' | 'free' (fallback to legacy flags)
|
||||
fetchUsers: { type: Boolean, default: false },
|
||||
fetchContacts: { type: Boolean, default: false },
|
||||
existingEmails: { type: Array, default: () => [] },
|
||||
validate: { type: Function, default: null },
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
emptyPlaceholder: { type: String, default: __('No results found') },
|
||||
// UI
|
||||
variant: { type: String, default: 'subtle' },
|
||||
placeholder: { type: String, default: '' },
|
||||
inputClass: { type: String, default: '' },
|
||||
})
|
||||
|
||||
// v-model values
|
||||
const values = defineModel()
|
||||
|
||||
// Determine effective mode (backwards compatibility with old components)
|
||||
const effectiveMode = computed(() => {
|
||||
if (props.mode) return props.mode
|
||||
if (props.fetchUsers) return 'users'
|
||||
if (props.fetchContacts) return 'contacts'
|
||||
return 'free'
|
||||
})
|
||||
|
||||
// Common state
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
const tempSelection = ref(null)
|
||||
|
||||
// Users data
|
||||
const { users } = usersStore()
|
||||
|
||||
// Contacts resource (only if needed)
|
||||
const filterOptions = ref(null)
|
||||
const lastLoadedQuery = ref('')
|
||||
|
||||
if (effectiveMode.value === 'contacts') {
|
||||
filterOptions.value = createResource({
|
||||
url: 'crm.api.contact.search_emails',
|
||||
method: 'POST',
|
||||
cache: ['ContactEmails'],
|
||||
params: { txt: '' },
|
||||
transform: (data) => {
|
||||
let allData = (data || []).map((option) => {
|
||||
const fullName = option[0]
|
||||
const email = option[1]
|
||||
const name = option[2]
|
||||
return { label: fullName || name || email, value: email }
|
||||
})
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((o) => !props.existingEmails.includes(o.value))
|
||||
}
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (lastLoadedQuery.value === val && options.value?.length) return
|
||||
lastLoadedQuery.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
}
|
||||
|
||||
function reload(val) {
|
||||
if (effectiveMode.value !== 'contacts' || !filterOptions.value) return
|
||||
filterOptions.value.update({ params: { txt: val } })
|
||||
filterOptions.value.reload()
|
||||
}
|
||||
|
||||
// Options computed
|
||||
const options = computed(() => {
|
||||
const mode = effectiveMode.value
|
||||
if (mode === 'users') {
|
||||
let list = users?.data?.allUsers || []
|
||||
list = list.map((u) => ({
|
||||
label: u.full_name || u.name || u.email,
|
||||
value: u.email,
|
||||
}))
|
||||
if (props.existingEmails?.length) {
|
||||
list = list.filter((o) => !props.existingEmails.includes(o.value))
|
||||
}
|
||||
if (query.value) {
|
||||
const q = query.value.toLowerCase()
|
||||
list = list.filter(
|
||||
(o) =>
|
||||
o.label?.toLowerCase().includes(q) ||
|
||||
o.value?.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
if (mode === 'contacts') {
|
||||
const list = filterOptions.value?.data ? [...filterOptions.value.data] : []
|
||||
if (!list.length && query.value) {
|
||||
list.push({ label: query.value, value: query.value })
|
||||
}
|
||||
return list
|
||||
}
|
||||
// Free / manual mode
|
||||
return query.value ? [{ label: query.value, value: query.value }] : []
|
||||
})
|
||||
|
||||
const showSearchIcon = computed(() => effectiveMode.value !== 'free')
|
||||
const emptyStateText = computed(() => {
|
||||
if (effectiveMode.value === 'free') return __(props.emptyPlaceholder)
|
||||
return options.value.length ? '' : __(props.emptyPlaceholder)
|
||||
})
|
||||
|
||||
function addValue(input) {
|
||||
if (!input) return
|
||||
error.value = null
|
||||
info.value = null
|
||||
const parts = input
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
for (const email of parts) {
|
||||
if (values.value?.includes(email)) {
|
||||
info.value = __('email already exists')
|
||||
continue
|
||||
}
|
||||
if (props.validate && !props.validate(email)) {
|
||||
error.value = props.errorMessage(email)
|
||||
query.value = email
|
||||
break
|
||||
}
|
||||
if (!values.value) values.value = [email]
|
||||
else values.value.push(email)
|
||||
}
|
||||
}
|
||||
|
||||
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 setFocus() {
|
||||
search.value?.focus?.()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value
|
||||
showOptions.value = true
|
||||
}
|
||||
|
||||
function onSelect(val) {
|
||||
if (!val) return
|
||||
addValue(val)
|
||||
if (!error.value) {
|
||||
query.value = ''
|
||||
tempSelection.value = null
|
||||
showOptions.value = false
|
||||
nextTick(() => setFocus())
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
if (query.value) onSelect(query.value)
|
||||
}
|
||||
</script>
|
||||
@ -178,21 +178,27 @@
|
||||
@change="(e) => fieldChange(e.target.checked, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<TimePicker
|
||||
v-else-if="field.fieldtype === 'Time'"
|
||||
:value="row[field.fieldname]"
|
||||
variant="outline"
|
||||
:format="getFormat('', '', false, true, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:format="getFormat('', '', true, false, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:format="getFormat('', '', true, true, false)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
@ -349,6 +355,7 @@ import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FormControl,
|
||||
Checkbox,
|
||||
TimePicker,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
|
||||
@ -1,304 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
: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>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-white hover:bg-surface-white'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!options.length"
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="fetchContacts"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{
|
||||
fetchContacts
|
||||
? __('No results found')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-3': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Popover from '@/components/frappe-ui/Popover.vue'
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchContacts: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const text = ref('')
|
||||
const showOptions = ref(false)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
})
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || ''
|
||||
if (text.value === val && options.value?.length) return
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: 'crm.api.contact.search_emails',
|
||||
method: 'POST',
|
||||
cache: [text.value, 'Contact'],
|
||||
params: { txt: text.value },
|
||||
transform: (data) => {
|
||||
let allData = data.map((option) => {
|
||||
let fullName = option[0]
|
||||
let email = option[1]
|
||||
let name = option[2]
|
||||
return {
|
||||
label: fullName || name || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out existing emails
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let searchedContacts = props.fetchContacts ? filterOptions.data : []
|
||||
if (!searchedContacts?.length && query.value) {
|
||||
searchedContacts.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
return searchedContacts || []
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
if (!props.fetchContacts) return
|
||||
|
||||
filterOptions.update({
|
||||
params: { txt: val },
|
||||
})
|
||||
filterOptions.reload()
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
info.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
query.value = value
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
} else {
|
||||
info.value = __('email already exists')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
</script>
|
||||
@ -1,278 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
: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>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-white hover:bg-surface-white'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!options.length"
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="fetchUsers"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{
|
||||
fetchUsers
|
||||
? __('No results found')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-3': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Popover from '@/components/frappe-ui/Popover.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchUsers: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const { users } = usersStore()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
|
||||
|
||||
if (props.fetchUsers) {
|
||||
userEmails = userEmails.map((user) => ({
|
||||
label: user.full_name || user.name || user.email,
|
||||
value: user.email,
|
||||
}))
|
||||
|
||||
if (props.existingEmails?.length) {
|
||||
userEmails = userEmails.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
if (query.value) {
|
||||
userEmails = userEmails.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(query.value.toLowerCase()),
|
||||
)
|
||||
}
|
||||
} else if (!userEmails?.length && query.value) {
|
||||
userEmails.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
|
||||
return userEmails || []
|
||||
})
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
info.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
query.value = value
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
} else {
|
||||
info.value = __('email already exists')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
</script>
|
||||
@ -20,11 +20,12 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
|
||||
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
|
||||
<MultiSelectEmailInput
|
||||
<EmailMultiSelect
|
||||
class="flex-1"
|
||||
variant="ghost"
|
||||
v-model="toEmails"
|
||||
:validate="validateEmail"
|
||||
:fetchContacts="true"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
@ -54,11 +55,12 @@
|
||||
</div>
|
||||
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
|
||||
<MultiSelectEmailInput
|
||||
<EmailMultiSelect
|
||||
ref="ccInput"
|
||||
class="flex-1"
|
||||
variant="ghost"
|
||||
v-model="ccEmails"
|
||||
:fetchContacts="true"
|
||||
:validate="validateEmail"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
@ -67,11 +69,12 @@
|
||||
</div>
|
||||
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
|
||||
<MultiSelectEmailInput
|
||||
<EmailMultiSelect
|
||||
ref="bccInput"
|
||||
class="flex-1"
|
||||
variant="ghost"
|
||||
v-model="bccEmails"
|
||||
:fetchContacts="true"
|
||||
:validate="validateEmail"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
@ -179,7 +182,7 @@ import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
|
||||
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
|
||||
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
||||
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
||||
import { capture } from '@/telemetry'
|
||||
|
||||
@ -130,10 +130,18 @@
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<TimePicker
|
||||
v-else-if="field.fieldtype === 'Time'"
|
||||
:value="data[field.fieldname]"
|
||||
:format="getFormat('', '', false, true, false)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:format="getFormat('', '', true, true, false)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
@ -141,7 +149,7 @@
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:format="getFormat('', '', true, false, false)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
@ -225,7 +233,7 @@ import { flt } from '@/utils/numberFormat.js'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
import { Tooltip, DatePicker, DateTimePicker, TimePicker } from 'frappe-ui'
|
||||
import { computed, provide, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
14
frontend/src/components/Icons/DescriptionIcon.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.66699 12.7109C7.89499 12.7574 8.06641 12.9594 8.06641 13.2012C8.06625 13.4428 7.89489 13.6449 7.66699 13.6914L7.56641 13.7012H1.5C1.22406 13.7011 1.00018 13.4771 1 13.2012C1 12.9251 1.22395 12.7013 1.5 12.7012H7.56641L7.66699 12.7109ZM14.6006 9.24414C14.8284 9.29081 15 9.4928 15 9.73438C14.9999 9.97585 14.8283 10.178 14.6006 10.2246L14.5 10.2344H1.5C1.22403 10.2343 1.00013 10.0103 1 9.73438C1 9.4583 1.22395 9.23448 1.5 9.23438H14.5L14.6006 9.24414ZM3.56934 2.45996C3.78682 2.52747 3.96526 2.69406 4.04297 2.91699L5.2168 6.29199C5.31837 6.58437 5.10155 6.88965 4.79199 6.88965H4.77441C4.57659 6.88946 4.40241 6.75957 4.34473 6.57031L4.11133 5.80664H2.53613L2.30273 6.57129C2.24483 6.7604 2.06986 6.88965 1.87207 6.88965H1.85742C1.54804 6.88951 1.33114 6.5843 1.43262 6.29199L2.59961 2.93066C2.70432 2.62909 2.98841 2.42694 3.30762 2.42676H3.56934V2.45996ZM14.6006 5.77734C14.8283 5.82404 15 6.02602 15 6.26758C14.9999 6.50907 14.8283 6.71112 14.6006 6.75781L14.5 6.76758H7.56641C7.29038 6.7675 7.06648 6.5436 7.06641 6.26758C7.06641 5.99149 7.29033 5.76766 7.56641 5.76758H14.5L14.6006 5.77734ZM2.75 5.1084H3.89844L3.35254 3.31738H3.29688L2.75 5.1084ZM14.6006 2.31055C14.8283 2.35723 14.9999 2.55931 15 2.80078C15 3.04233 14.8283 3.24432 14.6006 3.29102L14.5 3.30078H7.56641C7.29033 3.3007 7.06641 3.07687 7.06641 2.80078C7.06651 2.52478 7.2904 2.30086 7.56641 2.30078H14.5L14.6006 2.31055Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -1,20 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1 5C1 2.79086 2.79086 1 5 1H13C15.2091 1 17 2.79086 17 5V13C17 15.2091 15.2091 17 13 17H5C2.79086 17 1 15.2091 1 13V5Z"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.7819 6.27142H11.5136H8.02453H6.28001V4.84002H11.7819V6.27142ZM8.02451 9.62623V11.5944H11.8267V13.0258H6.27999V8.19484H8.02451H11.5135V9.62623H8.02451Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.6611 8.2289V9.77773H7.88672V11.9066H11.999V13.4545H6V8.2289H11.6611ZM11.9512 4.6V6.14883H6V4.6H11.9512Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<rect x="1.5" y="1.5" width="15" height="15" rx="3.5" stroke="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
16
frontend/src/components/Icons/EventIcon.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.5 1C5.5 0.723858 5.27614 0.5 5 0.5C4.72386 0.5 4.5 0.723858 4.5 1V2.00057C3.42774 2.00446 2.83574 2.03488 2.36942 2.27248C1.89901 2.51217 1.51656 2.89462 1.27688 3.36502C1.00439 3.8998 1.00439 4.59987 1.00439 6V9.0642C1 9.33719 1 9.64625 1 10V11C1 12.4001 1 13.1002 1.27248 13.635C1.51217 14.1054 1.89462 14.4878 2.36502 14.7275C2.8998 15 3.59987 15 5 15L5.00439 15L11 15L11.0044 15L11.2588 14.9999C12.4914 14.9989 13.138 14.983 13.6394 14.7275C14.1098 14.4878 14.4922 14.1054 14.7319 13.635C15.0044 13.1002 15.0044 12.4001 15.0044 11V6C15.0044 4.59987 15.0044 3.8998 14.7319 3.36502C14.4922 2.89462 14.1098 2.51217 13.6394 2.27248C13.1718 2.03423 12.5778 2.0043 11.5 2.00054V1C11.5 0.723858 11.2761 0.5 11 0.5C10.7239 0.5 10.5 0.723858 10.5 1V2H5.5V1ZM10.5 4V3H5.5V4C5.5 4.27614 5.27614 4.5 5 4.5C4.72386 4.5 4.5 4.27614 4.5 4V3.00063C4.05122 3.0023 3.71688 3.00843 3.44383 3.03074C3.08879 3.05975 2.92633 3.11105 2.82341 3.16349C2.54117 3.3073 2.31169 3.53677 2.16788 3.81901C2.11544 3.92194 2.06414 4.0844 2.03513 4.43944C2.00517 4.80615 2.00439 5.28343 2.00439 6V6.49671C2.11748 6.41228 2.23805 6.33718 2.36502 6.27248C2.8998 6 3.59987 6 5 6H11C12.4001 6 13.1002 6 13.635 6.27248C13.7653 6.33886 13.8888 6.41619 14.0044 6.5033V6C14.0044 5.28343 14.0036 4.80615 13.9737 4.43944C13.9446 4.0844 13.8933 3.92194 13.8409 3.81901C13.6971 3.53677 13.4676 3.3073 13.1854 3.16349C13.0825 3.11105 12.92 3.05975 12.565 3.03074C12.2901 3.00829 11.9532 3.00222 11.5 3.00059V4C11.5 4.27614 11.2761 4.5 11 4.5C10.7239 4.5 10.5 4.27614 10.5 4ZM3.44383 13.9693C3.75328 13.9945 4.14147 13.999 4.68573 13.9998L4.87281 14L5.00439 14L11 14L11.0044 14L11.2621 13.9999C11.8362 13.9993 12.2405 13.9954 12.5606 13.9693C12.9156 13.9403 13.0781 13.889 13.181 13.8365C13.4632 13.6927 13.6927 13.4632 13.8365 13.181C13.889 13.0781 13.9403 12.9156 13.9693 12.5606C13.9992 12.1938 14 11.7166 14 11V10C14 9.28343 13.9992 8.80615 13.9693 8.43944C13.9403 8.0844 13.889 7.92194 13.8365 7.81901C13.6927 7.53677 13.4632 7.3073 13.181 7.16349C13.0781 7.11105 12.9156 7.05975 12.5606 7.03074C12.1939 7.00078 11.7166 7 11 7H5C4.28343 7 3.80615 7.00078 3.43944 7.03074C3.0844 7.05975 2.92194 7.11105 2.81901 7.16349C2.53677 7.3073 2.3073 7.53677 2.16349 7.81901C2.11105 7.92194 2.05975 8.0844 2.03074 8.43944C2.01608 8.61883 2.00841 8.82469 2.00439 9.07208V11C2.00439 11.7166 2.00517 12.1938 2.03513 12.5606C2.06414 12.9156 2.11544 13.0781 2.16788 13.181C2.31169 13.4632 2.54117 13.6927 2.82341 13.8365C2.92633 13.889 3.08879 13.9403 3.44383 13.9693ZM6.8125 10.4375C6.8125 9.78166 7.34416 9.25 8 9.25C8.65584 9.25 9.1875 9.78166 9.1875 10.4375C9.1875 11.0933 8.65584 11.625 8 11.625C7.34416 11.625 6.8125 11.0933 6.8125 10.4375ZM8 8.25C6.79188 8.25 5.8125 9.22938 5.8125 10.4375C5.8125 11.6456 6.79188 12.625 8 12.625C9.20812 12.625 10.1875 11.6456 10.1875 10.4375C10.1875 9.22938 9.20812 8.25 8 8.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
15
frontend/src/components/Icons/HelpdeskIcon.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 18 18"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect x="1.5" y="1.5" width="15" height="15" rx="3.5" stroke="currentColor" />
|
||||
<path
|
||||
d="M13.7928 8.0619V5H4.29999V6.39494H12.3621V7.72014C11.787 7.88056 11.37 8.39669 11.37 9.00349C11.37 9.61029 11.787 10.1194 12.3621 10.2799V11.6051L5.79999 11.6051V7.96425H4.29999V13H13.8V9.9381L12.9444 9.34525V8.66173L13.8 8.06888L13.7928 8.0619Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
14
frontend/src/components/Icons/PeopleIcon.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1ZM10.75 9.5C10.0172 9.5 9.50422 9.65253 9.12793 9.84277C8.74778 10.035 8.48265 10.2774 8.24707 10.5049C8.08807 10.6584 8.00001 10.8573 8 11.0352V14C10.4873 14 12.6207 12.4863 13.5303 10.3301C12.9601 9.92399 12.0744 9.5 10.75 9.5ZM4.75 9.5C4.01981 9.5 3.50767 9.6516 3.13184 9.84082C2.85955 9.97794 2.64585 10.1409 2.45996 10.3057C3.24038 12.1787 4.94236 13.5697 7 13.915V11.0352C7.00001 10.7133 7.099 10.4099 7.25781 10.1514C6.69171 9.81028 5.88188 9.50007 4.75 9.5ZM8 2C4.68629 2 2 4.68629 2 8C2 8.43945 2.04801 8.8677 2.1377 9.28027C2.29548 9.1649 2.47567 9.05099 2.68164 8.94727C3.2047 8.68387 3.87233 8.5 4.75 8.5C6.21316 8.50007 7.25578 8.94284 7.96582 9.41309C8.16037 9.25535 8.39427 9.09305 8.67676 8.9502C9.20055 8.68539 9.86925 8.5 10.75 8.5C12.1371 8.5 13.144 8.89812 13.8477 9.33789C13.9457 8.90751 14 8.46009 14 8C14 4.68629 11.3137 2 8 2ZM10.5 5.5C11.1875 5.5 11.75 6.0625 11.75 6.75C11.75 7.4375 11.1875 8 10.5 8C9.8125 8 9.25 7.4375 9.25 6.75C9.25 6.0625 9.8125 5.5 10.5 5.5ZM6 4.5C6.825 4.5 7.5 5.175 7.5 6C7.5 6.825 6.825 7.5 6 7.5C5.175 7.5 4.5 6.825 4.5 6C4.5 5.175 5.175 4.5 6 4.5Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -56,7 +56,7 @@
|
||||
<Dropdown :options="actions(column)">
|
||||
<template #default>
|
||||
<Button
|
||||
class="hidden group-hover:flex"
|
||||
class="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity"
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
/>
|
||||
|
||||
@ -157,6 +157,7 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
@ -233,6 +234,11 @@ const links = [
|
||||
icon: TaskIcon,
|
||||
to: 'Tasks',
|
||||
},
|
||||
{
|
||||
label: 'Calendar',
|
||||
icon: CalendarIcon,
|
||||
to: 'Calendar',
|
||||
},
|
||||
{
|
||||
label: 'Call Logs',
|
||||
icon: PhoneIcon,
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex gap-1 border rounded mb-4 p-2 text-ink-gray-5">
|
||||
<FeatherIcon name="info" class="size-3.5" />
|
||||
<p class="text-sm">
|
||||
<FeatherIcon name="info" class="size-3.5 mt-0.5" />
|
||||
<p class="text-p-sm">
|
||||
{{
|
||||
__(
|
||||
'Add existing system users to this CRM. Assign them a role to grant access with their current credentials.',
|
||||
@ -21,13 +21,14 @@
|
||||
</label>
|
||||
|
||||
<div class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded">
|
||||
<MultiSelectUserInput
|
||||
<EmailMultiSelect
|
||||
v-if="users?.data?.crmUsers?.length"
|
||||
class="flex-1"
|
||||
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
|
||||
:placeholder="__('john@doe.com')"
|
||||
v-model="newUsers"
|
||||
:validate="validateEmail"
|
||||
:fetchUsers="true"
|
||||
:existingEmails="[
|
||||
...users.data.crmUsers.map((user) => user.name),
|
||||
'admin@example.com',
|
||||
@ -35,6 +36,7 @@
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
:emptyPlaceholder="__('No users found')"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
@ -61,7 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
|
||||
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
|
||||
import { validateEmail } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { createResource, toast } from 'frappe-ui'
|
||||
|
||||
439
frontend/src/components/Modals/EventModal.vue
Normal file
@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<template #body-header>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{
|
||||
mode === 'edit'
|
||||
? __('Edit an event')
|
||||
: mode === 'duplicate'
|
||||
? __('Duplicate an event')
|
||||
: __('Create an event')
|
||||
}}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button v-if="mode === 'edit'" variant="ghost" @click="deleteEvent">
|
||||
<template #icon>
|
||||
<LucideTrash2 class="h-4 w-4 text-ink-gray-9" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="mode === 'edit'"
|
||||
variant="ghost"
|
||||
@click="duplicateEvent"
|
||||
>
|
||||
<template #icon>
|
||||
<LucideCopy class="h-4 w-4 text-ink-gray-9" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" @click="show = false">
|
||||
<template #icon>
|
||||
<LucideX class="h-4 w-4 text-ink-gray-9" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center">
|
||||
<div class="text-base text-ink-gray-7 w-3/12">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<div class="flex gap-1 w-9/12">
|
||||
<Dropdown class="" :options="colors">
|
||||
<div
|
||||
class="flex items-center justify-center size-7 shrink-0 border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm rounded cursor-pointer"
|
||||
>
|
||||
<div
|
||||
class="size-2.5 rounded-full cursor-pointer"
|
||||
:style="{
|
||||
backgroundColor: _event.color || '#30A66D',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<TextInput
|
||||
class="w-full"
|
||||
ref="title"
|
||||
size="sm"
|
||||
v-model="_event.title"
|
||||
:placeholder="__('Call with John Doe')"
|
||||
variant="outline"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="text-base text-ink-gray-7 w-3/12">
|
||||
{{ __('All day') }}
|
||||
</div>
|
||||
<Switch v-model="_event.isFullDay" />
|
||||
</div>
|
||||
<div class="border-t border-outline-gray-1" />
|
||||
<div class="flex items-center">
|
||||
<div class="text-base text-ink-gray-7 w-3/12">
|
||||
{{ __('Date & Time') }}
|
||||
</div>
|
||||
<div class="flex gap-2 w-9/12">
|
||||
<DatePicker
|
||||
:class="[_event.isFullDay ? 'w-full' : 'w-[158px]']"
|
||||
variant="outline"
|
||||
:value="_event.fromDate"
|
||||
:format="'MMM D, YYYY'"
|
||||
:placeholder="__('May 1, 2025')"
|
||||
:clearable="false"
|
||||
@update:modelValue="(date) => updateDate(date, true)"
|
||||
>
|
||||
<template #suffix="{ togglePopover }">
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 w-4 cursor-pointer"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</DatePicker>
|
||||
<TimePicker
|
||||
v-if="!_event.isFullDay"
|
||||
class="max-w-[112px]"
|
||||
variant="outline"
|
||||
:modelValue="_event.fromTime"
|
||||
:placeholder="__('Start Time')"
|
||||
@update:modelValue="(time) => updateTime(time, true)"
|
||||
/>
|
||||
<TimePicker
|
||||
v-if="!_event.isFullDay"
|
||||
class="max-w-[112px]"
|
||||
variant="outline"
|
||||
:modelValue="_event.toTime"
|
||||
:options="toOptions"
|
||||
:placeholder="__('End Time')"
|
||||
placement="bottom-end"
|
||||
@update:modelValue="(time) => updateTime(time)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<div class="text-base text-ink-gray-7 mt-1.5 w-3/12">
|
||||
{{ __('Attendees') }}
|
||||
</div>
|
||||
<div class="w-9/12">
|
||||
<Attendee
|
||||
v-model="peoples"
|
||||
:validate="validateEmail"
|
||||
:error-message="
|
||||
(value) => __('{0} is an invalid email address', [value])
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div class="mt-2 text-base text-ink-gray-7 w-3/12">
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<div class="w-9/12">
|
||||
<TextEditor
|
||||
editor-class="!prose-sm overflow-auto min-h-[80px] max-h-80 py-1.5 px-2 rounded border border-outline-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-3 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"
|
||||
:bubbleMenu="true"
|
||||
:content="_event.description"
|
||||
@change="(val) => (_event.description = val)"
|
||||
:placeholder="__('Add description.')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div v-if="eventsResource" class="flex gap-2 justify-end">
|
||||
<Button :label="__('Cancel')" @click="show = false" />
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="
|
||||
mode === 'edit'
|
||||
? __('Update')
|
||||
: mode === 'duplicate'
|
||||
? __('Duplicate')
|
||||
: __('Create')
|
||||
"
|
||||
:disabled="!dirty"
|
||||
:loading="
|
||||
mode === 'edit'
|
||||
? eventsResource.setValue.loading
|
||||
: eventsResource.insert.loading
|
||||
"
|
||||
@click="update"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import Attendee from '@/components/Calendar/Attendee.vue'
|
||||
import {
|
||||
Switch,
|
||||
TextEditor,
|
||||
ErrorMessage,
|
||||
Dialog,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
dayjs,
|
||||
Dropdown,
|
||||
} from 'frappe-ui'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { validateEmail } from '@/utils'
|
||||
import {
|
||||
useEvent,
|
||||
normalizeParticipants,
|
||||
buildEndTimeOptions,
|
||||
computeAutoToTime,
|
||||
validateTimeRange,
|
||||
} from '@/composables/event'
|
||||
import { CalendarColorMap as colorMap } from 'frappe-ui'
|
||||
import { onMounted, ref, computed, h } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const { eventsResource } = useEvent(props.doctype, props.docname)
|
||||
|
||||
const title = ref(null)
|
||||
const error = ref(null)
|
||||
const mode = computed(() => {
|
||||
return _event.value.id == 'duplicate'
|
||||
? 'duplicate'
|
||||
: _event.value.id
|
||||
? 'edit'
|
||||
: 'create'
|
||||
})
|
||||
|
||||
const oldEvent = ref({})
|
||||
const _event = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
fromDate: '',
|
||||
toDate: '',
|
||||
fromTime: '',
|
||||
toTime: '',
|
||||
isFullDay: false,
|
||||
eventType: 'Public',
|
||||
color: 'green',
|
||||
referenceDoctype: '',
|
||||
referenceDocname: '',
|
||||
event_participants: [],
|
||||
})
|
||||
|
||||
const dirty = computed(() => {
|
||||
return JSON.stringify(_event.value) !== JSON.stringify(oldEvent.value)
|
||||
})
|
||||
|
||||
const peoples = computed({
|
||||
get() {
|
||||
return _event.value.event_participants || []
|
||||
},
|
||||
set(list) {
|
||||
_event.value.event_participants = normalizeParticipants(list)
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.event) {
|
||||
let start = dayjs(props.event.starts_on)
|
||||
let end = dayjs(props.event.ends_on)
|
||||
|
||||
if (!props.event.name) {
|
||||
start = dayjs()
|
||||
end = dayjs().add(1, 'hour')
|
||||
}
|
||||
|
||||
_event.value = {
|
||||
id: props.event.name || '',
|
||||
title: props.event.subject,
|
||||
description: props.event.description,
|
||||
fromDate: start.format('YYYY-MM-DD'),
|
||||
toDate: end.format('YYYY-MM-DD'),
|
||||
fromTime: start.format('HH:mm'),
|
||||
toTime: end.format('HH:mm'),
|
||||
isFullDay: props.event.all_day,
|
||||
eventType: props.event.event_type,
|
||||
color: props.event.color,
|
||||
referenceDoctype: props.event.reference_doctype,
|
||||
referenceDocname: props.event.reference_docname,
|
||||
event_participants: props.event.event_participants || [],
|
||||
}
|
||||
|
||||
oldEvent.value = JSON.parse(JSON.stringify(_event.value))
|
||||
|
||||
setTimeout(() => title.value?.el?.focus(), 100)
|
||||
}
|
||||
})
|
||||
|
||||
function updateDate(d) {
|
||||
_event.value.fromDate = d
|
||||
_event.value.toDate = d
|
||||
}
|
||||
|
||||
function updateTime(t, fromTime = false) {
|
||||
error.value = null
|
||||
const prevTo = _event.value.toTime
|
||||
if (fromTime) {
|
||||
_event.value.fromTime = t
|
||||
if (!_event.value.toTime || _event.value.toTime <= t) {
|
||||
_event.value.toTime = computeAutoToTime(t)
|
||||
}
|
||||
} else {
|
||||
_event.value.toTime = t
|
||||
}
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
_event.value.toTime = prevTo
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
error.value = null
|
||||
if (!_event.value.title) {
|
||||
error.value = __('Title is required')
|
||||
title.value.el.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const { valid, error: err } = validateTimeRange({
|
||||
fromDate: _event.value.fromDate,
|
||||
fromTime: _event.value.fromTime,
|
||||
toTime: _event.value.toTime,
|
||||
isFullDay: _event.value.isFullDay,
|
||||
})
|
||||
if (!valid) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
if (_event.value.id && _event.value.id !== 'duplicate') {
|
||||
updateEvent()
|
||||
} else {
|
||||
createEvent()
|
||||
}
|
||||
}
|
||||
|
||||
function createEvent() {
|
||||
eventsResource.insert.submit(
|
||||
{
|
||||
subject: _event.value.title,
|
||||
description: _event.value.description,
|
||||
starts_on: _event.value.fromDate + ' ' + _event.value.fromTime,
|
||||
ends_on: _event.value.toDate + ' ' + _event.value.toTime,
|
||||
all_day: _event.value.isFullDay || false,
|
||||
event_type: _event.value.eventType,
|
||||
color: _event.value.color,
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
event_participants: _event.value.event_participants,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await eventsResource.reload()
|
||||
show.value = false
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function updateEvent() {
|
||||
if (!_event.value.id) {
|
||||
error.value = __('Event ID is required')
|
||||
return
|
||||
}
|
||||
|
||||
eventsResource.setValue.submit(
|
||||
{
|
||||
name: _event.value.id,
|
||||
subject: _event.value.title,
|
||||
description: _event.value.description,
|
||||
starts_on: _event.value.fromDate + ' ' + _event.value.fromTime,
|
||||
ends_on: _event.value.toDate + ' ' + _event.value.toTime,
|
||||
all_day: _event.value.isFullDay,
|
||||
event_type: _event.value.eventType,
|
||||
color: _event.value.color,
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
event_participants: _event.value.event_participants,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await eventsResource.reload()
|
||||
show.value = false
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function duplicateEvent() {
|
||||
if (!_event.value.id) return
|
||||
|
||||
_event.value.id = 'duplicate'
|
||||
_event.value.title = _event.value.title + ' (Copy)'
|
||||
setTimeout(() => title.value?.el?.focus(), 100)
|
||||
}
|
||||
|
||||
function deleteEvent() {
|
||||
if (!_event.value.id) return
|
||||
|
||||
$dialog({
|
||||
title: __('Delete'),
|
||||
message: __('Are you sure you want to delete this event?'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
onClick: (close) => {
|
||||
eventsResource.delete.submit(_event.value.id, {
|
||||
onSuccess: async () => {
|
||||
await eventsResource.reload()
|
||||
show.value = false
|
||||
close()
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
|
||||
|
||||
const colors = Object.keys(colorMap).map((c) => ({
|
||||
label: c.charAt(0).toUpperCase() + c.slice(1),
|
||||
value: colorMap[c].color,
|
||||
icon: h('div', {
|
||||
class: '!size-2.5 rounded-full',
|
||||
style: { backgroundColor: colorMap[c].color },
|
||||
}),
|
||||
onClick: () => (_event.value.color = colorMap[c].color),
|
||||
}))
|
||||
</script>
|
||||
@ -176,4 +176,4 @@ function openAddressModal(_address) {
|
||||
address: _address,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@ -21,9 +21,11 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormControl
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
{{ __('Title') }}
|
||||
</div>
|
||||
<TextInput
|
||||
ref="title"
|
||||
:label="__('Title')"
|
||||
v-model="_task.title"
|
||||
:placeholder="__('Call with John Doe')"
|
||||
required
|
||||
@ -225,8 +227,8 @@ async function updateTask() {
|
||||
|
||||
function render() {
|
||||
editMode.value = false
|
||||
setTimeout(() => title.value?.el?.focus?.(), 100)
|
||||
nextTick(() => {
|
||||
title.value?.el?.focus?.()
|
||||
_task.value = { ...props.task }
|
||||
if (_task.value.title) {
|
||||
editMode.value = true
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { DropdownOption } from '@/utils'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
11
frontend/src/components/Settings/HelpdeskSettings.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<SettingsPage
|
||||
doctype="Helpdesk CRM Settings"
|
||||
:title="__('Helpdesk settings')"
|
||||
:successMessage="__('Helpdesk settings updated')"
|
||||
class="p-8"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import SettingsPage from '@/components/Settings/SettingsPage.vue'
|
||||
</script>
|
||||
@ -49,6 +49,7 @@ import TrendingUpDownIcon from '~icons/lucide/trending-up-down'
|
||||
import SparkleIcon from '@/components/Icons/SparkleIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
||||
import HelpdeskIcon from '@/components/Icons/HelpdeskIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
@ -58,6 +59,7 @@ import InviteUserPage from '@/components/Settings/InviteUserPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||
import HelpdeskSettings from '@/components/Settings/HelpdeskSettings.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import HomeActions from '@/components/Settings/HomeActions.vue'
|
||||
import ForecastingSettings from '@/components/Settings/ForecastingSettings.vue'
|
||||
@ -196,6 +198,12 @@ const tabs = computed(() => {
|
||||
component: markRaw(ERPNextSettings),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Helpdesk'),
|
||||
icon: HelpdeskIcon,
|
||||
component: markRaw(HelpdeskSettings),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
],
|
||||
condition: () => isManager() || isTelephonyAgent(),
|
||||
},
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { validateEmail } from '../../utils'
|
||||
|
||||
const LogoGmail = '/images/gmail.png'
|
||||
const LogoOutlook = '/images/outlook.png'
|
||||
const LogoSendgrid = '/images/sendgrid.png'
|
||||
const LogoSparkpost = '/images/sparkpost.webp'
|
||||
const LogoYahoo = '/images/yahoo.png'
|
||||
const LogoYandex = '/images/yandex.png'
|
||||
const LogoFrappeMail = '/images/frappe-mail.svg'
|
||||
import LogoGmail from '@/images/gmail.png'
|
||||
import LogoOutlook from '@/images/outlook.png'
|
||||
import LogoSendgrid from '@/images/sendgrid.png'
|
||||
import LogoSparkpost from '@/images/sparkpost.webp'
|
||||
import LogoYahoo from '@/images/yahoo.png'
|
||||
import LogoYandex from '@/images/yandex.png'
|
||||
import LogoFrappeMail from '@/images/frappe-mail.svg'
|
||||
|
||||
const fixedFields = [
|
||||
{
|
||||
|
||||
104
frontend/src/components/ShortcutTooltip.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Tooltip v-if="!disabled">
|
||||
<template #body>
|
||||
<div
|
||||
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
<span>{{ label }}</span>
|
||||
<!-- Primary combos (one or many) -->
|
||||
<template
|
||||
v-for="(combo, idx) in primaryCombosDisplay"
|
||||
:key="'prim-' + idx + combo"
|
||||
>
|
||||
<KeyboardShortcut
|
||||
bg
|
||||
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
|
||||
:combo="combo"
|
||||
/>
|
||||
</template>
|
||||
<!-- Alternate combos / equivalents -->
|
||||
<template
|
||||
v-for="(alt, idx) in altCombosDisplay"
|
||||
:key="'alt-' + idx + alt"
|
||||
>
|
||||
<KeyboardShortcut
|
||||
bg
|
||||
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
|
||||
:combo="alt"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
</Tooltip>
|
||||
<slot v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip, KeyboardShortcut } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
combo?: string | string[]
|
||||
altCombos?: string[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
combo: '',
|
||||
altCombos: () => [],
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const isMac = computed(() => {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const platform =
|
||||
(navigator as any).userAgentData?.platform || navigator.platform || ''
|
||||
if (/Mac|iPod|iPhone|iPad/i.test(platform)) return true
|
||||
return /Mac OS X|Macintosh|iPhone|iPad|iPod/i.test(navigator.userAgent || '')
|
||||
})
|
||||
|
||||
function normalizeCombo(raw: string): string {
|
||||
if (!raw) return ''
|
||||
if (/^mod\+/i.test(raw)) {
|
||||
const rest = raw.split('+').slice(1).join('+')
|
||||
return (isMac.value ? 'Cmd' : 'Ctrl') + '+' + rest
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function normalizeList(list: string | string[]): string[] {
|
||||
const arr = Array.isArray(list) ? list : list ? [list] : []
|
||||
return arr.map(normalizeCombo)
|
||||
}
|
||||
|
||||
// Dedupe Backspace/Delete (prefer Backspace) on macOS
|
||||
function dedupeMacDeleteVariants(primary: string[], alts: string[]) {
|
||||
if (!isMac.value) return { primary, alts }
|
||||
const all = [...primary, ...alts]
|
||||
if (all.includes('Delete') && all.includes('Backspace')) {
|
||||
return {
|
||||
primary: primary.filter((k) => k !== 'Delete'),
|
||||
alts: alts.filter((k) => k !== 'Delete'),
|
||||
}
|
||||
}
|
||||
return { primary, alts }
|
||||
}
|
||||
|
||||
// Base normalized lists
|
||||
const normalizedPrimary = computed(() => normalizeList(props.combo))
|
||||
const normalizedAlt = computed(() => props.altCombos.map(normalizeCombo))
|
||||
|
||||
// Apply dedupe once to both arrays to avoid circular dependency
|
||||
const deduped = computed(() =>
|
||||
dedupeMacDeleteVariants(normalizedPrimary.value, normalizedAlt.value),
|
||||
)
|
||||
|
||||
const primaryCombosDisplay = computed(() => deduped.value.primary)
|
||||
const altCombosDisplay = computed(() => deduped.value.alts)
|
||||
|
||||
defineOptions({ name: 'ShortcutTooltip' })
|
||||
</script>
|
||||
@ -173,19 +173,26 @@
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Time'"
|
||||
class="form-control"
|
||||
>
|
||||
<TimePicker
|
||||
:value="doc[field.fieldname]"
|
||||
:format="getFormat('', '', false, true, false)"
|
||||
:placeholder="field.placeholder"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
class="form-control"
|
||||
>
|
||||
<DateTimePicker
|
||||
icon-left=""
|
||||
:value="doc[field.fieldname]"
|
||||
:formatter="
|
||||
(date) => getFormat(date, '', true, true)
|
||||
"
|
||||
:format="getFormat('', '', true, true, false)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
:hideIcon="true"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
@ -194,12 +201,10 @@
|
||||
class="form-control"
|
||||
>
|
||||
<DatePicker
|
||||
icon-left=""
|
||||
:value="doc[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:format="getFormat('', '', true, false, false)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
:hideIcon="true"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
@ -322,7 +327,7 @@ import { usersStore } from '@/stores/users'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||
import { Tooltip, DateTimePicker, DatePicker, TimePicker } from 'frappe-ui'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { ref, computed, getCurrentInstance } from 'vue'
|
||||
|
||||
|
||||
@ -58,8 +58,7 @@ import { getSettings } from '@/stores/settings'
|
||||
import { showSettings, isMobileView } from '@/composables/settings'
|
||||
import { showAboutModal } from '@/composables/modals'
|
||||
import { confirmLoginToFrappeCloud } from '@/composables/frappecloud'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { theme, toggleTheme } from '@/stores/theme'
|
||||
import { Dropdown, useTheme } from 'frappe-ui'
|
||||
import { computed, h, markRaw } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -72,6 +71,7 @@ const props = defineProps({
|
||||
const { settings, brand } = getSettings()
|
||||
const { logout } = sessionStore()
|
||||
const { getUser } = usersStore()
|
||||
const { currentTheme, toggleTheme } = useTheme()
|
||||
|
||||
const user = computed(() => getUser() || {})
|
||||
|
||||
@ -134,7 +134,7 @@ function getStandardItem(item) {
|
||||
}
|
||||
case 'toggle_theme':
|
||||
return {
|
||||
icon: theme.value === 'dark' ? 'sun' : item.icon,
|
||||
icon: currentTheme.value === 'dark' ? 'sun' : item.icon,
|
||||
label: __(item.label),
|
||||
onClick: toggleTheme,
|
||||
}
|
||||
|
||||
@ -60,13 +60,14 @@
|
||||
class="flex flex-row-reverse gap-2 items-center min-w-11"
|
||||
>
|
||||
<Dropdown
|
||||
placement="right-start"
|
||||
side="right"
|
||||
:offset="15"
|
||||
:options="viewControls.viewActions(item, close)"
|
||||
>
|
||||
<template #default>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!size-5 hidden group-hover:block"
|
||||
class="!size-5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity"
|
||||
icon="more-horizontal"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
@ -157,7 +157,7 @@ const props = defineProps({
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
default: 'sm',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
@ -282,7 +282,7 @@ const inputClasses = computed(() => {
|
||||
let variant = props.disabled ? 'disabled' : props.variant
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
'border border-gray-100 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',
|
||||
'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',
|
||||
outline:
|
||||
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-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',
|
||||
disabled: [
|
||||
|
||||
@ -1,166 +0,0 @@
|
||||
<template>
|
||||
<Menu as="div" class="relative inline-block text-left" v-slot="{ open }">
|
||||
<Popover
|
||||
:transition="dropdownTransition"
|
||||
:show="open"
|
||||
:placement="popoverPlacement"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<MenuButton as="template">
|
||||
<slot v-if="$slots.default" v-bind="{ open, togglePopover }" />
|
||||
<Button v-else :active="open" v-bind="button">
|
||||
{{ button ? button?.label || null : 'Options' }}
|
||||
</Button>
|
||||
</MenuButton>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
:class="{
|
||||
'mt-2': ['bottom', 'left', 'right'].includes(placement),
|
||||
'ml-2': placement == 'right-start',
|
||||
}"
|
||||
>
|
||||
<MenuItems
|
||||
class="min-w-40 divide-y divide-outline-gray-modals"
|
||||
:class="{
|
||||
'left-0 origin-top-left': placement == 'left',
|
||||
'right-0 origin-top-right': placement == 'right',
|
||||
'inset-x-0 origin-top': placement == 'center',
|
||||
'mt-0 origin-top-right': placement == 'right-start',
|
||||
}"
|
||||
>
|
||||
<div v-for="group in groups" :key="group.key" class="p-1.5">
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="item in group.items"
|
||||
:key="item.label"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<slot name="item" v-bind="{ item, active }">
|
||||
<component
|
||||
v-if="item.component"
|
||||
:is="item.component"
|
||||
:active="active"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
:class="[
|
||||
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
|
||||
'group flex h-7 w-full items-center rounded px-2 text-base',
|
||||
]"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="item.icon && typeof item.icon === 'string'"
|
||||
:name="item.icon"
|
||||
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component
|
||||
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
|
||||
v-else-if="item.icon"
|
||||
:is="item.icon"
|
||||
/>
|
||||
<span class="whitespace-nowrap text-ink-gray-7">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</slot>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems>
|
||||
<div v-if="slots.footer" class="border-t p-1.5">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
|
||||
import { Popover, Button, FeatherIcon } from 'frappe-ui'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = defineProps({
|
||||
button: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const slots = useSlots()
|
||||
|
||||
const dropdownTransition = {
|
||||
enterActiveClass: 'transition duration-100 ease-out',
|
||||
enterFromClass: 'transform scale-95 opacity-0',
|
||||
enterToClass: 'transform scale-100 opacity-100',
|
||||
leaveActiveClass: 'transition duration-75 ease-in',
|
||||
leaveFromClass: 'transform scale-100 opacity-100',
|
||||
leaveToClass: 'transform scale-95 opacity-0',
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: '', items: props.options }]
|
||||
|
||||
return groups.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: filterOptions(group.items),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const popoverPlacement = computed(() => {
|
||||
if (props.placement === 'left') return 'bottom-start'
|
||||
if (props.placement === 'right') return 'bottom-end'
|
||||
if (props.placement === 'center') return 'bottom-center'
|
||||
if (props.placement === 'right-start') return 'right-start'
|
||||
return 'bottom'
|
||||
})
|
||||
|
||||
function normalizeDropdownItem(option) {
|
||||
let onClick = option.onClick || null
|
||||
if (!onClick && option.route && router) {
|
||||
onClick = () => router.push(option.route)
|
||||
}
|
||||
|
||||
return {
|
||||
name: option.name,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
group: option.group,
|
||||
component: option.component,
|
||||
onClick,
|
||||
}
|
||||
}
|
||||
|
||||
function filterOptions(options) {
|
||||
return (options || [])
|
||||
.filter(Boolean)
|
||||
.filter((option) => (option.condition ? option.condition() : true))
|
||||
.map((option) => normalizeDropdownItem(option))
|
||||
}
|
||||
</script>
|
||||
@ -1,277 +0,0 @@
|
||||
<template>
|
||||
<div ref="reference">
|
||||
<div
|
||||
ref="target"
|
||||
:class="['flex', $attrs.class]"
|
||||
@click="updatePosition"
|
||||
@focusin="updatePosition"
|
||||
@keydown="updatePosition"
|
||||
@mouseover="onMouseover"
|
||||
@mouseleave="onMouseleave"
|
||||
>
|
||||
<slot
|
||||
name="target"
|
||||
v-bind="{ togglePopover, updatePosition, open, close, isOpen }"
|
||||
/>
|
||||
</div>
|
||||
<teleport to="#frappeui-popper-root">
|
||||
<div
|
||||
ref="popover"
|
||||
class="relative z-[100]"
|
||||
:class="[popoverContainerClass, popoverClass]"
|
||||
:style="{ minWidth: targetWidth ? targetWidth + 'px' : null }"
|
||||
@mouseover="pointerOverTargetOrPopup = true"
|
||||
@mouseleave="onMouseleave"
|
||||
>
|
||||
<transition v-bind="popupTransition">
|
||||
<div v-show="isOpen">
|
||||
<slot
|
||||
name="body"
|
||||
v-bind="{ togglePopover, updatePosition, open, close, isOpen }"
|
||||
>
|
||||
<div class="rounded-lg border border-gray-100 bg-surface-white shadow-xl">
|
||||
<slot
|
||||
name="body-main"
|
||||
v-bind="{
|
||||
togglePopover,
|
||||
updatePosition,
|
||||
open,
|
||||
close,
|
||||
isOpen,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createPopper } from '@popperjs/core'
|
||||
|
||||
export default {
|
||||
name: 'Popover',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
show: {
|
||||
default: undefined,
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'click', // click, hover
|
||||
},
|
||||
hoverDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
leaveDelay: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-start',
|
||||
},
|
||||
popoverClass: [String, Object, Array],
|
||||
transition: {
|
||||
default: null,
|
||||
},
|
||||
hideOnBlur: {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close', 'update:show'],
|
||||
expose: ['open', 'close'],
|
||||
data() {
|
||||
return {
|
||||
popoverContainerClass: 'body-container',
|
||||
showPopup: false,
|
||||
targetWidth: null,
|
||||
pointerOverTargetOrPopup: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(val) {
|
||||
if (val) {
|
||||
this.open()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
if (typeof window === 'undefined') return
|
||||
if (!document.getElementById('frappeui-popper-root')) {
|
||||
const root = document.createElement('div')
|
||||
root.id = 'frappeui-popper-root'
|
||||
document.body.appendChild(root)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.listener = (e) => {
|
||||
const clickedElement = e.target
|
||||
const reference = this.$refs.reference
|
||||
const popoverBody = this.$refs.popover
|
||||
const insideClick =
|
||||
clickedElement === reference ||
|
||||
clickedElement === popoverBody ||
|
||||
reference?.contains(clickedElement) ||
|
||||
popoverBody?.contains(clickedElement)
|
||||
if (insideClick) {
|
||||
return
|
||||
}
|
||||
|
||||
const root = document.getElementById('frappeui-popper-root')
|
||||
const insidePopoverRoot = root.contains(clickedElement)
|
||||
if (!insidePopoverRoot) {
|
||||
return this.close()
|
||||
}
|
||||
|
||||
const bodyClass = `.${this.popoverContainerClass}`
|
||||
const clickedElementBody = clickedElement?.closest(bodyClass)
|
||||
const currentPopoverBody = reference?.closest(bodyClass)
|
||||
const isSiblingClicked =
|
||||
clickedElementBody &&
|
||||
currentPopoverBody &&
|
||||
clickedElementBody === currentPopoverBody
|
||||
|
||||
if (isSiblingClicked) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
if (this.hideOnBlur) {
|
||||
document.addEventListener('click', this.listener)
|
||||
document.addEventListener('mousedown', this.listener)
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.targetWidth = this.$refs['target'].clientWidth
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.popper && this.popper.destroy()
|
||||
document.removeEventListener('click', this.listener)
|
||||
document.removeEventListener('mousedown', this.listener)
|
||||
},
|
||||
computed: {
|
||||
showPropPassed() {
|
||||
return this.show != null
|
||||
},
|
||||
isOpen: {
|
||||
get() {
|
||||
if (this.showPropPassed) {
|
||||
return this.show
|
||||
}
|
||||
return this.showPopup
|
||||
},
|
||||
set(val) {
|
||||
val = Boolean(val)
|
||||
if (this.showPropPassed) {
|
||||
this.$emit('update:show', val)
|
||||
} else {
|
||||
this.showPopup = val
|
||||
}
|
||||
if (val === false) {
|
||||
this.$emit('close')
|
||||
} else if (val === true) {
|
||||
this.$emit('open')
|
||||
}
|
||||
},
|
||||
},
|
||||
popupTransition() {
|
||||
let templates = {
|
||||
default: {
|
||||
enterActiveClass: 'transition duration-150 ease-out',
|
||||
enterFromClass: 'translate-y-1 opacity-0',
|
||||
enterToClass: 'translate-y-0 opacity-100',
|
||||
leaveActiveClass: 'transition duration-150 ease-in',
|
||||
leaveFromClass: 'translate-y-0 opacity-100',
|
||||
leaveToClass: 'translate-y-1 opacity-0',
|
||||
},
|
||||
}
|
||||
if (typeof this.transition === 'string') {
|
||||
return templates[this.transition]
|
||||
}
|
||||
return this.transition
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setupPopper() {
|
||||
if (!this.popper) {
|
||||
this.popper = createPopper(this.$refs.reference, this.$refs.popover, {
|
||||
placement: this.placement,
|
||||
})
|
||||
} else {
|
||||
this.updatePosition()
|
||||
}
|
||||
},
|
||||
updatePosition() {
|
||||
this.popper && this.popper.update()
|
||||
},
|
||||
togglePopover(flag) {
|
||||
if (flag instanceof Event) {
|
||||
flag = null
|
||||
}
|
||||
if (flag == null) {
|
||||
flag = !this.isOpen
|
||||
}
|
||||
flag = Boolean(flag)
|
||||
if (flag) {
|
||||
this.open()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.isOpen = true
|
||||
this.$nextTick(() => this.setupPopper())
|
||||
},
|
||||
close() {
|
||||
this.isOpen = false
|
||||
},
|
||||
onMouseover() {
|
||||
this.pointerOverTargetOrPopup = true
|
||||
if (this.leaveTimer) {
|
||||
clearTimeout(this.leaveTimer)
|
||||
this.leaveTimer = null
|
||||
}
|
||||
if (this.trigger === 'hover') {
|
||||
if (this.hoverDelay) {
|
||||
this.hoverTimer = setTimeout(() => {
|
||||
if (this.pointerOverTargetOrPopup) {
|
||||
this.open()
|
||||
}
|
||||
}, Number(this.hoverDelay) * 1000)
|
||||
} else {
|
||||
this.open()
|
||||
}
|
||||
}
|
||||
},
|
||||
onMouseleave(e) {
|
||||
this.pointerOverTargetOrPopup = false
|
||||
if (this.hoverTimer) {
|
||||
clearTimeout(this.hoverTimer)
|
||||
this.hoverTimer = null
|
||||
}
|
||||
if (this.trigger === 'hover') {
|
||||
if (this.leaveTimer) {
|
||||
clearTimeout(this.leaveTimer)
|
||||
}
|
||||
if (this.leaveDelay) {
|
||||
this.leaveTimer = setTimeout(() => {
|
||||
if (!this.pointerOverTargetOrPopup) {
|
||||
this.close()
|
||||
}
|
||||
}, Number(this.leaveDelay) * 1000)
|
||||
} else {
|
||||
if (!this.pointerOverTargetOrPopup) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
237
frontend/src/composables/event.js
Normal file
@ -0,0 +1,237 @@
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { dayjs, createListResource } from 'frappe-ui'
|
||||
import { sameArrayContents } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { allTimeSlots } from '@/components/Calendar/utils'
|
||||
|
||||
export const showEventModal = ref(false)
|
||||
export const activeEvent = ref(null)
|
||||
|
||||
export function useEvent(doctype, docname) {
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const eventsResource = createListResource({
|
||||
doctype: 'Event',
|
||||
cache: ['calendar', docname],
|
||||
fields: [
|
||||
'name',
|
||||
'status',
|
||||
'subject',
|
||||
'description',
|
||||
'starts_on',
|
||||
'ends_on',
|
||||
'all_day',
|
||||
'event_type',
|
||||
'color',
|
||||
'owner',
|
||||
'reference_doctype',
|
||||
'reference_docname',
|
||||
'creation',
|
||||
],
|
||||
filters: {
|
||||
reference_doctype: doctype,
|
||||
reference_docname: docname,
|
||||
},
|
||||
auto: true,
|
||||
orderBy: 'creation desc',
|
||||
onSuccess: (d) => {
|
||||
console.log(d)
|
||||
},
|
||||
})
|
||||
|
||||
const eventParticipantsResource = createListResource({
|
||||
doctype: 'Event Participants',
|
||||
fields: ['*'],
|
||||
parent: 'Event',
|
||||
})
|
||||
|
||||
const events = computed(() => {
|
||||
if (!eventsResource.data) return []
|
||||
const eventNames = eventsResource.data.map((e) => e.name)
|
||||
if (
|
||||
!eventParticipantsResource.data?.length ||
|
||||
eventsParticipantIsUpdated(eventNames)
|
||||
) {
|
||||
eventParticipantsResource.update({
|
||||
filters: {
|
||||
parenttype: 'Event',
|
||||
parentfield: 'event_participants',
|
||||
parent: ['in', eventNames],
|
||||
},
|
||||
})
|
||||
!eventParticipantsResource.list.loading &&
|
||||
eventParticipantsResource.reload()
|
||||
} else {
|
||||
eventsResource.data.forEach((event) => {
|
||||
if (typeof event.owner !== 'object') {
|
||||
event.owner = {
|
||||
label: getUser(event.owner).full_name,
|
||||
image: getUser(event.owner).user_image,
|
||||
name: event.owner,
|
||||
}
|
||||
}
|
||||
|
||||
event.event_participants = [
|
||||
...eventParticipantsResource.data.filter(
|
||||
(participant) => participant.parent === event.name,
|
||||
),
|
||||
]
|
||||
|
||||
event.participants = [
|
||||
event.owner,
|
||||
...eventParticipantsResource.data
|
||||
.filter((participant) => participant.parent === event.name)
|
||||
.map((participant) => ({
|
||||
label: getUser(participant.email).full_name || participant.email,
|
||||
image: getUser(participant.email).user_image || '',
|
||||
name: participant.email,
|
||||
})),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
return eventsResource.data
|
||||
})
|
||||
|
||||
function eventsParticipantIsUpdated(eventNames) {
|
||||
const parentFilter = eventParticipantsResource.filters?.parent?.[1]
|
||||
|
||||
if (eventNames.length && !sameArrayContents(parentFilter, eventNames))
|
||||
return true
|
||||
|
||||
let d = eventsResource.setValue.data
|
||||
if (!d) return false
|
||||
|
||||
let newParticipants = d.event_participants.map((p) => p.name)
|
||||
let oldParticipants = eventParticipantsResource.data
|
||||
.filter((p) => p.parent === d.name)
|
||||
.map((p) => p.name)
|
||||
|
||||
return !sameArrayContents(newParticipants, oldParticipants)
|
||||
}
|
||||
|
||||
const startEndTime = (
|
||||
startTime,
|
||||
endTime,
|
||||
isFullDay = false,
|
||||
format = 'h:mm a',
|
||||
) => {
|
||||
const start = dayjs(startTime)
|
||||
const end = dayjs(endTime)
|
||||
|
||||
if (isFullDay) return __('All day')
|
||||
|
||||
return `${start.format(format)} - ${end.format(format)}`
|
||||
}
|
||||
|
||||
const startDate = (startTime, format = 'ddd, D MMM YYYY') => {
|
||||
const start = dayjs(startTime)
|
||||
return start.format(format)
|
||||
}
|
||||
|
||||
return {
|
||||
eventsResource,
|
||||
eventParticipantsResource,
|
||||
events,
|
||||
startEndTime,
|
||||
startDate,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeParticipants(list = []) {
|
||||
const seen = new Set()
|
||||
const out = []
|
||||
for (const a of list || []) {
|
||||
if (!a?.email || seen.has(a.email)) continue
|
||||
seen.add(a.email)
|
||||
out.push({
|
||||
email: a.email,
|
||||
reference_doctype: a.reference_doctype || 'Contact',
|
||||
reference_docname: a.reference_docname || '',
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function formatDuration(mins) {
|
||||
if (mins < 60) return __('{0} mins', [mins])
|
||||
let hours = mins / 60
|
||||
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
|
||||
hours = hours.toFixed(2)
|
||||
}
|
||||
if (Number.isInteger(hours)) {
|
||||
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
|
||||
}
|
||||
return `${hours} hrs`
|
||||
}
|
||||
|
||||
export function buildEndTimeOptions(fromTime) {
|
||||
const timeSlots = allTimeSlots()
|
||||
if (!fromTime) return timeSlots
|
||||
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
|
||||
if (startIndex === -1) return []
|
||||
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
|
||||
const fromTotal = fh * 60 + fm
|
||||
return timeSlots.slice(startIndex).map((o) => {
|
||||
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
|
||||
const toTotal = th * 60 + tm
|
||||
const duration = toTotal - fromTotal
|
||||
return { ...o, label: `${o.label} (${formatDuration(duration)})` }
|
||||
})
|
||||
}
|
||||
|
||||
export function computeAutoToTime(fromTime) {
|
||||
if (!fromTime) return ''
|
||||
const [hour, minute] = fromTime.split(':').map((n) => parseInt(n))
|
||||
let nh = hour + 1
|
||||
let nm = minute
|
||||
if (nh >= 24) {
|
||||
nh = 23
|
||||
nm = 59
|
||||
}
|
||||
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function validateTimeRange({ fromDate, fromTime, toTime, isFullDay }) {
|
||||
if (isFullDay) return { valid: true, error: null }
|
||||
if (!fromTime || !toTime) {
|
||||
return { valid: false, error: __('Start and end time are required') }
|
||||
}
|
||||
const start = dayjs(fromDate + ' ' + fromTime)
|
||||
const end = dayjs(fromDate + ' ' + toTime)
|
||||
if (!start.isValid() || !end.isValid()) {
|
||||
return { valid: false, error: __('Invalid start or end time') }
|
||||
}
|
||||
if (end.diff(start, 'minute') <= 0) {
|
||||
return { valid: false, error: __('End time should be after start time') }
|
||||
}
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
|
||||
export function parseEventDoc(doc) {
|
||||
if (!doc) return {}
|
||||
const { getUser } = usersStore()
|
||||
return {
|
||||
id: doc.name,
|
||||
title: doc.subject,
|
||||
description: doc.description,
|
||||
status: doc.status,
|
||||
fromDate: dayjs(doc.starts_on).format('YYYY-MM-DD'),
|
||||
toDate: dayjs(doc.ends_on).format('YYYY-MM-DD'),
|
||||
fromTime: dayjs(doc.starts_on).format('HH:mm'),
|
||||
toTime: dayjs(doc.ends_on).format('HH:mm'),
|
||||
isFullDay: doc.all_day,
|
||||
eventType: doc.event_type,
|
||||
color: doc.color,
|
||||
referenceDoctype: doc.reference_doctype,
|
||||
referenceDocname: doc.reference_docname,
|
||||
event_participants: doc.event_participants || [],
|
||||
owner: doc.owner
|
||||
? {
|
||||
label: getUser(doc.owner).full_name,
|
||||
image: getUser(doc.owner).user_image,
|
||||
value: doc.owner,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
}
|
||||
78
frontend/src/composables/useKeyboardShortcuts.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { onMounted, onBeforeUnmount, unref } from 'vue'
|
||||
import { isDialogOpen } from '@/utils/dialogs'
|
||||
|
||||
/**
|
||||
* Generic global keyboard shortcuts composable.
|
||||
*
|
||||
* Usage:
|
||||
* useKeyboardShortcuts({
|
||||
* active: () => true, // boolean | () => boolean (reactive allowed)
|
||||
* shortcuts: [
|
||||
* { keys: 'Escape', action: close },
|
||||
* { keys: ['Delete', 'Backspace'], action: onDelete },
|
||||
* { match: e => (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd', action: duplicate }
|
||||
* ],
|
||||
* ignoreTyping: true // skip when focus is in input/textarea/contenteditable (default true)
|
||||
* })
|
||||
*/
|
||||
export function useKeyboardShortcuts(options) {
|
||||
const {
|
||||
active = true,
|
||||
shortcuts = [],
|
||||
ignoreTyping = true,
|
||||
target = typeof window !== 'undefined' ? window : null,
|
||||
skipWhenDialogOpen = true,
|
||||
} = options || {}
|
||||
|
||||
function isTypingEvent(e) {
|
||||
if (!ignoreTyping) return false
|
||||
const el = e.target
|
||||
if (!el) return false
|
||||
const tag = el.tagName
|
||||
return (
|
||||
el.isContentEditable ||
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
(el.closest && el.closest('[contenteditable="true"]'))
|
||||
)
|
||||
}
|
||||
|
||||
function matchShortcut(def, e) {
|
||||
if (def.match) return def.match(e)
|
||||
let keys = def.keys
|
||||
if (!keys) return false
|
||||
if (!Array.isArray(keys)) keys = [keys]
|
||||
return keys.some((k) => k === e.key)
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
if (!target) return
|
||||
const isActive = typeof active === 'function' ? active() : unref(active)
|
||||
if (!isActive) return
|
||||
if (isTypingEvent(e)) return
|
||||
if (skipWhenDialogOpen && isDialogOpen()) return
|
||||
|
||||
for (const def of shortcuts) {
|
||||
if (!def) continue
|
||||
if (def.guard && !def.guard(e)) continue
|
||||
if (matchShortcut(def, e)) {
|
||||
if (def.preventDefault !== false) e.preventDefault()
|
||||
if (def.stopPropagation) e.stopPropagation()
|
||||
def.action && def.action(e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
target && target.addEventListener('keydown', handler)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
target && target.removeEventListener('keydown', handler)
|
||||
})
|
||||
|
||||
return {
|
||||
stop: () => target && target.removeEventListener('keydown', handler),
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 763 B After Width: | Height: | Size: 763 B |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
475
frontend/src/pages/Calendar.vue
Normal file
@ -0,0 +1,475 @@
|
||||
<template>
|
||||
<LayoutHeader>
|
||||
<template #left-header>
|
||||
<ViewBreadcrumbs routeName="Calendar" />
|
||||
</template>
|
||||
<template #right-header>
|
||||
<ShortcutTooltip :label="__('Create event')" combo="Mod+E">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('Create')"
|
||||
:disabled="isCreateDisabled"
|
||||
@click="newEvent"
|
||||
>
|
||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||
</Button>
|
||||
</ShortcutTooltip>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<Calendar
|
||||
class="flex-1 overflow-hidden"
|
||||
ref="calendar"
|
||||
:config="{
|
||||
defaultMode: 'Week',
|
||||
isEditMode: true,
|
||||
eventIcons: {},
|
||||
allowCustomClickEvents: true,
|
||||
enableShortcuts: false,
|
||||
noBorder: true,
|
||||
}"
|
||||
:events="events.data"
|
||||
@create="(event) => createEvent(event)"
|
||||
@update="(event) => updateEvent(event, true)"
|
||||
@delete="(eventID) => deleteEvent(eventID)"
|
||||
:onClick="showDetails"
|
||||
:onDblClick="editDetails"
|
||||
:onCellClick="newEvent"
|
||||
>
|
||||
<template
|
||||
#header="{
|
||||
currentMonthYear,
|
||||
enabledModes,
|
||||
activeView,
|
||||
selectedMonthDate,
|
||||
decrement,
|
||||
increment,
|
||||
updateActiveView,
|
||||
onMonthYearChange,
|
||||
setCalendarDate,
|
||||
}"
|
||||
>
|
||||
<div class="my-4 mx-5 flex justify-between">
|
||||
<!-- left side -->
|
||||
<!-- Month Year -->
|
||||
<div class="flex items-center">
|
||||
<DatePicker
|
||||
:modelValue="selectedMonthDate"
|
||||
@update:modelValue="(val) => onMonthYearChange(val)"
|
||||
:clearable="false"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-lg font-medium text-ink-gray-7"
|
||||
:label="currentMonthYear"
|
||||
iconRight="chevron-down"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<!-- right side -->
|
||||
<!-- actions buttons for calendar -->
|
||||
<div class="flex gap-x-1">
|
||||
<!-- Increment and Decrement Button -->
|
||||
|
||||
<Button @click="decrement" variant="ghost" icon="chevron-left" />
|
||||
<Button
|
||||
:label="__('Today')"
|
||||
variant="ghost"
|
||||
@click="setCalendarDate()"
|
||||
/>
|
||||
<Button @click="increment" variant="ghost" icon="chevron-right" />
|
||||
|
||||
<!-- View change button, default is months or can be set via props! -->
|
||||
<TabButtons
|
||||
:buttons="enabledModes"
|
||||
class="ml-2"
|
||||
:modelValue="activeView"
|
||||
@update:modelValue="updateActiveView($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Calendar>
|
||||
|
||||
<!-- Event Panel Container -->
|
||||
<div
|
||||
class="overflow-hidden flex-none transition-all duration-300 ease-in-out flex flex-col"
|
||||
:class="
|
||||
showEventPanel
|
||||
? 'w-[352px] border-l bg-surface-white'
|
||||
: 'w-0 border-l-0'
|
||||
"
|
||||
>
|
||||
<CalendarEventPanel
|
||||
ref="eventPanel"
|
||||
v-if="showEventPanel"
|
||||
v-model="showEventPanel"
|
||||
v-model:event="event"
|
||||
:mode="mode"
|
||||
@new="newEvent"
|
||||
@save="saveEvent"
|
||||
@edit="editDetails"
|
||||
@delete="deleteEvent"
|
||||
@duplicate="duplicateEvent"
|
||||
@details="showDetails"
|
||||
@close="close"
|
||||
@sync="syncEvent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import CalendarEventPanel from '@/components/Calendar/CalendarEventPanel.vue'
|
||||
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
|
||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||
import ShortcutTooltip from '@/components/ShortcutTooltip.vue'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||
import {
|
||||
Calendar,
|
||||
createListResource,
|
||||
TabButtons,
|
||||
dayjs,
|
||||
DatePicker,
|
||||
CalendarActiveEvent as activeEvent,
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { onMounted, ref, computed, provide } from 'vue'
|
||||
|
||||
const { user } = sessionStore()
|
||||
const { $dialog } = globalStore()
|
||||
|
||||
const calendar = ref(null)
|
||||
|
||||
const events = createListResource({
|
||||
doctype: 'Event',
|
||||
cache: ['calendar', user],
|
||||
fields: [
|
||||
'name',
|
||||
'status',
|
||||
'subject',
|
||||
'description',
|
||||
'starts_on',
|
||||
'ends_on',
|
||||
'all_day',
|
||||
'event_type',
|
||||
'color',
|
||||
'reference_doctype',
|
||||
'reference_docname',
|
||||
],
|
||||
filters: { status: 'Open', owner: user },
|
||||
pageLength: 9999,
|
||||
auto: true,
|
||||
transform: (data) =>
|
||||
data.map((ev) => ({
|
||||
id: ev.name,
|
||||
title: ev.subject,
|
||||
description: ev.description,
|
||||
status: ev.status,
|
||||
fromDate: dayjs(ev.starts_on).format('YYYY-MM-DD'),
|
||||
toDate: dayjs(ev.ends_on).format('YYYY-MM-DD'),
|
||||
fromTime: dayjs(ev.starts_on).format('HH:mm'),
|
||||
toTime: dayjs(ev.ends_on).format('HH:mm'),
|
||||
isFullDay: ev.all_day,
|
||||
eventType: ev.event_type,
|
||||
color: ev.color,
|
||||
referenceDoctype: ev.reference_doctype,
|
||||
referenceDocname: ev.reference_docname,
|
||||
})),
|
||||
})
|
||||
|
||||
provide('events', events)
|
||||
|
||||
const eventPanel = ref(null)
|
||||
const showEventPanel = ref(false)
|
||||
const event = ref({})
|
||||
const mode = ref('')
|
||||
|
||||
const isCreateDisabled = computed(() =>
|
||||
['edit', 'new', 'duplicate'].includes(mode.value),
|
||||
)
|
||||
|
||||
// Temp event helpers
|
||||
const TEMP_EVENT_IDS = new Set(['new-event', 'duplicate-event'])
|
||||
const isTempEvent = (id) => TEMP_EVENT_IDS.has(id)
|
||||
function removeTempEvents() {
|
||||
if (!Array.isArray(events.data)) return
|
||||
events.data = events.data.filter((ev) => !isTempEvent(ev.id))
|
||||
}
|
||||
|
||||
function openEvent(e, nextMode, reloadEvent = false) {
|
||||
const _e = e?.calendarEvent || e
|
||||
if (!_e?.id || isTempEvent(_e.id)) return
|
||||
removeTempEvents()
|
||||
showEventPanel.value = true
|
||||
event.value = { id: _e.id, reloadEvent }
|
||||
activeEvent.value = _e.id
|
||||
mode.value = nextMode
|
||||
}
|
||||
|
||||
function saveEvent(_event) {
|
||||
if (!_event?.id || isTempEvent(_event.id)) return createEvent(_event)
|
||||
updateEvent(_event)
|
||||
}
|
||||
|
||||
function buildEventPayload(_event) {
|
||||
return {
|
||||
subject: _event.title,
|
||||
description: _event.description,
|
||||
starts_on: `${_event.fromDate} ${_event.fromTime}`,
|
||||
ends_on: `${_event.toDate} ${_event.toTime}`,
|
||||
all_day: _event.isFullDay || false,
|
||||
event_type: _event.eventType,
|
||||
color: _event.color,
|
||||
reference_doctype: _event.referenceDoctype,
|
||||
reference_docname: _event.referenceDocname,
|
||||
event_participants: _event.event_participants,
|
||||
}
|
||||
}
|
||||
|
||||
function createEvent(_event) {
|
||||
if (!_event?.title) return
|
||||
events.insert.submit(buildEventPayload(_event), {
|
||||
onSuccess: async (e) => {
|
||||
await events.reload()
|
||||
showDetails({ id: e.name })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function updateEvent(_event, afterDrag = false) {
|
||||
if (!_event.id) return
|
||||
|
||||
_event.fromTime = dayjs(_event.fromTime, 'HH:mm').format('HH:mm')
|
||||
_event.toTime = dayjs(_event.toTime, 'HH:mm').format('HH:mm')
|
||||
|
||||
if (
|
||||
['duplicate', 'new'].includes(mode.value) &&
|
||||
!['duplicate-event', 'new-event'].includes(_event.id) &&
|
||||
afterDrag
|
||||
) {
|
||||
event.value = { id: _event.id }
|
||||
activeEvent.value = _event.id
|
||||
mode.value = 'details'
|
||||
}
|
||||
|
||||
if (mode.value == 'edit' && afterDrag) {
|
||||
eventPanel.value.updateEvent({
|
||||
fromDate: _event.fromDate,
|
||||
toDate: _event.toDate,
|
||||
fromTime: _event.fromTime,
|
||||
toTime: _event.toTime,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!mode.value || mode.value == 'edit' || mode.value === 'details') {
|
||||
// Ensure Contacts exist for participants referencing a new/unknown Contact, if not create them
|
||||
if (
|
||||
Array.isArray(_event.event_participants) &&
|
||||
_event.event_participants.length
|
||||
) {
|
||||
_event.event_participants = await ensureParticipantContacts(
|
||||
_event.event_participants,
|
||||
)
|
||||
}
|
||||
|
||||
events.setValue.submit(
|
||||
{ name: _event.id, ...buildEventPayload(_event) },
|
||||
{
|
||||
onSuccess: async (e) => {
|
||||
await events.reload()
|
||||
showEventPanel.value && showDetails({ id: e.name }, true)
|
||||
},
|
||||
},
|
||||
)
|
||||
} else {
|
||||
event.value = { ..._event }
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEvent(eventID) {
|
||||
if (!eventID) return
|
||||
|
||||
$dialog({
|
||||
title: __('Delete'),
|
||||
message: __('Are you sure you want to delete this event?'),
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
onClick: (close) => {
|
||||
events.delete.submit(eventID, {
|
||||
onSuccess: () => events.reload(),
|
||||
})
|
||||
showEventPanel.value = false
|
||||
event.value = {}
|
||||
activeEvent.value = ''
|
||||
mode.value = ''
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function syncEvent(eventID, _event) {
|
||||
if (!eventID) return
|
||||
Object.assign(events.data.filter((event) => event.id === eventID)[0], _event)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
activeEvent.value = ''
|
||||
mode.value = ''
|
||||
showEventPanel.value = false
|
||||
})
|
||||
|
||||
// Global shortcut: Cmd/Ctrl + E -> new event (when not already creating/editing)
|
||||
useKeyboardShortcuts({
|
||||
shortcuts: [
|
||||
{
|
||||
match: (e) =>
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
e.key.toLowerCase() === 'e',
|
||||
guard: () => !isCreateDisabled.value,
|
||||
action: () =>
|
||||
newEvent({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
time: dayjs().format('HH:mm'),
|
||||
isFullDay: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
function showDetails(e, reloadEvent = false) {
|
||||
openEvent(e, 'details', reloadEvent)
|
||||
}
|
||||
|
||||
function editDetails(e) {
|
||||
openEvent(e, 'edit')
|
||||
}
|
||||
|
||||
function buildTempEvent(e, duplicate) {
|
||||
const id = duplicate ? 'duplicate-event' : 'new-event'
|
||||
return {
|
||||
id,
|
||||
title: e.title,
|
||||
description: e.description || '',
|
||||
date: e.fromDate,
|
||||
fromDate: e.fromDate,
|
||||
toDate: e.toDate,
|
||||
fromTime: e.fromTime,
|
||||
toTime: e.toTime,
|
||||
isFullDay: e.isFullDay || false,
|
||||
eventType: e.eventType || 'Public',
|
||||
color: e.color || 'green',
|
||||
referenceDoctype: e.referenceDoctype,
|
||||
referenceDocname: e.referenceDocname,
|
||||
event_participants: e.event_participants || [],
|
||||
}
|
||||
}
|
||||
|
||||
function newEvent(e = {}, duplicate = false) {
|
||||
removeTempEvents()
|
||||
|
||||
let base = { ...e }
|
||||
if (!duplicate) {
|
||||
const [fromTime, toTime] = getFromToTime(e.time)
|
||||
const fromDate = dayjs(e.date).format('YYYY-MM-DD')
|
||||
base = {
|
||||
...base,
|
||||
fromDate,
|
||||
toDate: fromDate,
|
||||
fromTime,
|
||||
toTime,
|
||||
isFullDay: e.isFullDay,
|
||||
}
|
||||
}
|
||||
|
||||
event.value = buildTempEvent(base, duplicate)
|
||||
events.data.push(event.value)
|
||||
showEventPanel.value = true
|
||||
activeEvent.value = event.value.id
|
||||
mode.value = duplicate ? 'duplicate' : 'new'
|
||||
}
|
||||
|
||||
function duplicateEvent(e) {
|
||||
newEvent(e, true)
|
||||
}
|
||||
|
||||
function close() {
|
||||
showEventPanel.value = false
|
||||
event.value = {}
|
||||
activeEvent.value = ''
|
||||
mode.value = ''
|
||||
|
||||
removeTempEvents()
|
||||
}
|
||||
|
||||
// utils
|
||||
function getFromToTime(time) {
|
||||
const pad = (v) => String(v).padStart(2, '0')
|
||||
let now = dayjs()
|
||||
let h = now.hour()
|
||||
let m = Math.floor(now.minute() / 15) * 15
|
||||
let fromHour = h
|
||||
let fromMinute = m
|
||||
if (time) {
|
||||
if (/am|pm/i.test(time)) {
|
||||
const raw = time.trim().replace(' ', '')
|
||||
const ampm = raw.slice(-2).toLowerCase()
|
||||
let hour = parseInt(raw.slice(0, -2))
|
||||
if (ampm === 'pm' && hour < 12) hour += 12
|
||||
if (ampm === 'am' && hour === 12) hour = 0
|
||||
fromHour = hour
|
||||
fromMinute = 0
|
||||
} else if (/^\d{1,2}:?\d{0,2}$/.test(time)) {
|
||||
const [hh, mm = '00'] = time.split(':')
|
||||
fromHour = parseInt(hh)
|
||||
fromMinute = parseInt(mm) || 0
|
||||
}
|
||||
}
|
||||
const toHour = (fromHour + 1) % 24
|
||||
return [
|
||||
`${pad(fromHour)}:${pad(fromMinute)}`,
|
||||
`${pad(toHour)}:${pad(fromMinute)}`,
|
||||
]
|
||||
}
|
||||
|
||||
async function ensureParticipantContacts(participants) {
|
||||
if (!Array.isArray(participants) || !participants.length) return participants
|
||||
const updated = []
|
||||
for (const part of participants) {
|
||||
const p = { ...part }
|
||||
try {
|
||||
if (
|
||||
p.reference_doctype === 'Contact' &&
|
||||
(!p.reference_docname || p.reference_docname === 'new') &&
|
||||
p.email
|
||||
) {
|
||||
const firstName = p.email.split('@')[0] || p.email
|
||||
const contactDoc = await call('frappe.client.insert', {
|
||||
doc: {
|
||||
doctype: 'Contact',
|
||||
first_name: firstName,
|
||||
email_ids: [{ email_id: p.email, is_primary: 1 }],
|
||||
},
|
||||
})
|
||||
if (contactDoc?.name) p.reference_docname = contactDoc.name
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed creating contact for participant', p.email, e)
|
||||
}
|
||||
updated.push(p)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
</script>
|
||||
@ -324,6 +324,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
|
||||
import EventIcon from '@/components/Icons/EventIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
@ -541,6 +542,11 @@ const tabs = computed(() => {
|
||||
label: __('Data'),
|
||||
icon: DetailsIcon,
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
label: __('Events'),
|
||||
icon: EventIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
|
||||
@ -224,6 +224,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
|
||||
import EventIcon from '@/components/Icons/EventIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
@ -398,6 +399,11 @@ const tabs = computed(() => {
|
||||
label: __('Data'),
|
||||
icon: DetailsIcon,
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
label: __('Events'),
|
||||
icon: EventIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
|
||||
@ -237,7 +237,7 @@ const breadcrumbs = computed(() => {
|
||||
let view = getView(
|
||||
route.query.view,
|
||||
route.query.viewType,
|
||||
'CRM Organization'
|
||||
'CRM Organization',
|
||||
)
|
||||
if (view) {
|
||||
items.push({
|
||||
|
||||
@ -213,7 +213,7 @@ import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { formatDate, timeAgo } from '@/utils'
|
||||
import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
|
||||
@ -84,6 +84,11 @@ const routes = [
|
||||
name: 'Call Logs',
|
||||
component: () => import('@/pages/CallLogs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/calendar',
|
||||
name: 'Calendar',
|
||||
component: () => import('@/pages/Calendar.vue'),
|
||||
},
|
||||
{
|
||||
path: '/welcome',
|
||||
name: 'Welcome',
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
export const theme = useStorage('theme', 'light')
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme')
|
||||
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
}
|
||||
|
||||
export function setTheme(value) {
|
||||
theme.value = value || theme.value
|
||||
if (['light', 'dark'].includes(theme.value)) {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,10 @@ import { reactive, ref } from 'vue'
|
||||
|
||||
let dialogs = ref([])
|
||||
|
||||
export function isDialogOpen() {
|
||||
return dialogs.value.some((d) => d.show)
|
||||
}
|
||||
|
||||
export let Dialogs = {
|
||||
name: 'Dialogs',
|
||||
render() {
|
||||
@ -18,9 +22,7 @@ export let Dialogs = {
|
||||
dialog.message && (
|
||||
<p class="text-p-base text-ink-gray-7">{dialog.message}</p>
|
||||
),
|
||||
dialog.html && (
|
||||
<div v-html={dialog.html} />
|
||||
),
|
||||
dialog.html && <div v-html={dialog.html} />,
|
||||
<ErrorMessage class="mt-2" message={dialog.error} />,
|
||||
]
|
||||
},
|
||||
|
||||
@ -45,7 +45,7 @@ export function getFormat(
|
||||
onlyTime = false,
|
||||
withDate = true,
|
||||
) {
|
||||
if (!date) return ''
|
||||
if (!date && withDate) return ''
|
||||
let dateFormat =
|
||||
window.sysdefaults.date_format
|
||||
.replace('mm', 'MM')
|
||||
@ -684,3 +684,33 @@ export function validateConditions(conditions) {
|
||||
|
||||
return conditions.length > 0
|
||||
}
|
||||
|
||||
// sameArrayContents: returns true if both arrays have exactly the same elements
|
||||
// (including duplicate counts) irrespective of order.
|
||||
// Non-arrays or arrays of different length return false.
|
||||
export function sameArrayContents(a, b) {
|
||||
if (a === b) return true
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
||||
if (a.length !== b.length) return false
|
||||
if (a.length === 0) return true
|
||||
const counts = new Map()
|
||||
for (const v of a) {
|
||||
counts.set(v, (counts.get(v) || 0) + 1)
|
||||
}
|
||||
for (const v of b) {
|
||||
const c = counts.get(v)
|
||||
if (!c) return false
|
||||
if (c === 1) counts.delete(v)
|
||||
else counts.set(v, c - 1)
|
||||
}
|
||||
return counts.size === 0
|
||||
}
|
||||
|
||||
// orderSensitiveEqual: returns true only if arrays are strictly equal index-wise
|
||||
export function orderSensitiveEqual(a, b) {
|
||||
if (a === b) return true
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
||||
return true
|
||||
}
|
||||
|
||||