Compare commits
1210 Commits
main
...
feat/fb-le
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453dccb33f | ||
|
|
f85a8cc281 | ||
|
|
d62229b9be | ||
|
|
38e10f91cb | ||
|
|
1b87635d3d | ||
|
|
176c7c73bb | ||
|
|
91b8a48c0e | ||
|
|
45bc5925d1 | ||
|
|
eb0b189d4c | ||
|
|
49b2c66299 | ||
|
|
4e5c2f5426 | ||
|
|
595c1b0b11 | ||
|
|
b6f8cbd5eb | ||
|
|
872f271eb1 | ||
|
|
5fc0ca9bf0 | ||
|
|
19c69d1495 | ||
|
|
5feaddcd6f | ||
|
|
56de0ce0d9 | ||
|
|
24f800c8e2 | ||
|
|
094869d2e7 | ||
|
|
373102d182 | ||
|
|
d69b86bcaf | ||
|
|
4148640472 | ||
|
|
9237398342 | ||
|
|
788fe36cfa | ||
|
|
cd366839fc | ||
|
|
6e1dd04819 | ||
|
|
d125d12a36 | ||
|
|
6db4d94e8a | ||
|
|
b12c55531f | ||
|
|
ddabd82f21 | ||
|
|
f435d74d0a | ||
|
|
5e5929ef40 | ||
|
|
7d2ccc2e58 | ||
|
|
9cd86e99c3 | ||
|
|
3ac4a582f5 | ||
|
|
d0cccc2e61 | ||
|
|
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):
|
||||
|
||||
@ -55,7 +55,11 @@
|
||||
"first_response_time",
|
||||
"first_responded_on",
|
||||
"log_tab",
|
||||
"status_change_log"
|
||||
"status_change_log",
|
||||
"syncing_tab",
|
||||
"facebook_lead_id",
|
||||
"column_break_ixmu",
|
||||
"facebook_form_id"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -325,13 +329,33 @@
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "syncing_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Syncing"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_lead_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook Lead ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ixmu",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_form_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Facebook Form ID"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-14 19:51:06.184569",
|
||||
"modified": "2025-10-19 18:36:24.683076",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead",
|
||||
|
||||
@ -14,6 +14,53 @@ from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
|
||||
|
||||
class CRMLead(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crm.fcrm.doctype.crm_products.crm_products import CRMProducts
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import CRMStatusChangeLog
|
||||
from frappe.types import DF
|
||||
|
||||
annual_revenue: DF.Currency
|
||||
communication_status: DF.Link | None
|
||||
converted: DF.Check
|
||||
email: DF.Data | None
|
||||
facebook_form_id: DF.Data | None
|
||||
facebook_lead_id: DF.Data | None
|
||||
first_name: DF.Data
|
||||
first_responded_on: DF.Datetime | None
|
||||
first_response_time: DF.Duration | None
|
||||
gender: DF.Link | None
|
||||
image: DF.AttachImage | None
|
||||
industry: DF.Link | None
|
||||
job_title: DF.Data | None
|
||||
last_name: DF.Data | None
|
||||
lead_name: DF.Data | None
|
||||
lead_owner: DF.Link | None
|
||||
middle_name: DF.Data | None
|
||||
mobile_no: DF.Data | None
|
||||
naming_series: DF.Literal["CRM-LEAD-.YYYY.-"]
|
||||
net_total: DF.Currency
|
||||
no_of_employees: DF.Literal["1-10", "11-50", "51-200", "201-500", "501-1000", "1000+"]
|
||||
organization: DF.Data | None
|
||||
phone: DF.Data | None
|
||||
products: DF.Table[CRMProducts]
|
||||
response_by: DF.Datetime | None
|
||||
salutation: DF.Link | None
|
||||
sla: DF.Link | None
|
||||
sla_creation: DF.Datetime | None
|
||||
sla_status: DF.Literal["", "First Response Due", "Failed", "Fulfilled"]
|
||||
source: DF.Link | None
|
||||
status: DF.Link
|
||||
status_change_log: DF.Table[CRMStatusChangeLog]
|
||||
territory: DF.Link | None
|
||||
total: DF.Currency
|
||||
website: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def before_validate(self):
|
||||
self.set_sla()
|
||||
|
||||
|
||||
@ -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
|
||||
41
crm/hooks.py
@ -22,6 +22,8 @@ add_to_apps_screen = [
|
||||
}
|
||||
]
|
||||
|
||||
export_python_type_annotations = True
|
||||
|
||||
# Includes in <head>
|
||||
# ------------------
|
||||
|
||||
@ -167,23 +169,28 @@ doc_events = {
|
||||
# Scheduled Tasks
|
||||
# ---------------
|
||||
|
||||
# scheduler_events = {
|
||||
# "all": [
|
||||
# "crm.tasks.all"
|
||||
# ],
|
||||
# "daily": [
|
||||
# "crm.tasks.daily"
|
||||
# ],
|
||||
# "hourly": [
|
||||
# "crm.tasks.hourly"
|
||||
# ],
|
||||
# "weekly": [
|
||||
# "crm.tasks.weekly"
|
||||
# ],
|
||||
# "monthly": [
|
||||
# "crm.tasks.monthly"
|
||||
# ],
|
||||
# }
|
||||
scheduler_events = {
|
||||
"daily_long": [
|
||||
"crm.tasks.sync_leads_from_sources_daily"
|
||||
],
|
||||
"hourly_long": [
|
||||
"crm.tasks.sync_leads_from_sources_hourly"
|
||||
],
|
||||
"monthly_long": [
|
||||
"crm.tasks.sync_leads_from_sources_monthly"
|
||||
],
|
||||
"cron": {
|
||||
"*/5 * * * *": [
|
||||
"crm.tasks.sync_leads_from_sources_5_minutes"
|
||||
],
|
||||
"*/10 * * * *": [
|
||||
"crm.tasks.sync_leads_from_sources_10_minutes"
|
||||
],
|
||||
"*/15 * * * *": [
|
||||
"crm.tasks.sync_leads_from_sources_15_minutes"
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
# Testing
|
||||
# -------
|
||||
|
||||
0
crm/lead_syncing/__init__.py
Normal file
0
crm/lead_syncing/doctype/__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("Facebook Lead Form", {
|
||||
refresh(frm) {
|
||||
//
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,81 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:id",
|
||||
"creation": "2025-09-26 19:01:48.325681",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"page",
|
||||
"id",
|
||||
"column_break_ahyo",
|
||||
"form_name",
|
||||
"section_break_iqhq",
|
||||
"questions"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "page",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Page",
|
||||
"options": "Facebook Page",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "id",
|
||||
"fieldtype": "Data",
|
||||
"label": "ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ahyo",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "form_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Form Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_iqhq",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "questions",
|
||||
"fieldtype": "Table",
|
||||
"label": "Questions",
|
||||
"options": "Facebook Lead Form Question"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-29 18:50:19.215513",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Lead Form",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "hussain@frappe.io",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "form_name"
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class FacebookLeadForm(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crm.lead_syncing.doctype.facebook_lead_form_question.facebook_lead_form_question import FacebookLeadFormQuestion
|
||||
from frappe.types import DF
|
||||
|
||||
form_name: DF.Data | None
|
||||
id: DF.Data | None
|
||||
page: DF.Link
|
||||
questions: DF.Table[FacebookLeadFormQuestion]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@ -0,0 +1,22 @@
|
||||
# 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 IntegrationTestFacebookLeadForm(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for FacebookLeadForm.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -0,0 +1,67 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-09-26 19:45:47.696180",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"label",
|
||||
"id",
|
||||
"column_break_hgde",
|
||||
"key",
|
||||
"type",
|
||||
"mapped_to_crm_field"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "label",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Label"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_hgde",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Key",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "id",
|
||||
"fieldtype": "Data",
|
||||
"label": "ID"
|
||||
},
|
||||
{
|
||||
"default": "Not Synced",
|
||||
"fieldname": "mapped_to_crm_field",
|
||||
"fieldtype": "Autocomplete",
|
||||
"in_list_view": 1,
|
||||
"label": "Mapped to CRM Field"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-29 18:45:21.800960",
|
||||
"modified_by": "hussain@frappe.io",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Lead Form Question",
|
||||
"owner": "hussain@frappe.io",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class FacebookLeadFormQuestion(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
id: DF.Data | None
|
||||
key: DF.Data
|
||||
label: DF.Data | None
|
||||
mapped_to_crm_field: DF.Autocomplete | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
type: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
0
crm/lead_syncing/doctype/facebook_page/__init__.py
Normal file
8
crm/lead_syncing/doctype/facebook_page/facebook_page.js
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("Facebook Page", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
83
crm/lead_syncing/doctype/facebook_page/facebook_page.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "field:id",
|
||||
"creation": "2025-09-26 18:59:12.833879",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"page_name",
|
||||
"account_id",
|
||||
"category",
|
||||
"column_break_eteo",
|
||||
"id",
|
||||
"access_token"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "category",
|
||||
"fieldtype": "Data",
|
||||
"label": "Category"
|
||||
},
|
||||
{
|
||||
"fieldname": "id",
|
||||
"fieldtype": "Data",
|
||||
"label": "ID",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "account_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Account ID"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_eteo",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Access Token"
|
||||
},
|
||||
{
|
||||
"fieldname": "page_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Page Name"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Facebook Lead Form",
|
||||
"link_fieldname": "page"
|
||||
}
|
||||
],
|
||||
"modified": "2025-09-26 19:36:59.413214",
|
||||
"modified_by": "hussain@frappe.io",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Facebook Page",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "hussain@frappe.io",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "page_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
24
crm/lead_syncing/doctype/facebook_page/facebook_page.py
Normal file
@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class FacebookPage(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
access_token: DF.SmallText | None
|
||||
account_id: DF.Data | None
|
||||
category: DF.Data | None
|
||||
id: DF.Data | None
|
||||
page_name: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
22
crm/lead_syncing/doctype/facebook_page/test_facebook_page.py
Normal file
@ -0,0 +1,22 @@
|
||||
# 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 IntegrationTestFacebookPage(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for FacebookPage.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Failed Lead Sync Log", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
@ -0,0 +1,90 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-10-19 17:29:10.261307",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"column_break_dhay",
|
||||
"source",
|
||||
"section_break_fhot",
|
||||
"lead_data",
|
||||
"section_break_knec",
|
||||
"traceback"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "Failure",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Duplicate\nFailure",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "lead_data",
|
||||
"fieldtype": "Code",
|
||||
"label": "Lead Data",
|
||||
"options": "JSON",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_dhay",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "source",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Source",
|
||||
"options": "Lead Sync Source",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_fhot",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_knec",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "traceback",
|
||||
"fieldtype": "Code",
|
||||
"label": "Traceback",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-19 18:59:17.152547",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Failed Lead Sync Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class FailedLeadSyncLog(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
lead_data: DF.Code | None
|
||||
source: DF.Link | None
|
||||
traceback: DF.Code | None
|
||||
type: DF.Literal["Duplicate", "Failure"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@ -0,0 +1,22 @@
|
||||
# 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 IntegrationTestFailedLeadSyncLog(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for FailedLeadSyncLog.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
214
crm/lead_syncing/doctype/lead_sync_source/facebook.py
Normal file
@ -0,0 +1,214 @@
|
||||
import frappe
|
||||
from frappe.exceptions import ValidationError
|
||||
from frappe.integrations.utils import make_get_request
|
||||
|
||||
FB_GRAPH_API_BASE = "https://graph.facebook.com"
|
||||
FB_GRAPH_API_VERSION = "v23.0"
|
||||
|
||||
|
||||
class DuplicateLeadError(ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def get_fb_graph_api_url(endpoint: str) -> str:
|
||||
if endpoint.startswith("/"):
|
||||
endpoint = endpoint[1:]
|
||||
|
||||
return f"{FB_GRAPH_API_BASE}/{FB_GRAPH_API_VERSION}/{endpoint}"
|
||||
|
||||
|
||||
class FacebookSyncSource:
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
form_id: str,
|
||||
source_name: str | None = None,
|
||||
):
|
||||
self.access_token = access_token
|
||||
self.form_id = form_id
|
||||
self.source_name = source_name
|
||||
|
||||
def get_api_url(self, endpoint: str) -> str:
|
||||
return get_fb_graph_api_url(endpoint)
|
||||
|
||||
def sync(self):
|
||||
leads = self.fetch_leads()
|
||||
question_to_field_map = self.get_form_questions_mapping()
|
||||
|
||||
for lead in leads:
|
||||
lead_data = {item["name"]: item["values"][0] for item in lead["field_data"]}
|
||||
crm_lead_data = {
|
||||
question_to_field_map.get(k): v for k, v in lead_data.items() if k in question_to_field_map
|
||||
}
|
||||
crm_lead_data["source"] = "Facebook"
|
||||
crm_lead_data["facebook_lead_id"] = lead["id"]
|
||||
crm_lead_data["facebook_form_id"] = self.form_id
|
||||
|
||||
try:
|
||||
self.validate_duplicate_lead(crm_lead_data, question_to_field_map)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Lead",
|
||||
**crm_lead_data,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
except (frappe.UniqueValidationError, DuplicateLeadError):
|
||||
self.create_failure_log(lead, "Duplicate")
|
||||
except Exception:
|
||||
self.create_failure_log(lead, traceback=frappe.get_traceback(with_context=True))
|
||||
|
||||
self.update_last_synced_at()
|
||||
|
||||
def fetch_leads(self):
|
||||
url = self.get_api_url(f"/{self.form_id}/leads")
|
||||
|
||||
if self.last_synced_at:
|
||||
timestamp = frappe.utils.data.get_timestamp(self.last_synced_at)
|
||||
filtering = (
|
||||
f"filtering=[{{'field':'time_created','operator':'GREATER_THAN','value':{timestamp}}}]"
|
||||
)
|
||||
url = f"{url}?{filtering}"
|
||||
|
||||
return make_get_request(
|
||||
url,
|
||||
params={
|
||||
"access_token": self.access_token,
|
||||
"fields": "id,created_time,field_data",
|
||||
"limit": 100000, # TODO: pagination
|
||||
},
|
||||
).get("data", [])
|
||||
|
||||
def get_form_questions_mapping(self):
|
||||
form_questions = frappe.db.get_all(
|
||||
"Facebook Lead Form Question",
|
||||
filters={"parent": self.form_id},
|
||||
fields=["key", "mapped_to_crm_field"],
|
||||
)
|
||||
|
||||
return {q["key"]: q["mapped_to_crm_field"] for q in form_questions if q["mapped_to_crm_field"]}
|
||||
|
||||
@property
|
||||
def last_synced_at(self):
|
||||
return frappe.db.get_value(
|
||||
"Lead Sync Source", self.source_name or {"facebook_lead_form": self.form_id}, "last_synced_at"
|
||||
)
|
||||
|
||||
def create_failure_log(
|
||||
self, lead_data: dict | None = None, type: str = "Failure", traceback: str | None = None
|
||||
):
|
||||
return frappe.get_doc(
|
||||
{
|
||||
"doctype": "Failed Lead Sync Log",
|
||||
"type": type,
|
||||
"lead_data": frappe.as_json(lead_data),
|
||||
"source": self.get_source_name(),
|
||||
"traceback": traceback,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
def update_last_synced_at(self):
|
||||
frappe.db.set_value(
|
||||
"Lead Sync Source",
|
||||
self.source_name or {"facebook_lead_form": self.form_id},
|
||||
"last_synced_at",
|
||||
frappe.utils.now(),
|
||||
)
|
||||
|
||||
def get_source_name(self):
|
||||
if self.source_name:
|
||||
return self.source_name
|
||||
|
||||
return frappe.db.get_value("Lead Sync Source", {"facebook_lead_form": self.form_id}, "name")
|
||||
|
||||
def validate_duplicate_lead(self, lead_data: dict, field_mapping: dict):
|
||||
validation_filters = {crm_field: lead_data[crm_field] for crm_field in field_mapping.values()}
|
||||
validation_filters["facebook_form_id"] = lead_data["facebook_form_id"] # only for this campaign
|
||||
if frappe.db.exists("CRM Lead", validation_filters):
|
||||
raise DuplicateLeadError
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def fetch_and_store_pages_from_facebook(access_token: str) -> list[dict]:
|
||||
if not access_token:
|
||||
frappe.throw(frappe._("Access token is required"))
|
||||
|
||||
account_details = get_fb_account_details(access_token)
|
||||
if not account_details.get("id"):
|
||||
frappe.throw(frappe._("Invalid access token provided for Facebook."))
|
||||
|
||||
url = get_fb_graph_api_url("/me/accounts")
|
||||
pages = make_get_request(url, params={"access_token": access_token}).get("data", [])
|
||||
for page in pages:
|
||||
page_id = page["id"]
|
||||
already_synced = frappe.db.exists("Facebook Page", page_id)
|
||||
if not already_synced:
|
||||
create_facebook_page_in_db(page, account_details)
|
||||
forms = fetch_and_store_leadgen_forms_from_facebook(page_id, page["access_token"])
|
||||
page["forms"] = forms
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
def get_fb_account_details(access_token: str) -> dict:
|
||||
url = get_fb_graph_api_url("me")
|
||||
try:
|
||||
response = make_get_request(url, params={"access_token": access_token})
|
||||
except Exception as _:
|
||||
frappe.throw(frappe._("Please check your access token"))
|
||||
return response
|
||||
|
||||
|
||||
def create_facebook_page_in_db(page: dict, account_details: dict) -> None:
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Facebook Page",
|
||||
"page_name": page["name"],
|
||||
"id": page["id"],
|
||||
"category": page["category"],
|
||||
"access_token": page["access_token"],
|
||||
"account_id": account_details["id"],
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def fetch_and_store_leadgen_forms_from_facebook(page_id: str, page_access_token: str) -> list[dict]:
|
||||
fields = "id,name,questions"
|
||||
url = get_fb_graph_api_url(f"/{page_id}/leadgen_forms")
|
||||
forms = make_get_request(
|
||||
url,
|
||||
params={
|
||||
"access_token": page_access_token,
|
||||
"fields": fields,
|
||||
"limit": 15000,
|
||||
},
|
||||
).get("data", [])
|
||||
for form in forms:
|
||||
form_id = form["id"]
|
||||
already_synced = frappe.db.exists("Facebook Lead Form", form_id)
|
||||
if already_synced:
|
||||
continue
|
||||
create_facebook_lead_form_in_db(form, page_id)
|
||||
|
||||
return forms
|
||||
|
||||
|
||||
def create_facebook_lead_form_in_db(form: dict, page_id: str) -> None:
|
||||
form_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Facebook Lead Form",
|
||||
"form_name": form["name"],
|
||||
"id": form["id"],
|
||||
"page": page_id,
|
||||
"questions": form["questions"],
|
||||
}
|
||||
)
|
||||
form_doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_pages_with_forms() -> list[dict]:
|
||||
pages = frappe.db.get_all("Facebook Page", fields=["id", "name"])
|
||||
for page in pages:
|
||||
forms = frappe.db.get_all("Facebook Lead Form", filters={"page": page["id"]}, fields=["id", "name"])
|
||||
page["forms"] = forms
|
||||
return pages
|
||||
@ -0,0 +1,12 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Lead Sync Source", {
|
||||
refresh(frm) {
|
||||
frm.add_custom_button(__('Sync Now'), () => {
|
||||
frm.call("sync_leads").then(() => {
|
||||
frappe.msgprint(__('Lead sync initiated.'));
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
118
crm/lead_syncing/doctype/lead_sync_source/lead_sync_source.json
Normal file
@ -0,0 +1,118 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "prompt",
|
||||
"creation": "2025-09-26 18:51:41.145560",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"access_token",
|
||||
"column_break_lwcw",
|
||||
"last_synced_at",
|
||||
"enabled",
|
||||
"background_sync_frequency",
|
||||
"facebook_section",
|
||||
"facebook_page",
|
||||
"column_break_zukm",
|
||||
"facebook_lead_form"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "Facebook",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Facebook",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lwcw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_synced_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Synced At",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token",
|
||||
"fieldtype": "Password",
|
||||
"label": "Access Token",
|
||||
"length": 500,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_page",
|
||||
"fieldtype": "Link",
|
||||
"label": "Facebook Page",
|
||||
"options": "Facebook Page"
|
||||
},
|
||||
{
|
||||
"fieldname": "facebook_lead_form",
|
||||
"fieldtype": "Link",
|
||||
"label": "Facebook Lead Form",
|
||||
"options": "Facebook Lead Form",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "enabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enabled?"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.type===\"Facebook\"",
|
||||
"fieldname": "facebook_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Facebook"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_zukm",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "Hourly",
|
||||
"fieldname": "background_sync_frequency",
|
||||
"fieldtype": "Select",
|
||||
"label": "Background Sync Frequency",
|
||||
"options": "Every 5 Minutes\nEvery 10 Minutes\nEvery 15 Minutes\nHourly\nDaily\nMonthly",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [
|
||||
{
|
||||
"link_doctype": "Failed Lead Sync Log",
|
||||
"link_fieldname": "source"
|
||||
}
|
||||
],
|
||||
"modified": "2025-10-19 18:57:54.288252",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Lead Syncing",
|
||||
"name": "Lead Sync Source",
|
||||
"naming_rule": "Set by user",
|
||||
"owner": "hussain@frappe.io",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.lead_syncing.doctype.lead_sync_source.facebook import (
|
||||
FacebookSyncSource,
|
||||
fetch_and_store_pages_from_facebook,
|
||||
)
|
||||
|
||||
|
||||
class LeadSyncSource(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
access_token: DF.Password
|
||||
background_sync_frequency: DF.Literal["Every 5 Minutes", "Every 10 Minutes", "Every 15 Minutes", "Hourly", "Daily", "Monthly"]
|
||||
enabled: DF.Check
|
||||
facebook_lead_form: DF.Link | None
|
||||
facebook_page: DF.Link | None
|
||||
last_synced_at: DF.Datetime | None
|
||||
type: DF.Literal["Facebook"]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.validate_same_fb_form_active()
|
||||
|
||||
def validate_same_fb_form_active(self):
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if not self.facebook_lead_form:
|
||||
return
|
||||
|
||||
already_active = frappe.db.exists(
|
||||
"Lead Sync Source",
|
||||
{"enabled": 1, "facebook_lead_form": self.facebook_lead_form, "name": ["!=", self.name]},
|
||||
)
|
||||
|
||||
if already_active:
|
||||
frappe.throw(frappe._("A lead sync source is already enabled for this Facebook Lead Form!"))
|
||||
|
||||
def before_insert(self):
|
||||
if self.type == "Facebook" and self.access_token:
|
||||
fetch_and_store_pages_from_facebook(self.access_token)
|
||||
# rest of the source types can be added here
|
||||
|
||||
@frappe.whitelist()
|
||||
def sync_leads(self):
|
||||
self._sync_leads()
|
||||
# frappe.enqueue_doc(self.doctype, self.name, "_sync_leads", queue="long")
|
||||
|
||||
def _sync_leads(self):
|
||||
if self.type == "Facebook" and self.access_token:
|
||||
if not self.facebook_lead_form:
|
||||
frappe.throw(frappe._("Please select a lead gen form before syncing!"))
|
||||
|
||||
FacebookSyncSource(
|
||||
self.get_password("access_token"),
|
||||
self.facebook_lead_form
|
||||
).sync()
|
||||
@ -0,0 +1,22 @@
|
||||
# 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 IntegrationTestLeadSyncSource(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for LeadSyncSource.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -1 +1,2 @@
|
||||
FCRM
|
||||
FCRM
|
||||
Lead Syncing
|
||||
32
crm/tasks.py
Normal file
@ -0,0 +1,32 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def sync_leads_from_all_enabled_sources(frequency: str | None = None) -> None:
|
||||
enabled_sources = frappe.get_all(
|
||||
"Lead Sync Source", filters={"enabled": 1, "background_sync_frequency": frequency}, pluck="name"
|
||||
)
|
||||
for source in enabled_sources:
|
||||
lead_sync_source = frappe.get_cached_doc("Lead Sync Source", source)
|
||||
try:
|
||||
lead_sync_source._sync_leads()
|
||||
except Exception as _:
|
||||
frappe.log_error(f"Error syncing leads for source {source}")
|
||||
|
||||
|
||||
def sync_leads_from_sources_5_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 5 Minutes")
|
||||
|
||||
def sync_leads_from_sources_10_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 10 Minutes")
|
||||
|
||||
def sync_leads_from_sources_15_minutes() -> None:
|
||||
sync_leads_from_all_enabled_sources("Every 15 Minutes")
|
||||
|
||||
def sync_leads_from_sources_hourly() -> None:
|
||||
sync_leads_from_all_enabled_sources("Hourly")
|
||||
|
||||
def sync_leads_from_sources_daily() -> None:
|
||||
sync_leads_from_all_enabled_sources("Daily")
|
||||
|
||||
def sync_leads_from_sources_monthly() -> None:
|
||||
sync_leads_from_all_enabled_sources("Monthly")
|
||||
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>
|
||||
39
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,11 +99,11 @@ 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']
|
||||
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
||||
EditLeadSyncSource: typeof import('./src/components/Settings/LeadSyncing/EditLeadSyncSource.vue')['default']
|
||||
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
||||
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
|
||||
EmailAccountCard: typeof import('./src/components/Settings/EmailAccountCard.vue')['default']
|
||||
@ -101,6 +116,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,9 +125,13 @@ 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']
|
||||
FacebookIcon: typeof import('./src/components/Icons/FacebookIcon.vue')['default']
|
||||
FadedScrollableDiv: typeof import('./src/components/FadedScrollableDiv.vue')['default']
|
||||
Field: typeof import('./src/components/FieldLayout/Field.vue')['default']
|
||||
FieldLayout: typeof import('./src/components/FieldLayout/FieldLayout.vue')['default']
|
||||
@ -140,6 +160,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']
|
||||
@ -158,6 +180,10 @@ declare module 'vue' {
|
||||
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
|
||||
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
|
||||
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
|
||||
LeadSyncSettings: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSettings.vue')['default']
|
||||
LeadSyncSourceForm: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSourceForm.vue')['default']
|
||||
LeadSyncSourcePage: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSourcePage.vue')['default']
|
||||
LeadSyncSources: typeof import('./src/components/Settings/LeadSyncing/LeadSyncSources.vue')['default']
|
||||
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
|
||||
@ -168,6 +194,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,10 +209,9 @@ 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']
|
||||
NewLeadSyncSource: typeof import('./src/components/Settings/LeadSyncing/NewLeadSyncSource.vue')['default']
|
||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
|
||||
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
|
||||
@ -194,12 +223,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 +248,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)"
|
||||
/>
|
||||
@ -265,6 +271,16 @@
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<Autocomplete
|
||||
v-else-if="field.fieldtype === 'Autocomplete'"
|
||||
class="text-sm text-ink-gray-8"
|
||||
:modelValue="row[field.fieldname]"
|
||||
@update:modelValue="(v) => row[field.fieldname] = typeof v == 'object' ? v.value : v"
|
||||
@change="(v) => fieldChange(typeof v == 'object' ? v.value : v, field, row)"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
class="text-sm text-ink-gray-8"
|
||||
@ -349,10 +365,12 @@ import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FormControl,
|
||||
Checkbox,
|
||||
TimePicker,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
dayjs,
|
||||
Autocomplete
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed, inject, provide } from 'vue'
|
||||
@ -374,6 +392,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
overrides: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
}
|
||||
})
|
||||
|
||||
const triggerOnChange = inject('triggerOnChange', () => {})
|
||||
@ -442,11 +464,18 @@ function getFieldObj(field) {
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
const fieldObjWithFilters ={
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
placeholder: field.placeholder || field.label,
|
||||
}
|
||||
|
||||
return {
|
||||
...fieldObjWithFilters,
|
||||
...props.overrides.fields?.find(
|
||||
(f) => f.fieldname === field.fieldname,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const gridTemplateColumns = computed(() => {
|
||||
|
||||
@ -127,6 +127,14 @@ watchDebounced(
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.filters,
|
||||
() => {
|
||||
reload('', true)
|
||||
},
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
url: 'frappe.desk.search.search_link',
|
||||
cache: [props.doctype, text.value, props.hideMe, props.filters],
|
||||
@ -154,13 +162,14 @@ const options = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
function reload(val, force=false) {
|
||||
if (!props.doctype) return
|
||||
if (
|
||||
!force &&
|
||||
options.data?.length &&
|
||||
val === options.params?.txt &&
|
||||
props.doctype === options.params?.doctype
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
options.update({
|
||||
|
||||
@ -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>
|
||||
26
frontend/src/components/Icons/FacebookIcon.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" version="1.1" id="svg9"
|
||||
width="666.66669" height="666.66718" viewBox="0 0 666.66668 666.66717">
|
||||
<defs id="defs13">
|
||||
<clipPath clipPathUnits="userSpaceOnUse" id="clipPath25">
|
||||
<path d="M 0,700 H 700 V 0 H 0 Z" id="path23" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g id="g17" transform="matrix(1.3333333,0,0,-1.3333333,-133.33333,799.99999)">
|
||||
<g id="g19">
|
||||
<g id="g21" clip-path="url(#clipPath25)">
|
||||
<g id="g27" transform="translate(600,350)">
|
||||
<path
|
||||
d="m 0,0 c 0,138.071 -111.929,250 -250,250 -138.071,0 -250,-111.929 -250,-250 0,-117.245 80.715,-215.622 189.606,-242.638 v 166.242 h -51.552 V 0 h 51.552 v 32.919 c 0,85.092 38.508,124.532 122.048,124.532 15.838,0 43.167,-3.105 54.347,-6.211 V 81.986 c -5.901,0.621 -16.149,0.932 -28.882,0.932 -40.993,0 -56.832,-15.528 -56.832,-55.9 V 0 h 81.659 l -14.028,-76.396 h -67.631 V -248.169 C -95.927,-233.218 0,-127.818 0,0"
|
||||
style="fill:#0866ff;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path29" />
|
||||
</g>
|
||||
<g id="g31" transform="translate(447.9175,273.6036)">
|
||||
<path
|
||||
d="M 0,0 14.029,76.396 H -67.63 v 27.019 c 0,40.372 15.838,55.899 56.831,55.899 12.733,0 22.981,-0.31 28.882,-0.931 v 69.253 c -11.18,3.106 -38.509,6.212 -54.347,6.212 -83.539,0 -122.048,-39.441 -122.048,-124.533 V 76.396 h -51.552 V 0 h 51.552 v -166.242 c 19.343,-4.798 39.568,-7.362 60.394,-7.362 10.254,0 20.358,0.632 30.288,1.831 L -67.63,0 Z"
|
||||
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none" id="path33" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</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({
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
@click="handleSelect(s)"
|
||||
>
|
||||
<EmailProviderIcon
|
||||
:service-name="s.name"
|
||||
:label="s.name"
|
||||
:logo="s.icon"
|
||||
:selected="selectedService?.name === s?.name"
|
||||
/>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<div class="w-fit">
|
||||
<EmailProviderIcon
|
||||
:logo="emailIcon[accountData.service]"
|
||||
:service-name="accountData.service"
|
||||
:label="accountData.service"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner for setting up email account -->
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
>
|
||||
<img :src="logo" class="w-4 h-4" />
|
||||
</div>
|
||||
<p v-if="serviceName" class="text-xs text-center text-ink-gray-6 mt-2">
|
||||
{{ serviceName }}
|
||||
<p v-if="label" class="text-xs text-center text-ink-gray-6 mt-2">
|
||||
{{ label }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@ -16,7 +16,7 @@ defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
serviceName: {
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
@ -128,7 +128,6 @@ import {
|
||||
FormControl,
|
||||
Switch,
|
||||
toast,
|
||||
call,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { computed, inject, onMounted, ref } from 'vue'
|
||||
|
||||
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>
|
||||
@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 pt-2">
|
||||
<div class="flex gap-1 -ml-4 w-9/12">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="isLocal ? __('New Lead Sync Source') : syncSource.name"
|
||||
size="md"
|
||||
@click="() => emit('updateStep', 'source-list')"
|
||||
class="cursor-pointer hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex item-center space-x-4 w-3/12 justify-end">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch size="sm" v-model="syncSource.enabled" />
|
||||
<span class="text-sm text-ink-gray-7">{{ __('Enabled') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="isLocal ? __('Create') : __('Update')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
:loading="sources.setValue.loading || sources.insert.loading || docResource?.loading"
|
||||
@click="createOrUpdateSource"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<FormControl
|
||||
type="autocomplete"
|
||||
required="true"
|
||||
v-model="syncSource.type"
|
||||
:options="supportedSourceTypes"
|
||||
:label="__('Source Type')"
|
||||
:placeholder="__('Select Source Type')"
|
||||
>
|
||||
<template v-if="syncSource.type" #prefix>
|
||||
<component
|
||||
class="mr-2 size-4"
|
||||
:is="syncSource.type.icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-prefix="{ option }">
|
||||
<component
|
||||
class="size-4"
|
||||
:is="option.icon"
|
||||
/>
|
||||
</template>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
type="text"
|
||||
v-if="isLocal"
|
||||
required="true"
|
||||
v-model="syncSource.name"
|
||||
:label="__('Source Name')"
|
||||
:placeholder="__('Enter Source Name')"
|
||||
/>
|
||||
|
||||
|
||||
<FormControl
|
||||
v-if="fieldsMap.background_sync_frequency"
|
||||
type="select"
|
||||
required="true"
|
||||
:options="fieldsMap.background_sync_frequency.options"
|
||||
v-model="syncSource.background_sync_frequency"
|
||||
:label="__('Background Sync Frequency')"
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
type="password"
|
||||
required="true"
|
||||
v-model="syncSource.access_token"
|
||||
:label="__('Access Token')"
|
||||
:placeholder="__('Enter Access Token')"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="!isLocal"
|
||||
label="Facebook Page"
|
||||
v-model="syncSource.facebook_page"
|
||||
doctype="Facebook Page"
|
||||
/>
|
||||
|
||||
<Link
|
||||
v-if="!isLocal && syncSource.facebook_page"
|
||||
label="Lead Form"
|
||||
v-model="syncSource.facebook_lead_form"
|
||||
doctype="Facebook Lead Form"
|
||||
:filters="{
|
||||
'page': syncSource.facebook_page
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mapping Grid -->
|
||||
<div v-if="syncSource.facebook_lead_form && mappingFormDocResource && mappingFormDocResource.document?.doc">
|
||||
<Grid
|
||||
v-model="mappingFormDocResource.document.doc.questions"
|
||||
v-model:parent="mappingFormDocResource.document.doc"
|
||||
doctype="Facebook Lead Form Question"
|
||||
parentDoctype="Facebook Lead Form"
|
||||
parentFieldname="questions"
|
||||
:overrides="{
|
||||
fields: [
|
||||
{'fieldname': 'mapped_to_crm_field', 'options': getCRMLeadFields, 'placeholder': __('Not Synced')}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDocument } from "@/data/document";
|
||||
import { onMounted, inject, ref, computed, watch } from "vue";
|
||||
import { supportedSourceTypes } from "./leadSyncSourceConfig";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Switch,
|
||||
Avatar,
|
||||
toast,
|
||||
createResource,
|
||||
} from "frappe-ui";
|
||||
|
||||
import { getMeta } from "@/stores/meta";
|
||||
import Link from "@/components/Controls/Link.vue";
|
||||
import Grid from "@/components/Controls/Grid.vue";
|
||||
|
||||
const props = defineProps({
|
||||
sourceData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["updateStep"]);
|
||||
|
||||
const docResource = ref(null);
|
||||
const mappingFormDocResource = ref(null);
|
||||
|
||||
const sourceDoc = computed(() => {
|
||||
if (!docResource.value) return;
|
||||
return docResource.value?.document?.doc;
|
||||
});
|
||||
|
||||
const { meta, getFields } = getMeta("Lead Sync Source");
|
||||
const fields = ref(getFields());
|
||||
|
||||
watch(
|
||||
() => meta.data,
|
||||
() => {
|
||||
fields.value = getFields();
|
||||
},
|
||||
);
|
||||
|
||||
const fieldsMap = computed(() => {
|
||||
if (!fields.value) return {};
|
||||
|
||||
const map = {};
|
||||
for (const field of fields.value) {
|
||||
map[field.fieldname] = field;
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const sources = inject("sources");
|
||||
const syncSource = ref({
|
||||
name: "",
|
||||
type: "",
|
||||
access_token: "",
|
||||
facebook_page: "",
|
||||
facebook_lead_form: "",
|
||||
enabled: true,
|
||||
background_sync_frequency:
|
||||
fieldsMap.value.background_sync_frequency?.default || "Hourly",
|
||||
});
|
||||
|
||||
const isLocal = ref(true);
|
||||
|
||||
function updateSource(data) {
|
||||
sources.setValue.submit(
|
||||
{
|
||||
name: syncSource.value.name,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (docResource.value) {
|
||||
docResource.value.document.reload();
|
||||
}
|
||||
|
||||
mappingFormDocResource.value.document.save.submit();
|
||||
},
|
||||
onError(e) {
|
||||
toast.error(e.messages[0] || __("Error updating Lead Sync Source"));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createSource() {
|
||||
sources.insert.submit(
|
||||
{
|
||||
...syncSource.value,
|
||||
type: syncSource.value.type.value,
|
||||
},
|
||||
{
|
||||
onSuccess: (newDoc) => {
|
||||
toast.success(__("Lead Sync Source created successfully"));
|
||||
isLocal.value = false;
|
||||
docResource.value = useDocument("Lead Sync Source", newDoc.name);
|
||||
},
|
||||
onError(error) {
|
||||
toast.error(error.messages[0] || __("Error creating Lead Sync Source"));
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createOrUpdateSource() {
|
||||
if (isLocal.value) {
|
||||
createSource();
|
||||
} else {
|
||||
updateSource({
|
||||
...syncSource.value,
|
||||
type: syncSource.value.type.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.sourceData?.name) {
|
||||
Object.assign(syncSource.value, props.sourceData);
|
||||
isLocal.value = false; // edit form
|
||||
docResource.value = useDocument("Lead Sync Source", props.sourceData.name);
|
||||
}
|
||||
|
||||
if (syncSource.value.facebook_lead_form) {
|
||||
mappingFormDocResource.value = useDocument(
|
||||
"Facebook Lead Form",
|
||||
syncSource.value.facebook_lead_form,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => sourceDoc.value,
|
||||
(newDoc) => {
|
||||
if (newDoc) {
|
||||
Object.assign(syncSource.value, {
|
||||
...newDoc,
|
||||
type:
|
||||
supportedSourceTypes.find((type) => type.value === newDoc.type) ||
|
||||
newDoc.type,
|
||||
});
|
||||
|
||||
mappingFormDocResource.value = useDocument(
|
||||
"Facebook Lead Form",
|
||||
syncSource.value.facebook_lead_form,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => syncSource.value.facebook_page,
|
||||
(_, oldValue) => {
|
||||
if (!oldValue) return; // on mount, the value changes from empty
|
||||
syncSource.value.facebook_lead_form = "";
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => syncSource.value.facebook_lead_form,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
mappingFormDocResource.value = useDocument(
|
||||
"Facebook Lead Form",
|
||||
newVal,
|
||||
);
|
||||
} else {
|
||||
mappingFormDocResource.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const leadFields = createResource({
|
||||
url: "crm.api.doc.get_fields_meta",
|
||||
params: {
|
||||
doctype: "CRM Lead",
|
||||
as_array: true,
|
||||
},
|
||||
cache: ["fieldsMeta", "CRM Lead"],
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let restrictedFields = [
|
||||
"name",
|
||||
"owner",
|
||||
"creation",
|
||||
"modified",
|
||||
"modified_by",
|
||||
"docstatus",
|
||||
"_comments",
|
||||
"_user_tags",
|
||||
"_assign",
|
||||
"_liked_by",
|
||||
];
|
||||
console.log("data", data);
|
||||
return data.filter((field) => !restrictedFields.includes(field.fieldname));
|
||||
},
|
||||
});
|
||||
|
||||
const getCRMLeadFields = computed(() => {
|
||||
if (leadFields.data) {
|
||||
return leadFields.data.map((field) => ({
|
||||
label: field.label,
|
||||
value: field.fieldname,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex-1 p-6">
|
||||
<LeadSyncSourceForm
|
||||
v-if="step === 'new-source'"
|
||||
:sourceData="source"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<LeadSyncSources
|
||||
v-else-if="step === 'source-list'"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
<LeadSyncSourceForm
|
||||
v-else-if="step === 'edit-source'"
|
||||
:sourceData="source"
|
||||
@updateStep="updateStep"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LeadSyncSources from "./LeadSyncSources.vue"
|
||||
import LeadSyncSourceForm from "./LeadSyncSourceForm.vue";
|
||||
|
||||
import { createListResource } from 'frappe-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
const step = ref('source-list')
|
||||
const source = ref(null)
|
||||
|
||||
const sources = createListResource({
|
||||
type: 'list',
|
||||
doctype: 'Lead Sync Source',
|
||||
cache: 'lead_sync_sources',
|
||||
fields: [
|
||||
'name',
|
||||
'enabled',
|
||||
'type',
|
||||
'last_synced_at',
|
||||
'facebook_lead_form'
|
||||
],
|
||||
auto: true,
|
||||
orderBy: 'modified desc',
|
||||
pageLength: 20,
|
||||
})
|
||||
|
||||
provide('sources', sources)
|
||||
|
||||
function updateStep(newStep, data) {
|
||||
step.value = newStep
|
||||
source.value = data
|
||||
}
|
||||
</script>
|
||||
220
frontend/src/components/Settings/LeadSyncing/LeadSyncSources.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col gap-6 text-ink-gray-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between px-2 pt-2">
|
||||
<div class="flex flex-col gap-1 w-9/12">
|
||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||
{{ __('Lead sources') }}
|
||||
</h2>
|
||||
<p class="text-p-base text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
'Add, edit, and manage sources for automatic lead syncing to your CRM',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex item-center space-x-2 w-3/12 justify-end">
|
||||
<Button
|
||||
:label="__('New')"
|
||||
icon-left="plus"
|
||||
variant="solid"
|
||||
@click="emit('updateStep', 'new-source')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- loading state -->
|
||||
<div
|
||||
v-if="sources.loading"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<Button
|
||||
:loading="sources.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!sources.loading && !sources.data?.length"
|
||||
class="flex justify-between w-full h-full"
|
||||
>
|
||||
<div
|
||||
class="text-ink-gray-4 border border-dashed rounded w-full flex items-center justify-center"
|
||||
>
|
||||
{{ __('No lead sources found') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead source list -->
|
||||
<div
|
||||
class="flex flex-col overflow-hidden"
|
||||
v-if="!sources.loading && sources.data?.length"
|
||||
>
|
||||
<div class="flex items-center py-2 px-4 text-sm text-ink-gray-5">
|
||||
<div class="w-4/6">{{ __('Name') }}</div>
|
||||
<div class="w-1/6">{{ __('Source') }}</div>
|
||||
<div class="w-1/6">{{ __('Enabled') }}</div>
|
||||
</div>
|
||||
<div class="h-px border-t mx-4 border-outline-gray-modals" />
|
||||
<ul class="overflow-y-auto px-2">
|
||||
<template v-for="(source, i) in sourcesList" :key="source.name">
|
||||
<li
|
||||
class="flex items-center justify-between p-3 cursor-pointer hover:bg-surface-menu-bar rounded"
|
||||
@click="() => emit('updateStep', 'edit-source', { ...source })"
|
||||
>
|
||||
<div class="flex flex-col w-4/6 pr-5">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ source.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-1/6 pr-5">
|
||||
<div class="text-p-base font-medium text-ink-gray-7 truncate">
|
||||
{{ source.type }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between w-1/6">
|
||||
<Switch
|
||||
size="sm"
|
||||
v-model="source.enabled"
|
||||
@update:model-value="toggleLeadSyncSourceEnabled(source)"
|
||||
@click.stop
|
||||
/>
|
||||
<Dropdown
|
||||
class=""
|
||||
:options="getDropdownOptions(source)"
|
||||
placement="right"
|
||||
:button="{
|
||||
icon: 'more-horizontal',
|
||||
variant: 'ghost',
|
||||
onblur: (e) => {
|
||||
e.stopPropagation()
|
||||
confirmDelete = false
|
||||
},
|
||||
}"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
<div
|
||||
v-if="sourcesList.length !== i + 1"
|
||||
class="h-px border-t mx-2 border-outline-gray-modals"
|
||||
/>
|
||||
</template>
|
||||
<!-- Load More Button -->
|
||||
<div
|
||||
v-if="!sources.loading && sources.hasNextPage"
|
||||
class="flex justify-center"
|
||||
>
|
||||
|
||||
<Button
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => sources.next()"
|
||||
:loading="sources.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
TextInput,
|
||||
FormControl,
|
||||
Switch,
|
||||
Dropdown,
|
||||
FeatherIcon,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { ref, computed, inject } from "vue";
|
||||
|
||||
const emit = defineEmits(["updateStep"]);
|
||||
|
||||
const sources = inject("sources");
|
||||
|
||||
const search = ref("");
|
||||
const confirmDelete = ref(false);
|
||||
|
||||
const sourcesList = computed(() => {
|
||||
let list = sources.data || [];
|
||||
if (search.value) {
|
||||
list = list.filter(
|
||||
(source) =>
|
||||
source.name.toLowerCase().includes(search.value.toLowerCase()) ||
|
||||
source.subject.toLowerCase().includes(search.value.toLowerCase()),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function toggleLeadSyncSourceEnabled(source) {
|
||||
sources.setValue.submit(
|
||||
{
|
||||
name: source.name,
|
||||
enabled: source.enabled ? 1 : 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
source.enabled
|
||||
? __('Source enabled successfully')
|
||||
: __('Source disabled successfully'),
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.messages[0] || __('Failed to update source'))
|
||||
// Revert the change if there was an error
|
||||
source.enabled = !source.enabled
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function deleteLeadSource(source) {
|
||||
confirmDelete.value = false;
|
||||
sources.delete.submit(source.name, {
|
||||
onSuccess: () => {
|
||||
toast.success(__("Lead Sync Source deleted successfully"));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.messages[0] || __("Failed to delete Lead Sync Source"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDropdownOptions(source) {
|
||||
let options = [
|
||||
{
|
||||
label: __("Duplicate"),
|
||||
icon: "copy",
|
||||
onClick: () => emit("updateStep", "new-source", { ...source }),
|
||||
},
|
||||
{
|
||||
label: __("Delete"),
|
||||
icon: "trash-2",
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
confirmDelete.value = true;
|
||||
},
|
||||
condition: () => !confirmDelete.value,
|
||||
},
|
||||
{
|
||||
label: __("Confirm Delete"),
|
||||
icon: "trash-2",
|
||||
theme: "red",
|
||||
onClick: () => deleteLeadSource(source),
|
||||
condition: () => confirmDelete.value,
|
||||
},
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,32 @@
|
||||
import LogoFacebook from '@/components/Icons/FacebookIcon.vue';
|
||||
|
||||
|
||||
export const supportedSourceTypes = [
|
||||
{
|
||||
label: 'Facebook',
|
||||
value: 'Facebook',
|
||||
icon: LogoFacebook,
|
||||
info: __("You will need a Meta developer account and an access token to sync leads from Facebook. Read more "),
|
||||
link: 'https://www.facebook.com/business/help/503306463479099?id=2190812977867143',
|
||||
custom: false,
|
||||
}
|
||||
]
|
||||
|
||||
export const sourceIcon = {
|
||||
'Facebook': LogoFacebook
|
||||
}
|
||||
|
||||
export const fbSourceFields = [
|
||||
{
|
||||
name: "name",
|
||||
label: __("Name"),
|
||||
type: "text",
|
||||
placeholder: __("Add a name for your source"),
|
||||
},
|
||||
{
|
||||
name: "access_token",
|
||||
label: __("Access Token"),
|
||||
type: "password",
|
||||
placeholder: __("Enter your Facebook Access Token"),
|
||||
}
|
||||
];
|
||||
@ -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,8 @@ 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 LeadSyncSourcePage from '@/components/Settings/LeadSyncing/LeadSyncSourcePage.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import HomeActions from '@/components/Settings/HomeActions.vue'
|
||||
import ForecastingSettings from '@/components/Settings/ForecastingSettings.vue'
|
||||
@ -196,6 +199,18 @@ const tabs = computed(() => {
|
||||
component: markRaw(ERPNextSettings),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Helpdesk'),
|
||||
icon: HelpdeskIcon,
|
||||
component: markRaw(HelpdeskSettings),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Lead Syncing'),
|
||||
icon: 'refresh-cw',
|
||||
component: markRaw(LeadSyncSourcePage),
|
||||
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 |