Compare commits
648 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 | ||
|
|
2ce5e02d99 | ||
|
|
9d609e528c | ||
|
|
de4647f5ab | ||
|
|
4017a9373b | ||
|
|
c6e5fa2aea | ||
|
|
7a35c5224b | ||
|
|
2828e76657 | ||
|
|
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
|
||||
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:
|
||||
|
||||
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_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):
|
||||
|
||||
@ -129,15 +129,13 @@
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Email",
|
||||
"options": "Email",
|
||||
"read_only": 1
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Mobile No",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"default": "Qualification",
|
||||
@ -251,8 +249,7 @@
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Primary Phone",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
"options": "Phone"
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
@ -435,7 +432,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-13 11:54:20.608489",
|
||||
"modified": "2025-08-26 12:12:56.324245",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -7,10 +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.utils import get_exchange_rate
|
||||
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):
|
||||
@ -27,7 +25,7 @@ class CRMDeal(Document):
|
||||
add_status_change_log(self)
|
||||
if frappe.db.get_value("CRM Deal Status", self.status, "type") == "Won":
|
||||
self.closed_date = frappe.utils.nowdate()
|
||||
self.validate_forcasting_fields()
|
||||
self.validate_forecasting_fields()
|
||||
self.validate_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
|
||||
@ -139,12 +137,12 @@ 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 update_default_probability(self):
|
||||
"""
|
||||
@ -153,14 +151,26 @@ class CRMDeal(Document):
|
||||
if not self.probability or self.probability == 0:
|
||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||
|
||||
def validate_forcasting_fields(self):
|
||||
self.update_close_date()
|
||||
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):
|
||||
"""
|
||||
@ -177,7 +187,7 @@ class CRMDeal(Document):
|
||||
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, frappe.utils.nowdate())
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import frappe
|
||||
|
||||
from crm.api.doc import 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
|
||||
@ -4,7 +4,7 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.utils import get_exchange_rate
|
||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import get_exchange_rate
|
||||
|
||||
|
||||
class CRMOrganization(Document):
|
||||
@ -16,7 +16,7 @@ class CRMOrganization(Document):
|
||||
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, frappe.utils.nowdate())
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency)
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ def get_duration(from_date, to_date):
|
||||
|
||||
|
||||
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 = (
|
||||
@ -41,7 +43,6 @@ def add_status_change_log(doc):
|
||||
"log_owner": frappe.session.user,
|
||||
},
|
||||
)
|
||||
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
||||
last_status_change = doc.status_change_log[-1]
|
||||
last_status_change.to = doc.status
|
||||
last_status_change.to_type = to_status_type or ""
|
||||
|
||||
@ -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,7 +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",
|
||||
@ -72,12 +78,47 @@
|
||||
"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-13 11:58:34.857638",
|
||||
"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
|
||||
@ -132,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)
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -23,6 +24,9 @@ def after_install(force=False):
|
||||
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()
|
||||
|
||||
|
||||
@ -190,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}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -419,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
2637
crm/locale/main.pot
2637
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
@ -14,4 +14,6 @@ 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_type
|
||||
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()
|
||||
@ -27,7 +27,7 @@ def execute():
|
||||
]
|
||||
|
||||
for status in deal_statuses:
|
||||
if status.type is None or status.type == "":
|
||||
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:
|
||||
|
||||
@ -267,20 +267,3 @@ def sales_user_only(fn):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_exchange_rate(from_currency, to_currency, date=None):
|
||||
if not date:
|
||||
date = "latest"
|
||||
|
||||
url = f"https://api.frankfurter.app/{date}?from={from_currency}&to={to_currency}"
|
||||
|
||||
response = requests.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
rate = data["rates"].get(to_currency)
|
||||
return rate
|
||||
else:
|
||||
frappe.throw(_("Failed to fetch historical exchange rate from external API. Please try again later."))
|
||||
return None
|
||||
|
||||
@ -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 {
|
||||
|
||||
}
|
||||
25
frontend/components.d.ts
vendored
25
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,7 +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/General/BrandSettings.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']
|
||||
@ -61,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']
|
||||
@ -80,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']
|
||||
@ -99,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']
|
||||
@ -125,11 +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/General/GeneralSettings.vue')['default']
|
||||
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.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']
|
||||
@ -140,7 +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/General/HomeActions.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']
|
||||
@ -167,11 +168,6 @@ declare module 'vue' {
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
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']
|
||||
@ -186,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']
|
||||
@ -205,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']
|
||||
@ -232,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.166",
|
||||
"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,7 @@
|
||||
<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)"
|
||||
/>
|
||||
@ -438,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()
|
||||
@ -490,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'
|
||||
@ -513,6 +514,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
tabs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@ -523,10 +528,13 @@ 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)
|
||||
@ -542,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()),
|
||||
})
|
||||
@ -572,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()
|
||||
}
|
||||
@ -594,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,
|
||||
@ -767,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
|
||||
}
|
||||
@ -792,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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -11,19 +11,18 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="isManager()"
|
||||
:tooltip="__('Edit fields layout')"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
:icon="EditIcon"
|
||||
@click="openGridRowFieldsModal"
|
||||
>
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
icon="x"
|
||||
variant="ghost"
|
||||
class="w-7"
|
||||
@click="show = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<FileUploader
|
||||
:file-types="image_type"
|
||||
class="text-base"
|
||||
@success="
|
||||
(file) => {
|
||||
$emit('upload', file.file_url)
|
||||
@ -10,21 +9,28 @@
|
||||
>
|
||||
<template v-slot="{ progress, uploading, openFileSelector }">
|
||||
<div class="flex items-end space-x-1">
|
||||
<Button @click="openFileSelector">
|
||||
{{
|
||||
<Button
|
||||
@click="openFileSelector"
|
||||
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
|
||||
:label="
|
||||
uploading
|
||||
? `Uploading ${progress}%`
|
||||
? __('Uploading {0}%', [progress])
|
||||
: image_url
|
||||
? 'Change'
|
||||
: 'Upload'
|
||||
}}
|
||||
</Button>
|
||||
<Button v-if="image_url" @click="$emit('remove')">Remove</Button>
|
||||
? __('Change')
|
||||
: __('Upload')
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
v-if="image_url"
|
||||
:label="__('Remove')"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</template>
|
||||
<script setup>
|
||||
import ImageUpIcon from '~icons/lucide/image-up'
|
||||
import { FileUploader, Button } from 'frappe-ui'
|
||||
|
||||
const prop = defineProps({
|
||||
@ -33,10 +39,6 @@ const prop = defineProps({
|
||||
type: String,
|
||||
default: 'image/*',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['upload', 'remove'])
|
||||
</script>
|
||||
|
||||
@ -48,24 +48,18 @@
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
iconLeft="plus"
|
||||
@click="() => attrs.onCreate(value, close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Clear')"
|
||||
iconLeft="x"
|
||||
@click="() => clearValue(close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="x" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
|
||||
@ -18,14 +18,10 @@
|
||||
:key="g.label"
|
||||
>
|
||||
<Dropdown :options="g.action" v-slot="{ open }">
|
||||
<Button :label="g.label">
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
:label="g.label"
|
||||
:iconRight="open ? 'chevron-up' : 'chevron-down'"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
165
frontend/src/components/Dashboard/AddChartModal.vue
Normal file
165
frontend/src/components/Dashboard/AddChartModal.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ title: __('Add chart') }"
|
||||
@close="show = false"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
v-model="chartType"
|
||||
type="select"
|
||||
:label="__('Chart Type')"
|
||||
:options="chartTypes"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'number_chart'"
|
||||
v-model="numberChart"
|
||||
type="select"
|
||||
:label="__('Number chart')"
|
||||
:options="numberCharts"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'axis_chart'"
|
||||
v-model="axisChart"
|
||||
type="select"
|
||||
:label="__('Axis chart')"
|
||||
:options="axisCharts"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="chartType === 'donut_chart'"
|
||||
v-model="donutChart"
|
||||
type="select"
|
||||
:label="__('Donut chart')"
|
||||
:options="donutCharts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" :label="__('Cancel')" @click="show = false" />
|
||||
<Button variant="solid" :label="__('Add')" @click="addChart" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getRandom } from '@/utils'
|
||||
import { createResource, Dialog, FormControl } from 'frappe-ui'
|
||||
import { ref, reactive, inject } from 'vue'
|
||||
|
||||
const show = defineModel({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
})
|
||||
|
||||
const items = defineModel('items', {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const fromDate = inject('fromDate', ref(''))
|
||||
const toDate = inject('toDate', ref(''))
|
||||
const filters = inject('filters', reactive({ period: '', user: '' }))
|
||||
|
||||
const chartType = ref('spacer')
|
||||
const chartTypes = [
|
||||
{ label: __('Spacer'), value: 'spacer' },
|
||||
{ label: __('Number chart'), value: 'number_chart' },
|
||||
{ label: __('Axis chart'), value: 'axis_chart' },
|
||||
{ label: __('Donut chart'), value: 'donut_chart' },
|
||||
]
|
||||
|
||||
const numberChart = ref('')
|
||||
const numberCharts = [
|
||||
{ label: __('Total leads'), value: 'total_leads' },
|
||||
{ label: __('Ongoing deals'), value: 'ongoing_deals' },
|
||||
{ label: __('Avg ongoing deal value'), value: 'average_ongoing_deal_value' },
|
||||
{ label: __('Won deals'), value: 'won_deals' },
|
||||
{ label: __('Avg won deal value'), value: 'average_won_deal_value' },
|
||||
{ label: __('Avg deal value'), value: 'average_deal_value' },
|
||||
{
|
||||
label: __('Avg time to close a lead'),
|
||||
value: 'average_time_to_close_a_lead',
|
||||
},
|
||||
{
|
||||
label: __('Avg time to close a deal'),
|
||||
value: 'average_time_to_close_a_deal',
|
||||
},
|
||||
]
|
||||
|
||||
const axisChart = ref('sales_trend')
|
||||
const axisCharts = [
|
||||
{ label: __('Sales trend'), value: 'sales_trend' },
|
||||
{ label: __('Forecasted revenue'), value: 'forecasted_revenue' },
|
||||
{ label: __('Funnel conversion'), value: 'funnel_conversion' },
|
||||
{ label: __('Deals by ongoing & won stage'), value: 'deals_by_stage_axis' },
|
||||
{ label: __('Lost deal reasons'), value: 'lost_deal_reasons' },
|
||||
{ label: __('Deals by territory'), value: 'deals_by_territory' },
|
||||
{ label: __('Deals by salesperson'), value: 'deals_by_salesperson' },
|
||||
]
|
||||
|
||||
const donutChart = ref('deals_by_stage_donut')
|
||||
const donutCharts = [
|
||||
{ label: __('Deals by stage'), value: 'deals_by_stage_donut' },
|
||||
{ label: __('Leads by source'), value: 'leads_by_source' },
|
||||
{ label: __('Deals by source'), value: 'deals_by_source' },
|
||||
]
|
||||
|
||||
async function addChart() {
|
||||
show.value = false
|
||||
if (chartType.value == 'spacer') {
|
||||
items.value.push({
|
||||
name: 'spacer',
|
||||
type: 'spacer',
|
||||
layout: { x: 0, y: 0, w: 4, h: 2, i: 'spacer_' + getRandom(4) },
|
||||
})
|
||||
} else {
|
||||
await getChart(chartType.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function getChart(type: string) {
|
||||
let name =
|
||||
type == 'number_chart'
|
||||
? numberChart.value
|
||||
: type == 'axis_chart'
|
||||
? axisChart.value
|
||||
: donutChart.value
|
||||
|
||||
await createResource({
|
||||
url: 'crm.api.dashboard.get_chart',
|
||||
params: {
|
||||
name,
|
||||
type,
|
||||
from_date: fromDate.value,
|
||||
to_date: toDate.value,
|
||||
user: filters.user,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data = {}) => {
|
||||
let width = 4
|
||||
let height = 2
|
||||
|
||||
if (['axis_chart', 'donut_chart'].includes(type)) {
|
||||
width = 10
|
||||
height = 7
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
name,
|
||||
type,
|
||||
layout: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: width,
|
||||
h: height,
|
||||
i: name + '_' + getRandom(4),
|
||||
},
|
||||
data: data,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<GridLayout
|
||||
v-if="items.length > 0"
|
||||
class="h-fit w-full"
|
||||
:class="[editing ? 'mb-[20rem] !select-none' : '']"
|
||||
:cols="20"
|
||||
:rowHeight="42"
|
||||
:disabled="!editing"
|
||||
:modelValue="items.map((item) => item.layout)"
|
||||
@update:modelValue="
|
||||
(newLayout) => {
|
||||
items.forEach((item, idx) => {
|
||||
item.layout = newLayout[idx]
|
||||
})
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ index }">
|
||||
<div class="group relative flex h-full w-full p-2 text-ink-gray-8">
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center"
|
||||
:class="
|
||||
editing
|
||||
? 'pointer-events-none [&>div:first-child]:rounded [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<DashboardItem
|
||||
:index="index"
|
||||
:item="items[index]"
|
||||
:editing="editing"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="editing"
|
||||
class="flex absolute right-0 top-0 bg-surface-gray-6 rounded cursor-pointer opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<div
|
||||
class="rounded p-1 hover:bg-surface-gray-5"
|
||||
@click="items.splice(index, 1)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="size-3 text-ink-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</GridLayout>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { GridLayout } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const items = defineModel()
|
||||
</script>
|
||||
54
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
54
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div
|
||||
v-if="item.type == 'number_chart'"
|
||||
class="flex h-full w-full rounded shadow overflow-hidden cursor-pointer"
|
||||
>
|
||||
<Tooltip :text="__(item.data.tooltip)">
|
||||
<NumberChart
|
||||
class="!items-start"
|
||||
v-if="item.data"
|
||||
:key="index"
|
||||
:config="item.data"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'spacer'"
|
||||
class="rounded bg-surface-white h-full overflow-hidden text-ink-gray-5 flex items-center justify-center"
|
||||
:class="editing ? 'border border-dashed border-outline-gray-2' : ''"
|
||||
>
|
||||
{{ editing ? __('Spacer') : '' }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'axis_chart'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow"
|
||||
>
|
||||
<AxisChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.type == 'donut_chart'"
|
||||
class="h-full w-full rounded-md bg-surface-white shadow overflow-hidden"
|
||||
>
|
||||
<DonutChart v-if="item.data" :config="item.data" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { AxisChart, DonutChart, NumberChart, Tooltip } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
editing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -2,7 +2,7 @@
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<template #body v-if="!confirmDeleteInfo.show">
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{
|
||||
@ -32,11 +32,12 @@
|
||||
{
|
||||
label: 'Document',
|
||||
key: 'title',
|
||||
width: '19rem',
|
||||
},
|
||||
{
|
||||
label: 'Master',
|
||||
key: 'reference_doctype',
|
||||
width: '30%',
|
||||
width: '12rem',
|
||||
},
|
||||
]"
|
||||
@selectionsChanged="
|
||||
|
||||
@ -123,11 +123,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="{
|
||||
@ -138,21 +139,20 @@
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button variant="ghost" @click="openFileSelector()">
|
||||
<template #icon>
|
||||
<AttachmentIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
:tooltip="__('Attach a file')"
|
||||
:icon="AttachmentIcon"
|
||||
variant="ghost"
|
||||
@click="openFileSelector()"
|
||||
/>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<Button
|
||||
:tooltip="__('Insert Email Template')"
|
||||
variant="ghost"
|
||||
:icon="EmailTemplateIcon"
|
||||
@click="showEmailTemplateSelectorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<EmailTemplateIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
|
||||
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
|
||||
|
||||
@ -89,12 +89,9 @@
|
||||
v-if="data[field.fieldname] && field.edit"
|
||||
class="shrink-0"
|
||||
:label="__('Edit')"
|
||||
:iconLeft="EditIcon"
|
||||
@click="field.edit(data[field.fieldname])"
|
||||
>
|
||||
<template #prefix>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TableMultiselectInput
|
||||
|
||||
@ -169,13 +169,10 @@
|
||||
<Button
|
||||
class="w-full !h-8 !bg-surface-modal"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
iconLeft="plus"
|
||||
@click="togglePopover()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
@ -198,6 +195,7 @@
|
||||
class="w-full h-8"
|
||||
variant="subtle"
|
||||
:label="__('Add Section')"
|
||||
iconLeft="plus"
|
||||
@click="
|
||||
tabs[tabIndex].sections.push({
|
||||
label: __('New Section'),
|
||||
@ -206,11 +204,7 @@
|
||||
columns: [{ name: 'column_' + getRandom(), fields: [] }],
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
filesUploaderArea?.showWebLink || filesUploaderArea?.showCamera
|
||||
"
|
||||
:label="isMobileView ? __('Back') : __('Back to file upload')"
|
||||
iconLeft="arrow-left"
|
||||
@click="
|
||||
() => {
|
||||
filesUploaderArea.showWebLink = false
|
||||
@ -37,11 +38,7 @@
|
||||
filesUploaderArea.cameraImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="arrow-left" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
filesUploaderArea?.showCamera && !filesUploaderArea?.cameraImage
|
||||
|
||||
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