Compare commits
736 Commits
mergify/bp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf849f7dff | ||
|
|
3e9fdb8d20 | ||
|
|
260755fd2e | ||
|
|
85df56b464 | ||
|
|
a9e956b5fc | ||
|
|
905eb63c5f | ||
|
|
3eee437014 | ||
|
|
4498de2041 | ||
|
|
bfb0d25765 | ||
|
|
9dd6ffa1e1 | ||
|
|
05322e805f | ||
|
|
8dc6db3218 | ||
|
|
1b63384eac | ||
|
|
37bbbb6b4b | ||
|
|
6f0244c2b6 | ||
|
|
e08bc9cd20 | ||
|
|
2043157567 | ||
|
|
8093a422cd | ||
|
|
4bef919d38 | ||
|
|
874947b8ae | ||
|
|
ca90c0406e | ||
|
|
6bf27d852b | ||
|
|
793cb76789 | ||
|
|
ff07054ca3 | ||
|
|
2ee5269d3e | ||
|
|
e27954da52 | ||
|
|
2faa0d0f04 | ||
|
|
f1664eec2f | ||
|
|
7f2efea7cb | ||
|
|
d2cc6b7c2e | ||
|
|
1afb6d6827 | ||
|
|
ccaf136830 | ||
|
|
58a41d1b11 | ||
|
|
777f3ac06c | ||
|
|
105c78e264 | ||
|
|
46cc1d2924 | ||
|
|
ff4ca9fe66 | ||
|
|
4989dc0921 | ||
|
|
1e613ebcd1 | ||
|
|
4f8f195d77 | ||
|
|
af64b86a04 | ||
|
|
7e9bc0524e | ||
|
|
9d0a0d1d32 | ||
|
|
9c84a8be7f | ||
|
|
d24537489e | ||
|
|
bd89b3b356 | ||
|
|
988fb90ddb | ||
|
|
8018b1766c | ||
|
|
9c4c2a0aca | ||
|
|
803e639961 | ||
|
|
fabd362b2a | ||
|
|
7dd98733f1 | ||
|
|
ee4b7721b0 | ||
|
|
95bc551254 | ||
|
|
2546bdabb1 | ||
|
|
af248964c6 | ||
|
|
8749f7bfd0 | ||
|
|
d18618b856 | ||
|
|
ce4af4907a | ||
|
|
84d24a384b | ||
|
|
29d86859d4 | ||
|
|
be452fee58 | ||
|
|
100eec0677 | ||
|
|
a4d3852c0e | ||
|
|
0399fc32be | ||
|
|
a79192ef4d | ||
|
|
e10ec543a7 | ||
|
|
1962b9a103 | ||
|
|
a3abaa57ec | ||
|
|
1bf3f7a38c | ||
|
|
af81750388 | ||
|
|
7330a3c2a5 | ||
|
|
9e8a4024a5 | ||
|
|
7ef00965fa | ||
|
|
97925aae15 | ||
|
|
0b75228722 | ||
|
|
a360fa774b | ||
|
|
4601b56ee1 | ||
|
|
d985a44291 | ||
|
|
ce66705e9c | ||
|
|
004923419c | ||
|
|
49ed1ac174 | ||
|
|
a7dd1e9bf6 | ||
|
|
4cfd0022f4 | ||
|
|
fc3d8cd94d | ||
|
|
5473a93b5e | ||
|
|
57d306ea1f | ||
|
|
7c324bd07f | ||
|
|
09421217b4 | ||
|
|
630dfcd0e7 | ||
|
|
b999a375b3 | ||
|
|
b5f5a3b5d5 | ||
|
|
814d39572a | ||
|
|
6fc4c0699a | ||
|
|
50ea06a568 | ||
|
|
daf0ddaab4 | ||
|
|
70107a4836 | ||
|
|
f2e6380d3e | ||
|
|
eed1e23b5b | ||
|
|
1bb75dd911 | ||
|
|
1086ce406f | ||
|
|
39c5497363 | ||
|
|
4bc31431e7 | ||
|
|
fe290877e4 | ||
|
|
e4bdc0586e | ||
|
|
695f9e1303 | ||
|
|
98747bdc2a | ||
|
|
16ed1ad060 | ||
|
|
ff312d964b | ||
|
|
49d7af5548 | ||
|
|
a30d21c346 | ||
|
|
f459bd57ba | ||
|
|
b4d89e1a5a | ||
|
|
25bc9d8acb | ||
|
|
568477f9c7 | ||
|
|
9c04ade20d | ||
|
|
7940211fad | ||
|
|
df0968ed67 | ||
|
|
89462e63cb | ||
|
|
23c53ffa9a | ||
|
|
ae3df8d391 | ||
|
|
2d51933ceb | ||
|
|
a7958dc2a2 | ||
|
|
1c4f78b01c | ||
|
|
2722ef6cad | ||
|
|
ac7d3907c2 | ||
|
|
17b7c6ecef | ||
|
|
8d7a155d78 | ||
|
|
6dc85ad1b2 | ||
|
|
ac98c1a090 | ||
|
|
90a6bde438 | ||
|
|
5fea7bf0e2 | ||
|
|
1031e9c4ec | ||
|
|
c6e4b9b5d3 | ||
|
|
f2a4b9ec56 | ||
|
|
14a1af4455 | ||
|
|
e3ab227124 | ||
|
|
5f6cc26126 | ||
|
|
8051fd1f99 | ||
|
|
1fc98f619c | ||
|
|
5921af8c0b | ||
|
|
fe505d33d2 | ||
|
|
300f0b24e2 | ||
|
|
b7c26e35e9 | ||
|
|
4c7c8b915a | ||
|
|
d80feb1e77 | ||
|
|
2a8c7307f0 | ||
|
|
0683d93621 | ||
|
|
5ce2fcd368 | ||
|
|
5372fdcaf1 | ||
|
|
9f46bbd25d | ||
|
|
87e82ce39e | ||
|
|
443aeddca0 | ||
|
|
dc7d5f57b8 | ||
|
|
1cbd633e5b | ||
|
|
cf58a634e9 | ||
|
|
b118e75bec | ||
|
|
2b1048fd83 | ||
|
|
7468f19b48 | ||
|
|
f994dd36a9 | ||
|
|
dc98613296 | ||
|
|
f132a46206 | ||
|
|
cfeac13c9c | ||
|
|
b82dc0473a | ||
|
|
8f0e8f3f52 | ||
|
|
7a9da275da | ||
|
|
cc25feea09 | ||
|
|
b64058bd10 | ||
|
|
b6af543243 | ||
|
|
7bcee291c7 | ||
|
|
570a45eeaf | ||
|
|
dcadd3d0c1 | ||
|
|
8d4975554d | ||
|
|
4bf8c8d0b8 | ||
|
|
0d9264e5e2 | ||
|
|
068f303448 | ||
|
|
e8e4367eb7 | ||
|
|
afadf9f8f4 | ||
|
|
8ab6da55de | ||
|
|
b066eb8c75 | ||
|
|
c33f2e936a | ||
|
|
2f02b8ccfd | ||
|
|
9e9b0f7266 | ||
|
|
148e6ef8bb | ||
|
|
00398b188c | ||
|
|
80e43864b7 | ||
|
|
9a9b91b76d | ||
|
|
d555222ebb | ||
|
|
222ba09c67 | ||
|
|
0c8a3edf5e | ||
|
|
c44f7d5834 | ||
|
|
f072fc992c | ||
|
|
092310846c | ||
|
|
eb00a7c692 | ||
|
|
8a7ed1e8f1 | ||
|
|
a852e33605 | ||
|
|
7f3732f0e0 | ||
|
|
75a9bd290c | ||
|
|
687c1d362c | ||
|
|
e771a63640 | ||
|
|
e87785fac5 | ||
|
|
c84da0c7b4 | ||
|
|
aeb4590a6f | ||
|
|
d6567c7f01 | ||
|
|
8083fc46a8 | ||
|
|
7f6a20ff17 | ||
|
|
95df85f676 | ||
|
|
728150e324 | ||
|
|
2ac8a06bae | ||
|
|
2f56af0384 | ||
|
|
06d647dd72 | ||
|
|
ee964637ac | ||
|
|
4f113fab91 | ||
|
|
8be5535a2b | ||
|
|
c0050dda71 | ||
|
|
56eecdd260 | ||
|
|
ead86735d4 | ||
|
|
76f6da88f3 | ||
|
|
e252ac8370 | ||
|
|
03b920570e | ||
|
|
da4e84d6d0 | ||
|
|
2f90a075fc | ||
|
|
de85fa5d5b | ||
|
|
e43500887f | ||
|
|
8f69a49e2c | ||
|
|
31cbe78320 | ||
|
|
fa85c61a0a | ||
|
|
e87e1ad61d | ||
|
|
bd75fed217 | ||
|
|
1ede125c80 | ||
|
|
e1e2049b1f | ||
|
|
32d9e1e2ba | ||
|
|
ea5b0fdacd | ||
|
|
a7374bedb2 | ||
|
|
5b7437d36a | ||
|
|
8fca1842c2 | ||
|
|
2473cf88ef | ||
|
|
4de04d6760 | ||
|
|
f4dc6f8338 | ||
|
|
df5ee5a50c | ||
|
|
7b4a3dee6e | ||
|
|
3dd6c5d1ed | ||
|
|
bc7a148999 | ||
|
|
f3f0322003 | ||
|
|
aa128a3411 | ||
|
|
57f74dbbff | ||
|
|
88b9c6af1d | ||
|
|
243832812f | ||
|
|
be09a8fe30 | ||
|
|
f5e54eefef | ||
|
|
867da93a39 | ||
|
|
580f61ab71 | ||
|
|
b55011a58e | ||
|
|
a8504a910e | ||
|
|
ffc048977f | ||
|
|
212709782a | ||
|
|
9fb0d3175d | ||
|
|
4839298ba4 | ||
|
|
b40c72b95c | ||
|
|
5a941e0944 | ||
|
|
4987545595 | ||
|
|
f53e35117a | ||
|
|
de2939d863 | ||
|
|
25a26d3e2c | ||
|
|
efd02c3459 | ||
|
|
13e72bf7f3 | ||
|
|
7b8cc6caa3 | ||
|
|
d1e66cd5bb | ||
|
|
3a9bad7954 | ||
|
|
3f6e69c3f6 | ||
|
|
57fb0e07cf | ||
|
|
4cfab1fbb6 | ||
|
|
c851b9f4b3 | ||
|
|
7d8f980c87 | ||
|
|
2ccae25bfe | ||
|
|
1da242fd37 | ||
|
|
35b7e69426 | ||
|
|
fbaca7f154 | ||
|
|
913094dce3 | ||
|
|
4d619a908c | ||
|
|
6dc8975c08 | ||
|
|
1b0a92c0c5 | ||
|
|
bd3aca85ac | ||
|
|
6d7372240f | ||
|
|
c799d1502b | ||
|
|
acdfe6c82b | ||
|
|
17687690cf | ||
|
|
f28920dd60 | ||
|
|
90f61dffa6 | ||
|
|
556f8e6b11 | ||
|
|
78aea3bbac | ||
|
|
67f75289c7 | ||
|
|
8e86a06858 | ||
|
|
445f5d7ade | ||
|
|
015228488b | ||
|
|
9a0e0ed721 | ||
|
|
89b392d59b | ||
|
|
799dedb582 | ||
|
|
9418df86f9 | ||
|
|
57dd748cae | ||
|
|
06b4e4d544 | ||
|
|
e98d54cdd9 | ||
|
|
a00838b7a0 | ||
|
|
fad4bc994d | ||
|
|
b3752d63db | ||
|
|
4c13c5143f | ||
|
|
9423af5cc1 | ||
|
|
dcc3b88d4f | ||
|
|
a55b69a945 | ||
|
|
89abee30bf | ||
|
|
066371bd76 | ||
|
|
f61a40698d | ||
|
|
0b9c0915c7 | ||
|
|
e6781ea4bb | ||
|
|
277fb85e32 | ||
|
|
4592dfcd13 | ||
|
|
cf0f922f2f | ||
|
|
bc35d6b98f | ||
|
|
3fbd40b591 | ||
|
|
f50b8a78eb | ||
|
|
5212a61388 | ||
|
|
0d1c057cf3 | ||
|
|
5a243d29da | ||
|
|
992b47e531 | ||
|
|
ee8f806f64 | ||
|
|
bc0ca74f88 | ||
|
|
55dabaf877 | ||
|
|
006c7efc06 | ||
|
|
4c809a9166 | ||
|
|
9e19e54f75 | ||
|
|
806f75a2fe | ||
|
|
295b0f4c2a | ||
|
|
7945527fd6 | ||
|
|
696531f392 | ||
|
|
c0d43a9b58 | ||
|
|
584250c4e5 | ||
|
|
ec467ae126 | ||
|
|
579fe78e6f | ||
|
|
a246a5e6e4 | ||
|
|
daaf015462 | ||
|
|
edb68fe08b | ||
|
|
7e736b2892 | ||
|
|
3b18f3a86a | ||
|
|
f6cf935c9c | ||
|
|
1dfbcd1055 | ||
|
|
3326230062 | ||
|
|
de4471876f | ||
|
|
41ce361f04 | ||
|
|
466a2b58ee | ||
|
|
0e71880463 | ||
|
|
1afbc001b5 | ||
|
|
9e3eba8ab2 | ||
|
|
32405f3120 | ||
|
|
d7735d634d | ||
|
|
b97a80249c | ||
|
|
9c3ddeaf7d | ||
|
|
de14eb3ffb | ||
|
|
74cce77dc5 | ||
|
|
2352f51838 | ||
|
|
0e2be93e92 | ||
|
|
903c214c3d | ||
|
|
9c653cf5e7 | ||
|
|
3c296a67a2 | ||
|
|
9196369c17 | ||
|
|
853ef4859d | ||
|
|
c861fbda49 | ||
|
|
a81f3685bc | ||
|
|
73d13351b5 | ||
|
|
43e8f2a48f | ||
|
|
f347919dbf | ||
|
|
14622a00b3 | ||
|
|
4a83b52ab5 | ||
|
|
30fed5f6ad | ||
|
|
96031d2288 | ||
|
|
58a0ef2d0e | ||
|
|
7b2168232e | ||
|
|
d0dc642b12 | ||
|
|
e4722a79cc | ||
|
|
671ce54380 | ||
|
|
d4ac8772ae | ||
|
|
1df71070d0 | ||
|
|
58d4b7afae | ||
|
|
9e08b68f22 | ||
|
|
3a38ccace0 | ||
|
|
f18b1c56ed | ||
|
|
24e7e234be | ||
|
|
53f7b614b0 | ||
|
|
277cebfe7b | ||
|
|
4500595a75 | ||
|
|
a1445903db | ||
|
|
1060f3a873 | ||
|
|
9f4c278a6b | ||
|
|
c017397787 | ||
|
|
1412600bdb | ||
|
|
c9b26e5559 | ||
|
|
9050137a39 | ||
|
|
b464c7cc4f | ||
|
|
543310e55a | ||
|
|
15d0a29ed9 | ||
|
|
f65cb3c4da | ||
|
|
fafa4f6541 | ||
|
|
2358198660 | ||
|
|
6342656477 | ||
|
|
966e7a832d | ||
|
|
872f22e293 | ||
|
|
5c246c513a | ||
|
|
e33fb939b3 | ||
|
|
61dddd76a0 | ||
|
|
9e5a6edba0 | ||
|
|
8cbcd4dff3 | ||
|
|
5b747cbbff | ||
|
|
8cbd4291a5 | ||
|
|
182259e7d2 | ||
|
|
ed6bdfae96 | ||
|
|
6c382f7a0f | ||
|
|
ec655c17a4 | ||
|
|
22ba79aebf | ||
|
|
b26593d8cc | ||
|
|
57b1af5153 | ||
|
|
3d55706d60 | ||
|
|
b0009e1701 | ||
|
|
c3b6b380f4 | ||
|
|
5b34142a40 | ||
|
|
d39500f706 | ||
|
|
29e8c73d50 | ||
|
|
5ac003e860 | ||
|
|
9b988f638e | ||
|
|
2bbd0206c6 | ||
|
|
8c3733a212 | ||
|
|
7e53e0f4e4 | ||
|
|
e5ed7e0daf | ||
|
|
0f2ad3590d | ||
|
|
2d912e01b7 | ||
|
|
6bd1dd2f64 | ||
|
|
95aa5ac4d3 | ||
|
|
45a8bdaf5f | ||
|
|
17f63d1484 | ||
|
|
50ada2ad64 | ||
|
|
326cad3275 | ||
|
|
7e1dde24d0 | ||
|
|
238d75c398 | ||
|
|
24f4ae4294 | ||
|
|
4dc6eb8c4e | ||
|
|
9c9d998ef5 | ||
|
|
8fa583dbc6 | ||
|
|
d08e15a4b5 | ||
|
|
bad30d6232 | ||
|
|
63d3bc8bd5 | ||
|
|
5eb3bcbf43 | ||
|
|
e3baeabbb4 | ||
|
|
4a3504fce3 | ||
|
|
cde1084ed4 | ||
|
|
b5925ef0e9 | ||
|
|
fe02a9077f | ||
|
|
6b00ede6ad | ||
|
|
d94c99b161 | ||
|
|
12b92b3f21 | ||
|
|
9ebd7bde2c | ||
|
|
6a729fb5e7 | ||
|
|
bed6e346ef | ||
|
|
788c565e71 | ||
|
|
72c61f6cc4 | ||
|
|
b1dfa6e9c9 | ||
|
|
971265d000 | ||
|
|
0e177818cc | ||
|
|
07bea95479 | ||
|
|
ab51a54ea2 | ||
|
|
f6e1b01645 | ||
|
|
11c564835a | ||
|
|
9697af7417 | ||
|
|
9c6e2bb34b | ||
|
|
ce77d3a705 | ||
|
|
4cf1598dfe | ||
|
|
12422d6ad4 | ||
|
|
4bd67bd993 | ||
|
|
ef80e5084e | ||
|
|
b0f5cafc27 | ||
|
|
b049cbc3d2 | ||
|
|
ff35a03f9d | ||
|
|
3a613a88ef | ||
|
|
c9e9c9bd59 | ||
|
|
61930ec4a1 | ||
|
|
02c4d4b9f1 | ||
|
|
352ddd6ed1 | ||
|
|
eb5d8cd2c6 | ||
|
|
7e89408105 | ||
|
|
441c4f7edd | ||
|
|
6f9ec66d02 | ||
|
|
2b61b68cb5 | ||
|
|
84f9b0589d | ||
|
|
3c464fe58e | ||
|
|
3ca2883efb | ||
|
|
2708274f9f | ||
|
|
5f9fe3a12e | ||
|
|
f1786b7cd9 | ||
|
|
32e6ba0645 | ||
|
|
cfee08d8de | ||
|
|
268fff442b | ||
|
|
b175f13974 | ||
|
|
0340ae4ee4 | ||
|
|
c8a54e4d9f | ||
|
|
69b1237b10 | ||
|
|
97d9866c0e | ||
|
|
aa7ebab3a1 | ||
|
|
03bc281763 | ||
|
|
a525914420 | ||
|
|
361d82e982 | ||
|
|
30cc791cc7 | ||
|
|
acf47d7570 | ||
|
|
14a9571fdc | ||
|
|
c4df4d3253 | ||
|
|
53781cbf1e | ||
|
|
1c4dd8a59d | ||
|
|
5e1ae83b72 | ||
|
|
65366f3430 | ||
|
|
1f51a97f04 | ||
|
|
cab54be0ab | ||
|
|
c9b4bf801d | ||
|
|
bdcd7c5487 | ||
|
|
7072686565 | ||
|
|
74063026c8 | ||
|
|
1e0e9fce92 | ||
|
|
092249a159 | ||
|
|
1fdc8bbe34 | ||
|
|
2b84ca7fd6 | ||
|
|
97826bccac | ||
|
|
85b2650ccb | ||
|
|
7c5bde4d78 | ||
|
|
43b13c30ac | ||
|
|
4537cc6e6c | ||
|
|
21e3d126e3 | ||
|
|
8e6409dd9b | ||
|
|
8d92094324 | ||
|
|
b555449dcf | ||
|
|
01418c43f6 | ||
|
|
a6e05c2a65 | ||
|
|
59c4d90278 | ||
|
|
657e758f5c | ||
|
|
d2b544dafd | ||
|
|
2c2d49718a | ||
|
|
5f347a8593 | ||
|
|
f6bd72411d | ||
|
|
e33bfd82a8 | ||
|
|
6091f44650 | ||
|
|
f0b58ba28a | ||
|
|
b8dde8c41c | ||
|
|
bafe1c7e0a | ||
|
|
c345d5b5bd | ||
|
|
3e51d875f7 | ||
|
|
96ff1c2fdb | ||
|
|
6bb18198e8 | ||
|
|
bead791e23 | ||
|
|
3b2cc05b3f | ||
|
|
b5283e73e5 | ||
|
|
500664328d | ||
|
|
04eed192d2 | ||
|
|
9e276352d5 | ||
|
|
155326cb83 | ||
|
|
ec10eb1c27 | ||
|
|
866147ce6a | ||
|
|
5347b9d858 | ||
|
|
c03dd38433 | ||
|
|
b07b80c8b9 | ||
|
|
40cd0b3bc2 | ||
|
|
42f886d7f1 | ||
|
|
4eb21e2c46 | ||
|
|
3d73dbd393 | ||
|
|
bae3b574ec | ||
|
|
c9ed426231 | ||
|
|
504d23e920 | ||
|
|
edb545b84b | ||
|
|
bced2c5b29 | ||
|
|
e9f8af6f98 | ||
|
|
de5348f61e | ||
|
|
02c2478cdc | ||
|
|
f448c7315e | ||
|
|
23963edf88 | ||
|
|
09a22264da | ||
|
|
c0d00e7776 | ||
|
|
2b10583b7c | ||
|
|
e540dcc39e | ||
|
|
3c419a7cf3 | ||
|
|
0cba0cf658 | ||
|
|
0a3c84ae95 | ||
|
|
15164b348a | ||
|
|
5e94726d42 | ||
|
|
4f490fef0d | ||
|
|
67ab8d2619 | ||
|
|
98e5242d6e | ||
|
|
3324913959 | ||
|
|
cd1355a6b6 | ||
|
|
b74b137ff8 | ||
|
|
2e209b6a5f | ||
|
|
22391adbb9 | ||
|
|
b38007f7b5 | ||
|
|
f46609c242 | ||
|
|
25217e6214 | ||
|
|
bca8cdfe52 | ||
|
|
77b5c66e3d | ||
|
|
6dc7372658 | ||
|
|
b55d3da612 | ||
|
|
69f8733f2b | ||
|
|
cd0e076952 | ||
|
|
b0989566d9 | ||
|
|
fa5d17a121 | ||
|
|
51d39a1dbb | ||
|
|
6d164cdac4 | ||
|
|
98c830f3ae | ||
|
|
09b4e25500 | ||
|
|
78dc0f6af6 | ||
|
|
52bc66dc3d | ||
|
|
053650ee75 | ||
|
|
5c3ef0158c | ||
|
|
99402b8e43 | ||
|
|
4d7a5f17d6 | ||
|
|
ba0ef22911 | ||
|
|
d099d7e3e5 | ||
|
|
859d93404d | ||
|
|
6f71d738b3 | ||
|
|
ba4ffdac86 | ||
|
|
151560fa5e | ||
|
|
d80f7965ec | ||
|
|
3e72981993 | ||
|
|
67774f3014 | ||
|
|
55c4ad9533 | ||
|
|
c027bcf59b | ||
|
|
12814dc5b0 | ||
|
|
ecccc6340f | ||
|
|
1b7e337176 | ||
|
|
1be736cb6f | ||
|
|
8defe6285f | ||
|
|
3332a74dab | ||
|
|
c879fa57cf | ||
|
|
92879d3cbe | ||
|
|
1a672dd274 | ||
|
|
0e8758e798 | ||
|
|
64f4ed62ce | ||
|
|
bd27266b69 | ||
|
|
6efd81073d | ||
|
|
f091899da7 | ||
|
|
6da91d67f3 | ||
|
|
eaf61005cd | ||
|
|
65a8ba139d | ||
|
|
da5192bee3 | ||
|
|
72dc3904c0 | ||
|
|
107eeae116 | ||
|
|
209da0f84e | ||
|
|
a6c60ffecb | ||
|
|
e6033dfb9a | ||
|
|
55ad85dda8 | ||
|
|
697ed4eb98 | ||
|
|
64dfa77022 | ||
|
|
d52ee6653c | ||
|
|
f7bcc4b910 | ||
|
|
c5193f3105 | ||
|
|
5bd01ca7d9 | ||
|
|
2ce5e02d99 | ||
|
|
9d609e528c | ||
|
|
1f445b49cf | ||
|
|
de4647f5ab | ||
|
|
742fd750cf | ||
|
|
3a88910711 | ||
|
|
7d695a13cf | ||
|
|
3a27fb91c1 | ||
|
|
b95565a70c | ||
|
|
b369211965 | ||
|
|
40f74b4316 | ||
|
|
c1983e8241 | ||
|
|
61c2180498 | ||
|
|
95773bec71 | ||
|
|
d75cde4bb0 | ||
|
|
59030f8d44 | ||
|
|
f733ef2fe7 | ||
|
|
69780ecbd9 | ||
|
|
e91ebc7eb9 | ||
|
|
756fdd212a | ||
|
|
6e3737403d | ||
|
|
910c1e0c78 | ||
|
|
016af283ca | ||
|
|
da04edccb2 | ||
|
|
6e71321e7d | ||
|
|
e81c49ee7b | ||
|
|
37c931c9c2 | ||
|
|
a59afeb327 | ||
|
|
39a419889f | ||
|
|
6caff5cd59 | ||
|
|
02a60b01cb | ||
|
|
10cf868df5 | ||
|
|
6ae7787811 | ||
|
|
63542f79c7 | ||
|
|
d5aefb3c98 | ||
|
|
1b3709c8a0 | ||
|
|
0302e9958b | ||
|
|
1f8e3f3802 | ||
|
|
fab101aa66 | ||
|
|
ca15b5e3b8 | ||
|
|
cd24337dfc | ||
|
|
4017a9373b | ||
|
|
c6e5fa2aea | ||
|
|
e8f81ec2e7 | ||
|
|
ae9ffed017 | ||
|
|
3c6bfa3dd0 | ||
|
|
136aa73822 | ||
|
|
8b04f3da3e | ||
|
|
a78a110914 | ||
|
|
69277c5fb0 | ||
|
|
be7154506f | ||
|
|
ca52a29b6e | ||
|
|
7a35c5224b | ||
|
|
2828e76657 | ||
|
|
b6b5d47168 | ||
|
|
8c91f38fee | ||
|
|
042b740cf9 | ||
|
|
48006552ee | ||
|
|
02dc62d8ce | ||
|
|
39d97e1e0b | ||
|
|
412fa76875 | ||
|
|
4d162cd0dd | ||
|
|
9f6832a5b6 | ||
|
|
b50c2e6d00 | ||
|
|
d0d67bf2ad | ||
|
|
5f459f58cb | ||
|
|
e4abb0f9de | ||
|
|
a7bc3abcdd | ||
|
|
9b315c2e0c | ||
|
|
6647a83485 | ||
|
|
040692c13c | ||
|
|
e0cdeea2f9 | ||
|
|
6aa4a03b0f | ||
|
|
252fe05edc | ||
|
|
b027ff0e73 | ||
|
|
a8f3f0703c | ||
|
|
ba81ab772e | ||
|
|
388b10fb93 | ||
|
|
7870748b41 | ||
|
|
e575f516d8 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,5 @@ dev-dist
|
||||
tags
|
||||
node_modules
|
||||
crm/public/frontend
|
||||
frontend/yarn.lock
|
||||
crm/www/crm.html
|
||||
build
|
||||
|
||||
@ -84,6 +84,14 @@ The motivation behind building Frappe CRM stems from the need for a simple, cust
|
||||
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||
|
||||
### Compatibility
|
||||
This app is compatible with the following versions of Frappe and ERPNext:
|
||||
|
||||
| CRM branch | Stability | Frappe branch | ERPNext branch |
|
||||
| :-------------------- | :-------- | :------------------- | :------------------- |
|
||||
| main - v1.x | stable | v15.x | v15.x |
|
||||
| develop - future/v2.x | unstable | develop - future/v16 | develop - future/v16 |
|
||||
|
||||
## Getting Started (Production)
|
||||
|
||||
### Managed Hosting
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
|
||||
__version__ = "1.48.2"
|
||||
__version__ = "1.53.1"
|
||||
__title__ = "Frappe CRM"
|
||||
|
||||
|
||||
32
crm/api/assignment_rule.py
Normal file
32
crm/api/assignment_rule.py
Normal file
@ -0,0 +1,32 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_assignment_rules_list():
|
||||
assignment_rules = []
|
||||
for docname in frappe.get_all(
|
||||
"Assignment Rule", filters={"document_type": ["in", ["CRM Lead", "CRM Deal"]]}
|
||||
):
|
||||
doc = frappe.get_value(
|
||||
"Assignment Rule",
|
||||
docname,
|
||||
fieldname=[
|
||||
"name",
|
||||
"description",
|
||||
"disabled",
|
||||
"priority",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
users_exists = bool(frappe.db.exists("Assignment Rule User", {"parent": docname.name}))
|
||||
assignment_rules.append({**doc, "users_exists": users_exists})
|
||||
return assignment_rules
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def duplicate_assignment_rule(docname, new_name):
|
||||
doc = frappe.get_doc("Assignment Rule", docname)
|
||||
doc.name = new_name
|
||||
doc.assignment_rule_name = new_name
|
||||
doc.insert()
|
||||
return doc
|
||||
@ -14,24 +14,16 @@ def update_deals_email_mobile_no(doc):
|
||||
)
|
||||
|
||||
for linked_deal in linked_deals:
|
||||
deal = frappe.get_cached_doc("CRM Deal", linked_deal.parent)
|
||||
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0]
|
||||
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
|
||||
deal.email = doc.email_id
|
||||
deal.mobile_no = doc.mobile_no
|
||||
deal.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_contact(name):
|
||||
contact = frappe.get_doc("Contact", name)
|
||||
contact.check_permission("read")
|
||||
|
||||
contact = contact.as_dict()
|
||||
|
||||
if not len(contact):
|
||||
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
|
||||
|
||||
return contact
|
||||
frappe.db.set_value(
|
||||
"CRM Deal",
|
||||
linked_deal.parent,
|
||||
{
|
||||
"email": doc.email_id,
|
||||
"mobile_no": doc.mobile_no,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
1142
crm/api/dashboard.py
Normal file
1142
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
139
crm/api/doc.py
139
crm/api/doc.py
@ -662,7 +662,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
|
||||
assignees = json.loads(assignees)
|
||||
assignees = frappe.parse_json(assignees)
|
||||
|
||||
if not assignees:
|
||||
return
|
||||
@ -750,7 +750,11 @@ def getCounts(d, doctype):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_docs_of_document(doctype, docname):
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
try:
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
except frappe.DoesNotExistError:
|
||||
return []
|
||||
|
||||
linked_docs = get_linked_docs(doc)
|
||||
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
||||
|
||||
@ -759,7 +763,14 @@ def get_linked_docs_of_document(doctype, docname):
|
||||
|
||||
docs_data = []
|
||||
for doc in linked_docs:
|
||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||
if not doc.get("reference_doctype") or not doc.get("reference_docname"):
|
||||
continue
|
||||
|
||||
try:
|
||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
continue
|
||||
|
||||
title = data.get("title")
|
||||
if data.doctype == "CRM Call Log":
|
||||
title = f"Call from {data.get('from')} to {data.get('to')}"
|
||||
@ -767,6 +778,9 @@ def get_linked_docs_of_document(doctype, docname):
|
||||
if data.doctype == "CRM Deal":
|
||||
title = data.get("organization")
|
||||
|
||||
if data.doctype == "CRM Notification":
|
||||
title = data.get("message")
|
||||
|
||||
docs_data.append(
|
||||
{
|
||||
"doc": data.doctype,
|
||||
@ -779,25 +793,51 @@ def get_linked_docs_of_document(doctype, docname):
|
||||
|
||||
|
||||
def remove_doc_link(doctype, docname):
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"reference_doctype": None,
|
||||
"reference_docname": None,
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
if not doctype or not docname:
|
||||
return
|
||||
|
||||
try:
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
if doctype == "CRM Notification":
|
||||
delete_notification_type = {
|
||||
"notification_type_doctype": "",
|
||||
"notification_type_doc": "",
|
||||
}
|
||||
delete_references = {
|
||||
"reference_doctype": "",
|
||||
"reference_name": "",
|
||||
}
|
||||
if linked_doc_data.get("notification_type_doctype") == linked_doc_data.get("reference_doctype"):
|
||||
delete_references.update(delete_notification_type)
|
||||
|
||||
linked_doc_data.update(delete_references)
|
||||
else:
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"reference_doctype": "",
|
||||
"reference_docname": "",
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def remove_contact_link(doctype, docname):
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"contact": None,
|
||||
"contacts": [],
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
if not doctype or not docname:
|
||||
return
|
||||
|
||||
try:
|
||||
linked_doc_data = frappe.get_doc(doctype, docname)
|
||||
linked_doc_data.update(
|
||||
{
|
||||
"contact": None,
|
||||
"contacts": [],
|
||||
}
|
||||
)
|
||||
linked_doc_data.save(ignore_permissions=True)
|
||||
except (frappe.DoesNotExistError, frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -806,13 +846,19 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||
items = frappe.parse_json(items)
|
||||
|
||||
for item in items:
|
||||
if remove_contact:
|
||||
remove_contact_link(item["doctype"], item["docname"])
|
||||
else:
|
||||
remove_doc_link(item["doctype"], item["docname"])
|
||||
if not item.get("doctype") or not item.get("docname"):
|
||||
continue
|
||||
|
||||
if delete:
|
||||
frappe.delete_doc(item["doctype"], item["docname"])
|
||||
try:
|
||||
if remove_contact:
|
||||
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):
|
||||
# Skip if document doesn't exist or has validation errors
|
||||
continue
|
||||
|
||||
return "success"
|
||||
|
||||
@ -821,19 +867,40 @@ def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||
def delete_bulk_docs(doctype, items, delete_linked=False):
|
||||
from frappe.desk.reportview import delete_bulk
|
||||
|
||||
if not doctype:
|
||||
frappe.throw("Doctype is required")
|
||||
|
||||
if not items:
|
||||
frappe.throw("Items are required")
|
||||
|
||||
items = frappe.parse_json(items)
|
||||
if not isinstance(items, list):
|
||||
frappe.throw("Items must be a list")
|
||||
|
||||
for doc in items:
|
||||
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||
for linked_doc in linked_docs:
|
||||
remove_linked_doc_reference(
|
||||
[
|
||||
{
|
||||
"doctype": linked_doc["reference_doctype"],
|
||||
"docname": linked_doc["reference_docname"],
|
||||
}
|
||||
],
|
||||
remove_contact=doctype == "Contact",
|
||||
delete=delete_linked,
|
||||
try:
|
||||
if not frappe.db.exists(doctype, doc):
|
||||
frappe.log_error(f"Document {doctype} {doc} does not exist", "Bulk Delete Error")
|
||||
continue
|
||||
|
||||
linked_docs = get_linked_docs_of_document(doctype, doc)
|
||||
for linked_doc in linked_docs:
|
||||
if not linked_doc.get("reference_doctype") or not linked_doc.get("reference_docname"):
|
||||
continue
|
||||
|
||||
remove_linked_doc_reference(
|
||||
[
|
||||
{
|
||||
"doctype": linked_doc["reference_doctype"],
|
||||
"docname": linked_doc["reference_docname"],
|
||||
}
|
||||
],
|
||||
remove_contact=doctype == "Contact",
|
||||
delete=delete_linked,
|
||||
)
|
||||
except Exception as e:
|
||||
frappe.log_error(
|
||||
f"Error processing linked docs for {doctype} {doc}: {str(e)}", "Bulk Delete Error"
|
||||
)
|
||||
|
||||
if len(items) > 10:
|
||||
|
||||
@ -23,9 +23,6 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name)
|
||||
user.is_admin = user.name == "Administrator"
|
||||
|
||||
user.roles = frappe.get_roles(user.name)
|
||||
|
||||
user.role = ""
|
||||
@ -42,7 +39,7 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
|
||||
crm_users = []
|
||||
|
||||
|
||||
137
crm/api/todo.py
137
crm/api/todo.py
@ -1,90 +1,79 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||
|
||||
|
||||
def after_insert(doc, method):
|
||||
if (
|
||||
doc.reference_type in ["CRM Lead", "CRM Deal"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||
lead_owner = frappe.db.get_value(
|
||||
doc.reference_type, doc.reference_name, fieldname
|
||||
)
|
||||
if not lead_owner:
|
||||
frappe.db.set_value(
|
||||
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to
|
||||
)
|
||||
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to:
|
||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||
owner = frappe.db.get_value(doc.reference_type, doc.reference_name, fieldname)
|
||||
if not owner:
|
||||
frappe.db.set_value(
|
||||
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to, update_modified=False
|
||||
)
|
||||
|
||||
if (
|
||||
doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
notify_assigned_user(doc)
|
||||
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
|
||||
notify_assigned_user(doc)
|
||||
|
||||
|
||||
def on_update(doc, method):
|
||||
if (
|
||||
doc.has_value_changed("status")
|
||||
and doc.status == "Cancelled"
|
||||
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
notify_assigned_user(doc, is_cancelled=True)
|
||||
if (
|
||||
doc.has_value_changed("status")
|
||||
and doc.status == "Cancelled"
|
||||
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
notify_assigned_user(doc, is_cancelled=True)
|
||||
|
||||
|
||||
def notify_assigned_user(doc, is_cancelled=False):
|
||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||
|
||||
message = (
|
||||
_("Your assignment on {0} {1} has been removed by {2}").format(
|
||||
doc.reference_type, doc.reference_name, owner
|
||||
)
|
||||
if is_cancelled
|
||||
else _("{0} assigned a {1} {2} to you").format(
|
||||
owner, doc.reference_type, doc.reference_name
|
||||
)
|
||||
)
|
||||
message = (
|
||||
_("Your assignment on {0} {1} has been removed by {2}").format(
|
||||
doc.reference_type, doc.reference_name, owner
|
||||
)
|
||||
if is_cancelled
|
||||
else _("{0} assigned a {1} {2} to you").format(owner, doc.reference_type, doc.reference_name)
|
||||
)
|
||||
|
||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||
|
||||
notify_user(
|
||||
{
|
||||
"owner": frappe.session.user,
|
||||
"assigned_to": doc.allocated_to,
|
||||
"notification_type": "Assignment",
|
||||
"message": message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": doc.reference_type,
|
||||
"reference_docname": doc.reference_name,
|
||||
"redirect_to_doctype": redirect_to_doctype,
|
||||
"redirect_to_docname": redirect_to_name,
|
||||
}
|
||||
)
|
||||
notify_user(
|
||||
{
|
||||
"owner": frappe.session.user,
|
||||
"assigned_to": doc.allocated_to,
|
||||
"notification_type": "Assignment",
|
||||
"message": message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": doc.reference_type,
|
||||
"reference_docname": doc.reference_name,
|
||||
"redirect_to_doctype": redirect_to_doctype,
|
||||
"redirect_to_docname": redirect_to_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
name = doc.reference_name
|
||||
doctype = doc.reference_type
|
||||
name = doc.reference_name
|
||||
doctype = doc.reference_type
|
||||
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
|
||||
if doctype in ["lead", "deal"]:
|
||||
name = (
|
||||
reference_doc.lead_name or name
|
||||
if doctype == "lead"
|
||||
else reference_doc.organization or reference_doc.lead_name or name
|
||||
)
|
||||
if doctype in ["lead", "deal"]:
|
||||
name = (
|
||||
reference_doc.lead_name or name
|
||||
if doctype == "lead"
|
||||
else reference_doc.organization or reference_doc.lead_name or name
|
||||
)
|
||||
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||
doctype,
|
||||
@ -94,7 +83,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||
<span>{ _('assigned a {0} {1} to you').format(
|
||||
@ -104,9 +93,9 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
</div>
|
||||
"""
|
||||
|
||||
if doctype == "task":
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
if doctype == "task":
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
||||
@ -114,7 +103,7 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
return f"""
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||
<span>{ _('assigned a new task {0} to you').format(
|
||||
@ -125,8 +114,8 @@ def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
|
||||
|
||||
def get_redirect_to_doc(doc):
|
||||
if doc.reference_type == "CRM Task":
|
||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||
if doc.reference_type == "CRM Task":
|
||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||
|
||||
return doc.reference_type, doc.reference_name
|
||||
return doc.reference_type, doc.reference_name
|
||||
|
||||
@ -10,8 +10,15 @@ from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||
def validate(doc, method):
|
||||
if doc.type == "Incoming" and doc.get("from"):
|
||||
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
||||
doc.reference_doctype = doctype
|
||||
doc.reference_name = name
|
||||
if name != None:
|
||||
doc.reference_doctype = doctype
|
||||
doc.reference_name = name
|
||||
|
||||
if doc.type == "Outgoing" and doc.get("to"):
|
||||
name, doctype = get_lead_or_deal_from_number(doc.get("to"))
|
||||
if name != None:
|
||||
doc.reference_doctype = doctype
|
||||
doc.reference_name = name
|
||||
|
||||
|
||||
def on_update(doc, method):
|
||||
@ -29,7 +36,7 @@ def on_update(doc, method):
|
||||
def notify_agent(doc):
|
||||
if doc.type == "Incoming":
|
||||
doctype = doc.reference_doctype
|
||||
if doctype.startswith("CRM "):
|
||||
if doctype and doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
|
||||
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
0
crm/fcrm/doctype/crm_dashboard/__init__.py
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.js
Normal file
8
crm/fcrm/doctype/crm_dashboard/crm_dashboard.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("CRM Dashboard", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
105
crm/fcrm/doctype/crm_dashboard/crm_dashboard.json
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:title",
|
||||
"creation": "2025-07-14 12:19:49.725022",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"private",
|
||||
"column_break_exbw",
|
||||
"user",
|
||||
"section_break_hfza",
|
||||
"layout"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "column_break_exbw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hfza",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"default": "[]",
|
||||
"fieldname": "layout",
|
||||
"fieldtype": "Code",
|
||||
"label": "Layout",
|
||||
"options": "JSON"
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Name",
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "private",
|
||||
"fieldname": "user",
|
||||
"fieldtype": "Link",
|
||||
"label": "User",
|
||||
"mandatory_depends_on": "private",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "private",
|
||||
"fieldtype": "Check",
|
||||
"label": "Private"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-14 12:36:10.831351",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Dashboard",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
34
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
34
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
@ -0,0 +1,34 @@
|
||||
# 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 CRMDashboard(Document):
|
||||
pass
|
||||
|
||||
|
||||
def default_manager_dashboard_layout():
|
||||
"""
|
||||
Returns the default layout for the CRM Manager Dashboard.
|
||||
"""
|
||||
return '[{"name":"total_leads","type":"number_chart","tooltip":"Total number of leads","layout":{"x":0,"y":0,"w":4,"h":3,"i":"total_leads"}},{"name":"ongoing_deals","type":"number_chart","tooltip":"Total number of ongoing deals","layout":{"x":8,"y":0,"w":4,"h":3,"i":"ongoing_deals"}},{"name":"won_deals","type":"number_chart","tooltip":"Total number of won deals","layout":{"x":12,"y":0,"w":4,"h":3,"i":"won_deals"}},{"name":"average_won_deal_value","type":"number_chart","tooltip":"Average value of won deals","layout":{"x":16,"y":0,"w":4,"h":3,"i":"average_won_deal_value"}},{"name":"average_deal_value","type":"number_chart","tooltip":"Average deal value of ongoing and won deals","layout":{"x":0,"y":2,"w":4,"h":3,"i":"average_deal_value"}},{"name":"average_time_to_close_a_lead","type":"number_chart","tooltip":"Average time taken to close a lead","layout":{"x":4,"y":0,"w":4,"h":3,"i":"average_time_to_close_a_lead"}},{"name":"average_time_to_close_a_deal","type":"number_chart","layout":{"x":4,"y":2,"w":4,"h":3,"i":"average_time_to_close_a_deal"}},{"name":"spacer","type":"spacer","layout":{"x":8,"y":2,"w":12,"h":3,"i":"spacer"}},{"name":"sales_trend","type":"axis_chart","layout":{"x":0,"y":4,"w":10,"h":9,"i":"sales_trend"}},{"name":"forecasted_revenue","type":"axis_chart","layout":{"x":10,"y":4,"w":10,"h":9,"i":"forecasted_revenue"}},{"name":"funnel_conversion","type":"axis_chart","layout":{"x":0,"y":11,"w":10,"h":9,"i":"funnel_conversion"}},{"name":"deals_by_stage_donut","type":"donut_chart","layout":{"x":10,"y":11,"w":10,"h":9,"i":"deals_by_stage_donut"}},{"name":"lost_deal_reasons","type":"axis_chart","layout":{"x":0,"y":32,"w":20,"h":9,"i":"lost_deal_reasons"}},{"name":"leads_by_source","type":"donut_chart","layout":{"x":0,"y":18,"w":10,"h":9,"i":"leads_by_source"}},{"name":"deals_by_source","type":"donut_chart","layout":{"x":10,"y":18,"w":10,"h":9,"i":"deals_by_source"}},{"name":"deals_by_territory","type":"axis_chart","layout":{"x":0,"y":25,"w":10,"h":9,"i":"deals_by_territory"}},{"name":"deals_by_salesperson","type":"axis_chart","layout":{"x":10,"y":25,"w":10,"h":9,"i":"deals_by_salesperson"}}]'
|
||||
|
||||
|
||||
def create_default_manager_dashboard(force=False):
|
||||
"""
|
||||
Creates the default CRM Manager Dashboard if it does not exist.
|
||||
"""
|
||||
if not frappe.db.exists("CRM Dashboard", "Manager Dashboard"):
|
||||
doc = frappe.new_doc("CRM Dashboard")
|
||||
doc.title = "Manager Dashboard"
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.insert(ignore_permissions=True)
|
||||
else:
|
||||
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
||||
if force:
|
||||
doc.layout = default_manager_dashboard_layout()
|
||||
doc.save(ignore_permissions=True)
|
||||
return doc.layout
|
||||
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
30
crm/fcrm/doctype/crm_dashboard/test_crm_dashboard.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
# 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 UnitTestCRMDashboard(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMDashboard.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMDashboard(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMDashboard.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -1,20 +1,5 @@
|
||||
import frappe
|
||||
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal(name):
|
||||
deal = frappe.get_doc("CRM Deal", name)
|
||||
deal.check_permission("read")
|
||||
|
||||
deal = deal.as_dict()
|
||||
|
||||
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
||||
deal["_form_script"] = get_form_script("CRM Deal")
|
||||
return deal
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal_contacts(name):
|
||||
@ -32,24 +17,12 @@ def get_deal_contacts(name):
|
||||
is_primary = contact.is_primary
|
||||
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
||||
|
||||
def get_primary_email(contact):
|
||||
for email in contact.email_ids:
|
||||
if email.is_primary:
|
||||
return email.email_id
|
||||
return contact.email_ids[0].email_id if contact.email_ids else ""
|
||||
|
||||
def get_primary_mobile_no(contact):
|
||||
for phone in contact.phone_nos:
|
||||
if phone.is_primary:
|
||||
return phone.phone
|
||||
return contact.phone_nos[0].phone if contact.phone_nos else ""
|
||||
|
||||
_contact = {
|
||||
"name": contact.name,
|
||||
"image": contact.image,
|
||||
"full_name": contact.full_name,
|
||||
"email": get_primary_email(contact),
|
||||
"mobile_no": get_primary_mobile_no(contact),
|
||||
"email": contact.email_id,
|
||||
"mobile_no": contact.mobile_no,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
deal_contacts.append(_contact)
|
||||
|
||||
@ -14,11 +14,15 @@
|
||||
"column_break_ijan",
|
||||
"status",
|
||||
"deal_owner",
|
||||
"lost_reason",
|
||||
"lost_notes",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"expected_deal_value",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
"closed_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
@ -35,6 +39,7 @@
|
||||
"column_break_xbyf",
|
||||
"territory",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"industry",
|
||||
"person_section",
|
||||
@ -91,11 +96,6 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "close_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Close Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_step",
|
||||
"fieldtype": "Data",
|
||||
@ -128,13 +128,13 @@
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
"label": "Primary Email",
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Mobile No",
|
||||
"label": "Primary Mobile No",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
@ -248,7 +248,7 @@
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone",
|
||||
"label": "Primary Phone",
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
@ -391,12 +391,48 @@
|
||||
{
|
||||
"fieldname": "column_break_kpxa",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lost Reason",
|
||||
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
|
||||
"options": "CRM Lost Reason"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_notes",
|
||||
"fieldtype": "Text",
|
||||
"label": "Lost Notes",
|
||||
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Expected Deal Value",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_closure_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Closure Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "closed_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Closed Date"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-16 11:42:49.413483",
|
||||
"modified": "2025-08-26 12:12:56.324245",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -7,9 +7,8 @@ from frappe.desk.form.assign_to import add as assign
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import (
|
||||
add_status_change_log,
|
||||
)
|
||||
from crm.fcrm.doctype.crm_status_change_log.crm_status_change_log import add_status_change_log
|
||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
||||
|
||||
|
||||
class CRMDeal(Document):
|
||||
@ -24,7 +23,11 @@ class CRMDeal(Document):
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
self.validate_forcasting_fields()
|
||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
self.validate_forecasting_fields()
|
||||
self.validate_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
@ -134,20 +137,59 @@ class CRMDeal(Document):
|
||||
if sla:
|
||||
sla.apply(self)
|
||||
|
||||
def update_close_date(self):
|
||||
def update_closed_date(self):
|
||||
"""
|
||||
Update the close date based on the "Won" status.
|
||||
Update the closed date based on the "Won" status.
|
||||
"""
|
||||
if self.status == "Won" and not self.close_date:
|
||||
self.close_date = frappe.utils.nowdate()
|
||||
if self.status == "Won" and not self.closed_date:
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
|
||||
def validate_forcasting_fields(self):
|
||||
self.update_close_date()
|
||||
def update_default_probability(self):
|
||||
"""
|
||||
Update the default probability based on the status.
|
||||
"""
|
||||
if not self.probability or self.probability == 0:
|
||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||
|
||||
def update_expected_deal_value(self):
|
||||
"""
|
||||
Update the expected deal value based on the net total or total.
|
||||
"""
|
||||
if (
|
||||
frappe.db.get_single_value("FCRM Settings", "auto_update_expected_deal_value")
|
||||
and (self.net_total or self.total)
|
||||
and self.expected_deal_value
|
||||
):
|
||||
self.expected_deal_value = self.net_total or self.total
|
||||
|
||||
def validate_forecasting_fields(self):
|
||||
self.update_closed_date()
|
||||
self.update_default_probability()
|
||||
self.update_expected_deal_value()
|
||||
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||
if not self.deal_value or self.deal_value == 0:
|
||||
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
|
||||
if not self.close_date:
|
||||
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
|
||||
if not self.expected_deal_value or self.expected_deal_value == 0:
|
||||
frappe.throw(_("Expected Deal Value is required."), frappe.MandatoryError)
|
||||
if not self.expected_closure_date:
|
||||
frappe.throw(_("Expected Closure Date is required."), frappe.MandatoryError)
|
||||
|
||||
def validate_lost_reason(self):
|
||||
"""
|
||||
Validate the lost reason if the status is set to "Lost".
|
||||
"""
|
||||
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
|
||||
if not self.lost_reason:
|
||||
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
|
||||
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"color",
|
||||
"type",
|
||||
"position",
|
||||
"probability"
|
||||
"column_break_ojiu",
|
||||
"probability",
|
||||
"color"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -39,12 +41,24 @@
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Probability"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ojiu",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-01 12:06:42.937440",
|
||||
"modified": "2025-07-11 16:03:28.077955",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
|
||||
@ -160,7 +160,7 @@ def add_forecasting_section(layout, doctype):
|
||||
"columns": [
|
||||
{
|
||||
"name": "column_" + str(random_string(4)),
|
||||
"fields": ["close_date", "probability", "deal_value"],
|
||||
"fields": ["expected_closure_date", "probability", "expected_deal_value"],
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import frappe
|
||||
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lead(name):
|
||||
lead = frappe.get_doc("CRM Lead", name)
|
||||
lead.check_permission("read")
|
||||
|
||||
lead = lead.as_dict()
|
||||
|
||||
lead["fields_meta"] = get_fields_meta("CRM Lead")
|
||||
lead["_form_script"] = get_form_script("CRM Lead")
|
||||
return lead
|
||||
@ -27,9 +27,10 @@
|
||||
"label": "Details"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-02 22:13:30.498404",
|
||||
"modified": "2025-06-30 16:53:51.721752",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Source",
|
||||
@ -44,7 +45,7 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
@ -60,6 +61,15 @@
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
@ -71,7 +81,8 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.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("CRM Lost Reason", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lost_reason",
|
||||
"creation": "2025-06-30 16:51:31.082360",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"lost_reason",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Lost Reason",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-30 16:59:15.094049",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lost Reason",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@ -0,0 +1,9 @@
|
||||
# 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 CRMLostReason(Document):
|
||||
pass
|
||||
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
# 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 UnitTestCRMLostReason(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMLostReason.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMLostReason.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -10,6 +10,7 @@
|
||||
"organization_name",
|
||||
"no_of_employees",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"organization_logo",
|
||||
"column_break_pnpp",
|
||||
@ -74,12 +75,18 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Address",
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
}
|
||||
],
|
||||
"image_field": "organization_logo",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-17 18:37:10.341062",
|
||||
"modified": "2025-07-15 11:40:12.175598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Organization",
|
||||
@ -111,7 +118,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,51 +4,65 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
||||
|
||||
|
||||
class CRMOrganization(Document):
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Organization',
|
||||
'type': 'Data',
|
||||
'key': 'organization_name',
|
||||
'width': '16rem',
|
||||
},
|
||||
{
|
||||
'label': 'Website',
|
||||
'type': 'Data',
|
||||
'key': 'website',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Industry',
|
||||
'type': 'Link',
|
||||
'key': 'industry',
|
||||
'options': 'CRM Industry',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Annual Revenue',
|
||||
'type': 'Currency',
|
||||
'key': 'annual_revenue',
|
||||
'width': '14rem',
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"organization_name",
|
||||
"organization_logo",
|
||||
"website",
|
||||
"industry",
|
||||
"currency",
|
||||
"annual_revenue",
|
||||
"modified",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
def validate(self):
|
||||
self.update_exchange_rate()
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
"label": "Organization",
|
||||
"type": "Data",
|
||||
"key": "organization_name",
|
||||
"width": "16rem",
|
||||
},
|
||||
{
|
||||
"label": "Website",
|
||||
"type": "Data",
|
||||
"key": "website",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Industry",
|
||||
"type": "Link",
|
||||
"key": "industry",
|
||||
"options": "CRM Industry",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Annual Revenue",
|
||||
"type": "Currency",
|
||||
"key": "annual_revenue",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
"name",
|
||||
"organization_name",
|
||||
"organization_logo",
|
||||
"website",
|
||||
"industry",
|
||||
"currency",
|
||||
"annual_revenue",
|
||||
"modified",
|
||||
]
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
@ -13,6 +13,8 @@
|
||||
"column_break_mwmz",
|
||||
"duration",
|
||||
"last_status_change_log",
|
||||
"from_type",
|
||||
"to_type",
|
||||
"log_owner"
|
||||
],
|
||||
"fields": [
|
||||
@ -61,18 +63,31 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Owner",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "From Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "to_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "To Type"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-06 13:26:40.597277",
|
||||
"modified": "2025-07-13 12:37:41.278584",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Status Change Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from datetime import datetime
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
|
||||
class CRMStatusChangeLog(Document):
|
||||
pass
|
||||
|
||||
|
||||
def get_duration(from_date, to_date):
|
||||
if not isinstance(from_date, datetime):
|
||||
from_date = get_datetime(from_date)
|
||||
@ -18,28 +20,45 @@ def get_duration(from_date, to_date):
|
||||
duration = to_date - from_date
|
||||
return duration.total_seconds()
|
||||
|
||||
|
||||
def add_status_change_log(doc):
|
||||
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
||||
|
||||
if not doc.is_new():
|
||||
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
||||
previous_status_type = (
|
||||
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
|
||||
)
|
||||
if not doc.status_change_log and previous_status:
|
||||
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
|
||||
doc.append("status_change_log", {
|
||||
"from": previous_status,
|
||||
"to": "",
|
||||
"from_date": now_minus_one_minute,
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": previous_status,
|
||||
"from_type": previous_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": now_minus_one_minute,
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
},
|
||||
)
|
||||
last_status_change = doc.status_change_log[-1]
|
||||
last_status_change.to = doc.status
|
||||
last_status_change.to_type = to_status_type or ""
|
||||
last_status_change.to_date = datetime.now()
|
||||
last_status_change.log_owner = frappe.session.user
|
||||
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
||||
|
||||
doc.append("status_change_log", {
|
||||
"from": doc.status,
|
||||
"to": "",
|
||||
"from_date": datetime.now(),
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": doc.status,
|
||||
"from_type": to_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": datetime.now(),
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
},
|
||||
)
|
||||
|
||||
@ -63,8 +63,7 @@
|
||||
"fieldname": "twiml_sid",
|
||||
"fieldtype": "Data",
|
||||
"label": "TwiML SID",
|
||||
"permlevel": 1,
|
||||
"read_only": 1
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ssqj",
|
||||
@ -105,7 +104,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-15 19:35:13.406254",
|
||||
"modified": "2025-08-19 13:36:19.823197",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Twilio Settings",
|
||||
@ -152,8 +151,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -128,14 +128,35 @@ def get_quotation_url(crm_deal, organization):
|
||||
address = address.get("name") if address else None
|
||||
|
||||
if not erpnext_crm_settings.is_erpnext_in_different_site:
|
||||
quotation_url = get_url_to_list("Quotation")
|
||||
return f"{quotation_url}/new?quotation_to=CRM Deal&crm_deal={crm_deal}&party_name={crm_deal}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
||||
base_url = f"{get_url_to_list('Quotation')}/new"
|
||||
params = {
|
||||
"quotation_to": "CRM Deal",
|
||||
"crm_deal": crm_deal,
|
||||
"party_name": crm_deal,
|
||||
"company": erpnext_crm_settings.erpnext_company,
|
||||
"contact_person": contact,
|
||||
"customer_address": address
|
||||
}
|
||||
else:
|
||||
site_url = erpnext_crm_settings.get("erpnext_site_url")
|
||||
quotation_url = f"{site_url}/app/quotation"
|
||||
|
||||
base_url = f"{site_url}/app/quotation/new"
|
||||
prospect = create_prospect_in_remote_site(crm_deal, erpnext_crm_settings)
|
||||
return f"{quotation_url}/new?quotation_to=Prospect&crm_deal={crm_deal}&party_name={prospect}&company={erpnext_crm_settings.erpnext_company}&contact_person={contact}&customer_address={address}"
|
||||
params = {
|
||||
"quotation_to": "Prospect",
|
||||
"crm_deal": crm_deal,
|
||||
"party_name": prospect,
|
||||
"company": erpnext_crm_settings.erpnext_company,
|
||||
"contact_person": contact,
|
||||
"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
|
||||
)
|
||||
|
||||
return f"{base_url}?{query_string}"
|
||||
|
||||
|
||||
def create_prospect_in_remote_site(crm_deal, erpnext_crm_settings):
|
||||
|
||||
@ -8,6 +8,13 @@
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"auto_update_expected_deal_value",
|
||||
"currency_tab",
|
||||
"currency",
|
||||
"exchange_rate_provider_section",
|
||||
"service_provider",
|
||||
"column_break_vqck",
|
||||
"access_key",
|
||||
"branding_tab",
|
||||
"brand_name",
|
||||
"brand_logo",
|
||||
@ -60,16 +67,58 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"fieldname": "enable_forecasting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Forecasting"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "exchange_rate_provider_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Exchange Rate Provider"
|
||||
},
|
||||
{
|
||||
"default": "frankfurter.app",
|
||||
"fieldname": "service_provider",
|
||||
"fieldtype": "Select",
|
||||
"label": "Service Provider",
|
||||
"options": "frankfurter.app\nexchangerate.host"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.service_provider == 'exchangerate.host';",
|
||||
"fieldname": "access_key",
|
||||
"fieldtype": "Data",
|
||||
"label": "Access Key",
|
||||
"mandatory_depends_on": "eval:doc.service_provider == 'exchangerate.host';"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_vqck",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "Automatically update \"Expected Deal Value\" based on the total value of associated products in a deal",
|
||||
"fieldname": "auto_update_expected_deal_value",
|
||||
"fieldtype": "Check",
|
||||
"label": "Auto Update Expected Deal Value"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-01 13:20:48.757603",
|
||||
"modified": "2025-09-16 17:33:26.406549",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
||||
from frappe.model.document import Document
|
||||
@ -17,6 +18,7 @@ class FCRMSettings(Document):
|
||||
def validate(self):
|
||||
self.do_not_allow_to_delete_if_standard()
|
||||
self.setup_forecasting()
|
||||
self.make_currency_read_only()
|
||||
|
||||
def do_not_allow_to_delete_if_standard(self):
|
||||
if not self.has_value_changed("dropdown_items"):
|
||||
@ -37,29 +39,39 @@ class FCRMSettings(Document):
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
)
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"deal_value",
|
||||
"expected_deal_value",
|
||||
)
|
||||
else:
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"close_date",
|
||||
"expected_closure_date",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"deal_value",
|
||||
"expected_deal_value",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
|
||||
def make_currency_read_only(self):
|
||||
if self.currency and self.has_value_changed("currency"):
|
||||
make_property_setter(
|
||||
"FCRM Settings",
|
||||
"currency",
|
||||
"read_only",
|
||||
1,
|
||||
"Check",
|
||||
)
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
@ -121,3 +133,76 @@ def get_forecasting_script():
|
||||
this.doc.probability = status.probability
|
||||
}
|
||||
}"""
|
||||
|
||||
|
||||
def get_exchange_rate(from_currency, to_currency, date=None):
|
||||
if not date:
|
||||
date = "latest"
|
||||
|
||||
api_used = "frankfurter"
|
||||
|
||||
api_endpoint = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
||||
res = requests.get(api_endpoint, timeout=5)
|
||||
if res.ok:
|
||||
data = res.json()
|
||||
return data["rates"][to_currency]
|
||||
|
||||
# Fallback to exchangerate.host if Frankfurter API fails
|
||||
settings = FCRMSettings("FCRM Settings")
|
||||
if settings and settings.service_provider == "exchangerate.host":
|
||||
api_used = "exchangerate.host"
|
||||
if not settings.access_key:
|
||||
frappe.throw(
|
||||
_("Access Key is required for Service Provider: {0}").format(
|
||||
frappe.bold(settings.service_provider)
|
||||
)
|
||||
)
|
||||
|
||||
params = {
|
||||
"access_key": settings.access_key,
|
||||
"from": from_currency,
|
||||
"to": to_currency,
|
||||
"amount": 1,
|
||||
}
|
||||
|
||||
if date != "latest":
|
||||
params["date"] = date
|
||||
|
||||
api_endpoint = "https://api.exchangerate.host/convert"
|
||||
|
||||
res = requests.get(api_endpoint, params=params, timeout=5)
|
||||
if res.ok:
|
||||
data = res.json()
|
||||
return data["result"]
|
||||
|
||||
frappe.log_error(
|
||||
title="Exchange Rate Fetch Error",
|
||||
message=f"Failed to fetch exchange rate from {from_currency} to {to_currency} using {api_used} API.",
|
||||
)
|
||||
|
||||
if api_used == "frankfurter":
|
||||
user = frappe.session.user
|
||||
is_manager = (
|
||||
"System Manager" in frappe.get_roles(user)
|
||||
or "Sales Manager" in frappe.get_roles(user)
|
||||
or user == "Administrator"
|
||||
)
|
||||
|
||||
if not is_manager:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Ask your manager to set up the Exchange Rate Provider, as default provider does not support currency conversion for {0} to {1}."
|
||||
).format(from_currency, to_currency)
|
||||
)
|
||||
else:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Setup the Exchange Rate Provider as 'Exchangerate Host' in settings, as default provider does not support currency conversion for {0} to {1}."
|
||||
).format(from_currency, to_currency)
|
||||
)
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"Failed to fetch exchange rate from {0} to {1} on {2}. Please check your internet connection or try again later."
|
||||
).format(from_currency, to_currency, date)
|
||||
)
|
||||
|
||||
130
crm/install.py
130
crm/install.py
@ -4,6 +4,7 @@ import click
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from crm.fcrm.doctype.crm_dashboard.crm_dashboard import create_default_manager_dashboard
|
||||
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||
|
||||
|
||||
@ -20,8 +21,12 @@ def after_install(force=False):
|
||||
add_email_template_custom_fields()
|
||||
add_default_industries()
|
||||
add_default_lead_sources()
|
||||
add_default_lost_reasons()
|
||||
add_standard_dropdown_items()
|
||||
add_default_scripts()
|
||||
create_default_manager_dashboard(force)
|
||||
create_assignment_rule_custom_fields()
|
||||
add_assignment_rule_property_setters()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -68,36 +73,43 @@ def add_default_deal_statuses():
|
||||
statuses = {
|
||||
"Qualification": {
|
||||
"color": "gray",
|
||||
"type": "Open",
|
||||
"probability": 10,
|
||||
"position": 1,
|
||||
},
|
||||
"Demo/Making": {
|
||||
"color": "orange",
|
||||
"type": "Ongoing",
|
||||
"probability": 25,
|
||||
"position": 2,
|
||||
},
|
||||
"Proposal/Quotation": {
|
||||
"color": "blue",
|
||||
"type": "Ongoing",
|
||||
"probability": 50,
|
||||
"position": 3,
|
||||
},
|
||||
"Negotiation": {
|
||||
"color": "yellow",
|
||||
"type": "Ongoing",
|
||||
"probability": 70,
|
||||
"position": 4,
|
||||
},
|
||||
"Ready to Close": {
|
||||
"color": "purple",
|
||||
"type": "Ongoing",
|
||||
"probability": 90,
|
||||
"position": 5,
|
||||
},
|
||||
"Won": {
|
||||
"color": "green",
|
||||
"type": "Won",
|
||||
"probability": 100,
|
||||
"position": 6,
|
||||
},
|
||||
"Lost": {
|
||||
"color": "red",
|
||||
"type": "Lost",
|
||||
"probability": 0,
|
||||
"position": 7,
|
||||
},
|
||||
@ -110,6 +122,7 @@ def add_default_deal_statuses():
|
||||
doc = frappe.new_doc("CRM Deal Status")
|
||||
doc.deal_status = status
|
||||
doc.color = statuses[status]["color"]
|
||||
doc.type = statuses[status]["type"]
|
||||
doc.probability = statuses[status]["probability"]
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
@ -181,7 +194,7 @@ def add_default_fields_layout(force=False):
|
||||
},
|
||||
"CRM Deal-Data Fields": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"label": "Details", "name": "details_section", "opened": true, "columns": [{"name": "column_z9XL", "fields": ["organization", "annual_revenue", "next_step"]}, {"name": "column_gM4w", "fields": ["website", "close_date", "deal_owner"]}, {"name": "column_gWmE", "fields": ["territory", "probability"]}]}]',
|
||||
"layout": '[{"name":"first_tab","sections":[{"label":"Details","name":"details_section","opened":true,"columns":[{"name":"column_z9XL","fields":["organization","annual_revenue","next_step"]},{"name":"column_gM4w","fields":["website","closed_date","deal_owner"]},{"name":"column_gWmE","fields":["territory","probability"]}]},{"label":"Products","name":"section_jHhQ","opened":true,"columns":[{"name":"column_xiNF","fields":["products"]}],"editingLabel":false,"hideLabel":true},{"label":"New Section","name":"section_WNOQ","opened":true,"columns":[{"name":"column_ziBW","fields":["total"]},{"label":"","name":"column_wuwA","fields":["net_total"]}],"hideBorder":true,"hideLabel":true}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -351,6 +364,44 @@ def add_default_lead_sources():
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_default_lost_reasons():
|
||||
lost_reasons = [
|
||||
{
|
||||
"reason": "Pricing",
|
||||
"description": "The prospect found the pricing to be too high or not competitive.",
|
||||
},
|
||||
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
|
||||
{
|
||||
"reason": "Budget Constraints",
|
||||
"description": "The prospect did not have the budget to proceed with the purchase.",
|
||||
},
|
||||
{
|
||||
"reason": "Missing Features",
|
||||
"description": "The prospect felt that the product or service was missing key features they needed.",
|
||||
},
|
||||
{
|
||||
"reason": "Long Sales Cycle",
|
||||
"description": "The sales process took too long, leading to loss of interest.",
|
||||
},
|
||||
{
|
||||
"reason": "No Decision-Maker",
|
||||
"description": "The prospect was not the decision-maker and could not proceed.",
|
||||
},
|
||||
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
|
||||
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
|
||||
{"reason": "Other", "description": ""},
|
||||
]
|
||||
|
||||
for reason in lost_reasons:
|
||||
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
|
||||
continue
|
||||
|
||||
doc = frappe.new_doc("CRM Lost Reason")
|
||||
doc.lost_reason = reason["reason"]
|
||||
doc.description = reason["description"]
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_standard_dropdown_items():
|
||||
crm_settings = frappe.get_single("FCRM Settings")
|
||||
|
||||
@ -372,3 +423,80 @@ def add_default_scripts():
|
||||
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||
create_product_details_script(doctype)
|
||||
create_forecasting_script()
|
||||
|
||||
|
||||
def add_assignment_rule_property_setters():
|
||||
"""Add a property setter to the Assignment Rule DocType for assign_condition and unassign_condition."""
|
||||
|
||||
default_fields = {
|
||||
"doctype": "Property Setter",
|
||||
"doctype_or_field": "DocField",
|
||||
"doc_type": "Assignment Rule",
|
||||
"property_type": "Data",
|
||||
"is_system_generated": 1,
|
||||
}
|
||||
|
||||
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-assign_condition-depends_on"}):
|
||||
frappe.get_doc(
|
||||
{
|
||||
**default_fields,
|
||||
"name": "Assignment Rule-assign_condition-depends_on",
|
||||
"field_name": "assign_condition",
|
||||
"property": "depends_on",
|
||||
"value": "eval: !doc.assign_condition_json",
|
||||
}
|
||||
).insert()
|
||||
else:
|
||||
frappe.db.set_value(
|
||||
"Property Setter",
|
||||
{"name": "Assignment Rule-assign_condition-depends_on"},
|
||||
"value",
|
||||
"eval: !doc.assign_condition_json",
|
||||
)
|
||||
if not frappe.db.exists("Property Setter", {"name": "Assignment Rule-unassign_condition-depends_on"}):
|
||||
frappe.get_doc(
|
||||
{
|
||||
**default_fields,
|
||||
"name": "Assignment Rule-unassign_condition-depends_on",
|
||||
"field_name": "unassign_condition",
|
||||
"property": "depends_on",
|
||||
"value": "eval: !doc.unassign_condition_json",
|
||||
}
|
||||
).insert()
|
||||
else:
|
||||
frappe.db.set_value(
|
||||
"Property Setter",
|
||||
{"name": "Assignment Rule-unassign_condition-depends_on"},
|
||||
"value",
|
||||
"eval: !doc.unassign_condition_json",
|
||||
)
|
||||
|
||||
|
||||
def create_assignment_rule_custom_fields():
|
||||
if not frappe.get_meta("Assignment Rule").has_field("assign_condition_json"):
|
||||
click.secho("* Installing Custom Fields in Assignment Rule")
|
||||
|
||||
create_custom_fields(
|
||||
{
|
||||
"Assignment Rule": [
|
||||
{
|
||||
"description": "Autogenerated field by CRM App",
|
||||
"fieldname": "assign_condition_json",
|
||||
"fieldtype": "Code",
|
||||
"label": "Assign Condition JSON",
|
||||
"insert_after": "assign_condition",
|
||||
"depends_on": "eval: doc.assign_condition_json",
|
||||
},
|
||||
{
|
||||
"description": "Autogenerated field by CRM App",
|
||||
"fieldname": "unassign_condition_json",
|
||||
"fieldtype": "Code",
|
||||
"label": "Unassign Condition JSON",
|
||||
"insert_after": "unassign_condition",
|
||||
"depends_on": "eval: doc.unassign_condition_json",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
frappe.clear_cache(doctype="Assignment Rule")
|
||||
|
||||
@ -35,7 +35,7 @@ def set_default_calling_medium(medium):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Telephony Agent",
|
||||
"agent": frappe.session.user,
|
||||
"user": frappe.session.user,
|
||||
"default_medium": medium,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
@ -242,19 +242,18 @@ def get_call_log_status(call_payload, direction="inbound"):
|
||||
elif status == "failed":
|
||||
return "Failed"
|
||||
|
||||
status = call_payload.get("DialCallStatus")
|
||||
call_type = call_payload.get("CallType")
|
||||
dial_call_status = call_payload.get("DialCallStatus")
|
||||
status = call_payload.get("DialCallStatus") or call_payload.get("Status")
|
||||
|
||||
if call_type == "incomplete" and dial_call_status == "no-answer":
|
||||
if call_type == "incomplete" and status == "no-answer":
|
||||
status = "No Answer"
|
||||
elif call_type == "client-hangup" and dial_call_status == "canceled":
|
||||
elif call_type == "client-hangup" and status == "canceled":
|
||||
status = "Canceled"
|
||||
elif call_type == "incomplete" and dial_call_status == "failed":
|
||||
elif call_type == "incomplete" and status == "failed":
|
||||
status = "Failed"
|
||||
elif call_type == "completed":
|
||||
status = "Completed"
|
||||
elif dial_call_status == "busy":
|
||||
elif status == "busy":
|
||||
status = "Ringing"
|
||||
|
||||
return status
|
||||
|
||||
3731
crm/locale/ar.po
3731
crm/locale/ar.po
File diff suppressed because it is too large
Load Diff
3791
crm/locale/bs.po
3791
crm/locale/bs.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/cs.po
Normal file
6392
crm/locale/cs.po
Normal file
File diff suppressed because it is too large
Load Diff
6392
crm/locale/da.po
Normal file
6392
crm/locale/da.po
Normal file
File diff suppressed because it is too large
Load Diff
4095
crm/locale/de.po
4095
crm/locale/de.po
File diff suppressed because it is too large
Load Diff
3753
crm/locale/eo.po
3753
crm/locale/eo.po
File diff suppressed because it is too large
Load Diff
3735
crm/locale/es.po
3735
crm/locale/es.po
File diff suppressed because it is too large
Load Diff
3949
crm/locale/fa.po
3949
crm/locale/fa.po
File diff suppressed because it is too large
Load Diff
3741
crm/locale/fr.po
3741
crm/locale/fr.po
File diff suppressed because it is too large
Load Diff
5181
crm/locale/hr.po
5181
crm/locale/hr.po
File diff suppressed because it is too large
Load Diff
3753
crm/locale/hu.po
3753
crm/locale/hu.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/id.po
Normal file
6392
crm/locale/id.po
Normal file
File diff suppressed because it is too large
Load Diff
6392
crm/locale/it.po
Normal file
6392
crm/locale/it.po
Normal file
File diff suppressed because it is too large
Load Diff
2795
crm/locale/main.pot
2795
crm/locale/main.pot
File diff suppressed because it is too large
Load Diff
6392
crm/locale/nb.po
Normal file
6392
crm/locale/nb.po
Normal file
File diff suppressed because it is too large
Load Diff
6392
crm/locale/nl.po
Normal file
6392
crm/locale/nl.po
Normal file
File diff suppressed because it is too large
Load Diff
3813
crm/locale/pl.po
3813
crm/locale/pl.po
File diff suppressed because it is too large
Load Diff
4353
crm/locale/pt.po
4353
crm/locale/pt.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/pt_BR.po
Normal file
6392
crm/locale/pt_BR.po
Normal file
File diff suppressed because it is too large
Load Diff
3769
crm/locale/ru.po
3769
crm/locale/ru.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/sr.po
Normal file
6392
crm/locale/sr.po
Normal file
File diff suppressed because it is too large
Load Diff
6392
crm/locale/sr_CS.po
Normal file
6392
crm/locale/sr_CS.po
Normal file
File diff suppressed because it is too large
Load Diff
3809
crm/locale/sv.po
3809
crm/locale/sv.po
File diff suppressed because it is too large
Load Diff
4099
crm/locale/th.po
4099
crm/locale/th.po
File diff suppressed because it is too large
Load Diff
3733
crm/locale/tr.po
3733
crm/locale/tr.po
File diff suppressed because it is too large
Load Diff
6392
crm/locale/vi.po
Normal file
6392
crm/locale/vi.po
Normal file
File diff suppressed because it is too large
Load Diff
4687
crm/locale/zh.po
4687
crm/locale/zh.po
File diff suppressed because it is too large
Load Diff
@ -13,4 +13,7 @@ crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_layouts_to_new_format
|
||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
crm.patches.v1_0.update_deal_status_type
|
||||
crm.patches.v1_0.create_default_lost_reasons
|
||||
crm.patches.v1_0.add_fields_in_assignment_rule
|
||||
|
||||
9
crm/patches/v1_0/add_fields_in_assignment_rule.py
Normal file
9
crm/patches/v1_0/add_fields_in_assignment_rule.py
Normal file
@ -0,0 +1,9 @@
|
||||
from crm.install import (
|
||||
add_assignment_rule_property_setters,
|
||||
create_assignment_rule_custom_fields,
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
create_assignment_rule_custom_fields()
|
||||
add_assignment_rule_property_setters()
|
||||
5
crm/patches/v1_0/create_default_lost_reasons.py
Normal file
5
crm/patches/v1_0/create_default_lost_reasons.py
Normal file
@ -0,0 +1,5 @@
|
||||
from crm.install import add_default_lost_reasons
|
||||
|
||||
|
||||
def execute():
|
||||
add_default_lost_reasons()
|
||||
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
@ -0,0 +1,44 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "type", "deal_status"])
|
||||
|
||||
openStatuses = ["New", "Open", "Unassigned", "Qualification"]
|
||||
ongoingStatuses = [
|
||||
"Demo/Making",
|
||||
"Proposal/Quotation",
|
||||
"Negotiation",
|
||||
"Ready to Close",
|
||||
"Demo Scheduled",
|
||||
"Follow Up",
|
||||
]
|
||||
onHoldStatuses = ["On Hold", "Paused", "Stalled", "Awaiting Reply"]
|
||||
wonStatuses = ["Won", "Closed Won", "Successful", "Completed"]
|
||||
lostStatuses = [
|
||||
"Lost",
|
||||
"Closed",
|
||||
"Closed Lost",
|
||||
"Junk",
|
||||
"Unqualified",
|
||||
"Disqualified",
|
||||
"Cancelled",
|
||||
"No Response",
|
||||
]
|
||||
|
||||
for status in deal_statuses:
|
||||
if not status.type or status.type is None or status.type == "Open":
|
||||
if status.deal_status in openStatuses:
|
||||
type = "Open"
|
||||
elif status.deal_status in ongoingStatuses:
|
||||
type = "Ongoing"
|
||||
elif status.deal_status in onHoldStatuses:
|
||||
type = "On Hold"
|
||||
elif status.deal_status in wonStatuses:
|
||||
type = "Won"
|
||||
elif status.deal_status in lostStatuses:
|
||||
type = "Lost"
|
||||
else:
|
||||
type = "Ongoing"
|
||||
|
||||
frappe.db.set_value("CRM Deal Status", status.name, "type", type)
|
||||
@ -1,10 +1,14 @@
|
||||
from frappe import frappe
|
||||
import functools
|
||||
|
||||
import frappe
|
||||
import phonenumbers
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
from frappe.utils import floor
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers import PhoneNumberFormat as PNF
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
|
||||
|
||||
def parse_phone_number(phone_number, default_country="IN"):
|
||||
@ -97,6 +101,7 @@ def seconds_to_duration(seconds):
|
||||
else:
|
||||
return "0s"
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
|
||||
def get_linked_docs(doc, method="Delete"):
|
||||
from frappe.model.rename_doc import get_link_fields
|
||||
@ -161,6 +166,7 @@ def get_linked_docs(doc, method="Delete"):
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
|
||||
def get_dynamic_linked_docs(doc, method="Delete"):
|
||||
docs = []
|
||||
@ -222,3 +228,42 @@ def get_dynamic_linked_docs(doc, method="Delete"):
|
||||
}
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
def is_admin(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an admin
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an admin
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return user == "Administrator"
|
||||
|
||||
|
||||
def is_sales_user(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an agent
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an agent
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return is_admin() or "Sales Manager" in frappe.get_roles(user) or "Sales User" in frappe.get_roles(user)
|
||||
|
||||
|
||||
def sales_user_only(fn):
|
||||
"""Decorator to validate if user is an agent."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_sales_user():
|
||||
frappe.throw(
|
||||
msg=_("You are not permitted to access this resource."),
|
||||
title=_("Not Allowed"),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
files:
|
||||
- source: /crm/locale/main.pot
|
||||
translation: /crm/locale/%two_letters_code%.po
|
||||
pull_request_title: "chore: sync translations from crowdin"
|
||||
pull_request_labels:
|
||||
- translation
|
||||
commit_message: "chore: %language% translations"
|
||||
append_commit_message: false
|
||||
@ -8,21 +8,21 @@ else
|
||||
echo "Creating new bench..."
|
||||
fi
|
||||
|
||||
bench init --skip-redis-config-generation frappe-bench
|
||||
bench init --skip-redis-config-generation frappe-bench --version version-15
|
||||
|
||||
cd frappe-bench
|
||||
|
||||
# Use containers instead of localhost
|
||||
bench set-mariadb-host mariadb
|
||||
bench set-redis-cache-host redis:6379
|
||||
bench set-redis-queue-host redis:6379
|
||||
bench set-redis-socketio-host redis:6379
|
||||
bench set-redis-cache-host redis://redis:6379
|
||||
bench set-redis-queue-host redis://redis:6379
|
||||
bench set-redis-socketio-host redis://redis:6379
|
||||
|
||||
# Remove redis, watch from Procfile
|
||||
sed -i '/redis/d' ./Procfile
|
||||
sed -i '/watch/d' ./Procfile
|
||||
|
||||
bench get-app crm --branch develop
|
||||
bench get-app crm --branch main
|
||||
|
||||
bench new-site crm.localhost \
|
||||
--force \
|
||||
@ -32,8 +32,9 @@ bench new-site crm.localhost \
|
||||
|
||||
bench --site crm.localhost install-app crm
|
||||
bench --site crm.localhost set-config developer_mode 1
|
||||
bench --site crm.localhost clear-cache
|
||||
bench --site crm.localhost set-config mute_emails 1
|
||||
bench --site crm.localhost set-config server_script_enabled 1
|
||||
bench --site crm.localhost clear-cache
|
||||
bench use crm.localhost
|
||||
|
||||
bench start
|
||||
@ -1 +1 @@
|
||||
Subproject commit 424288f77af4779dd3bb71dc3d278fc627f95179
|
||||
Subproject commit c9a0fc937cc897864857271b3708a0c675379015
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@ -2,4 +2,5 @@ node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.local
|
||||
components.d.ts
|
||||
10
frontend/auto-imports.d.ts
vendored
Normal file
10
frontend/auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
23
frontend/components.d.ts
vendored
23
frontend/components.d.ts
vendored
@ -12,6 +12,7 @@ declare module 'vue' {
|
||||
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
||||
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
||||
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
||||
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
|
||||
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
|
||||
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
|
||||
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
|
||||
@ -24,6 +25,7 @@ declare module 'vue' {
|
||||
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
|
||||
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.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']
|
||||
@ -31,6 +33,7 @@ declare module 'vue' {
|
||||
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']
|
||||
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
|
||||
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
|
||||
@ -60,8 +63,11 @@ declare module 'vue' {
|
||||
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||
CurrencySettings: typeof import('./src/components/Settings/CurrencySettings.vue')['default']
|
||||
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
||||
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
|
||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
|
||||
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
|
||||
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
|
||||
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
|
||||
@ -79,7 +85,6 @@ declare module 'vue' {
|
||||
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']
|
||||
DropdownItem: typeof import('./src/components/DropdownItem.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']
|
||||
@ -98,11 +103,9 @@ declare module 'vue' {
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||
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']
|
||||
@ -124,10 +127,10 @@ declare module 'vue' {
|
||||
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
||||
Filter: typeof import('./src/components/Filter.vue')['default']
|
||||
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
|
||||
ForecastingSettings: typeof import('./src/components/Settings/ForecastingSettings.vue')['default']
|
||||
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
||||
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||
@ -138,6 +141,7 @@ declare module 'vue' {
|
||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.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']
|
||||
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
|
||||
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
|
||||
@ -162,12 +166,8 @@ declare module 'vue' {
|
||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
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']
|
||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['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']
|
||||
@ -182,7 +182,6 @@ declare module 'vue' {
|
||||
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']
|
||||
NestedPopover: typeof import('./src/components/NestedPopover.vue')['default']
|
||||
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
|
||||
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
|
||||
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
|
||||
@ -201,7 +200,8 @@ declare module 'vue' {
|
||||
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']
|
||||
ProfileImageEditor: typeof import('./src/components/Settings/ProfileImageEditor.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']
|
||||
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
|
||||
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
|
||||
@ -228,6 +228,7 @@ declare module 'vue' {
|
||||
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
||||
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
||||
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
|
||||
SparkleIcon: typeof import('./src/components/Icons/SparkleIcon.vue')['default']
|
||||
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
|
||||
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
|
||||
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.162",
|
||||
"frappe-ui": "^0.1.201",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout v-if="session().isLoggedIn">
|
||||
<router-view />
|
||||
<router-view :key="$route.fullPath"/>
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
</FrappeUIProvider>
|
||||
|
||||
@ -50,11 +50,13 @@
|
||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 sm:px-10"
|
||||
>
|
||||
<div
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||
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 != activities.length - 1 ? 'before:h-full' : 'before:h-4'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white"
|
||||
class="flex h-8 w-7 items-center justify-center bg-surface-white"
|
||||
>
|
||||
<CommentIcon class="text-ink-gray-8" />
|
||||
</div>
|
||||
@ -72,11 +74,13 @@
|
||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
|
||||
>
|
||||
<div
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||
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 != activities.length - 1 ? 'before:h-full' : 'before:h-4'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
||||
class="flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
||||
>
|
||||
<MissedCallIcon
|
||||
v-if="call.status == 'No Answer'"
|
||||
@ -116,11 +120,11 @@
|
||||
>
|
||||
<div
|
||||
v-if="['Activity', 'Emails'].includes(title)"
|
||||
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-outline-gray-modals"
|
||||
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 != activities.length - 1 ? 'before:h-full' : 'before:h-4']"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-7 w-7 items-center justify-center bg-surface-white"
|
||||
class="flex h-7 w-7 items-center justify-center bg-surface-white"
|
||||
:class="{
|
||||
'mt-2.5': ['communication'].includes(activity.activity_type),
|
||||
'bg-surface-white': ['added', 'removed', 'changed'].includes(
|
||||
@ -234,12 +238,9 @@
|
||||
<Button
|
||||
class="!size-4"
|
||||
variant="ghost"
|
||||
:icon="SelectIcon"
|
||||
@click="activity.show_others = !activity.show_others"
|
||||
>
|
||||
<template #icon>
|
||||
<SelectIcon />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@ -367,7 +368,8 @@
|
||||
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
|
||||
<DataFields
|
||||
:doctype="doctype"
|
||||
:docname="doc.data.name"
|
||||
:docname="docname"
|
||||
@beforeSave="(data) => emit('beforeSave', data)"
|
||||
@afterSave="(data) => emit('afterSave', data)"
|
||||
/>
|
||||
</div>
|
||||
@ -437,10 +439,9 @@
|
||||
:doc="doc"
|
||||
/>
|
||||
<FilesUploader
|
||||
v-if="doc.data?.name"
|
||||
v-model="showFilesUploader"
|
||||
:doctype="doctype"
|
||||
:docname="doc.data.name"
|
||||
:docname="docname"
|
||||
@after="
|
||||
() => {
|
||||
all_activities.reload()
|
||||
@ -489,6 +490,7 @@ import { timeAgo, formatDate, startCase } from '@/utils'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
@ -512,20 +514,27 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['afterSave'])
|
||||
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const doc = defineModel()
|
||||
const reload = defineModel('reload')
|
||||
const tabIndex = defineModel('tabIndex')
|
||||
|
||||
const { document: _document } = useDocument(props.doctype, props.docname)
|
||||
|
||||
const doc = computed(() => _document.doc || {})
|
||||
|
||||
const reload_email = ref(false)
|
||||
const modalRef = ref(null)
|
||||
const showFilesUploader = ref(false)
|
||||
@ -541,24 +550,25 @@ const changeTabTo = (tabName) => {
|
||||
|
||||
const all_activities = createResource({
|
||||
url: 'crm.api.activities.get_activities',
|
||||
params: { name: doc.value.data.name },
|
||||
cache: ['activity', doc.value.data.name],
|
||||
params: { name: props.docname },
|
||||
cache: ['activity', props.docname],
|
||||
auto: true,
|
||||
transform: ([versions, calls, notes, tasks, attachments]) => {
|
||||
return { versions, calls, notes, tasks, attachments }
|
||||
},
|
||||
onSuccess: () => nextTick(() => scroll()),
|
||||
})
|
||||
|
||||
const showWhatsappTemplates = ref(false)
|
||||
|
||||
const whatsappMessages = createResource({
|
||||
url: 'crm.api.whatsapp.get_whatsapp_messages',
|
||||
cache: ['whatsapp_messages', doc.value.data.name],
|
||||
cache: ['whatsapp_messages', props.docname],
|
||||
params: {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
reference_name: props.docname,
|
||||
},
|
||||
auto: true,
|
||||
auto: whatsappEnabled.value,
|
||||
transform: (data) => sortByCreation(data),
|
||||
onSuccess: () => nextTick(() => scroll()),
|
||||
})
|
||||
@ -571,7 +581,7 @@ onMounted(() => {
|
||||
$socket.on('whatsapp_message', (data) => {
|
||||
if (
|
||||
data.reference_doctype === props.doctype &&
|
||||
data.reference_name === doc.value.data.name
|
||||
data.reference_name === props.docname
|
||||
) {
|
||||
whatsappMessages.reload()
|
||||
}
|
||||
@ -593,8 +603,8 @@ function sendTemplate(template) {
|
||||
url: 'crm.api.whatsapp.send_whatsapp_template',
|
||||
params: {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
to: doc.value.data.mobile_no,
|
||||
reference_name: props.docname,
|
||||
to: doc.value.mobile_no,
|
||||
template,
|
||||
},
|
||||
auto: true,
|
||||
@ -766,6 +776,7 @@ const whatsappBox = ref(null)
|
||||
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
|
||||
if (reload_value || reload_email_value) {
|
||||
all_activities.reload()
|
||||
_document.reload()
|
||||
reload.value = false
|
||||
reload_email.value = false
|
||||
}
|
||||
@ -791,12 +802,12 @@ function scroll(hash) {
|
||||
const callActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
label: __('Create Call Log'),
|
||||
label: __('Log a Call'),
|
||||
onClick: () => modalRef.value.createCallLog(),
|
||||
},
|
||||
{
|
||||
label: __('Make a Call'),
|
||||
onClick: () => makeCall(doc.data.mobile_no),
|
||||
onClick: () => makeCall(doc.value.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
@ -9,23 +9,17 @@
|
||||
<Button
|
||||
v-if="title == 'Emails'"
|
||||
variant="solid"
|
||||
:label="__('New Email')"
|
||||
iconLeft="plus"
|
||||
@click="emailBox.show = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New Email') }}</span>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Comments'"
|
||||
variant="solid"
|
||||
:label="__('New Comment')"
|
||||
iconLeft="plus"
|
||||
@click="emailBox.showComment = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New Comment') }}</span>
|
||||
</Button>
|
||||
/>
|
||||
<MultiActionButton
|
||||
v-else-if="title == 'Calls'"
|
||||
variant="solid"
|
||||
@ -34,59 +28,45 @@
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
variant="solid"
|
||||
:label="__('New Note')"
|
||||
iconLeft="plus"
|
||||
@click="modalRef.showNote()"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New Note') }}</span>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Tasks'"
|
||||
variant="solid"
|
||||
:label="__('New Task')"
|
||||
iconLeft="plus"
|
||||
@click="modalRef.showTask()"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New Task') }}</span>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Attachments'"
|
||||
variant="solid"
|
||||
:label="__('Upload Attachment')"
|
||||
iconLeft="plus"
|
||||
@click="showFilesUploader = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('Upload Attachment') }}</span>
|
||||
</Button>
|
||||
/>
|
||||
<div class="flex gap-2 shrink-0" v-else-if="title == 'WhatsApp'">
|
||||
<Button
|
||||
:label="__('Send Template')"
|
||||
@click="showWhatsappTemplates = true"
|
||||
/>
|
||||
<Button variant="solid" @click="whatsappBox.show()">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New Message') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="__('New Message')"
|
||||
iconLeft="plus"
|
||||
@click="whatsappBox.show()"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown v-else :options="defaultActions" @click.stop>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid" class="flex items-center gap-1">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New') }}</span>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
class="flex items-center gap-1"
|
||||
:label="__('New')"
|
||||
iconLeft="plus"
|
||||
:iconRight="open ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@ -134,13 +114,13 @@ const defaultActions = computed(() => {
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Create Call Log'),
|
||||
label: __('Log a Call'),
|
||||
onClick: () => props.modalRef.createCallLog(),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Make a Call'),
|
||||
onClick: () => makeCall(props.doc.data.mobile_no),
|
||||
onClick: () => makeCall(props.doc.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
@ -177,14 +157,14 @@ function getTabIndex(name) {
|
||||
const callActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
label: __('Create Call Log'),
|
||||
label: __('Log a Call'),
|
||||
icon: 'plus',
|
||||
onClick: () => props.modalRef.createCallLog(),
|
||||
},
|
||||
{
|
||||
label: __('Make a Call'),
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
onClick: () => makeCall(props.doc.data.mobile_no),
|
||||
onClick: () => makeCall(props.doc.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
v-model:reloadTasks="activities"
|
||||
:task="task"
|
||||
:doctype="doctype"
|
||||
:doc="doc.data?.name"
|
||||
:doc="doc?.name"
|
||||
@after="redirect('tasks')"
|
||||
/>
|
||||
<NoteModal
|
||||
@ -12,7 +12,7 @@
|
||||
v-model:reloadNotes="activities"
|
||||
:note="note"
|
||||
:doctype="doctype"
|
||||
:doc="doc.data?.name"
|
||||
:doc="doc?.name"
|
||||
@after="redirect('notes')"
|
||||
/>
|
||||
<CallLogModal
|
||||
@ -92,8 +92,8 @@ const referenceDoc = ref({})
|
||||
|
||||
function createCallLog() {
|
||||
let doctype = props.doctype
|
||||
let docname = props.doc.data?.name
|
||||
referenceDoc.value = { ...props.doc.data }
|
||||
let docname = props.doc?.name
|
||||
referenceDoc.value = { ...props.doc }
|
||||
callLog.value = {
|
||||
reference_doctype: doctype,
|
||||
reference_docname: docname,
|
||||
|
||||
@ -38,35 +38,31 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-1">
|
||||
<Tooltip
|
||||
:text="
|
||||
<Button
|
||||
:tooltip="
|
||||
attachment.is_private ? __('Make public') : __('Make private')
|
||||
"
|
||||
class="!size-5"
|
||||
@click.stop="
|
||||
togglePrivate(attachment.name, attachment.is_private)
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
class="!size-5"
|
||||
@click.stop="
|
||||
togglePrivate(attachment.name, attachment.is_private)
|
||||
"
|
||||
>
|
||||
<FeatherIcon
|
||||
:name="attachment.is_private ? 'lock' : 'unlock'"
|
||||
class="size-3 text-ink-gray-7"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Delete attachment')">
|
||||
<div>
|
||||
<Button
|
||||
class="!size-5"
|
||||
@click.stop="() => deleteAttachment(attachment.name)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<template #icon>
|
||||
<FeatherIcon
|
||||
:name="attachment.is_private ? 'lock' : 'unlock'"
|
||||
class="size-3 text-ink-gray-7"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
:tooltip="__('Delete attachment')"
|
||||
class="!size-5"
|
||||
@click.stop="() => deleteAttachment(attachment.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full text-sm text-ink-gray-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" @click="playPause">
|
||||
<template #icon>
|
||||
<PlayIcon v-if="isPaused" class="size-4 text-ink-gray-5" />
|
||||
<PauseIcon v-else class="size-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-ink-gray-5"
|
||||
:icon="isPaused ? PlayIcon : PauseIcon"
|
||||
@click="playPause"
|
||||
/>
|
||||
<div class="flex gap-2 items-center justify-between flex-1">
|
||||
<input
|
||||
class="w-full slider !h-[0.5] bg-surface-gray-3 [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
|
||||
@ -61,11 +61,11 @@
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown :options="options">
|
||||
<Button variant="ghost" @click="showPlaybackSpeed = false">
|
||||
<template #icon>
|
||||
<FeatherIcon class="size-4" name="more-horizontal" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="showPlaybackSpeed = false"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div @click="showCallLogDetailModal = true" class="cursor-pointer">
|
||||
<div>
|
||||
<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
|
||||
@ -25,7 +25,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 border border-outline-gray-modals rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
||||
@click="showCallLogDetailModal = true"
|
||||
class="flex flex-col gap-2 border cursor-pointer border-outline-gray-modals rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex gap-2 items-center text-base font-medium">
|
||||
|
||||
@ -14,12 +14,10 @@
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
v-if="isManager() && !isMobileView"
|
||||
:tooltip="__('Edit fields layout')"
|
||||
:icon="EditIcon"
|
||||
@click="showDataFieldsModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
label="Save"
|
||||
:disabled="!document.isDirty"
|
||||
@ -66,7 +64,7 @@ import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, getCurrentInstance } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -79,10 +77,13 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['afterSave'])
|
||||
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const attrs = instance?.vnode?.props ?? {}
|
||||
|
||||
const showDataFieldsModal = ref(false)
|
||||
|
||||
const { document } = useDocument(props.doctype, props.docname)
|
||||
@ -107,9 +108,15 @@ function saveChanges() {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
document.save.submit(null, {
|
||||
onSuccess: () => emit('afterSave', changes),
|
||||
})
|
||||
const hasListener = attrs['onBeforeSave'] !== undefined
|
||||
|
||||
if (hasListener) {
|
||||
emit('beforeSave', changes)
|
||||
} else {
|
||||
document.save.submit(null, {
|
||||
onSuccess: () => emit('afterSave', changes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
<div
|
||||
class="cursor-pointer flex flex-col rounded-md shadow bg-surface-cards px-3 py-1.5 text-base transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="-mb-0.5 flex items-center justify-between gap-2 truncate text-ink-gray-9">
|
||||
<div
|
||||
class="-mb-0.5 flex items-center justify-between gap-2 truncate text-ink-gray-9"
|
||||
>
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<span>{{ activity.data.sender_full_name }}</span>
|
||||
<span class="sm:flex hidden text-sm text-ink-gray-5">
|
||||
@ -28,32 +30,20 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-0.5">
|
||||
<Tooltip :text="__('Reply')">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-ink-gray-7"
|
||||
@click="reply(activity.data)"
|
||||
>
|
||||
<template #icon>
|
||||
<ReplyIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip :text="__('Reply All')">
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-ink-gray-7"
|
||||
@click="reply(activity.data, true)"
|
||||
>
|
||||
<template #icon>
|
||||
<ReplyAllIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
:tooltip="__('Reply')"
|
||||
variant="ghost"
|
||||
class="text-ink-gray-7"
|
||||
:icon="ReplyIcon"
|
||||
@click="reply(activity.data)"
|
||||
/>
|
||||
<Button
|
||||
:tooltip="__('Reply All')"
|
||||
variant="ghost"
|
||||
:icon="ReplyAllIcon"
|
||||
class="text-ink-gray-7"
|
||||
@click="reply(activity.data, true)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -41,13 +41,13 @@
|
||||
:options="taskStatusOptions(modalRef.updateTaskStatus, task)"
|
||||
@click.stop
|
||||
>
|
||||
<Tooltip :text="__('Change Status')">
|
||||
<div>
|
||||
<Button variant="ghosted" class="hover:bg-surface-gray-4">
|
||||
<TaskStatusIcon :status="task.status" />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button
|
||||
:tooltip="__('Change status')"
|
||||
variant="ghosted"
|
||||
class="hover:bg-surface-gray-4"
|
||||
>
|
||||
<TaskStatusIcon :status="task.status" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
:options="[
|
||||
|
||||
@ -107,9 +107,9 @@ function sendTextMessage(event) {
|
||||
async function sendWhatsAppMessage() {
|
||||
let args = {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
reference_name: doc.value.name,
|
||||
message: content.value,
|
||||
to: doc.value.data.mobile_no,
|
||||
to: doc.value.mobile_no,
|
||||
attach: whatsapp.value.attach || '',
|
||||
reply_to: reply.value?.name || '',
|
||||
content_type: whatsapp.value.content_type,
|
||||
|
||||
@ -1,31 +1,99 @@
|
||||
<template>
|
||||
<component
|
||||
v-if="assignees?.length"
|
||||
:is="assignees?.length == 1 ? 'Button' : 'div'"
|
||||
>
|
||||
<MultipleAvatar :avatars="assignees" @click="showAssignmentModal = true" />
|
||||
</component>
|
||||
<Button v-else @click="showAssignmentModal = true">
|
||||
{{ __('Assign to') }}
|
||||
</Button>
|
||||
<AssignmentModal
|
||||
v-if="showAssignmentModal"
|
||||
v-model="showAssignmentModal"
|
||||
v-model:assignees="assignees"
|
||||
:doctype="doctype"
|
||||
:doc="data"
|
||||
/>
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<div class="flex items-center" @click="togglePopover">
|
||||
<component
|
||||
v-if="assignees?.length"
|
||||
:is="assignees?.length == 1 ? 'Button' : 'div'"
|
||||
>
|
||||
<MultipleAvatar :avatars="assignees" />
|
||||
</component>
|
||||
<Button v-else :label="__('Assign to')" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<AssignToBody
|
||||
v-show="isOpen"
|
||||
v-model="assignees"
|
||||
:docname="docname"
|
||||
:doctype="doctype"
|
||||
:open="isOpen"
|
||||
:onUpdate="ownerField && saveAssignees"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||
import { ref } from 'vue'
|
||||
import AssignToBody from '@/components/AssignToBody.vue'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { toast, Popover } from 'frappe-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object,
|
||||
doctype: String,
|
||||
docname: String,
|
||||
})
|
||||
|
||||
const showAssignmentModal = ref(false)
|
||||
const { document } = useDocument(props.doctype, props.docname)
|
||||
|
||||
const assignees = defineModel()
|
||||
|
||||
const ownerField = computed(() => {
|
||||
if (props.doctype === 'CRM Lead') {
|
||||
return 'lead_owner'
|
||||
} else if (props.doctype === 'CRM Deal') {
|
||||
return 'deal_owner'
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
async function saveAssignees(
|
||||
addedAssignees,
|
||||
removedAssignees,
|
||||
addAssignees,
|
||||
removeAssignees,
|
||||
) {
|
||||
removedAssignees.length && (await removeAssignees.submit(removedAssignees))
|
||||
addedAssignees.length && (await addAssignees.submit(addedAssignees))
|
||||
|
||||
const nextAssignee = assignees.value.find(
|
||||
(a) => a.name !== document.doc[ownerField.value],
|
||||
)
|
||||
|
||||
let owner = ownerField.value.replace('_', ' ')
|
||||
|
||||
if (
|
||||
document.doc[ownerField.value] &&
|
||||
removedAssignees.includes(document.doc[ownerField.value])
|
||||
) {
|
||||
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
|
||||
document.save.submit()
|
||||
|
||||
if (nextAssignee) {
|
||||
toast.info(
|
||||
__(
|
||||
'Since you removed {0} from the assignee, the {0} has been changed to the next available assignee {1}.',
|
||||
[owner, nextAssignee.label || nextAssignee.name],
|
||||
),
|
||||
)
|
||||
} else {
|
||||
toast.info(
|
||||
__(
|
||||
'Since you removed {0} from the assignee, the {0} has also been removed.',
|
||||
[owner],
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (!document.doc[ownerField.value] && nextAssignee) {
|
||||
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
|
||||
toast.info(
|
||||
__('Since you added a new assignee, the {0} has been set to {1}.', [
|
||||
owner,
|
||||
nextAssignee.label || nextAssignee.name,
|
||||
]),
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
211
frontend/src/components/AssignToBody.vue
Normal file
211
frontend/src/components/AssignToBody.vue
Normal file
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 my-2 w-[470px] rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black p-3 ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div class="text-base text-ink-gray-5">{{ __('Assign to') }}</div>
|
||||
<Link
|
||||
class="form-control"
|
||||
value=""
|
||||
doctype="User"
|
||||
@change="(option) => addValue(option) && ($refs.input.value = '')"
|
||||
:placeholder="__('John Doe')"
|
||||
:filters="{
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
}"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="w-full min-h-12 flex flex-wrap items-center gap-1.5 p-1.5 pb-5 rounded-lg bg-surface-gray-2 cursor-text"
|
||||
@click.stop="togglePopover"
|
||||
>
|
||||
<Tooltip
|
||||
:text="assignee.name"
|
||||
v-for="assignee in assignees"
|
||||
:key="assignee.name"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
class="flex items-center text-sm p-0.5 text-ink-gray-6 border border-outline-gray-1 bg-surface-modal rounded-full cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<UserAvatar :user="assignee.name" size="sm" />
|
||||
<div class="ml-1">{{ getUser(assignee.name).full_name }}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-full !size-4 m-1"
|
||||
@click.stop="removeValue(assignee.name)"
|
||||
>
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="h-3 w-3 text-ink-gray-6" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div
|
||||
class="text-base text-ink-gray-5 cursor-pointer select-none"
|
||||
@click="assignToMe = !assignToMe"
|
||||
>
|
||||
{{ __('Assign to me') }}
|
||||
</div>
|
||||
<Switch v-model="assignToMe" @click.stop />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Tooltip, Switch, createResource } from 'frappe-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
docname: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onUpdate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
const assignees = defineModel()
|
||||
const oldAssignees = ref([])
|
||||
const assignToMe = ref(false)
|
||||
|
||||
const error = ref('')
|
||||
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
const removeValue = (value) => {
|
||||
if (value === getUser('').name) {
|
||||
assignToMe.value = false
|
||||
}
|
||||
|
||||
assignees.value = assignees.value.filter(
|
||||
(assignee) => assignee.name !== value,
|
||||
)
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
if (value === getUser('').name) {
|
||||
assignToMe.value = true
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
let obj = {
|
||||
name: value,
|
||||
image: getUser(value).user_image,
|
||||
label: getUser(value).full_name,
|
||||
}
|
||||
if (!assignees.value.find((assignee) => assignee.name === value)) {
|
||||
assignees.value.push(obj)
|
||||
}
|
||||
}
|
||||
|
||||
watch(assignToMe, (val) => {
|
||||
let user = getUser('')
|
||||
if (val) {
|
||||
addValue(user.name)
|
||||
} else {
|
||||
removeValue(user.name)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val) {
|
||||
oldAssignees.value = [...(assignees.value || [])]
|
||||
|
||||
assignToMe.value = assignees.value.some(
|
||||
(assignee) => assignee.name === getUser('').name,
|
||||
)
|
||||
} else {
|
||||
updateAssignees()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function updateAssignees() {
|
||||
if (JSON.stringify(oldAssignees.value) === JSON.stringify(assignees.value))
|
||||
return
|
||||
|
||||
const removedAssignees = oldAssignees.value
|
||||
.filter(
|
||||
(assignee) => !assignees.value.find((a) => a.name === assignee.name),
|
||||
)
|
||||
.map((assignee) => assignee.name)
|
||||
|
||||
const addedAssignees = assignees.value
|
||||
.filter(
|
||||
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name),
|
||||
)
|
||||
.map((assignee) => assignee.name)
|
||||
|
||||
if (props.onUpdate) {
|
||||
props.onUpdate(
|
||||
addedAssignees,
|
||||
removedAssignees,
|
||||
addAssignees,
|
||||
removeAssignees,
|
||||
)
|
||||
} else {
|
||||
if (removedAssignees.length) {
|
||||
await removeAssignees.submit(removedAssignees)
|
||||
}
|
||||
if (addedAssignees.length) {
|
||||
addAssignees.submit(addedAssignees)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addAssignees = createResource({
|
||||
url: 'frappe.desk.form.assign_to.add',
|
||||
makeParams: (addedAssignees) => ({
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
assign_to: addedAssignees,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
capture('assign_to', { doctype: props.doctype })
|
||||
},
|
||||
})
|
||||
|
||||
const removeAssignees = createResource({
|
||||
url: 'crm.api.doc.remove_assignments',
|
||||
makeParams: (removedAssignees) => ({
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
assignees: removedAssignees,
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
@ -5,11 +5,9 @@
|
||||
:label="label"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:iconLeft="getIcon()"
|
||||
@click="toggleDialog()"
|
||||
>
|
||||
<template #prefix>
|
||||
<component :is="getIcon()" class="h-4 w-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5">
|
||||
<div class="text-ink-gray-5 text-base">
|
||||
{{
|
||||
__('Are you sure you want to delete {0} items?', [
|
||||
props.items?.length,
|
||||
@ -53,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5">
|
||||
<div class="text-ink-gray-5 text-base">
|
||||
{{
|
||||
confirmDeleteInfo.delete
|
||||
? __(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<NestedPopover>
|
||||
<template #target>
|
||||
<Button :label="__('Columns')">
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<Button :label="__('Columns')" @click="togglePopover">
|
||||
<template v-if="hideLabel">
|
||||
<ColumnsIcon class="h-4" />
|
||||
</template>
|
||||
@ -65,37 +65,28 @@
|
||||
<Button
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Column')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
iconLeft="plus"
|
||||
@click="togglePopover"
|
||||
/>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="columnsUpdated"
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="reset(close)"
|
||||
:label="__('Reset Changes')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ReloadIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
:iconLeft="ReloadIcon"
|
||||
@click="reset(close)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!is_default"
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="resetToDefault(close)"
|
||||
:label="__('Reset to Default')"
|
||||
>
|
||||
<template #prefix>
|
||||
<ReloadIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
:iconLeft="ReloadIcon"
|
||||
@click="resetToDefault(close)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
@ -144,7 +135,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NestedPopover>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -152,9 +143,9 @@ import ColumnsIcon from '@/components/Icons/ColumnsIcon.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DragIcon from '@/components/Icons/DragIcon.vue'
|
||||
import ReloadIcon from '@/components/Icons/ReloadIcon.vue'
|
||||
import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import { isTouchScreenDevice } from '@/utils'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { computed, ref } from 'vue'
|
||||
import { watchOnce } from '@vueuse/core'
|
||||
@ -219,6 +210,7 @@ const fields = computed(() => {
|
||||
})
|
||||
|
||||
function addColumn(c) {
|
||||
if (!c) return
|
||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
|
||||
? 'right'
|
||||
: 'left'
|
||||
|
||||
@ -45,11 +45,12 @@
|
||||
v-slot="{ togglePopover }"
|
||||
@update:modelValue="() => appendEmoji()"
|
||||
>
|
||||
<Button variant="ghost" @click="togglePopover()">
|
||||
<template #icon>
|
||||
<SmileIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
:tooltip="__('Insert Emoji')"
|
||||
:icon="SmileIcon"
|
||||
variant="ghost"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</IconPicker>
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
@ -61,14 +62,11 @@
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button
|
||||
theme="gray"
|
||||
:tooltip="__('Attach a file')"
|
||||
variant="ghost"
|
||||
:icon="AttachmentIcon"
|
||||
@click="openFileSelector()"
|
||||
>
|
||||
<template #icon>
|
||||
<AttachmentIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
|
||||
@ -8,24 +8,18 @@
|
||||
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||
]"
|
||||
:label="__('Reply')"
|
||||
:iconLeft="Email2Icon"
|
||||
@click="toggleEmailBox()"
|
||||
>
|
||||
<template #prefix>
|
||||
<Email2Icon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
:label="__('Comment')"
|
||||
:class="[
|
||||
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||
]"
|
||||
:iconLeft="CommentIcon"
|
||||
@click="toggleCommentBox()"
|
||||
>
|
||||
<template #prefix>
|
||||
<CommentIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -45,7 +39,7 @@
|
||||
onClick: () => {
|
||||
showEmailBox = false
|
||||
newEmailEditor.subject = subject
|
||||
newEmailEditor.toEmails = doc.data.email ? [doc.data.email] : []
|
||||
newEmailEditor.toEmails = doc.email ? [doc.email] : []
|
||||
newEmailEditor.ccEmails = []
|
||||
newEmailEditor.bccEmails = []
|
||||
newEmailEditor.cc = false
|
||||
@ -54,7 +48,7 @@
|
||||
},
|
||||
}"
|
||||
:editable="showEmailBox"
|
||||
v-model="doc.data"
|
||||
v-model="doc"
|
||||
v-model:attachments="attachments"
|
||||
:doctype="doctype"
|
||||
:subject="subject"
|
||||
@ -79,7 +73,7 @@
|
||||
},
|
||||
}"
|
||||
:editable="showCommentBox"
|
||||
v-model="doc.data"
|
||||
v-model="doc"
|
||||
v-model:attachments="attachments"
|
||||
:doctype="doctype"
|
||||
:placeholder="__('@John, can you please check this?')"
|
||||
@ -125,12 +119,12 @@ const attachments = ref([])
|
||||
|
||||
const subject = computed(() => {
|
||||
let prefix = ''
|
||||
if (doc.value.data?.lead_name) {
|
||||
prefix = doc.value.data.lead_name
|
||||
} else if (doc.value.data?.organization) {
|
||||
prefix = doc.value.data.organization
|
||||
if (doc.value?.lead_name) {
|
||||
prefix = doc.value.lead_name
|
||||
} else if (doc.value?.organization) {
|
||||
prefix = doc.value.organization
|
||||
}
|
||||
return `${prefix} (#${doc.value.data.name})`
|
||||
return `${prefix} (#${doc.value.name})`
|
||||
})
|
||||
|
||||
const signature = createResource({
|
||||
@ -199,7 +193,7 @@ async function sendMail() {
|
||||
subject: subject,
|
||||
content: newEmail.value,
|
||||
doctype: props.doctype,
|
||||
name: doc.value.data.name,
|
||||
name: doc.value.name,
|
||||
send_email: 1,
|
||||
sender: getUser().email,
|
||||
sender_full_name: getUser()?.full_name || undefined,
|
||||
@ -209,7 +203,7 @@ async function sendMail() {
|
||||
async function sendComment() {
|
||||
let comment = await call('frappe.desk.form.utils.add_comment', {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
reference_name: doc.value.name,
|
||||
content: newComment.value,
|
||||
comment_email: getUser().email,
|
||||
comment_by: getUser()?.full_name || undefined,
|
||||
|
||||
454
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal file
454
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal file
@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="[
|
||||
{
|
||||
'items-center': !props.isGroup,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 w-full"
|
||||
:class="[
|
||||
{
|
||||
'items-center justify-between': !props.isGroup,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div :class="'text-end text-base text-gray-600'">
|
||||
<div v-if="props.itemIndex == 0" class="min-w-[66px] text-start">
|
||||
{{ __('Where') }}
|
||||
</div>
|
||||
<div v-else class="min-w-[66px] flex items-start">
|
||||
<Button
|
||||
variant="subtle"
|
||||
class="w-max"
|
||||
@click="toggleConjunction"
|
||||
icon-right="refresh-cw"
|
||||
:disabled="props.itemIndex > 2"
|
||||
:label="conjunction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!props.isGroup" class="flex items-center gap-2 w-full">
|
||||
<div id="fieldname" class="w-full">
|
||||
<Autocomplete
|
||||
:options="filterableFields.data"
|
||||
v-model="props.condition[0]"
|
||||
:placeholder="__('Field')"
|
||||
@update:modelValue="updateField"
|
||||
/>
|
||||
</div>
|
||||
<div id="operator">
|
||||
<FormControl
|
||||
v-if="!props.condition[0]"
|
||||
disabled
|
||||
type="text"
|
||||
:placeholder="__('operator')"
|
||||
class="w-[100px]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
:disabled="!props.condition[0]"
|
||||
type="select"
|
||||
v-model="props.condition[1]"
|
||||
@change="updateOperator"
|
||||
:options="getOperators()"
|
||||
class="w-max min-w-[100px]"
|
||||
/>
|
||||
</div>
|
||||
<div id="value" class="w-full">
|
||||
<FormControl
|
||||
v-if="!props.condition[0]"
|
||||
disabled
|
||||
type="text"
|
||||
:placeholder="__('condition')"
|
||||
class="w-full"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="getValueControl()"
|
||||
v-model="props.condition[2]"
|
||||
@change="updateValue"
|
||||
:placeholder="__('condition')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CFConditions
|
||||
v-if="props.isGroup && !(props.level == 2 || props.level == 4)"
|
||||
:conditions="props.condition"
|
||||
:isChild="true"
|
||||
:level="props.level"
|
||||
:disableAddCondition="props.disableAddCondition"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="props.isGroup && (props.level == 2 || props.level == 4)"
|
||||
@click="show = true"
|
||||
:label="__('Open nested conditions')"
|
||||
/>
|
||||
</div>
|
||||
<div :class="'w-max'">
|
||||
<Dropdown placement="right" :options="dropdownOptions">
|
||||
<Button variant="ghost" icon="more-horizontal" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ size: '3xl', title: __('Nested conditions') }"
|
||||
>
|
||||
<template #body-content>
|
||||
<CFConditions
|
||||
:conditions="props.condition"
|
||||
:isChild="true"
|
||||
:level="props.level"
|
||||
:disableAddCondition="props.disableAddCondition"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
DatePicker,
|
||||
DateRangePicker,
|
||||
DateTimePicker,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Rating,
|
||||
} from 'frappe-ui'
|
||||
import { computed, defineEmits, h, ref } from 'vue'
|
||||
import GroupIcon from '~icons/lucide/group'
|
||||
import UnGroupIcon from '~icons/lucide/ungroup'
|
||||
import CFConditions from './CFConditions.vue'
|
||||
import { filterableFields } from './filterableFields'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = ref(false)
|
||||
const emit = defineEmits([
|
||||
'remove',
|
||||
'unGroupConditions',
|
||||
'toggleConjunction',
|
||||
'turnIntoGroup',
|
||||
])
|
||||
|
||||
const props = defineProps({
|
||||
condition: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isChild: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
itemIndex: {
|
||||
type: Number,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isGroup: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conjunction: {
|
||||
type: String,
|
||||
},
|
||||
disableAddCondition: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const dropdownOptions = computed(() => {
|
||||
const options = []
|
||||
|
||||
if (!props.isGroup && props.level < 4) {
|
||||
options.push({
|
||||
label: __('Turn into a group'),
|
||||
icon: () => h(GroupIcon),
|
||||
onClick: () => {
|
||||
emit('turnIntoGroup')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (props.isGroup) {
|
||||
options.push({
|
||||
label: __('Ungroup conditions'),
|
||||
icon: () => h(UnGroupIcon),
|
||||
onClick: () => {
|
||||
emit('unGroupConditions')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: __('Remove'),
|
||||
icon: 'trash-2',
|
||||
variant: 'red',
|
||||
onClick: () => emit('remove'),
|
||||
condition: () => !props.isGroup,
|
||||
})
|
||||
|
||||
options.push({
|
||||
label: __('Remove group'),
|
||||
icon: 'trash-2',
|
||||
variant: 'red',
|
||||
onClick: () => emit('remove'),
|
||||
condition: () => props.isGroup,
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const typeCheck = ['Check']
|
||||
const typeLink = ['Link', 'Dynamic Link']
|
||||
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
|
||||
const typeSelect = ['Select']
|
||||
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
|
||||
const typeDate = ['Date', 'Datetime']
|
||||
const typeRating = ['Rating']
|
||||
|
||||
function toggleConjunction() {
|
||||
emit('toggleConjunction', props.conjunction)
|
||||
}
|
||||
|
||||
const updateField = (field) => {
|
||||
props.condition[0] = field?.fieldname
|
||||
resetConditionValue()
|
||||
}
|
||||
|
||||
const resetConditionValue = () => {
|
||||
props.condition[2] = ''
|
||||
}
|
||||
|
||||
function getValueControl() {
|
||||
const [field, operator] = props.condition
|
||||
if (!field) return null
|
||||
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
|
||||
if (!fieldData) return null
|
||||
const { fieldtype, options } = fieldData
|
||||
if (operator == 'is') {
|
||||
return h(FormControl, {
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
label: 'Set',
|
||||
value: 'set',
|
||||
},
|
||||
{
|
||||
label: 'Not Set',
|
||||
value: 'not set',
|
||||
},
|
||||
],
|
||||
})
|
||||
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
|
||||
return h(FormControl, { type: 'text' })
|
||||
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
|
||||
const _options =
|
||||
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
|
||||
return h(FormControl, {
|
||||
type: 'select',
|
||||
options: _options.map((o) => ({
|
||||
label: o,
|
||||
value: o,
|
||||
})),
|
||||
})
|
||||
} else if (typeLink.includes(fieldtype)) {
|
||||
if (fieldtype == 'Dynamic Link') {
|
||||
return h(FormControl, { type: 'text' })
|
||||
}
|
||||
return h(Link, {
|
||||
class: 'form-control',
|
||||
doctype: options,
|
||||
value: props.condition[2],
|
||||
})
|
||||
} else if (typeNumber.includes(fieldtype)) {
|
||||
return h(FormControl, { type: 'number' })
|
||||
} else if (typeDate.includes(fieldtype) && operator == 'between') {
|
||||
return h(DateRangePicker, { value: props.condition[2], iconLeft: '' })
|
||||
} else if (typeDate.includes(fieldtype)) {
|
||||
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
|
||||
value: props.condition[2],
|
||||
iconLeft: '',
|
||||
})
|
||||
} else if (typeRating.includes(fieldtype)) {
|
||||
return h(Rating, {
|
||||
modelValue: props.condition[2] || 0,
|
||||
class: 'truncate',
|
||||
'update:modelValue': (v) => updateValue(v),
|
||||
})
|
||||
} else {
|
||||
return h(FormControl, { type: 'text' })
|
||||
}
|
||||
}
|
||||
|
||||
function updateValue(value) {
|
||||
value = value.target ? value.target.value : value
|
||||
if (props.condition[1] === 'between') {
|
||||
props.condition[2] = [value.split(',')[0], value.split(',')[1]]
|
||||
} else {
|
||||
props.condition[2] = value + ''
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectOptions(options) {
|
||||
return options.split('\n')
|
||||
}
|
||||
|
||||
function updateOperator(event) {
|
||||
let oldOperatorValue = event.target._value
|
||||
let newOperatorValue = event.target.value
|
||||
props.condition[1] = event.target.value
|
||||
if (!isSameTypeOperator(oldOperatorValue, newOperatorValue)) {
|
||||
props.condition[2] = getDefaultValue(props.condition[0])
|
||||
}
|
||||
resetConditionValue()
|
||||
}
|
||||
|
||||
function getOperators() {
|
||||
let options = []
|
||||
const field = props.condition[0]
|
||||
if (!field) return options
|
||||
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
|
||||
if (!fieldData) return options
|
||||
const { fieldtype, fieldname } = fieldData
|
||||
if (typeString.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Like', value: 'like' },
|
||||
{ label: 'Not Like', value: 'not like' },
|
||||
{ label: 'In', value: 'in' },
|
||||
{ label: 'Not In', value: 'not in' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (fieldname === '_assign') {
|
||||
options = [
|
||||
{ label: 'Like', value: 'like' },
|
||||
{ label: 'Not Like', value: 'not like' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
]
|
||||
}
|
||||
if (typeNumber.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Like', value: 'like' },
|
||||
{ label: 'Not Like', value: 'not like' },
|
||||
{ label: 'In', value: 'in' },
|
||||
{ label: 'Not In', value: 'not in' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
{ label: '<', value: '<' },
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: '>=', value: '>=' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (typeSelect.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'In', value: 'in' },
|
||||
{ label: 'Not In', value: 'not in' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (typeLink.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Like', value: 'like' },
|
||||
{ label: 'Not Like', value: 'not like' },
|
||||
{ label: 'In', value: 'in' },
|
||||
{ label: 'Not In', value: 'not in' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (typeCheck.includes(fieldtype)) {
|
||||
options.push(...[{ label: 'Equals', value: '==' }])
|
||||
}
|
||||
if (['Duration'].includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Like', value: 'like' },
|
||||
{ label: 'Not Like', value: 'not like' },
|
||||
{ label: 'In', value: 'in' },
|
||||
{ label: 'Not In', value: 'not in' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (typeDate.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
{ label: 'Between', value: 'between' },
|
||||
],
|
||||
)
|
||||
}
|
||||
if (typeRating.includes(fieldtype)) {
|
||||
options.push(
|
||||
...[
|
||||
{ label: 'Equals', value: '==' },
|
||||
{ label: 'Not Equals', value: '!=' },
|
||||
{ label: 'Is', value: 'is' },
|
||||
{ label: '>', value: '>' },
|
||||
{ label: '<', value: '<' },
|
||||
{ label: '>=', value: '>=' },
|
||||
{ label: '<=', value: '<=' },
|
||||
],
|
||||
)
|
||||
}
|
||||
const op = options.find((o) => o.value == props.condition[1])
|
||||
props.condition[1] = op?.value || options[0].value
|
||||
return options
|
||||
}
|
||||
|
||||
function getDefaultValue(field) {
|
||||
if (typeSelect.includes(field.fieldtype)) {
|
||||
return getSelectOptions(field.options)[0]
|
||||
}
|
||||
if (typeCheck.includes(field.fieldtype)) {
|
||||
return 'Yes'
|
||||
}
|
||||
if (typeDate.includes(field.fieldtype)) {
|
||||
return null
|
||||
}
|
||||
if (typeRating.includes(field.fieldtype)) {
|
||||
return 0
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function isSameTypeOperator(oldOperator, newOperator) {
|
||||
let textOperators = ['==', '!=', 'in', 'not in', '>', '<', '>=', '<=']
|
||||
if (
|
||||
textOperators.includes(oldOperator) &&
|
||||
textOperators.includes(newOperator)
|
||||
)
|
||||
return true
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
142
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal file
142
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-outline-gray-2 p-3 flex flex-col gap-4 w-full">
|
||||
<template v-for="(condition, i) in props.conditions" :key="condition.field">
|
||||
<CFCondition
|
||||
v-if="Array.isArray(condition)"
|
||||
:condition="condition"
|
||||
:isChild="props.isChild"
|
||||
:itemIndex="i"
|
||||
@remove="removeCondition(condition)"
|
||||
@unGroupConditions="unGroupConditions(condition)"
|
||||
:level="props.level + 1"
|
||||
@toggleConjunction="toggleConjunction"
|
||||
:isGroup="isGroupCondition(condition[0])"
|
||||
:conjunction="getConjunction()"
|
||||
@turnIntoGroup="turnIntoGroup(condition)"
|
||||
:disableAddCondition="props.disableAddCondition"
|
||||
/>
|
||||
</template>
|
||||
<div v-if="props.isChild" class="flex">
|
||||
<Dropdown v-slot="{ open }" :options="dropdownOptions">
|
||||
<Button
|
||||
:disabled="props.disableAddCondition"
|
||||
:label="__('Add condition')"
|
||||
icon-left="plus"
|
||||
:icon-right="open ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dropdown } from 'frappe-ui'
|
||||
import { computed, watch } from 'vue'
|
||||
import CFCondition from './CFCondition.vue'
|
||||
import { filterableFields } from './filterableFields'
|
||||
|
||||
const props = defineProps({
|
||||
conditions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
isChild: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
disableAddCondition: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const getConjunction = () => {
|
||||
let conjunction = 'and'
|
||||
props.conditions.forEach((condition) => {
|
||||
if (typeof condition == 'string') {
|
||||
conjunction = condition
|
||||
}
|
||||
})
|
||||
return conjunction
|
||||
}
|
||||
|
||||
const turnIntoGroup = (condition) => {
|
||||
props.conditions.splice(props.conditions.indexOf(condition), 1, [condition])
|
||||
}
|
||||
|
||||
const isGroupCondition = (condition) => {
|
||||
return Array.isArray(condition)
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
label: __('Add condition'),
|
||||
onClick: () => {
|
||||
const conjunction = getConjunction()
|
||||
props.conditions.push(conjunction, ['', '', ''])
|
||||
},
|
||||
},
|
||||
]
|
||||
if (props.level < 3) {
|
||||
options.push({
|
||||
label: __('Add condition group'),
|
||||
onClick: () => {
|
||||
const conjunction = getConjunction()
|
||||
props.conditions.push(conjunction, [[]])
|
||||
},
|
||||
})
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
function removeCondition(condition) {
|
||||
const conditionIndex = props.conditions.indexOf(condition)
|
||||
if (conditionIndex == 0) {
|
||||
props.conditions.splice(conditionIndex, 2)
|
||||
} else {
|
||||
props.conditions.splice(conditionIndex - 1, 2)
|
||||
}
|
||||
}
|
||||
|
||||
function unGroupConditions(condition) {
|
||||
const conjunction = getConjunction()
|
||||
const newConditions = condition.map((c) => {
|
||||
if (typeof c == 'string') {
|
||||
return conjunction
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
const index = props.conditions.indexOf(condition)
|
||||
if (index !== -1) {
|
||||
props.conditions.splice(index, 1, ...newConditions)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleConjunction(conjunction) {
|
||||
for (let i = 0; i < props.conditions.length; i++) {
|
||||
if (typeof props.conditions[i] == 'string') {
|
||||
props.conditions[i] = conjunction == 'and' ? 'or' : 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.doctype,
|
||||
(doctype) => {
|
||||
filterableFields.submit({
|
||||
doctype,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
17
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal file
17
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { createResource } from 'frappe-ui'
|
||||
|
||||
export const filterableFields = createResource({
|
||||
url: 'crm.api.doc.get_filterable_fields',
|
||||
transform: (data) => {
|
||||
data = data
|
||||
.filter((field) => !field.fieldname.startsWith('_'))
|
||||
.map((field) => {
|
||||
return {
|
||||
label: field.label,
|
||||
value: field.fieldname,
|
||||
...field,
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
@ -52,16 +52,14 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12">
|
||||
<div class="flex items-center justify-center w-12">
|
||||
<Button
|
||||
class="flex w-full items-center justify-center rounded !bg-surface-gray-2 border-0"
|
||||
:tooltip="__('Edit grid fields')"
|
||||
class="rounded !bg-surface-gray-2 border-0 !text-ink-gray-5"
|
||||
variant="outline"
|
||||
icon="settings"
|
||||
@click="showGridFieldsEditorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<FeatherIcon name="settings" class="size-4 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rows -->
|
||||
@ -72,6 +70,7 @@
|
||||
:delay="isTouchScreenDevice() ? 200 : 0"
|
||||
group="rows"
|
||||
item-key="name"
|
||||
@end="reorder"
|
||||
>
|
||||
<template #item="{ element: row, index }">
|
||||
<div
|
||||
@ -277,16 +276,14 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-row w-12">
|
||||
<div class="edit-row flex items-center justify-center w-12">
|
||||
<Button
|
||||
class="flex w-full items-center justify-center rounded border-0"
|
||||
:tooltip="__('Edit row')"
|
||||
class="rounded border-0 !text-ink-gray-7"
|
||||
variant="outline"
|
||||
:icon="EditIcon"
|
||||
@click="showRowList[index] = true"
|
||||
>
|
||||
<template #icon>
|
||||
<EditIcon class="text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
<GridRowModal
|
||||
v-if="showRowList[index]"
|
||||
@ -350,7 +347,6 @@ import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
Checkbox,
|
||||
DateTimePicker,
|
||||
@ -520,6 +516,13 @@ const deleteRows = () => {
|
||||
selectedRows.clear()
|
||||
}
|
||||
|
||||
const reorder = () => {
|
||||
rows.value.forEach((row, index) => {
|
||||
row.idx = index + 1
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
triggerOnChange(field.fieldname, value, row)
|
||||
}
|
||||
|
||||
@ -54,13 +54,10 @@
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="w-full mt-2"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
iconLeft="plus"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
@ -75,7 +72,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
v-if="dirty"
|
||||
class="w-full"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user