Compare commits
531 Commits
main
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8de2e89b68 | ||
|
|
0f49470bf6 | ||
|
|
7826565ce7 | ||
|
|
713571469b | ||
|
|
2f34fdd409 | ||
|
|
6fefa16ac3 | ||
|
|
b344f412c9 | ||
|
|
1e245e7719 | ||
|
|
28facd66c4 | ||
|
|
c8f01f08ed | ||
|
|
a89525f77e | ||
|
|
38a2fa87c3 | ||
|
|
46e6ed2e6f | ||
|
|
266952c404 | ||
|
|
b77e59589a | ||
|
|
1a5ae397dc | ||
|
|
7c4718ad02 | ||
|
|
d79341b6d9 | ||
|
|
84738ba00c | ||
|
|
3b0a8d8e4b | ||
|
|
2584cca128 | ||
|
|
05b8cea206 | ||
|
|
6e3d23a8e1 | ||
|
|
2a38d0fb5f | ||
|
|
97724c776b | ||
|
|
9b072058cc | ||
|
|
d2e65feaa6 | ||
|
|
37c2d3a2b0 | ||
|
|
0909423fe9 | ||
|
|
139bcb101c | ||
|
|
0f06715d0c | ||
|
|
4a783fcba8 | ||
|
|
fd38f0ac98 | ||
|
|
ed2208fe75 | ||
|
|
4320142132 | ||
|
|
160649bf97 | ||
|
|
e7a2efd14a | ||
|
|
81614418d4 | ||
|
|
bb08f3d377 | ||
|
|
5232da6ec3 | ||
|
|
e59547da30 | ||
|
|
de85ccfc51 | ||
|
|
f82019e510 | ||
|
|
7fc26a5202 | ||
|
|
dcb1e47564 | ||
|
|
61259f3d2e | ||
|
|
2dd2608c09 | ||
|
|
81dc4e1138 | ||
|
|
cb1f9f760c | ||
|
|
51530b7608 | ||
|
|
4e6d4a1d77 | ||
|
|
efc5dd93e9 | ||
|
|
210a9d8d06 | ||
|
|
9bd855ee2e | ||
|
|
970c215f40 | ||
|
|
7d157046ac | ||
|
|
1ae7018f79 | ||
|
|
6802567291 | ||
|
|
cbc127e947 | ||
|
|
d91d4765b5 | ||
|
|
328959cc39 | ||
|
|
36320f61ab | ||
|
|
faeacb9a7d | ||
|
|
5dcd416007 | ||
|
|
33e4072430 | ||
|
|
c7fbd6f8f1 | ||
|
|
a5d3694386 | ||
|
|
b3075416e2 | ||
|
|
743d97d690 | ||
|
|
2cb09dde4b | ||
|
|
d7ba5a5f62 | ||
|
|
a00bba35f8 | ||
|
|
71db65d21c | ||
|
|
37d820a67c | ||
|
|
4f02f0a4d7 | ||
|
|
f4b81b3761 | ||
|
|
0be737914a | ||
|
|
1b0d966db0 | ||
|
|
27f87883f7 | ||
|
|
f747e076ab | ||
|
|
4b12918ba5 | ||
|
|
c104b1b8b4 | ||
|
|
9d4106cd81 | ||
|
|
eddf8c9295 | ||
|
|
6b3e42a44e | ||
|
|
9b1d4832b6 | ||
|
|
4d2f054e40 | ||
|
|
6450b69ae7 | ||
|
|
223187c7ea | ||
|
|
3b34f73cb3 | ||
|
|
40c5c92230 | ||
|
|
6760798f18 | ||
|
|
42ea1ad16e | ||
|
|
96200aebe6 | ||
|
|
bcfe4b6a49 | ||
|
|
cb92e5e68d | ||
|
|
1fa6b5bb51 | ||
|
|
cafc4fb22f | ||
|
|
39eb5600d9 | ||
|
|
0b97462dc9 | ||
|
|
cab80edf60 | ||
|
|
6f3b58d1a5 | ||
|
|
fc89c7b93c | ||
|
|
4a57c4eb84 | ||
|
|
96cbdea820 | ||
|
|
a3a54aef94 | ||
|
|
c96e5ff6c5 | ||
|
|
144470877d | ||
|
|
391844512a | ||
|
|
d89c304b13 | ||
|
|
881126c7f1 | ||
|
|
5bbec00803 | ||
|
|
7730e46cfc | ||
|
|
97b2253e9d | ||
|
|
1afb2a783b | ||
|
|
0fdbfa3ad4 | ||
|
|
a7dc5e05b3 | ||
|
|
92d7280728 | ||
|
|
5d01b88a1e | ||
|
|
2b47e3f4c9 | ||
|
|
485360f291 | ||
|
|
17fdbb05ce | ||
|
|
adc22efcb1 | ||
|
|
4c70b1a06b | ||
|
|
4f58aa110a | ||
|
|
4d3fe722e8 | ||
|
|
6320e580ae | ||
|
|
611f4cde70 | ||
|
|
6d3268a61e | ||
|
|
bf0a1ecebd | ||
|
|
693c086930 | ||
|
|
7c307a9134 | ||
|
|
aae7e0e36c | ||
|
|
2014a3d6de | ||
|
|
2e5c1bc3b5 | ||
|
|
ac13b7a3bd | ||
|
|
6b7bdf5afb | ||
|
|
3eba628a8b | ||
|
|
9949478b36 | ||
|
|
ff657ec34c | ||
|
|
da4698d431 | ||
|
|
20d47ae323 | ||
|
|
f4f799f636 | ||
|
|
cc411f036d | ||
|
|
62d5c2a91f | ||
|
|
8350c5ee36 | ||
|
|
65435cf2b5 | ||
|
|
af4c64e633 | ||
|
|
41b913debe | ||
|
|
a3b9368953 | ||
|
|
28ece820ed | ||
|
|
cca420b1a0 | ||
|
|
05803c79b4 | ||
|
|
5932ccafec | ||
|
|
7cee017e20 | ||
|
|
b15a8d9c8a | ||
|
|
7e6d5c3e54 | ||
|
|
dd3d297dab | ||
|
|
e4f728d809 | ||
|
|
cd7bab9184 | ||
|
|
ec6b1558b1 | ||
|
|
1c3ee8b557 | ||
|
|
1db7f69f89 | ||
|
|
3c1ce1fe27 | ||
|
|
2d05b6a282 | ||
|
|
b5ed9692df | ||
|
|
9a326d791b | ||
|
|
7fbd240d97 | ||
|
|
58d4691354 | ||
|
|
594295b7c8 | ||
|
|
2c45673f54 | ||
|
|
7827afe606 | ||
|
|
bc7498e02b | ||
|
|
e5dd85aefb | ||
|
|
cf1fce3dc0 | ||
|
|
480cc07cd9 | ||
|
|
84d4327e80 | ||
|
|
34102ef6ef | ||
|
|
ca985a0b76 | ||
|
|
4b4a154261 | ||
|
|
e957327877 | ||
|
|
2fdea90ad4 | ||
|
|
ad1aee9c9e | ||
|
|
bd7451e86f | ||
|
|
0230360145 | ||
|
|
eee1190f10 | ||
|
|
96c8aae01e | ||
|
|
0ad65be961 | ||
|
|
0f8d484e28 | ||
|
|
364c369199 | ||
|
|
901bcb8460 | ||
|
|
d06ac91052 | ||
|
|
f818a4c1d6 | ||
|
|
85191e10c8 | ||
|
|
001a3231e1 | ||
|
|
d951dff5a9 | ||
|
|
dc82f837aa | ||
|
|
7f1db0b444 | ||
|
|
a317950567 | ||
|
|
4c7269e357 | ||
|
|
15fd763de8 | ||
|
|
0c314674fc | ||
|
|
efd03141f0 | ||
|
|
675bcb549d | ||
|
|
56425254a9 | ||
|
|
22856351fd | ||
|
|
dd1229309f | ||
|
|
fad7c5985c | ||
|
|
3234102e55 | ||
|
|
fb2f105520 | ||
|
|
03abe0b5cd | ||
|
|
6873c6db4e | ||
|
|
08bab927a2 | ||
|
|
d244567b30 | ||
|
|
2b1b21d2e2 | ||
|
|
12213de478 | ||
|
|
2a2c832e0b | ||
|
|
b534aae70b | ||
|
|
6d3e4406ae | ||
|
|
463d60b650 | ||
|
|
e9812495e9 | ||
|
|
0a836c78bb | ||
|
|
5c7f835e4c | ||
|
|
346849631e | ||
|
|
bf166bdaad | ||
|
|
123f183f68 | ||
|
|
bea1505c63 | ||
|
|
fac5ed5579 | ||
|
|
96fefbd8a3 | ||
|
|
548018997e | ||
|
|
9f3477e1cd | ||
|
|
baa03246e6 | ||
|
|
a17b1cd0e2 | ||
|
|
49d82870c4 | ||
|
|
6a72a4467a | ||
|
|
efbed6e0b6 | ||
|
|
5270670b65 | ||
|
|
b82f4ca02b | ||
|
|
0f451c7e3a | ||
|
|
824dc8dcdd | ||
|
|
20405be86c | ||
|
|
4edfa951dc | ||
|
|
029c16d1d0 | ||
|
|
b3acff8cba | ||
|
|
a98b0e3a00 | ||
|
|
b1cbcbd98d | ||
|
|
e079980598 | ||
|
|
2e27c0459c | ||
|
|
d87a237789 | ||
|
|
e9e0aa357b | ||
|
|
9af300bba8 | ||
|
|
7d79cbf5bd | ||
|
|
fdca27bb81 | ||
|
|
01f0213693 | ||
|
|
5d29a49120 | ||
|
|
33e6b80d5a | ||
|
|
100d931535 | ||
|
|
dba6dd1983 | ||
|
|
dc8898e1da | ||
|
|
ed79bf55eb | ||
|
|
fb644f5fbe | ||
|
|
ab409dfd2c | ||
|
|
42285dd911 | ||
|
|
db2a6c65b7 | ||
|
|
c6ad10857a | ||
|
|
f128a55f97 | ||
|
|
2a817e5861 | ||
|
|
5e616f1a50 | ||
|
|
c6e9d71e1f | ||
|
|
f58d44bf9c | ||
|
|
f72ab39c93 | ||
|
|
6c706e6162 | ||
|
|
8f81d207b8 | ||
|
|
b74c5f384d | ||
|
|
df412d51fe | ||
|
|
8942bb7e48 | ||
|
|
ca60679126 | ||
|
|
8db846ad5d | ||
|
|
bb6a90058b | ||
|
|
44df09fac2 | ||
|
|
e214ce8bfb | ||
|
|
6d281922e4 | ||
|
|
58f09331b0 | ||
|
|
9780a6b63e | ||
|
|
71f764c224 | ||
|
|
9362997246 | ||
|
|
28ea88f61e | ||
|
|
a25ff14dd4 | ||
|
|
d86caee7af | ||
|
|
c4caabe722 | ||
|
|
8dcb77634b | ||
|
|
c4feed116d | ||
|
|
e220767179 | ||
|
|
571126c36d | ||
|
|
832323f25e | ||
|
|
3b73432d8c | ||
|
|
3aa341370b | ||
|
|
895da1a812 | ||
|
|
d34ee6fe48 | ||
|
|
7298fe378c | ||
|
|
2da0b48c29 | ||
|
|
165509f5a0 | ||
|
|
c9b9dbb092 | ||
|
|
0cc1d5da8f | ||
|
|
c70dced268 | ||
|
|
df698387dc | ||
|
|
716dc056d6 | ||
|
|
cf91f3f72a | ||
|
|
51b87d0ac6 | ||
|
|
c83d7adddd | ||
|
|
549665bc61 | ||
|
|
7f5f43f0c2 | ||
|
|
af41469d58 | ||
|
|
43e1309bd8 | ||
|
|
91f7cf05fc | ||
|
|
875431a620 | ||
|
|
db0c0d98bc | ||
|
|
5406f4a11b | ||
|
|
bfdd3273fe | ||
|
|
8798103e7e | ||
|
|
203b5ab1ac | ||
|
|
ed1b26207b | ||
|
|
e0166a08e2 | ||
|
|
8af4e9b5e8 | ||
|
|
900c1d3570 | ||
|
|
b95a17a4e0 | ||
|
|
0f0b012a44 | ||
|
|
b291f82e4d | ||
|
|
86b7222916 | ||
|
|
7a12b80dd2 | ||
|
|
4a836a58ee | ||
|
|
b47fc5b93b | ||
|
|
f3b9103a51 | ||
|
|
dc3ccdddd4 | ||
|
|
807eb4a7d9 | ||
|
|
a24283eb5e | ||
|
|
fd7116b2e1 | ||
|
|
2e1289df28 | ||
|
|
6064ca5a4f | ||
|
|
3db1b3c0f3 | ||
|
|
06ffa203ef | ||
|
|
dd1db8f782 | ||
|
|
fe8e309399 | ||
|
|
e7a20374c7 | ||
|
|
4cfa0f512b | ||
|
|
64b4f6b759 | ||
|
|
2d421e6052 | ||
|
|
cd8dd683fa | ||
|
|
a2bdc7ab93 | ||
|
|
d4132c2411 | ||
|
|
4c6e273268 | ||
|
|
043f174e05 | ||
|
|
26e9fac1ed | ||
|
|
88f33db249 | ||
|
|
55a67bbc0c | ||
|
|
08f042589d | ||
|
|
52f540a014 | ||
|
|
e85ef93480 | ||
|
|
a757f80263 | ||
|
|
b9b8ff0e10 | ||
|
|
e0aad074ec | ||
|
|
ad88b4e046 | ||
|
|
5156814e7a | ||
|
|
f988d16215 | ||
|
|
f5a3fccad3 | ||
|
|
e3f0079578 | ||
|
|
b831ea3c47 | ||
|
|
a88545b8b9 | ||
|
|
44523a0392 | ||
|
|
dbc207a9a6 | ||
|
|
e68d861ee5 | ||
|
|
7851bbadfa | ||
|
|
9223d00af3 | ||
|
|
740c21532a | ||
|
|
9fdd8bbc17 | ||
|
|
0978fa58a2 | ||
|
|
1395a12d32 | ||
|
|
9aab0e7417 | ||
|
|
ddc5810c71 | ||
|
|
21c349e1d7 | ||
|
|
a7784c2985 | ||
|
|
0cc69d90f0 | ||
|
|
f125737d30 | ||
|
|
18aef2376a | ||
|
|
c8287ff107 | ||
|
|
baf344a697 | ||
|
|
8c94049e3c | ||
|
|
646c76c3cb | ||
|
|
adbb9f5765 | ||
|
|
d3a6cc968f | ||
|
|
d6ff40cc6a | ||
|
|
fdd6c46b5f | ||
|
|
26c892c2a0 | ||
|
|
3516e1ff44 | ||
|
|
0047077074 | ||
|
|
8459fac184 | ||
|
|
afe828f012 | ||
|
|
60ed0a2043 | ||
|
|
2c9bc07dec | ||
|
|
91ba11b565 | ||
|
|
8f79427720 | ||
|
|
32f3aaf38f | ||
|
|
76aaf7f37d | ||
|
|
7d37c606cc | ||
|
|
6bce89f277 | ||
|
|
5420fcfe29 | ||
|
|
8507c20481 | ||
|
|
914dd8bf93 | ||
|
|
960ebdc727 | ||
|
|
74ef956638 | ||
|
|
a6323f42af | ||
|
|
bc1c20c91f | ||
|
|
43297373ed | ||
|
|
5228755f7f | ||
|
|
7ded0a0742 | ||
|
|
d74ff9ab62 | ||
|
|
6ef27106df | ||
|
|
35a27101c1 | ||
|
|
6fbe75c8ad | ||
|
|
89fd754efc | ||
|
|
576763fe5b | ||
|
|
c67ec08e1a | ||
|
|
6f49573f2f | ||
|
|
12c3290f19 | ||
|
|
53c0706a3a | ||
|
|
556386e446 | ||
|
|
07b2d9f792 | ||
|
|
a2081da296 | ||
|
|
dde7db9489 | ||
|
|
f947f55fc6 | ||
|
|
7bbac6c703 | ||
|
|
420ecb6147 | ||
|
|
dcb2787498 | ||
|
|
336083a00f | ||
|
|
727d0a9acd | ||
|
|
29894ffcca | ||
|
|
e804fa39ba | ||
|
|
f866284240 | ||
|
|
9e3124d29e | ||
|
|
d7e0eb09b3 | ||
|
|
5fcd447bc8 | ||
|
|
6f04b85663 | ||
|
|
47262761fe | ||
|
|
b46e7a2185 | ||
|
|
2d484c1ad2 | ||
|
|
275fa90a4d | ||
|
|
f8956c70bf | ||
|
|
39fa9c78f8 | ||
|
|
d96a29543e | ||
|
|
d2d4abe91f | ||
|
|
5f567cf138 | ||
|
|
7bf7d94127 | ||
|
|
5b8d0d2aeb | ||
|
|
d37e585205 | ||
|
|
a30503ca5f | ||
|
|
e65899e384 | ||
|
|
16a3f3d66c | ||
|
|
1e2f325c55 | ||
|
|
ccd240f4e8 | ||
|
|
7b34c5eb66 | ||
|
|
6da3761e76 | ||
|
|
b03abdd2eb | ||
|
|
6ea4e985ef | ||
|
|
699d6cb08c | ||
|
|
ac70deaf19 | ||
|
|
4907db44eb | ||
|
|
81154d1f50 | ||
|
|
5eb46f6b6c | ||
|
|
001a6617f5 | ||
|
|
c009373a43 | ||
|
|
cef20e37c2 | ||
|
|
20d16c6a32 | ||
|
|
2fc3daee70 | ||
|
|
a7955ba9c5 | ||
|
|
84e773eab9 | ||
|
|
da4d3032be | ||
|
|
d89e71ac2f | ||
|
|
de806ee6d9 | ||
|
|
9c45877999 | ||
|
|
2059ecdb40 | ||
|
|
52d66b5de4 | ||
|
|
fb9b026ad6 | ||
|
|
8f1b6f6b67 | ||
|
|
0bd448a399 | ||
|
|
2b395a05ea | ||
|
|
dce17de000 | ||
|
|
3881179f72 | ||
|
|
da0a502756 | ||
|
|
cbf00e29ac | ||
|
|
a466766c5c | ||
|
|
a4781509c4 | ||
|
|
8a9361d822 | ||
|
|
e2522a492a | ||
|
|
bab551c511 | ||
|
|
c63bb16704 | ||
|
|
fa56dc4791 | ||
|
|
e92ee3b730 | ||
|
|
bb794f4887 | ||
|
|
a227389e3e | ||
|
|
d9f0b067ca | ||
|
|
c0b708462a | ||
|
|
adb0dfff47 | ||
|
|
6139cb5cb9 | ||
|
|
61d7924c54 | ||
|
|
899b09ac40 | ||
|
|
debc9fc1cb | ||
|
|
5c76adedf3 | ||
|
|
1ebb26e4c2 | ||
|
|
67378c1f52 | ||
|
|
469a22ef5f | ||
|
|
fdceb51fdc | ||
|
|
97a132e05f | ||
|
|
26fabddcbe | ||
|
|
40370067b2 | ||
|
|
f0bf6962e7 | ||
|
|
3b432a0209 | ||
|
|
c7a03922a0 | ||
|
|
e70b4c091e | ||
|
|
7e38d5e405 | ||
|
|
f810e82b45 | ||
|
|
dff9f93a6b | ||
|
|
c4109ad6ac | ||
|
|
7a6efb900e | ||
|
|
e080e47a35 | ||
|
|
82599f91d8 | ||
|
|
8fa156f625 | ||
|
|
55112cefa9 | ||
|
|
152c7c8a91 | ||
|
|
aa1c0da80e | ||
|
|
87174f207d | ||
|
|
400f879d29 |
45
.mergify.yml
Normal file
45
.mergify.yml
Normal file
@ -0,0 +1,45 @@
|
||||
pull_request_rules:
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- and:
|
||||
- author!=shariquerik
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
- or:
|
||||
- base=main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
**Simplify Sales, Amplify Relationships**
|
||||
|
||||

|
||||
[](https://github.com/frappe/crm/releases)
|
||||
|
||||
<div>
|
||||
<picture>
|
||||
@ -181,6 +181,7 @@ You need Docker, docker-compose and git setup on your machine. Refer [Docker doc
|
||||
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
|
||||
- [Documentation](https://docs.frappe.io/crm)
|
||||
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
||||
- [X/Twitter](https://x.com/frappetech)
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
from bs4 import BeautifulSoup
|
||||
import frappe
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import validate_email_address, split_emails, cstr
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
from bs4 import BeautifulSoup
|
||||
from frappe.core.api.file import get_max_file_size
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import cstr, split_emails, validate_email_address
|
||||
from frappe.utils.modules import get_modules_from_all_apps_for_user
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@ -63,9 +64,14 @@ def check_app_permission():
|
||||
if frappe.session.user == "Administrator":
|
||||
return True
|
||||
|
||||
allowed_modules = get_modules_from_all_apps_for_user()
|
||||
allowed_modules = [x["module_name"] for x in allowed_modules]
|
||||
if "FCRM" not in allowed_modules:
|
||||
return False
|
||||
|
||||
roles = frappe.get_roles()
|
||||
if any(
|
||||
role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
|
||||
role in ["System Manager", "Sales User", "Sales Manager"] for role in roles
|
||||
):
|
||||
return True
|
||||
|
||||
@ -93,9 +99,14 @@ def accept_invitation(key: str | None = None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def invite_by_email(emails: str, role: str):
|
||||
frappe.only_for("Sales Manager")
|
||||
frappe.only_for(["Sales Manager", "System Manager"])
|
||||
|
||||
if role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||
frappe.throw("Cannot invite for this role")
|
||||
|
||||
if not emails:
|
||||
return
|
||||
|
||||
email_string = validate_email_address(emails, throw=False)
|
||||
email_list = split_emails(email_string)
|
||||
if not email_list:
|
||||
@ -103,7 +114,10 @@ def invite_by_email(emails: str, role: str):
|
||||
existing_members = frappe.db.get_all("User", filters={"email": ["in", email_list]}, pluck="email")
|
||||
existing_invites = frappe.db.get_all(
|
||||
"CRM Invitation",
|
||||
filters={"email": ["in", email_list], "role": ["in", ["Sales Manager", "Sales User"]]},
|
||||
filters={
|
||||
"email": ["in", email_list],
|
||||
"role": ["in", ["System Manager", "Sales Manager", "Sales User"]],
|
||||
},
|
||||
pluck="email",
|
||||
)
|
||||
|
||||
@ -112,6 +126,12 @@ def invite_by_email(emails: str, role: str):
|
||||
for email in to_invite:
|
||||
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
||||
|
||||
return {
|
||||
"existing_members": existing_members,
|
||||
"existing_invites": existing_invites,
|
||||
"to_invite": to_invite,
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_file_uploader_defaults(doctype: str):
|
||||
|
||||
@ -124,6 +124,7 @@ def get_deal_activities(name):
|
||||
activity = {
|
||||
"activity_type": "communication",
|
||||
"communication_type": communication.communication_type,
|
||||
"communication_date": communication.communication_date or communication.creation,
|
||||
"creation": communication.creation,
|
||||
"data": {
|
||||
"subject": communication.subject,
|
||||
@ -255,6 +256,7 @@ def get_lead_activities(name):
|
||||
activity = {
|
||||
"activity_type": "communication",
|
||||
"communication_type": communication.communication_type,
|
||||
"communication_date": communication.communication_date or communication.creation,
|
||||
"creation": communication.creation,
|
||||
"data": {
|
||||
"subject": communication.subject,
|
||||
|
||||
@ -14,32 +14,16 @@ def update_deals_email_mobile_no(doc):
|
||||
)
|
||||
|
||||
for linked_deal in linked_deals:
|
||||
deal = frappe.get_cached_doc("CRM Deal", linked_deal.parent)
|
||||
deal = frappe.db.get_values("CRM Deal", linked_deal.parent, ["email", "mobile_no"], as_dict=True)[0]
|
||||
if deal.email != doc.email_id or deal.mobile_no != doc.mobile_no:
|
||||
deal.email = doc.email_id
|
||||
deal.mobile_no = doc.mobile_no
|
||||
deal.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_contact(name):
|
||||
Contact = frappe.qb.DocType("Contact")
|
||||
|
||||
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
|
||||
|
||||
contact = query.run(as_dict=True)
|
||||
if not len(contact):
|
||||
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
|
||||
contact = contact.pop()
|
||||
|
||||
contact["doctype"] = "Contact"
|
||||
contact["email_ids"] = frappe.get_all(
|
||||
"Contact Email", filters={"parent": name}, fields=["name", "email_id", "is_primary"]
|
||||
frappe.db.set_value(
|
||||
"CRM Deal",
|
||||
linked_deal.parent,
|
||||
{
|
||||
"email": doc.email_id,
|
||||
"mobile_no": doc.mobile_no,
|
||||
},
|
||||
)
|
||||
contact["phone_nos"] = frappe.get_all(
|
||||
"Contact Phone", filters={"parent": name}, fields=["name", "phone", "is_primary_mobile_no"]
|
||||
)
|
||||
return contact
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
1142
crm/api/dashboard.py
Normal file
1142
crm/api/dashboard.py
Normal file
File diff suppressed because it is too large
Load Diff
147
crm/api/doc.py
147
crm/api/doc.py
@ -3,6 +3,7 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
from frappe.desk.form.assign_to import set_status
|
||||
from frappe.model import no_value_fields
|
||||
from frappe.model.document import get_controller
|
||||
from frappe.utils import make_filter_tuple
|
||||
@ -10,6 +11,7 @@ from pypika import Criterion
|
||||
|
||||
from crm.api.views import get_views
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
from crm.utils import get_dynamic_linked_docs, get_linked_docs
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -418,16 +420,23 @@ def get_data(
|
||||
rows.append(field)
|
||||
|
||||
for kc in kanban_columns:
|
||||
column_filters = {column_field: kc.get("name")}
|
||||
# Start with base filters
|
||||
column_filters = []
|
||||
|
||||
# Convert and add the main filters first
|
||||
if filters:
|
||||
base_filters = convert_filter_to_tuple(doctype, filters)
|
||||
column_filters.extend(base_filters)
|
||||
|
||||
# Add the column-specific filter
|
||||
if column_field and kc.get("name"):
|
||||
column_filters.append([doctype, column_field, "=", kc.get("name")])
|
||||
|
||||
order = kc.get("order")
|
||||
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"):
|
||||
if kc.get("delete"):
|
||||
column_data = []
|
||||
else:
|
||||
column_filters.update(filters.copy())
|
||||
page_length = 20
|
||||
|
||||
if kc.get("page_length"):
|
||||
page_length = kc.get("page_length")
|
||||
page_length = kc.get("page_length", 20)
|
||||
|
||||
if order:
|
||||
column_data = get_records_based_on_order(
|
||||
@ -437,26 +446,20 @@ def get_data(
|
||||
column_data = frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
filters=convert_filter_to_tuple(doctype, column_filters),
|
||||
filters=column_filters,
|
||||
order_by=order_by,
|
||||
page_length=page_length,
|
||||
)
|
||||
|
||||
new_filters = filters.copy()
|
||||
new_filters.update({column_field: kc.get("name")})
|
||||
|
||||
all_count = frappe.get_list(
|
||||
doctype,
|
||||
filters=convert_filter_to_tuple(doctype, new_filters),
|
||||
filters=column_filters,
|
||||
fields="count(*) as total_count",
|
||||
)[0].total_count
|
||||
|
||||
kc["all_count"] = all_count
|
||||
kc["count"] = len(column_data)
|
||||
|
||||
for d in column_data:
|
||||
getCounts(d, doctype)
|
||||
|
||||
if order:
|
||||
column_data = sorted(
|
||||
column_data,
|
||||
@ -658,6 +661,25 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False, only_re
|
||||
return fields_meta
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_assignments(doctype, name, assignees, ignore_permissions=False):
|
||||
assignees = json.loads(assignees)
|
||||
|
||||
if not assignees:
|
||||
return
|
||||
|
||||
for assign_to in assignees:
|
||||
set_status(
|
||||
doctype,
|
||||
name,
|
||||
todo=None,
|
||||
assign_to=assign_to,
|
||||
status="Cancelled",
|
||||
ignore_permissions=ignore_permissions,
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_assigned_users(doctype, name, default_assigned_to=None):
|
||||
assigned_users = frappe.get_all(
|
||||
"ToDo",
|
||||
@ -725,3 +747,98 @@ def getCounts(d, doctype):
|
||||
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
|
||||
)
|
||||
return d
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_docs_of_document(doctype, docname):
|
||||
doc = frappe.get_doc(doctype, docname)
|
||||
linked_docs = get_linked_docs(doc)
|
||||
dynamic_linked_docs = get_dynamic_linked_docs(doc)
|
||||
|
||||
linked_docs.extend(dynamic_linked_docs)
|
||||
linked_docs = list({doc["reference_docname"]: doc for doc in linked_docs}.values())
|
||||
|
||||
docs_data = []
|
||||
for doc in linked_docs:
|
||||
data = frappe.get_doc(doc["reference_doctype"], doc["reference_docname"])
|
||||
title = data.get("title")
|
||||
if data.doctype == "CRM Call Log":
|
||||
title = f"Call from {data.get('from')} to {data.get('to')}"
|
||||
|
||||
if data.doctype == "CRM Deal":
|
||||
title = data.get("organization")
|
||||
|
||||
docs_data.append(
|
||||
{
|
||||
"doc": data.doctype,
|
||||
"title": title or data.get("name"),
|
||||
"reference_docname": doc["reference_docname"],
|
||||
"reference_doctype": doc["reference_doctype"],
|
||||
}
|
||||
)
|
||||
return docs_data
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_linked_doc_reference(items, remove_contact=None, delete=False):
|
||||
if isinstance(items, str):
|
||||
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 delete:
|
||||
frappe.delete_doc(item["doctype"], item["docname"])
|
||||
|
||||
return "success"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_bulk_docs(doctype, items, delete_linked=False):
|
||||
from frappe.desk.reportview import delete_bulk
|
||||
|
||||
items = frappe.parse_json(items)
|
||||
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,
|
||||
)
|
||||
|
||||
if len(items) > 10:
|
||||
frappe.enqueue("frappe.desk.reportview.delete_bulk", doctype=doctype, items=items)
|
||||
else:
|
||||
delete_bulk(doctype, items)
|
||||
return "success"
|
||||
|
||||
@ -23,11 +23,32 @@ def get_users():
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator"
|
||||
user.roles = frappe.get_roles(user.name)
|
||||
|
||||
user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
user.role = ""
|
||||
|
||||
return users
|
||||
if "System Manager" in user.roles:
|
||||
user.role = "System Manager"
|
||||
elif "Sales Manager" in user.roles:
|
||||
user.role = "Sales Manager"
|
||||
elif "Sales User" in user.roles:
|
||||
user.role = "Sales User"
|
||||
elif "Guest" in user.roles:
|
||||
user.role = "Guest"
|
||||
|
||||
if frappe.session.user == user.name:
|
||||
user.session_user = True
|
||||
|
||||
user.is_telephony_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
|
||||
crm_users = []
|
||||
|
||||
# crm users are users with role Sales User or Sales Manager
|
||||
for user in users:
|
||||
if "Sales User" in user.roles or "Sales Manager" in user.roles:
|
||||
crm_users.append(user)
|
||||
|
||||
return users, crm_users
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
99
crm/api/settings.py
Normal file
99
crm/api/settings.py
Normal file
@ -0,0 +1,99 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_email_account(data):
|
||||
service = data.get("service")
|
||||
service_config = email_service_config.get(service)
|
||||
if not service_config:
|
||||
return "Service not supported"
|
||||
|
||||
try:
|
||||
email_doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Email Account",
|
||||
"email_id": data.get("email_id"),
|
||||
"email_account_name": data.get("email_account_name"),
|
||||
"service": service,
|
||||
"enable_incoming": data.get("enable_incoming"),
|
||||
"enable_outgoing": data.get("enable_outgoing"),
|
||||
"default_incoming": data.get("default_incoming"),
|
||||
"default_outgoing": data.get("default_outgoing"),
|
||||
"email_sync_option": "ALL",
|
||||
"initial_sync_count": 100,
|
||||
"create_contact": 1,
|
||||
"track_email_status": 1,
|
||||
"use_tls": 1,
|
||||
"use_imap": 1,
|
||||
"smtp_port": 587,
|
||||
**service_config,
|
||||
}
|
||||
)
|
||||
if service == "Frappe Mail":
|
||||
email_doc.api_key = data.get("api_key")
|
||||
email_doc.api_secret = data.get("api_secret")
|
||||
email_doc.frappe_mail_site = data.get("frappe_mail_site")
|
||||
email_doc.append_to = "CRM Lead"
|
||||
else:
|
||||
email_doc.append("imap_folder", {"append_to": "CRM Lead", "folder_name": "INBOX"})
|
||||
email_doc.password = data.get("password")
|
||||
# validate whether the credentials are correct
|
||||
email_doc.get_incoming_server()
|
||||
|
||||
# if correct credentials, save the email account
|
||||
email_doc.save()
|
||||
except Exception as e:
|
||||
frappe.throw(str(e))
|
||||
|
||||
|
||||
email_service_config = {
|
||||
"Frappe Mail": {
|
||||
"domain": None,
|
||||
"password": None,
|
||||
"awaiting_password": 0,
|
||||
"ascii_encode_password": 0,
|
||||
"login_id_is_different": 0,
|
||||
"login_id": None,
|
||||
"use_imap": 0,
|
||||
"use_ssl": 0,
|
||||
"validate_ssl_certificate": 0,
|
||||
"use_starttls": 0,
|
||||
"email_server": None,
|
||||
"incoming_port": 0,
|
||||
"always_use_account_email_id_as_sender": 1,
|
||||
"use_tls": 0,
|
||||
"use_ssl_for_outgoing": 0,
|
||||
"smtp_server": None,
|
||||
"smtp_port": None,
|
||||
"no_smtp_authentication": 0,
|
||||
},
|
||||
"GMail": {
|
||||
"email_server": "imap.gmail.com",
|
||||
"use_ssl": 1,
|
||||
"smtp_server": "smtp.gmail.com",
|
||||
},
|
||||
"Outlook": {
|
||||
"email_server": "imap-mail.outlook.com",
|
||||
"use_ssl": 1,
|
||||
"smtp_server": "smtp-mail.outlook.com",
|
||||
},
|
||||
"Sendgrid": {
|
||||
"smtp_server": "smtp.sendgrid.net",
|
||||
"smtp_port": 587,
|
||||
},
|
||||
"SparkPost": {
|
||||
"smtp_server": "smtp.sparkpostmail.com",
|
||||
},
|
||||
"Yahoo": {
|
||||
"email_server": "imap.mail.yahoo.com",
|
||||
"use_ssl": 1,
|
||||
"smtp_server": "smtp.mail.yahoo.com",
|
||||
"smtp_port": 587,
|
||||
},
|
||||
"Yandex": {
|
||||
"email_server": "imap.yandex.com",
|
||||
"use_ssl": 1,
|
||||
"smtp_server": "smtp.yandex.com",
|
||||
"smtp_port": 587,
|
||||
},
|
||||
}
|
||||
84
crm/api/user.py
Normal file
84
crm/api/user.py
Normal file
@ -0,0 +1,84 @@
|
||||
import frappe
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_existing_users(users, role="Sales User"):
|
||||
"""
|
||||
Add existing users to the CRM by assigning them a role (Sales User or Sales Manager).
|
||||
:param users: List of user names to be added
|
||||
"""
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
users = frappe.parse_json(users)
|
||||
|
||||
for user in users:
|
||||
add_user(user, role)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_user_role(user, new_role):
|
||||
"""
|
||||
Update the role of the user to Sales Manager, Sales User, or System Manager.
|
||||
:param user: The name of the user
|
||||
:param new_role: The new role to assign (Sales Manager or Sales User)
|
||||
"""
|
||||
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
|
||||
if new_role not in ["System Manager", "Sales Manager", "Sales User"]:
|
||||
frappe.throw("Cannot assign this role")
|
||||
|
||||
user_doc = frappe.get_doc("User", user)
|
||||
|
||||
if new_role == "System Manager":
|
||||
user_doc.append_roles("System Manager", "Sales Manager", "Sales User")
|
||||
user_doc.set("block_modules", [])
|
||||
if new_role == "Sales Manager":
|
||||
user_doc.append_roles("Sales Manager", "Sales User")
|
||||
user_doc.remove_roles("System Manager")
|
||||
if new_role == "Sales User":
|
||||
user_doc.append_roles("Sales User")
|
||||
user_doc.remove_roles("Sales Manager", "System Manager")
|
||||
update_module_in_user(user_doc, "FCRM")
|
||||
|
||||
user_doc.save(ignore_permissions=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_user(user, role):
|
||||
"""
|
||||
Add a user means adding role (Sales User or/and Sales Manager) to the user.
|
||||
:param user: The name of the user to be added
|
||||
:param role: The role to be assigned (Sales User or Sales Manager)
|
||||
"""
|
||||
update_user_role(user, role)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_user(user):
|
||||
"""
|
||||
Remove a user means removing Sales User & Sales Manager roles from the user.
|
||||
:param user: The name of the user to be removed
|
||||
"""
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
|
||||
user_doc = frappe.get_doc("User", user)
|
||||
roles = [d.role for d in user_doc.roles]
|
||||
|
||||
if "Sales User" in roles:
|
||||
user_doc.remove_roles("Sales User")
|
||||
if "Sales Manager" in roles:
|
||||
user_doc.remove_roles("Sales Manager")
|
||||
|
||||
user_doc.save(ignore_permissions=True)
|
||||
frappe.msgprint(f"User {user} has been removed from CRM roles.")
|
||||
|
||||
|
||||
def update_module_in_user(user, module):
|
||||
block_modules = frappe.get_all(
|
||||
"Module Def",
|
||||
fields=["name as module"],
|
||||
filters={"name": ["!=", module]},
|
||||
)
|
||||
|
||||
if block_modules:
|
||||
user.set("block_modules", block_modules)
|
||||
@ -335,5 +335,5 @@ def get_from_name(message):
|
||||
else:
|
||||
from_name = doc.get("lead_name")
|
||||
else:
|
||||
from_name = doc.get("first_name") + " " + doc.get("last_name")
|
||||
from_name = " ".join(filter(None, [doc.get("first_name"), doc.get("last_name")]))
|
||||
return from_name
|
||||
|
||||
@ -41,13 +41,15 @@
|
||||
"fieldname": "from",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "From"
|
||||
"label": "From",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled"
|
||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "start_time",
|
||||
@ -69,13 +71,15 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Incoming\nOutgoing"
|
||||
"options": "Incoming\nOutgoing",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "to",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "To"
|
||||
"label": "To",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "Call duration in seconds",
|
||||
@ -153,7 +157,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-22 17:57:59.289548",
|
||||
"modified": "2025-04-01 16:01:54.479309",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Call Log",
|
||||
|
||||
@ -190,11 +190,20 @@ def get_call_log(name):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_lead_from_call_log(call_log):
|
||||
def create_lead_from_call_log(call_log, lead_details=None):
|
||||
lead = frappe.new_doc("CRM Lead")
|
||||
lead.first_name = "Lead from call " + call_log.get("from")
|
||||
lead.mobile_no = call_log.get("from")
|
||||
lead.lead_owner = frappe.session.user
|
||||
lead_details = frappe.parse_json(lead_details or "{}")
|
||||
|
||||
if not lead_details.get("lead_owner"):
|
||||
lead_details["lead_owner"] = frappe.session.user
|
||||
if not lead_details.get("mobile_no"):
|
||||
lead_details["mobile_no"] = call_log.get("from") or ""
|
||||
if not lead_details.get("first_name"):
|
||||
lead_details["first_name"] = "Lead from call " + (
|
||||
lead_details.get("mobile_no") or call_log.get("name")
|
||||
)
|
||||
|
||||
lead.update(lead_details)
|
||||
lead.save(ignore_permissions=True)
|
||||
|
||||
# link call log with lead
|
||||
|
||||
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"
|
||||
}
|
||||
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
33
crm/fcrm/doctype/crm_dashboard/crm_dashboard.py
Normal file
@ -0,0 +1,33 @@
|
||||
# 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)
|
||||
elif force:
|
||||
doc = frappe.get_doc("CRM Dashboard", "Manager Dashboard")
|
||||
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,16 +1,18 @@
|
||||
import frappe
|
||||
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
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).as_dict()
|
||||
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")
|
||||
deal["_assign"] = get_assigned_users("CRM Deal", deal.name)
|
||||
return deal
|
||||
|
||||
|
||||
@ -30,24 +32,12 @@ def get_deal_contacts(name):
|
||||
is_primary = contact.is_primary
|
||||
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
||||
|
||||
def get_primary_email(contact):
|
||||
for email in contact.email_ids:
|
||||
if email.is_primary:
|
||||
return email.email_id
|
||||
return contact.email_ids[0].email_id if contact.email_ids else ""
|
||||
|
||||
def get_primary_mobile_no(contact):
|
||||
for phone in contact.phone_nos:
|
||||
if phone.is_primary:
|
||||
return phone.phone
|
||||
return contact.phone_nos[0].phone if contact.phone_nos else ""
|
||||
|
||||
_contact = {
|
||||
"name": contact.name,
|
||||
"image": contact.image,
|
||||
"full_name": contact.full_name,
|
||||
"email": get_primary_email(contact),
|
||||
"mobile_no": get_primary_mobile_no(contact),
|
||||
"email": contact.email_id,
|
||||
"mobile_no": contact.mobile_no,
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
deal_contacts.append(_contact)
|
||||
|
||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Deal", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"discount_amount",
|
||||
discount_amount
|
||||
);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
}
|
||||
});
|
||||
|
||||
@ -11,11 +11,18 @@
|
||||
"naming_series",
|
||||
"organization",
|
||||
"next_step",
|
||||
"probability",
|
||||
"column_break_ijan",
|
||||
"status",
|
||||
"close_date",
|
||||
"deal_owner",
|
||||
"lost_reason",
|
||||
"lost_notes",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"expected_deal_value",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"expected_closure_date",
|
||||
"closed_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
@ -32,6 +39,7 @@
|
||||
"column_break_xbyf",
|
||||
"territory",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"industry",
|
||||
"person_section",
|
||||
@ -43,6 +51,12 @@
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"gender",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ccbj",
|
||||
"total",
|
||||
"column_break_udbq",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
@ -82,11 +96,6 @@
|
||||
"fieldtype": "Data",
|
||||
"label": "Website"
|
||||
},
|
||||
{
|
||||
"fieldname": "close_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Close Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "next_step",
|
||||
"fieldtype": "Data",
|
||||
@ -119,14 +128,16 @@
|
||||
{
|
||||
"fieldname": "email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
"options": "Email"
|
||||
"label": "Primary Email",
|
||||
"options": "Email",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "mobile_no",
|
||||
"fieldtype": "Data",
|
||||
"label": "Mobile No",
|
||||
"options": "Phone"
|
||||
"label": "Primary Mobile No",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "Qualification",
|
||||
@ -239,8 +250,9 @@
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone",
|
||||
"options": "Phone"
|
||||
"label": "Primary Phone",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
@ -334,11 +346,96 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ccbj",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_udbq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jgpm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Deal Value",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kpxa",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Link",
|
||||
"label": "Lost Reason",
|
||||
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
|
||||
"options": "CRM Lost Reason"
|
||||
},
|
||||
{
|
||||
"fieldname": "lost_notes",
|
||||
"fieldtype": "Text",
|
||||
"label": "Lost Notes",
|
||||
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"description": "The rate used to convert the deal\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_deal_value",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Expected Deal Value",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "expected_closure_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Expected Closure Date"
|
||||
},
|
||||
{
|
||||
"fieldname": "closed_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Closed Date"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-11 14:31:41.058895",
|
||||
"modified": "2025-07-13 11:54:20.608489",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
@ -370,6 +467,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
|
||||
@ -10,6 +10,7 @@ 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
|
||||
|
||||
|
||||
class CRMDeal(Document):
|
||||
@ -24,6 +25,11 @@ class CRMDeal(Document):
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
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_lost_reason()
|
||||
self.update_exchange_rate()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
@ -133,6 +139,48 @@ class CRMDeal(Document):
|
||||
if sla:
|
||||
sla.apply(self)
|
||||
|
||||
def update_close_date(self):
|
||||
"""
|
||||
Update the close date based on the "Won" status.
|
||||
"""
|
||||
if self.status == "Won" and not self.close_date:
|
||||
self.close_date = frappe.utils.nowdate()
|
||||
|
||||
def update_default_probability(self):
|
||||
"""
|
||||
Update the default probability based on the status.
|
||||
"""
|
||||
if not self.probability or self.probability == 0:
|
||||
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||
|
||||
def validate_forcasting_fields(self):
|
||||
self.update_close_date()
|
||||
self.update_default_probability()
|
||||
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)
|
||||
|
||||
def validate_lost_reason(self):
|
||||
"""
|
||||
Validate the lost reason if the status is set to "Lost".
|
||||
"""
|
||||
if self.status and frappe.get_cached_value("CRM Deal Status", self.status, "type") == "Lost":
|
||||
if not self.lost_reason:
|
||||
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
|
||||
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"color",
|
||||
"position"
|
||||
"type",
|
||||
"position",
|
||||
"column_break_ojiu",
|
||||
"probability",
|
||||
"color"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -32,11 +35,30 @@
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Position"
|
||||
},
|
||||
{
|
||||
"fieldname": "probability",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Probability"
|
||||
},
|
||||
{
|
||||
"default": "Open",
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Open\nOngoing\nOn Hold\nWon\nLost"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ojiu",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:56:44.552134",
|
||||
"modified": "2025-07-11 16:03:28.077955",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
@ -68,6 +90,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -27,7 +27,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
||||
if not tabs and type != "Required Fields":
|
||||
tabs = get_default_layout(doctype)
|
||||
|
||||
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
|
||||
has_tabs = False
|
||||
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
|
||||
has_tabs = any("sections" in tab for tab in tabs)
|
||||
|
||||
if not has_tabs:
|
||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||
@ -45,9 +47,19 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||
|
||||
required_fields = []
|
||||
|
||||
if type == "Required Fields":
|
||||
required_fields = [
|
||||
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
|
||||
]
|
||||
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if section.get("columns"):
|
||||
section["columns"] = [column for column in section.get("columns") if column]
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
column["fields"] = [field for field in column.get("fields") if field]
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
@ -55,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
||||
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
||||
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
||||
|
||||
# remove field from required_fields if it is already present
|
||||
if (
|
||||
type == "Required Fields"
|
||||
and field.reqd
|
||||
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
|
||||
):
|
||||
required_fields = [
|
||||
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
|
||||
]
|
||||
|
||||
if type == "Required Fields" and required_fields and tabs:
|
||||
tabs[-1].get("sections").append(
|
||||
{
|
||||
"label": "Required Fields",
|
||||
"name": "required_fields_section_" + str(random_string(4)),
|
||||
"opened": True,
|
||||
"hideLabel": True,
|
||||
"columns": [
|
||||
{
|
||||
"name": "required_fields_column_" + str(random_string(4)),
|
||||
"fields": [field.as_dict() for field in required_fields],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return tabs or []
|
||||
|
||||
|
||||
@ -78,6 +116,8 @@ def get_sidepanel_sections(doctype):
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||
|
||||
add_forecasting_section(layout, doctype)
|
||||
|
||||
for section in layout:
|
||||
section["name"] = section.get("name") or section.get("label")
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
@ -95,6 +135,38 @@ def get_sidepanel_sections(doctype):
|
||||
return layout
|
||||
|
||||
|
||||
def add_forecasting_section(layout, doctype):
|
||||
if (
|
||||
doctype == "CRM Deal"
|
||||
and frappe.db.get_single_value("FCRM Settings", "enable_forecasting")
|
||||
and not any(section.get("name") == "forecasted_sales_section" for section in layout)
|
||||
):
|
||||
contacts_section_index = next(
|
||||
(
|
||||
i
|
||||
for i, section in enumerate(layout)
|
||||
if section.get("name") == "contacts_section" or section.get("label") == "Contacts"
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if contacts_section_index is not None:
|
||||
layout.insert(
|
||||
contacts_section_index + 1,
|
||||
{
|
||||
"name": "forecasted_sales_section",
|
||||
"label": "Forecasted Sales",
|
||||
"opened": True,
|
||||
"columns": [
|
||||
{
|
||||
"name": "column_" + str(random_string(4)),
|
||||
"fields": ["expected_closure_date", "probability", "expected_deal_value"],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
||||
if field.permlevel == 0:
|
||||
return
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-16 19:40:19.340948",
|
||||
"modified": "2025-05-19 17:57:24.610295",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Form Script",
|
||||
@ -83,8 +83,18 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "All",
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Role",
|
||||
"options": "\nSales User\nSales Manager",
|
||||
"options": "\nSales User\nSales Manager\nSystem Manager",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@ -66,7 +66,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-03 14:59:29.450018",
|
||||
"modified": "2025-06-17 17:20:18.935395",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Invitation",
|
||||
@ -106,6 +106,7 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -21,7 +21,7 @@ class CRMInvitation(Document):
|
||||
if frappe.local.dev_server:
|
||||
print(f"Invite link for {self.email}: {invite_link}")
|
||||
|
||||
title = f"Frappe CRM"
|
||||
title = "Frappe CRM"
|
||||
template = "crm_invitation"
|
||||
|
||||
frappe.sendmail(
|
||||
@ -35,7 +35,7 @@ class CRMInvitation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def accept_invitation(self):
|
||||
frappe.only_for("System Manager")
|
||||
frappe.only_for(["System Manager", "Sales Manager"])
|
||||
self.accept()
|
||||
|
||||
def accept(self):
|
||||
@ -44,12 +44,28 @@ class CRMInvitation(Document):
|
||||
|
||||
user = self.create_user_if_not_exists()
|
||||
user.append_roles(self.role)
|
||||
if self.role == "System Manager":
|
||||
user.append_roles("Sales Manager", "Sales User")
|
||||
elif self.role == "Sales Manager":
|
||||
user.append_roles("Sales User")
|
||||
if self.role == "Sales User":
|
||||
self.update_module_in_user(user, "FCRM")
|
||||
user.save(ignore_permissions=True)
|
||||
|
||||
self.status = "Accepted"
|
||||
self.accepted_at = frappe.utils.now()
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
def update_module_in_user(self, user, module):
|
||||
block_modules = frappe.get_all(
|
||||
"Module Def",
|
||||
fields=["name as module"],
|
||||
filters={"name": ["!=", module]},
|
||||
)
|
||||
|
||||
if block_modules:
|
||||
user.set("block_modules", block_modules)
|
||||
|
||||
def create_user_if_not_exists(self):
|
||||
if not frappe.db.exists("User", self.email):
|
||||
first_name = self.email.split("@")[0].title()
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import frappe
|
||||
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
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).as_dict()
|
||||
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")
|
||||
lead["_assign"] = get_assigned_users("CRM Lead", lead.name)
|
||||
return lead
|
||||
|
||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Lead", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"discount_amount",
|
||||
discount_amount
|
||||
);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
}
|
||||
});
|
||||
@ -37,6 +37,12 @@
|
||||
"annual_revenue",
|
||||
"image",
|
||||
"converted",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ggwh",
|
||||
"total",
|
||||
"column_break_uisv",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
@ -285,12 +291,47 @@
|
||||
"fieldtype": "Table",
|
||||
"label": "Status Change Log",
|
||||
"options": "CRM Status Change Log"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ggwh",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uisv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-02 22:14:01.991054",
|
||||
"modified": "2025-05-14 19:51:06.184569",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead",
|
||||
@ -331,6 +372,7 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sender_field": "email",
|
||||
"sender_name_field": "first_name",
|
||||
"show_title_field_in_link": 1,
|
||||
|
||||
@ -27,9 +27,10 @@
|
||||
"label": "Details"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-02 22:13:30.498404",
|
||||
"modified": "2025-06-30 16:53:51.721752",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead Source",
|
||||
@ -44,7 +45,7 @@
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
@ -60,6 +61,15 @@
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
@ -71,6 +81,7 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Lost Reason", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lost_reason",
|
||||
"creation": "2025-06-30 16:51:31.082360",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"lost_reason",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Lost Reason",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-30 16:59:15.094049",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lost Reason",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMLostReason(Document):
|
||||
pass
|
||||
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMLostReason(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMLostReason.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMLostReason.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
@ -10,6 +10,7 @@
|
||||
"organization_name",
|
||||
"no_of_employees",
|
||||
"currency",
|
||||
"exchange_rate",
|
||||
"annual_revenue",
|
||||
"organization_logo",
|
||||
"column_break_pnpp",
|
||||
@ -74,12 +75,18 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Address",
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
"description": "The rate used to convert the organization\u2019s currency to your crm's base currency (set in CRM Settings). It is set once when the currency is first added and doesn't change automatically.",
|
||||
"fieldname": "exchange_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Exchange Rate"
|
||||
}
|
||||
],
|
||||
"image_field": "organization_logo",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-17 18:37:10.341062",
|
||||
"modified": "2025-07-15 11:40:12.175598",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Organization",
|
||||
@ -111,6 +118,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -4,41 +4,55 @@
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.utils import get_exchange_rate
|
||||
|
||||
|
||||
class CRMOrganization(Document):
|
||||
def validate(self):
|
||||
self.update_exchange_rate()
|
||||
|
||||
def update_exchange_rate(self):
|
||||
if self.has_value_changed("currency") or not self.exchange_rate:
|
||||
system_currency = frappe.db.get_single_value("FCRM Settings", "currency") or "USD"
|
||||
exchange_rate = 1
|
||||
if self.currency and self.currency != system_currency:
|
||||
exchange_rate = get_exchange_rate(self.currency, system_currency, frappe.utils.nowdate())
|
||||
|
||||
self.db_set("exchange_rate", exchange_rate)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
{
|
||||
'label': 'Organization',
|
||||
'type': 'Data',
|
||||
'key': 'organization_name',
|
||||
'width': '16rem',
|
||||
"label": "Organization",
|
||||
"type": "Data",
|
||||
"key": "organization_name",
|
||||
"width": "16rem",
|
||||
},
|
||||
{
|
||||
'label': 'Website',
|
||||
'type': 'Data',
|
||||
'key': 'website',
|
||||
'width': '14rem',
|
||||
"label": "Website",
|
||||
"type": "Data",
|
||||
"key": "website",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
'label': 'Industry',
|
||||
'type': 'Link',
|
||||
'key': 'industry',
|
||||
'options': 'CRM Industry',
|
||||
'width': '14rem',
|
||||
"label": "Industry",
|
||||
"type": "Link",
|
||||
"key": "industry",
|
||||
"options": "CRM Industry",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
'label': 'Annual Revenue',
|
||||
'type': 'Currency',
|
||||
'key': 'annual_revenue',
|
||||
'width': '14rem',
|
||||
"label": "Annual Revenue",
|
||||
"type": "Currency",
|
||||
"key": "annual_revenue",
|
||||
"width": "14rem",
|
||||
},
|
||||
{
|
||||
'label': 'Last Modified',
|
||||
'type': 'Datetime',
|
||||
'key': 'modified',
|
||||
'width': '8rem',
|
||||
"label": "Last Modified",
|
||||
"type": "Datetime",
|
||||
"key": "modified",
|
||||
"width": "8rem",
|
||||
},
|
||||
]
|
||||
rows = [
|
||||
@ -51,4 +65,4 @@ class CRMOrganization(Document):
|
||||
"annual_revenue",
|
||||
"modified",
|
||||
]
|
||||
return {'columns': columns, 'rows': rows}
|
||||
return {"columns": columns, "rows": rows}
|
||||
|
||||
0
crm/fcrm/doctype/crm_product/__init__.py
Normal file
0
crm/fcrm/doctype/crm_product/__init__.py
Normal file
9
crm/fcrm/doctype/crm_product/crm_product.js
Normal file
9
crm/fcrm/doctype/crm_product/crm_product.js
Normal file
@ -0,0 +1,9 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Product", {
|
||||
product_code: function (frm) {
|
||||
if (!frm.doc.product_name)
|
||||
frm.set_value("product_name", frm.doc.product_code);
|
||||
}
|
||||
});
|
||||
105
crm/fcrm/doctype/crm_product/crm_product.json
Normal file
105
crm/fcrm/doctype/crm_product/crm_product.json
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_import": 1,
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:product_code",
|
||||
"creation": "2025-04-28 11:45:09.309636",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"product_code",
|
||||
"product_name",
|
||||
"column_break_bpdj",
|
||||
"disabled",
|
||||
"standard_rate",
|
||||
"image",
|
||||
"section_break_rtwm",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "naming_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Naming Series",
|
||||
"options": "CRM-PROD-.YYYY.-"
|
||||
},
|
||||
{
|
||||
"fieldname": "product_code",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Product Code",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "product_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Product Name"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_bpdj",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
},
|
||||
{
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Image"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_rtwm",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"fieldname": "standard_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Standard Selling Rate"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2025-04-28 12:47:25.087957",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Product",
|
||||
"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
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "product_name,description",
|
||||
"show_name_in_global_search": 1,
|
||||
"show_preview_popup": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "product_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
16
crm/fcrm/doctype/crm_product/crm_product.py
Normal file
16
crm/fcrm/doctype/crm_product/crm_product.py
Normal file
@ -0,0 +1,16 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMProduct(Document):
|
||||
def validate(self):
|
||||
self.set_product_name()
|
||||
|
||||
def set_product_name(self):
|
||||
if not self.product_name:
|
||||
self.product_name = self.product_code
|
||||
else:
|
||||
self.product_name = self.product_name.strip()
|
||||
29
crm/fcrm/doctype/crm_product/test_crm_product.py
Normal file
29
crm/fcrm/doctype/crm_product/test_crm_product.py
Normal file
@ -0,0 +1,29 @@
|
||||
# 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 UnitTestCRMProduct(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMProduct.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMProduct(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMProduct.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
0
crm/fcrm/doctype/crm_products/__init__.py
Normal file
0
crm/fcrm/doctype/crm_products/__init__.py
Normal file
136
crm/fcrm/doctype/crm_products/crm_products.json
Normal file
136
crm/fcrm/doctype/crm_products/crm_products.json
Normal file
@ -0,0 +1,136 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-04-28 12:50:49.812915",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"product_code",
|
||||
"column_break_gvbc",
|
||||
"product_name",
|
||||
"section_break_fnvf",
|
||||
"qty",
|
||||
"column_break_ajac",
|
||||
"rate",
|
||||
"section_break_olqb",
|
||||
"discount_percentage",
|
||||
"column_break_uvra",
|
||||
"discount_amount",
|
||||
"section_break_cnpb",
|
||||
"column_break_pozr",
|
||||
"amount",
|
||||
"column_break_ejqw",
|
||||
"net_amount"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "column_break_gvbc",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "product_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Product Name",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_fnvf",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_olqb",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"fieldname": "discount_percentage",
|
||||
"fieldtype": "Percent",
|
||||
"label": "Discount %"
|
||||
},
|
||||
{
|
||||
"fieldname": "discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Discount Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_cnpb",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_pozr",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"fieldname": "rate",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Rate",
|
||||
"options": "currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"fieldname": "amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"depends_on": "discount_percentage",
|
||||
"description": "Amount after discount",
|
||||
"fieldname": "net_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Amount",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"columns": 5,
|
||||
"fieldname": "product_code",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Product",
|
||||
"options": "CRM Product"
|
||||
},
|
||||
{
|
||||
"bold": 1,
|
||||
"default": "1",
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Quantity"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ajac",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uvra",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ejqw",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-14 18:52:26.183306",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Products",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
110
crm/fcrm/doctype/crm_products/crm_products.py
Normal file
110
crm/fcrm/doctype/crm_products/crm_products.py
Normal file
@ -0,0 +1,110 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMProducts(Document):
|
||||
pass
|
||||
|
||||
|
||||
def create_product_details_script(doctype):
|
||||
if not frappe.db.exists("CRM Form Script", "Product Details Script for " + doctype):
|
||||
script = get_product_details_script(doctype)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Form Script",
|
||||
"name": "Product Details Script for " + doctype,
|
||||
"dt": doctype,
|
||||
"view": "Form",
|
||||
"script": script,
|
||||
"enabled": 1,
|
||||
"is_standard": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
def get_product_details_script(doctype):
|
||||
doctype_class = "class " + doctype.replace(" ", "")
|
||||
|
||||
return (
|
||||
doctype_class
|
||||
+ " {"
|
||||
+ """
|
||||
update_total() {
|
||||
let total = 0
|
||||
let total_qty = 0
|
||||
let net_total = 0
|
||||
let discount_applied = false
|
||||
|
||||
this.doc.products.forEach((d) => {
|
||||
total += d.amount
|
||||
net_total += d.net_amount
|
||||
if (d.discount_percentage > 0) {
|
||||
discount_applied = true
|
||||
}
|
||||
})
|
||||
|
||||
this.doc.total = total
|
||||
this.doc.net_total = net_total || total
|
||||
|
||||
if (!net_total && discount_applied) {
|
||||
this.doc.net_total = net_total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CRMProducts {
|
||||
products_add() {
|
||||
let row = this.doc.getRow('products')
|
||||
row.trigger('qty')
|
||||
this.doc.trigger('update_total')
|
||||
}
|
||||
|
||||
products_remove() {
|
||||
this.doc.trigger('update_total')
|
||||
}
|
||||
|
||||
async product_code(idx) {
|
||||
let row = this.doc.getRow('products', idx)
|
||||
|
||||
let a = await call("frappe.client.get_value", {
|
||||
doctype: "CRM Product",
|
||||
filters: { name: row.product_code },
|
||||
fieldname: ["product_name", "standard_rate"],
|
||||
})
|
||||
|
||||
row.product_name = a.product_name
|
||||
if (a.standard_rate && !row.rate) {
|
||||
row.rate = a.standard_rate
|
||||
row.trigger("rate")
|
||||
}
|
||||
}
|
||||
|
||||
qty(idx) {
|
||||
let row = this.doc.getRow('products', idx)
|
||||
row.amount = row.qty * row.rate
|
||||
row.trigger('discount_percentage', idx)
|
||||
}
|
||||
|
||||
rate() {
|
||||
let row = this.doc.getRow('products')
|
||||
row.amount = row.qty * row.rate
|
||||
row.trigger('discount_percentage')
|
||||
}
|
||||
|
||||
discount_percentage(idx) {
|
||||
let row = this.doc.getRow('products', idx)
|
||||
if (!row.discount_percentage) {
|
||||
row.net_amount = row.amount
|
||||
row.discount_amount = 0
|
||||
}
|
||||
if (row.discount_percentage && row.amount) {
|
||||
row.discount_amount = (row.discount_percentage / 100) * row.amount
|
||||
row.net_amount = row.amount - row.discount_amount
|
||||
}
|
||||
this.doc.trigger('update_total')
|
||||
}
|
||||
}"""
|
||||
)
|
||||
@ -13,6 +13,8 @@
|
||||
"column_break_mwmz",
|
||||
"duration",
|
||||
"last_status_change_log",
|
||||
"from_type",
|
||||
"to_type",
|
||||
"log_owner"
|
||||
],
|
||||
"fields": [
|
||||
@ -61,17 +63,30 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Owner",
|
||||
"options": "User"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "From Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "to_type",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "To Type"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-06 13:26:40.597277",
|
||||
"modified": "2025-07-13 12:37:41.278584",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Status Change Log",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from datetime import datetime
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import add_to_date, get_datetime
|
||||
|
||||
|
||||
class CRMStatusChangeLog(Document):
|
||||
pass
|
||||
|
||||
|
||||
def get_duration(from_date, to_date):
|
||||
if not isinstance(from_date, datetime):
|
||||
from_date = get_datetime(from_date)
|
||||
@ -18,28 +20,45 @@ def get_duration(from_date, to_date):
|
||||
duration = to_date - from_date
|
||||
return duration.total_seconds()
|
||||
|
||||
|
||||
def add_status_change_log(doc):
|
||||
to_status_type = frappe.db.get_value("CRM Deal Status", doc.status, "type") if doc.status else None
|
||||
|
||||
if not doc.is_new():
|
||||
previous_status = doc.get_doc_before_save().status if doc.get_doc_before_save() else None
|
||||
previous_status_type = (
|
||||
frappe.db.get_value("CRM Deal Status", previous_status, "type") if previous_status else None
|
||||
)
|
||||
if not doc.status_change_log and previous_status:
|
||||
now_minus_one_minute = add_to_date(datetime.now(), minutes=-1)
|
||||
doc.append("status_change_log", {
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": previous_status,
|
||||
"from_type": previous_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": now_minus_one_minute,
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
},
|
||||
)
|
||||
last_status_change = doc.status_change_log[-1]
|
||||
last_status_change.to = doc.status
|
||||
last_status_change.to_type = to_status_type or ""
|
||||
last_status_change.to_date = datetime.now()
|
||||
last_status_change.log_owner = frappe.session.user
|
||||
last_status_change.duration = get_duration(last_status_change.from_date, last_status_change.to_date)
|
||||
|
||||
doc.append("status_change_log", {
|
||||
doc.append(
|
||||
"status_change_log",
|
||||
{
|
||||
"from": doc.status,
|
||||
"from_type": to_status_type or "",
|
||||
"to": "",
|
||||
"to_type": "",
|
||||
"from_date": datetime.now(),
|
||||
"to_date": "",
|
||||
"log_owner": frappe.session.user,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@ -264,7 +264,7 @@ def create_customer_in_remote_site(customer, erpnext_crm_settings):
|
||||
@frappe.whitelist()
|
||||
def get_crm_form_script():
|
||||
return """
|
||||
async function setupForm({ doc, call, $dialog, updateField, createToast }) {
|
||||
async function setupForm({ doc, call, $dialog, updateField, toast }) {
|
||||
let actions = [];
|
||||
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
|
||||
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Title"
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "content",
|
||||
@ -49,7 +50,7 @@
|
||||
"link_fieldname": "note"
|
||||
}
|
||||
],
|
||||
"modified": "2024-01-19 21:56:30.123334",
|
||||
"modified": "2025-04-01 15:30:14.742001",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Note",
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
"field_order": [
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"currency",
|
||||
"branding_tab",
|
||||
"brand_name",
|
||||
"brand_logo",
|
||||
@ -28,7 +30,7 @@
|
||||
{
|
||||
"fieldname": "defaults_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Defaults"
|
||||
"label": "Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "branding_tab",
|
||||
@ -56,12 +58,26 @@
|
||||
"fieldname": "favicon",
|
||||
"fieldtype": "Attach",
|
||||
"label": "Favicon"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "It will make deal's \"Expected Closure Date\" & \"Expected Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"fieldname": "enable_forecasting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Forecasting"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-02-20 12:38:38.088477",
|
||||
"modified": "2025-07-13 11:58:34.857638",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
@ -95,6 +111,7 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.property_setter.property_setter import delete_property_setter, make_property_setter
|
||||
from frappe.model.document import Document
|
||||
|
||||
from crm.install import after_install
|
||||
@ -15,6 +16,8 @@ class FCRMSettings(Document):
|
||||
|
||||
def validate(self):
|
||||
self.do_not_allow_to_delete_if_standard()
|
||||
self.setup_forecasting()
|
||||
self.make_currency_read_only()
|
||||
|
||||
def do_not_allow_to_delete_if_standard(self):
|
||||
if not self.has_value_changed("dropdown_items"):
|
||||
@ -24,8 +27,53 @@ class FCRMSettings(Document):
|
||||
standard_old_items = [d.name1 for d in old_items if d.is_standard]
|
||||
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
|
||||
if deleted_standard_items:
|
||||
standard_dropdown_items = get_standard_dropdown_items()
|
||||
if not deleted_standard_items.intersection(standard_dropdown_items):
|
||||
return
|
||||
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
||||
|
||||
def setup_forecasting(self):
|
||||
if self.has_value_changed("enable_forecasting"):
|
||||
if not self.enable_forecasting:
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"expected_closure_date",
|
||||
)
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"expected_deal_value",
|
||||
)
|
||||
else:
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"expected_closure_date",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"expected_deal_value",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
|
||||
def make_currency_read_only(self):
|
||||
if self.currency and self.has_value_changed("currency"):
|
||||
make_property_setter(
|
||||
"FCRM Settings",
|
||||
"currency",
|
||||
"read_only",
|
||||
1,
|
||||
"Check",
|
||||
)
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
|
||||
|
||||
def after_migrate():
|
||||
@ -51,3 +99,36 @@ def sync_table(key, hook):
|
||||
crm_settings.set(key, items)
|
||||
|
||||
crm_settings.save()
|
||||
|
||||
|
||||
def create_forecasting_script():
|
||||
if not frappe.db.exists("CRM Form Script", "Forecasting Script"):
|
||||
script = get_forecasting_script()
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "CRM Form Script",
|
||||
"name": "Forecasting Script",
|
||||
"dt": "CRM Deal",
|
||||
"view": "Form",
|
||||
"script": script,
|
||||
"enabled": 1,
|
||||
"is_standard": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
|
||||
def get_forecasting_script():
|
||||
return """class CRMDeal {
|
||||
async status() {
|
||||
await this.doc.trigger('updateProbability')
|
||||
}
|
||||
async updateProbability() {
|
||||
let status = await call("frappe.client.get_value", {
|
||||
doctype: "CRM Deal Status",
|
||||
fieldname: "probability",
|
||||
filters: { name: this.doc.status },
|
||||
})
|
||||
|
||||
this.doc.probability = status.probability
|
||||
}
|
||||
}"""
|
||||
|
||||
24
crm/hooks.py
24
crm/hooks.py
@ -264,22 +264,6 @@ standard_dropdown_items = [
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "support_link",
|
||||
"label": "Support",
|
||||
"type": "Route",
|
||||
"icon": "life-buoy",
|
||||
"route": "https://t.me/frappecrm",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "docs_link",
|
||||
"label": "Docs",
|
||||
"type": "Route",
|
||||
"icon": "book-open",
|
||||
"route": "https://docs.frappe.io/crm",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "toggle_theme",
|
||||
"label": "Toggle theme",
|
||||
@ -303,6 +287,14 @@ standard_dropdown_items = [
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "about",
|
||||
"label": "About",
|
||||
"type": "Route",
|
||||
"icon": "info",
|
||||
"route": "#",
|
||||
"is_standard": 1,
|
||||
},
|
||||
{
|
||||
"name1": "separator",
|
||||
"label": "",
|
||||
|
||||
@ -4,6 +4,9 @@ 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
|
||||
|
||||
|
||||
def before_install():
|
||||
pass
|
||||
@ -18,7 +21,10 @@ def after_install(force=False):
|
||||
add_email_template_custom_fields()
|
||||
add_default_industries()
|
||||
add_default_lead_sources()
|
||||
add_default_lost_reasons()
|
||||
add_standard_dropdown_items()
|
||||
add_default_scripts()
|
||||
create_default_manager_dashboard(force)
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -65,30 +71,44 @@ def add_default_deal_statuses():
|
||||
statuses = {
|
||||
"Qualification": {
|
||||
"color": "gray",
|
||||
"type": "Open",
|
||||
"probability": 10,
|
||||
"position": 1,
|
||||
},
|
||||
"Demo/Making": {
|
||||
"color": "orange",
|
||||
"type": "Ongoing",
|
||||
"probability": 25,
|
||||
"position": 2,
|
||||
},
|
||||
"Proposal/Quotation": {
|
||||
"color": "blue",
|
||||
"type": "Ongoing",
|
||||
"probability": 50,
|
||||
"position": 3,
|
||||
},
|
||||
"Negotiation": {
|
||||
"color": "yellow",
|
||||
"type": "Ongoing",
|
||||
"probability": 70,
|
||||
"position": 4,
|
||||
},
|
||||
"Ready to Close": {
|
||||
"color": "purple",
|
||||
"type": "Ongoing",
|
||||
"probability": 90,
|
||||
"position": 5,
|
||||
},
|
||||
"Won": {
|
||||
"color": "green",
|
||||
"type": "Won",
|
||||
"probability": 100,
|
||||
"position": 6,
|
||||
},
|
||||
"Lost": {
|
||||
"color": "red",
|
||||
"type": "Lost",
|
||||
"probability": 0,
|
||||
"position": 7,
|
||||
},
|
||||
}
|
||||
@ -100,6 +120,8 @@ def add_default_deal_statuses():
|
||||
doc = frappe.new_doc("CRM Deal Status")
|
||||
doc.deal_status = status
|
||||
doc.color = statuses[status]["color"]
|
||||
doc.type = statuses[status]["type"]
|
||||
doc.probability = statuses[status]["probability"]
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
|
||||
@ -340,6 +362,44 @@ def add_default_lead_sources():
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_default_lost_reasons():
|
||||
lost_reasons = [
|
||||
{
|
||||
"reason": "Pricing",
|
||||
"description": "The prospect found the pricing to be too high or not competitive.",
|
||||
},
|
||||
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
|
||||
{
|
||||
"reason": "Budget Constraints",
|
||||
"description": "The prospect did not have the budget to proceed with the purchase.",
|
||||
},
|
||||
{
|
||||
"reason": "Missing Features",
|
||||
"description": "The prospect felt that the product or service was missing key features they needed.",
|
||||
},
|
||||
{
|
||||
"reason": "Long Sales Cycle",
|
||||
"description": "The sales process took too long, leading to loss of interest.",
|
||||
},
|
||||
{
|
||||
"reason": "No Decision-Maker",
|
||||
"description": "The prospect was not the decision-maker and could not proceed.",
|
||||
},
|
||||
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
|
||||
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
|
||||
{"reason": "Other", "description": ""},
|
||||
]
|
||||
|
||||
for reason in lost_reasons:
|
||||
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
|
||||
continue
|
||||
|
||||
doc = frappe.new_doc("CRM Lost Reason")
|
||||
doc.lost_reason = reason["reason"]
|
||||
doc.description = reason["description"]
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_standard_dropdown_items():
|
||||
crm_settings = frappe.get_single("FCRM Settings")
|
||||
|
||||
@ -353,3 +413,11 @@ def add_standard_dropdown_items():
|
||||
crm_settings.append("dropdown_items", item)
|
||||
|
||||
crm_settings.save()
|
||||
|
||||
|
||||
def add_default_scripts():
|
||||
from crm.fcrm.doctype.fcrm_settings.fcrm_settings import create_forecasting_script
|
||||
|
||||
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||
create_product_details_script(doctype)
|
||||
create_forecasting_script()
|
||||
|
||||
@ -110,12 +110,12 @@ def get_contact_by_phone_number(phone_number):
|
||||
number = parse_phone_number(phone_number)
|
||||
|
||||
if number.get("is_valid"):
|
||||
return get_contact(number.get("national_number"))
|
||||
return get_contact(number.get("national_number"), number.get("country"))
|
||||
else:
|
||||
return get_contact(phone_number, exact_match=True)
|
||||
return get_contact(phone_number, number.get("country"), exact_match=True)
|
||||
|
||||
|
||||
def get_contact(phone_number, exact_match=False):
|
||||
def get_contact(phone_number, country="IN", exact_match=False):
|
||||
if not phone_number:
|
||||
return {"mobile_no": phone_number}
|
||||
|
||||
@ -149,11 +149,11 @@ def get_contact(phone_number, exact_match=False):
|
||||
deal = frappe.db.get_value(
|
||||
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
|
||||
)
|
||||
if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(contact.mobile_no, phone_number, country, validate=not exact_match):
|
||||
contact["deal"] = deal
|
||||
return contact
|
||||
# Else, return the first contact
|
||||
if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(contacts[0].mobile_no, phone_number, country, validate=not exact_match):
|
||||
return contacts[0]
|
||||
|
||||
# Else, Check if the number is associated with a lead
|
||||
@ -173,7 +173,7 @@ def get_contact(phone_number, exact_match=False):
|
||||
|
||||
if len(leads):
|
||||
for lead in leads:
|
||||
if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(lead.mobile_no, phone_number, country, validate=not exact_match):
|
||||
lead["lead"] = lead.name
|
||||
lead["full_name"] = lead.lead_name
|
||||
return lead
|
||||
|
||||
@ -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
|
||||
|
||||
2445
crm/locale/main.pot
2445
crm/locale/main.pot
File diff suppressed because it is too large
Load Diff
@ -12,3 +12,6 @@ crm.patches.v1_0.create_default_sidebar_fields_layout
|
||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_layouts_to_new_format
|
||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
crm.patches.v1_0.update_deal_status_type
|
||||
5
crm/patches/v1_0/create_default_scripts.py
Normal file
5
crm/patches/v1_0/create_default_scripts.py
Normal file
@ -0,0 +1,5 @@
|
||||
from crm.install import add_default_scripts
|
||||
|
||||
|
||||
def execute():
|
||||
add_default_scripts()
|
||||
24
crm/patches/v1_0/update_deal_status_probabilities.py
Normal file
24
crm/patches/v1_0/update_deal_status_probabilities.py
Normal file
@ -0,0 +1,24 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "probability", "deal_status"])
|
||||
|
||||
for status in deal_statuses:
|
||||
if status.probability is None or status.probability == 0:
|
||||
if status.deal_status == "Qualification":
|
||||
probability = 10
|
||||
elif status.deal_status == "Demo/Making":
|
||||
probability = 25
|
||||
elif status.deal_status == "Proposal/Quotation":
|
||||
probability = 50
|
||||
elif status.deal_status == "Negotiation":
|
||||
probability = 70
|
||||
elif status.deal_status == "Ready to Close":
|
||||
probability = 90
|
||||
elif status.deal_status == "Won":
|
||||
probability = 100
|
||||
else:
|
||||
probability = 0
|
||||
|
||||
frappe.db.set_value("CRM Deal Status", status.name, "probability", probability)
|
||||
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
44
crm/patches/v1_0/update_deal_status_type.py
Normal file
@ -0,0 +1,44 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "type", "deal_status"])
|
||||
|
||||
openStatuses = ["New", "Open", "Unassigned", "Qualification"]
|
||||
ongoingStatuses = [
|
||||
"Demo/Making",
|
||||
"Proposal/Quotation",
|
||||
"Negotiation",
|
||||
"Ready to Close",
|
||||
"Demo Scheduled",
|
||||
"Follow Up",
|
||||
]
|
||||
onHoldStatuses = ["On Hold", "Paused", "Stalled", "Awaiting Reply"]
|
||||
wonStatuses = ["Won", "Closed Won", "Successful", "Completed"]
|
||||
lostStatuses = [
|
||||
"Lost",
|
||||
"Closed",
|
||||
"Closed Lost",
|
||||
"Junk",
|
||||
"Unqualified",
|
||||
"Disqualified",
|
||||
"Cancelled",
|
||||
"No Response",
|
||||
]
|
||||
|
||||
for status in deal_statuses:
|
||||
if not status.type or status.type is None or status.type == "Open":
|
||||
if status.deal_status in openStatuses:
|
||||
type = "Open"
|
||||
elif status.deal_status in ongoingStatuses:
|
||||
type = "Ongoing"
|
||||
elif status.deal_status in onHoldStatuses:
|
||||
type = "On Hold"
|
||||
elif status.deal_status in wonStatuses:
|
||||
type = "Won"
|
||||
elif status.deal_status in lostStatuses:
|
||||
type = "Lost"
|
||||
else:
|
||||
type = "Ongoing"
|
||||
|
||||
frappe.db.set_value("CRM Deal Status", status.name, "type", type)
|
||||
@ -1,4 +1,4 @@
|
||||
<h2>You have been invited to join Frappe CRM</h2>
|
||||
<p>You have been invited to join Frappe CRM</p>
|
||||
<p>
|
||||
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
|
||||
</p>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
import click
|
||||
import frappe
|
||||
|
||||
|
||||
def before_uninstall():
|
||||
delete_email_template_custom_fields()
|
||||
|
||||
|
||||
def delete_email_template_custom_fields():
|
||||
if frappe.get_meta("Email Template").has_field("enabled"):
|
||||
click.secho("* Uninstalling Custom Fields from Email Template")
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
import functools
|
||||
|
||||
import frappe
|
||||
import phonenumbers
|
||||
import requests
|
||||
from frappe import _
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
from frappe.utils import floor
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers import PhoneNumberFormat as PNF
|
||||
@ -93,3 +100,187 @@ def seconds_to_duration(seconds):
|
||||
return f"{seconds}s"
|
||||
else:
|
||||
return "0s"
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_linked
|
||||
def get_linked_docs(doc, method="Delete"):
|
||||
from frappe.model.rename_doc import get_link_fields
|
||||
|
||||
link_fields = get_link_fields(doc.doctype)
|
||||
ignored_doctypes = set()
|
||||
|
||||
if method == "Cancel" and (doc_ignore_flags := doc.get("ignore_linked_doctypes")):
|
||||
ignored_doctypes.update(doc_ignore_flags)
|
||||
if method == "Delete":
|
||||
ignored_doctypes.update(frappe.get_hooks("ignore_links_on_delete"))
|
||||
|
||||
docs = []
|
||||
|
||||
for lf in link_fields:
|
||||
link_dt, link_field, issingle = lf["parent"], lf["fieldname"], lf["issingle"]
|
||||
if link_dt in ignored_doctypes or (link_field == "amended_from" and method == "Cancel"):
|
||||
continue
|
||||
|
||||
try:
|
||||
meta = frappe.get_meta(link_dt)
|
||||
except frappe.DoesNotExistError:
|
||||
frappe.clear_last_message()
|
||||
# This mostly happens when app do not remove their customizations, we shouldn't
|
||||
# prevent link checks from failing in those cases
|
||||
continue
|
||||
|
||||
if issingle:
|
||||
if frappe.db.get_single_value(link_dt, link_field) == doc.name:
|
||||
docs.append({"doc": doc.name, "link_dt": link_dt, "link_field": link_field})
|
||||
continue
|
||||
|
||||
fields = ["name", "docstatus"]
|
||||
|
||||
if meta.istable:
|
||||
fields.extend(["parent", "parenttype"])
|
||||
|
||||
for item in frappe.db.get_values(link_dt, {link_field: doc.name}, fields, as_dict=True):
|
||||
# available only in child table cases
|
||||
item_parent = getattr(item, "parent", None)
|
||||
linked_parent_doctype = item.parenttype if item_parent else link_dt
|
||||
|
||||
if linked_parent_doctype in ignored_doctypes:
|
||||
continue
|
||||
|
||||
if method != "Delete" and (method != "Cancel" or not DocStatus(item.docstatus).is_submitted()):
|
||||
# don't raise exception if not
|
||||
# linked to a non-cancelled doc when deleting or to a submitted doc when cancelling
|
||||
continue
|
||||
elif link_dt == doc.doctype and (item_parent or item.name) == doc.name:
|
||||
# don't raise exception if not
|
||||
# linked to same item or doc having same name as the item
|
||||
continue
|
||||
else:
|
||||
reference_docname = item_parent or item.name
|
||||
docs.append(
|
||||
{
|
||||
"doc": doc.name,
|
||||
"reference_doctype": linked_parent_doctype,
|
||||
"reference_docname": reference_docname,
|
||||
}
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
# Extracted from frappe core frappe/model/delete_doc.py/check_if_doc_is_dynamically_linked
|
||||
def get_dynamic_linked_docs(doc, method="Delete"):
|
||||
docs = []
|
||||
for df in get_dynamic_link_map().get(doc.doctype, []):
|
||||
ignore_linked_doctypes = doc.get("ignore_linked_doctypes") or []
|
||||
|
||||
if df.parent in frappe.get_hooks("ignore_links_on_delete") or (
|
||||
df.parent in ignore_linked_doctypes and method == "Cancel"
|
||||
):
|
||||
# don't check for communication and todo!
|
||||
continue
|
||||
|
||||
meta = frappe.get_meta(df.parent)
|
||||
if meta.issingle:
|
||||
# dynamic link in single doc
|
||||
refdoc = frappe.db.get_singles_dict(df.parent)
|
||||
if (
|
||||
refdoc.get(df.options) == doc.doctype
|
||||
and refdoc.get(df.fieldname) == doc.name
|
||||
and (
|
||||
# linked to an non-cancelled doc when deleting
|
||||
(method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled())
|
||||
# linked to a submitted doc when cancelling
|
||||
or (method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted())
|
||||
)
|
||||
):
|
||||
docs.append({"doc": doc.name, "reference_doctype": df.parent, "reference_docname": df.parent})
|
||||
else:
|
||||
# dynamic link in table
|
||||
df["table"] = ", `parent`, `parenttype`, `idx`" if meta.istable else ""
|
||||
for refdoc in frappe.db.sql(
|
||||
"""select `name`, `docstatus` {table} from `tab{parent}` where
|
||||
`{options}`=%s and `{fieldname}`=%s""".format(**df),
|
||||
(doc.doctype, doc.name),
|
||||
as_dict=True,
|
||||
):
|
||||
# linked to an non-cancelled doc when deleting
|
||||
# or linked to a submitted doc when cancelling
|
||||
if (method == "Delete" and not DocStatus(refdoc.docstatus).is_cancelled()) or (
|
||||
method == "Cancel" and DocStatus(refdoc.docstatus).is_submitted()
|
||||
):
|
||||
reference_doctype = refdoc.parenttype if meta.istable else df.parent
|
||||
reference_docname = refdoc.parent if meta.istable else refdoc.name
|
||||
|
||||
if reference_doctype in frappe.get_hooks("ignore_links_on_delete") or (
|
||||
reference_doctype in ignore_linked_doctypes and method == "Cancel"
|
||||
):
|
||||
# don't check for communication and todo!
|
||||
continue
|
||||
|
||||
at_position = f"at Row: {refdoc.idx}" if meta.istable else ""
|
||||
|
||||
docs.append(
|
||||
{
|
||||
"doc": doc.name,
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_docname": reference_docname,
|
||||
"at_position": at_position,
|
||||
}
|
||||
)
|
||||
return docs
|
||||
|
||||
|
||||
def is_admin(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an admin
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an admin
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return user == "Administrator"
|
||||
|
||||
|
||||
def is_sales_user(user: str | None = None) -> bool:
|
||||
"""
|
||||
Check whether `user` is an agent
|
||||
|
||||
:param user: User to check against, defaults to current user
|
||||
:return: Whether `user` is an agent
|
||||
"""
|
||||
user = user or frappe.session.user
|
||||
return is_admin() or "Sales Manager" in frappe.get_roles(user) or "Sales User" in frappe.get_roles(user)
|
||||
|
||||
|
||||
def sales_user_only(fn):
|
||||
"""Decorator to validate if user is an agent."""
|
||||
|
||||
@functools.wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not is_sales_user():
|
||||
frappe.throw(
|
||||
msg=_("You are not permitted to access this resource."),
|
||||
title=_("Not Allowed"),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
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,8 +1,10 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# GNU GPLv3 License. See license.txt
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import frappe
|
||||
from frappe import safe_decode
|
||||
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
|
||||
from frappe.utils import cint, get_system_timezone
|
||||
from frappe.utils.telemetry import capture
|
||||
@ -49,3 +51,15 @@ def get_boot():
|
||||
|
||||
def get_default_route():
|
||||
return "/crm"
|
||||
|
||||
|
||||
def run_git_command(command):
|
||||
try:
|
||||
with open(os.devnull, "wb") as null_stream:
|
||||
result = subprocess.check_output(command, shell=True, stdin=null_stream, stderr=null_stream)
|
||||
return safe_decode(result).strip()
|
||||
except Exception:
|
||||
frappe.log_error(
|
||||
title="Git Command Error",
|
||||
)
|
||||
return ""
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 3423aa5b5c38d3a1b143ae8ab08cbde7360f9a7c
|
||||
Subproject commit b295b54aaa3a2a22f455df819d87433dc6b9ff6a
|
||||
47
frontend/components.d.ts
vendored
47
frontend/components.d.ts
vendored
@ -8,9 +8,12 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AboutModal: typeof import('./src/components/Modals/AboutModal.vue')['default']
|
||||
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']
|
||||
AllModals: typeof import('./src/components/Activities/AllModals.vue')['default']
|
||||
@ -29,6 +32,8 @@ 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']
|
||||
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']
|
||||
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
|
||||
@ -37,6 +42,7 @@ declare module 'vue' {
|
||||
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
|
||||
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
|
||||
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
|
||||
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
|
||||
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
|
||||
@ -52,16 +58,21 @@ declare module 'vue' {
|
||||
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
|
||||
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
||||
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
||||
ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.vue')['default']
|
||||
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']
|
||||
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']
|
||||
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
|
||||
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
|
||||
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
|
||||
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
|
||||
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
|
||||
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
|
||||
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
|
||||
@ -75,19 +86,28 @@ declare module 'vue' {
|
||||
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']
|
||||
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
||||
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
||||
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
|
||||
EmailAccountCard: typeof import('./src/components/Settings/EmailAccountCard.vue')['default']
|
||||
EmailAccountList: typeof import('./src/components/Settings/EmailAccountList.vue')['default']
|
||||
EmailAdd: typeof import('./src/components/Settings/EmailAdd.vue')['default']
|
||||
EmailArea: typeof import('./src/components/Activities/EmailArea.vue')['default']
|
||||
EmailAtIcon: typeof import('./src/components/Icons/EmailAtIcon.vue')['default']
|
||||
EmailConfig: typeof import('./src/components/Settings/EmailConfig.vue')['default']
|
||||
EmailContent: typeof import('./src/components/Activities/EmailContent.vue')['default']
|
||||
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
|
||||
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
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']
|
||||
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
|
||||
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
|
||||
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
|
||||
@ -106,9 +126,12 @@ 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']
|
||||
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
||||
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']
|
||||
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
|
||||
@ -118,6 +141,7 @@ declare module 'vue' {
|
||||
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
|
||||
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
|
||||
HomeActions: typeof import('./src/components/Settings/General/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']
|
||||
@ -125,21 +149,28 @@ declare module 'vue' {
|
||||
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
|
||||
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
|
||||
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
|
||||
InviteMemberPage: typeof import('./src/components/Settings/InviteMemberPage.vue')['default']
|
||||
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
|
||||
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
|
||||
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
|
||||
KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default']
|
||||
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
|
||||
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
|
||||
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
|
||||
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
|
||||
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
|
||||
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
|
||||
Link: typeof import('./src/components/Controls/Link.vue')['default']
|
||||
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default']
|
||||
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
|
||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||
LucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
LucidePenLine: typeof import('~icons/lucide/pen-line')['default']
|
||||
LucideRefreshCcw: typeof import('~icons/lucide/refresh-ccw')['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']
|
||||
@ -149,10 +180,13 @@ declare module 'vue' {
|
||||
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
|
||||
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
|
||||
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
|
||||
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
||||
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||
MultiSelectUserInput: typeof import('./src/components/Controls/MultiSelectUserInput.vue')['default']
|
||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||
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']
|
||||
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
|
||||
@ -162,6 +196,7 @@ declare module 'vue' {
|
||||
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
|
||||
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
|
||||
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
|
||||
Password: typeof import('./src/components/Controls/Password.vue')['default']
|
||||
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
|
||||
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
|
||||
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
|
||||
@ -169,7 +204,6 @@ 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']
|
||||
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']
|
||||
@ -196,6 +230,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']
|
||||
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']
|
||||
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
|
||||
@ -206,12 +241,14 @@ declare module 'vue' {
|
||||
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
|
||||
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
|
||||
TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.vue')['default']
|
||||
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
|
||||
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
||||
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
|
||||
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
||||
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
|
||||
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
|
||||
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
|
||||
Users: typeof import('./src/components/Settings/Users.vue')['default']
|
||||
ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
|
||||
ViewControls: typeof import('./src/components/ViewControls.vue')['default']
|
||||
ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default']
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
"name": "crm-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --base=/assets/crm/frontend/ && yarn copy-html-entry",
|
||||
@ -9,9 +10,10 @@
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-paragraph": "^2.12.0",
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.121",
|
||||
"frappe-ui": "^0.1.171",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
module.exports = {
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<Layout v-if="session().isLoggedIn">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
<Toasts />
|
||||
</FrappeUIProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { sessionStore as session } from '@/stores/session'
|
||||
import { setTheme } from '@/stores/theme'
|
||||
import { Toasts, setConfig } from 'frappe-ui'
|
||||
import { FrappeUIProvider, setConfig } from 'frappe-ui'
|
||||
import { computed, defineAsyncComponent, onMounted } from 'vue'
|
||||
|
||||
const MobileLayout = defineAsyncComponent(
|
||||
|
||||
@ -250,14 +250,14 @@
|
||||
</span>
|
||||
<span v-if="activity.type">{{ __(activity.type) }}</span>
|
||||
<span
|
||||
v-if="activity.data.field_label"
|
||||
v-if="activity.data?.field_label"
|
||||
class="max-w-xs truncate font-medium text-ink-gray-8"
|
||||
>
|
||||
{{ __(activity.data.field_label) }}
|
||||
</span>
|
||||
<span v-if="activity.value">{{ __(activity.value) }}</span>
|
||||
<span
|
||||
v-if="activity.data.old_value"
|
||||
v-if="activity.data?.old_value"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
@ -273,7 +273,7 @@
|
||||
</span>
|
||||
<span v-if="activity.to">{{ __('to') }}</span>
|
||||
<span
|
||||
v-if="activity.data.value"
|
||||
v-if="activity.data?.value"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
@ -307,7 +307,7 @@
|
||||
>
|
||||
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
|
||||
<span
|
||||
v-if="activity.data.field_label"
|
||||
v-if="activity.data?.field_label"
|
||||
class="max-w-xs truncate text-ink-gray-5"
|
||||
>
|
||||
{{ __(activity.data.field_label) }}
|
||||
@ -320,7 +320,7 @@
|
||||
{{ startCase(__(activity.type)) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activity.data.old_value"
|
||||
v-if="activity.data?.old_value"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
@ -336,7 +336,7 @@
|
||||
</span>
|
||||
<span v-if="activity.to">{{ __('to') }}</span>
|
||||
<span
|
||||
v-if="activity.data.value"
|
||||
v-if="activity.data?.value"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
@ -365,7 +365,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
|
||||
<DataFields :doctype="doctype" :docname="doc.data.name" />
|
||||
<DataFields
|
||||
:doctype="doctype"
|
||||
:docname="doc.data.name"
|
||||
@beforeSave="(data) => emit('beforeSave', data)"
|
||||
@afterSave="(data) => emit('afterSave', data)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@ -373,11 +378,7 @@
|
||||
>
|
||||
<component :is="emptyTextIcon" class="h-10 w-10" />
|
||||
<span>{{ __(emptyText) }}</span>
|
||||
<Button
|
||||
v-if="title == 'Calls'"
|
||||
:label="__('Make a Call')"
|
||||
@click="makeCall(doc.data.mobile_no)"
|
||||
/>
|
||||
<MultiActionButton v-if="title == 'Calls'" :options="callActions" />
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
:label="__('Create Note')"
|
||||
@ -470,6 +471,7 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
||||
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import DotIcon from '@/components/Icons/DotIcon.vue'
|
||||
@ -487,7 +489,7 @@ import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||
import { timeAgo, formatDate, startCase } from '@/utils'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { whatsappEnabled } from '@/composables/settings'
|
||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
@ -517,6 +519,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const doc = defineModel()
|
||||
@ -785,5 +789,23 @@ function scroll(hash) {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
defineExpose({ emailBox, all_activities })
|
||||
const callActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
label: __('Create Call Log'),
|
||||
onClick: () => modalRef.value.createCallLog(),
|
||||
},
|
||||
{
|
||||
label: __('Make a Call'),
|
||||
onClick: () => makeCall(doc.data.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
return actions.filter((action) =>
|
||||
action.condition ? action.condition() : true,
|
||||
)
|
||||
})
|
||||
|
||||
defineExpose({ emailBox, all_activities, changeTabTo })
|
||||
</script>
|
||||
|
||||
@ -26,16 +26,11 @@
|
||||
</template>
|
||||
<span>{{ __('New Comment') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
<MultiActionButton
|
||||
v-else-if="title == 'Calls'"
|
||||
variant="solid"
|
||||
@click="makeCall(doc.data.mobile_no)"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('Make a Call') }}</span>
|
||||
</Button>
|
||||
:options="callActions"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
variant="solid"
|
||||
@ -97,6 +92,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
@ -136,6 +132,11 @@ const defaultActions = computed(() => {
|
||||
label: __('New Comment'),
|
||||
onClick: () => (props.emailBox.showComment = true),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Create Call Log'),
|
||||
onClick: () => props.modalRef.createCallLog(),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Make a Call'),
|
||||
@ -172,4 +173,24 @@ const defaultActions = computed(() => {
|
||||
function getTabIndex(name) {
|
||||
return props.tabs.findIndex((tab) => tab.name === name)
|
||||
}
|
||||
|
||||
const callActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
label: __('Create Call Log'),
|
||||
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),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
return actions.filter((action) =>
|
||||
action.condition ? action.condition() : true,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -15,10 +15,18 @@
|
||||
:doc="doc.data?.name"
|
||||
@after="redirect('notes')"
|
||||
/>
|
||||
<CallLogModal
|
||||
v-if="showCallLogModal"
|
||||
v-model="showCallLogModal"
|
||||
:data="callLog"
|
||||
:referenceDoc="referenceDoc"
|
||||
:options="{ afterInsert: () => activities.reload() }"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||
import NoteModal from '@/components/Modals/NoteModal.vue'
|
||||
import CallLogModal from '@/components/Modals/CallLogModal.vue'
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@ -77,6 +85,22 @@ function showNote(n) {
|
||||
showNoteModal.value = true
|
||||
}
|
||||
|
||||
// Call Logs
|
||||
const showCallLogModal = ref(false)
|
||||
const callLog = ref({})
|
||||
const referenceDoc = ref({})
|
||||
|
||||
function createCallLog() {
|
||||
let doctype = props.doctype
|
||||
let docname = props.doc.data?.name
|
||||
referenceDoc.value = { ...props.doc.data }
|
||||
callLog.value = {
|
||||
reference_doctype: doctype,
|
||||
reference_docname: docname,
|
||||
}
|
||||
showCallLogModal.value = true
|
||||
}
|
||||
|
||||
// common
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -95,5 +119,6 @@ defineExpose({
|
||||
deleteTask,
|
||||
updateTaskStatus,
|
||||
showNote,
|
||||
createCallLog,
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -97,7 +97,11 @@
|
||||
v-model:callLogModal="showCallLogModal"
|
||||
v-model:callLog="callLog"
|
||||
/>
|
||||
<CallLogModal v-model="showCallLogModal" v-model:callLog="callLog" />
|
||||
<CallLogModal
|
||||
v-if="showCallLogModal"
|
||||
v-model="showCallLogModal"
|
||||
:data="callLog.data"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
||||
{{ __('Data') }}
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
v-if="document.isDirty"
|
||||
class="ml-3"
|
||||
:label="'Not Saved'"
|
||||
theme="orange"
|
||||
@ -16,20 +16,22 @@
|
||||
v-if="isManager() && !isMobileView"
|
||||
@click="showDataFieldsModal = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
label="Save"
|
||||
:disabled="!data.isDirty"
|
||||
:disabled="!document.isDirty"
|
||||
variant="solid"
|
||||
:loading="data.save.loading"
|
||||
:loading="document.save.loading"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.get.loading"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||
v-if="document.get.loading"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-6"
|
||||
>
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
@ -38,7 +40,7 @@
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
:data="data.doc"
|
||||
:data="document.doc"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
</div>
|
||||
@ -49,7 +51,7 @@
|
||||
@reload="
|
||||
() => {
|
||||
tabs.reload()
|
||||
data.reload()
|
||||
document.reload()
|
||||
}
|
||||
"
|
||||
/>
|
||||
@ -59,12 +61,12 @@
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
|
||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||
import { Badge, createResource, createDocumentResource } from 'frappe-ui'
|
||||
import { Badge, createResource } from 'frappe-ui'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import { createToast } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch, getCurrentInstance } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -77,32 +79,16 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
const attrs = instance?.vnode?.props ?? {}
|
||||
|
||||
const showDataFieldsModal = ref(false)
|
||||
|
||||
const data = createDocumentResource({
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
setValue: {
|
||||
onSuccess: () => {
|
||||
data.reload()
|
||||
createToast({
|
||||
title: 'Data Updated',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages[0],
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const { document } = useDocument(props.doctype, props.docname)
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
@ -112,6 +98,42 @@ const tabs = createResource({
|
||||
})
|
||||
|
||||
function saveChanges() {
|
||||
data.save.submit()
|
||||
if (!document.isDirty) return
|
||||
|
||||
const updatedDoc = { ...document.doc }
|
||||
const oldDoc = { ...document.originalDoc }
|
||||
|
||||
const changes = Object.keys(updatedDoc).reduce((acc, key) => {
|
||||
if (JSON.stringify(updatedDoc[key]) !== JSON.stringify(oldDoc[key])) {
|
||||
acc[key] = updatedDoc[key]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const hasListener = attrs['onBeforeSave'] !== undefined
|
||||
|
||||
if (hasListener) {
|
||||
emit('beforeSave', changes)
|
||||
} else {
|
||||
document.save.submit(null, {
|
||||
onSuccess: () => emit('afterSave', changes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => document.doc,
|
||||
(newValue, oldValue) => {
|
||||
if (!oldValue) return
|
||||
if (newValue && oldValue) {
|
||||
const isDirty =
|
||||
JSON.stringify(newValue) !== JSON.stringify(document.originalDoc)
|
||||
document.isDirty = isDirty
|
||||
if (isDirty) {
|
||||
document.save.loading = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
@ -22,9 +22,9 @@
|
||||
variant="subtle"
|
||||
:theme="status.color"
|
||||
/>
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<Tooltip :text="formatDate(activity.communication_date)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
{{ __(timeAgo(activity.communication_date)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-0.5">
|
||||
|
||||
154
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
154
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<template #body>
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{ __('Delete') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{
|
||||
__('Are you sure you want to delete {0} items?', [
|
||||
props.items?.length,
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:label="__('Delete {0} items', [props.items.length])"
|
||||
icon-left="trash-2"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="confirmDelete()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Unlink and delete {0} items', [props.items.length])"
|
||||
icon-left="unlock"
|
||||
variant="solid"
|
||||
@click="confirmUnlink()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body v-if="confirmDeleteInfo.show">
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{ __('Delete') }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-ink-gray-5">
|
||||
{{
|
||||
confirmDeleteInfo.delete
|
||||
? __(
|
||||
'This will delete selected items and items linked to it, are you sure?',
|
||||
)
|
||||
: __(
|
||||
'This will delete selected items and unlink linked items to it, are you sure?',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
:label="
|
||||
confirmDeleteInfo.delete ? __('Delete') : __('Unlink and delete')
|
||||
"
|
||||
:icon-left="confirmDeleteInfo.delete ? 'trash-2' : 'unlock'"
|
||||
variant="solid"
|
||||
theme="red"
|
||||
@click="deleteDocs()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Cancel')"
|
||||
variant="subtle"
|
||||
@click="confirmDeleteInfo.show = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
reload: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const confirmDeleteInfo = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
message: '',
|
||||
delete: false,
|
||||
})
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirmDeleteInfo.value = {
|
||||
show: true,
|
||||
title: __('Delete'),
|
||||
message: __('Are you sure you want to delete {0} linked doc(s)?', [
|
||||
props.items.length,
|
||||
]),
|
||||
delete: true,
|
||||
}
|
||||
}
|
||||
|
||||
const confirmUnlink = () => {
|
||||
confirmDeleteInfo.value = {
|
||||
show: true,
|
||||
title: __('Unlink'),
|
||||
message: __('Are you sure you want to unlink {0} linked doc(s)?', [
|
||||
props.items.length,
|
||||
]),
|
||||
delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
const deleteDocs = () => {
|
||||
call('crm.api.doc.delete_bulk_docs', {
|
||||
items: props.items,
|
||||
doctype: props.doctype,
|
||||
delete_linked: confirmDeleteInfo.value.delete,
|
||||
}).then(() => {
|
||||
confirmDeleteInfo.value = {
|
||||
show: false,
|
||||
title: '',
|
||||
}
|
||||
show.value = false
|
||||
props.reload()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@ -30,20 +30,24 @@
|
||||
<DragIcon class="h-3.5" />
|
||||
<div>{{ __(element.label) }}</div>
|
||||
</div>
|
||||
<div class="flex cursor-pointer items-center gap-1">
|
||||
<div class="flex cursor-pointer items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-5 w-5 !p-1"
|
||||
@click="editColumn(element)"
|
||||
>
|
||||
<template #icon>
|
||||
<EditIcon class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-5 w-5 !p-1"
|
||||
@click="removeColumn(element)"
|
||||
>
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -215,7 +219,9 @@ const fields = computed(() => {
|
||||
})
|
||||
|
||||
function addColumn(c) {
|
||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type) ? 'right' : 'left'
|
||||
let align = ['Float', 'Int', 'Percent', 'Currency'].includes(c.type)
|
||||
? 'right'
|
||||
: 'left'
|
||||
let _column = {
|
||||
label: c.label,
|
||||
type: c.fieldtype,
|
||||
|
||||
@ -149,7 +149,7 @@ function removeAttachment(attachment) {
|
||||
|
||||
const users = computed(() => {
|
||||
return (
|
||||
usersList.data
|
||||
usersList.data?.crmUsers
|
||||
?.filter((user) => user.enabled)
|
||||
.map((user) => ({
|
||||
label: user.full_name.trimEnd(),
|
||||
|
||||
58
frontend/src/components/Controls/FormattedInput.vue
Normal file
58
frontend/src/components/Controls/FormattedInput.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputRef"
|
||||
:value="displayValue"
|
||||
@focus="handleFocus"
|
||||
@blur="isFocused = false"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<slot name="description">
|
||||
<p v-if="attrs.description" class="mt-1.5" :class="descriptionClasses">
|
||||
{{ attrs.description }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextInput } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
formattedValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isFocused = ref(false)
|
||||
const inputRef = ref(null)
|
||||
|
||||
function handleFocus() {
|
||||
isFocused.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.el?.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return isFocused.value ? props.value : props.formattedValue || props.value
|
||||
})
|
||||
|
||||
const descriptionClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@ -33,10 +33,23 @@
|
||||
<div
|
||||
v-for="field in fields"
|
||||
class="border-r border-outline-gray-2 p-2 truncate"
|
||||
:class="
|
||||
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
|
||||
? 'text-right'
|
||||
: ''
|
||||
"
|
||||
:key="field.fieldname"
|
||||
:title="field.label"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12">
|
||||
@ -45,7 +58,9 @@
|
||||
variant="outline"
|
||||
@click="showGridFieldsEditorModal = true"
|
||||
>
|
||||
<FeatherIcon name="settings" class="h-4 w-4 text-ink-gray-7" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="settings" class="size-4 text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,18 +108,37 @@
|
||||
:key="field.fieldname"
|
||||
>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
v-if="
|
||||
field.read_only &&
|
||||
![
|
||||
'Int',
|
||||
'Float',
|
||||
'Currency',
|
||||
'Percent',
|
||||
'Check',
|
||||
].includes(field.fieldtype)
|
||||
"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
v-else-if="
|
||||
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||
"
|
||||
class="text-sm text-ink-gray-8"
|
||||
v-model="row[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:value="row[field.fieldname]"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link'
|
||||
? field.options
|
||||
: row[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
:onCreate="
|
||||
(value, close) => field.create(v, field, row, close)
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
@ -112,7 +146,7 @@
|
||||
:value="getUser(row[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (row[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
:placeholder="field.placeholder"
|
||||
:hideMe="true"
|
||||
>
|
||||
@ -142,23 +176,26 @@
|
||||
class="cursor-pointer duration-300"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="!gridSettings.editable_grid"
|
||||
@change="(e) => fieldChange(e.target.checked, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
@ -169,13 +206,8 @@
|
||||
rows="1"
|
||||
type="textarea"
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
@ -184,6 +216,55 @@
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
@change="(e) => fieldChange(e.target.value, field, row)"
|
||||
/>
|
||||
<Password
|
||||
v-else-if="field.fieldtype === 'Password'"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname] || '0'"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="(row[field.fieldname] || '0') + '%'"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="row[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getCurrencyWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="
|
||||
getFormattedCurrency(field.fieldname, row, parentDoc)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
@ -192,6 +273,7 @@
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -201,7 +283,9 @@
|
||||
variant="outline"
|
||||
@click="showRowList[index] = true"
|
||||
>
|
||||
<EditIcon class="h-4 w-4 text-ink-gray-7" />
|
||||
<template #icon>
|
||||
<EditIcon class="text-ink-gray-7" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<GridRowModal
|
||||
@ -252,6 +336,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
||||
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
||||
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
||||
@ -259,8 +345,10 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
@ -268,9 +356,10 @@ import {
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
dayjs,
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, reactive, computed, inject, provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@ -285,15 +374,32 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
parentFieldname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { getGridViewSettings, getFields, getGridSettings } = getMeta(
|
||||
props.doctype,
|
||||
)
|
||||
const triggerOnChange = inject('triggerOnChange', () => {})
|
||||
const triggerOnRowAdd = inject('triggerOnRowAdd', () => {})
|
||||
const triggerOnRowRemove = inject('triggerOnRowRemove', () => {})
|
||||
|
||||
const {
|
||||
getGridViewSettings,
|
||||
getFields,
|
||||
getFloatWithPrecision,
|
||||
getCurrencyWithPrecision,
|
||||
getFormattedCurrency,
|
||||
getGridSettings,
|
||||
} = getMeta(props.doctype)
|
||||
getMeta(props.parentDoctype)
|
||||
const { getUser } = usersStore()
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
const rows = defineModel()
|
||||
const parentDoc = defineModel('parent')
|
||||
|
||||
provide('parentDoc', parentDoc)
|
||||
|
||||
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
||||
const selectedRows = reactive(new Set())
|
||||
|
||||
@ -316,7 +422,30 @@ const fields = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const allFields = computed(() => {
|
||||
return getFields()?.map((f) => getFieldObj(f)) || []
|
||||
})
|
||||
|
||||
function getFieldObj(field) {
|
||||
if (field.fieldtype === 'Link' && field.options !== 'User') {
|
||||
if (!field.create) {
|
||||
field.create = (value, field, row, close) => {
|
||||
const callback = (d) => {
|
||||
if (d) fieldChange(d.name, field, row)
|
||||
}
|
||||
createDocument(field.options, value, close, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
@ -361,21 +490,70 @@ const toggleSelectRow = (row) => {
|
||||
|
||||
const addRow = () => {
|
||||
const newRow = {}
|
||||
fields.value?.forEach((field) => {
|
||||
if (field.fieldtype === 'Check') newRow[field.fieldname] = false
|
||||
else newRow[field.fieldname] = ''
|
||||
allFields.value?.forEach((field) => {
|
||||
if (field.fieldtype === 'Check') {
|
||||
newRow[field.fieldname] = false
|
||||
} else {
|
||||
newRow[field.fieldname] = ''
|
||||
}
|
||||
|
||||
if (field.default) {
|
||||
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
|
||||
}
|
||||
})
|
||||
newRow.name = getRandom(10)
|
||||
showRowList.value.push(false)
|
||||
newRow['__islocal'] = true
|
||||
newRow['idx'] = rows.value.length + 1
|
||||
newRow['doctype'] = props.doctype
|
||||
newRow['parentfield'] = props.parentFieldname
|
||||
newRow['parenttype'] = props.parentDoctype
|
||||
rows.value.push(newRow)
|
||||
triggerOnRowAdd(newRow)
|
||||
}
|
||||
|
||||
const deleteRows = () => {
|
||||
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
||||
triggerOnRowRemove(selectedRows, rows.value)
|
||||
|
||||
showRowList.value.pop()
|
||||
selectedRows.clear()
|
||||
}
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
triggerOnChange(field.fieldname, value, row)
|
||||
}
|
||||
|
||||
function getDefaultValue(defaultValue, fieldtype) {
|
||||
if (['Float', 'Currency', 'Percent'].includes(fieldtype)) {
|
||||
return flt(defaultValue)
|
||||
} else if (fieldtype === 'Check') {
|
||||
if (['1', 'true', 'True'].includes(defaultValue)) {
|
||||
return true
|
||||
} else if (['0', 'false', 'False'].includes(defaultValue)) {
|
||||
return false
|
||||
}
|
||||
} else if (fieldtype === 'Int') {
|
||||
return parseInt(defaultValue)
|
||||
} else if (defaultValue === 'Today' && fieldtype === 'Date') {
|
||||
return dayjs().format('YYYY-MM-DD')
|
||||
} else if (
|
||||
['Now', 'now'].includes(defaultValue) &&
|
||||
fieldtype === 'Datetime'
|
||||
) {
|
||||
return dayjs().format('YYYY-MM-DD HH:mm:ss')
|
||||
} else if (['Now', 'now'].includes(defaultValue) && fieldtype === 'Time') {
|
||||
return dayjs().format('HH:mm:ss')
|
||||
} else if (fieldtype === 'Date') {
|
||||
return dayjs(defaultValue).format('YYYY-MM-DD')
|
||||
} else if (fieldtype === 'Datetime') {
|
||||
return dayjs(defaultValue).format('YYYY-MM-DD HH:mm:ss')
|
||||
} else if (fieldtype === 'Time') {
|
||||
return dayjs(defaultValue).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -139,9 +139,14 @@ const oldFields = computed(() => {
|
||||
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
||||
|
||||
const dropdownFields = computed(() => {
|
||||
return getFields()?.filter(
|
||||
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
|
||||
return getFields()?.filter((field) => {
|
||||
return (
|
||||
!fields.value.find((f) => f.fieldname === field.fieldname) &&
|
||||
!['Tab Break', 'Section Break', 'Column Break', 'Table'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function reset() {
|
||||
|
||||
@ -15,15 +15,25 @@
|
||||
class="w-7"
|
||||
@click="openGridRowFieldsModal"
|
||||
>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
<Button variant="ghost" class="w-7" @click="show = false">
|
||||
<FeatherIcon name="x" class="h-4 w-4" />
|
||||
<template #icon>
|
||||
<FeatherIcon name="x" class="size-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" />
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
:data="data"
|
||||
:doctype="doctype"
|
||||
:isGridRow="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
:size="attrs.size || 'sm'"
|
||||
:variant="attrs.variant"
|
||||
:placeholder="attrs.placeholder"
|
||||
:disabled="attrs.disabled"
|
||||
:placement="attrs.placement"
|
||||
:filterable="false"
|
||||
>
|
||||
<template #target="{ open, togglePopover }">
|
||||
@ -159,6 +161,7 @@ const options = createResource({
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
if (!props.doctype) return
|
||||
if (
|
||||
options.data?.length &&
|
||||
val === options.params?.txt &&
|
||||
|
||||
@ -58,6 +58,21 @@
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!options.length"
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="fetchContacts"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{
|
||||
fetchContacts
|
||||
? __('No results found')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
@ -137,6 +152,14 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchContacts: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
@ -186,22 +209,32 @@ const filterOptions = createResource({
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out existing emails
|
||||
if (props.existingEmails?.length) {
|
||||
allData = allData.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let searchedContacts = filterOptions.data || []
|
||||
if (!searchedContacts.length && query.value) {
|
||||
let searchedContacts = props.fetchContacts ? filterOptions.data : []
|
||||
if (!searchedContacts?.length && query.value) {
|
||||
searchedContacts.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
return searchedContacts
|
||||
return searchedContacts || []
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
if (!props.fetchContacts) return
|
||||
|
||||
filterOptions.update({
|
||||
params: { txt: val },
|
||||
})
|
||||
|
||||
278
frontend/src/components/Controls/MultiSelectUserInput.vue
Normal file
278
frontend/src/components/Controls/MultiSelectUserInput.vue
Normal file
@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
ref="emails"
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:class="{
|
||||
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
}"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover class="w-full" v-model:show="showOptions">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-white hover:bg-surface-white'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value
|
||||
showOptions = true
|
||||
}
|
||||
"
|
||||
autocomplete="off"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-if="!options.length"
|
||||
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<FeatherIcon
|
||||
v-if="fetchUsers"
|
||||
name="search"
|
||||
class="h-4"
|
||||
/>
|
||||
{{
|
||||
fetchUsers
|
||||
? __('No results found')
|
||||
: __('Type an email address to invite')
|
||||
}}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option"
|
||||
v-slot="{ active }"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-surface-gray-3': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from '@headlessui/vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Popover from '@/components/frappe-ui/Popover.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'subtle',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inputClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
fetchUsers: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
existingEmails: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const values = defineModel()
|
||||
|
||||
const { users } = usersStore()
|
||||
|
||||
const emails = ref([])
|
||||
const search = ref(null)
|
||||
const error = ref(null)
|
||||
const info = ref(null)
|
||||
const query = ref('')
|
||||
const showOptions = ref(false)
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || '',
|
||||
set: (val) => {
|
||||
query.value = ''
|
||||
if (val) {
|
||||
showOptions.value = false
|
||||
}
|
||||
val?.value && addValue(val.value)
|
||||
},
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
|
||||
|
||||
if (props.fetchUsers) {
|
||||
userEmails = userEmails.map((user) => ({
|
||||
label: user.full_name || user.name || user.email,
|
||||
value: user.email,
|
||||
}))
|
||||
|
||||
if (props.existingEmails?.length) {
|
||||
userEmails = userEmails.filter((option) => {
|
||||
return !props.existingEmails.includes(option.value)
|
||||
})
|
||||
}
|
||||
|
||||
if (query.value) {
|
||||
userEmails = userEmails.filter(
|
||||
(option) =>
|
||||
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(query.value.toLowerCase()),
|
||||
)
|
||||
}
|
||||
} else if (!userEmails?.length && query.value) {
|
||||
userEmails.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
})
|
||||
}
|
||||
|
||||
return userEmails || []
|
||||
})
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null
|
||||
info.value = null
|
||||
if (value) {
|
||||
const splitValues = value.split(',')
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim()
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value)
|
||||
query.value = value
|
||||
return
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value]
|
||||
} else {
|
||||
values.value.push(value)
|
||||
}
|
||||
value = value.replace(value, '')
|
||||
} else {
|
||||
info.value = __('email already exists')
|
||||
}
|
||||
}
|
||||
})
|
||||
!error.value && (value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop()
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el
|
||||
emailRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
emailRef?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
</script>
|
||||
62
frontend/src/components/Controls/Password.vue
Normal file
62
frontend/src/components/Controls/Password.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<FormControl
|
||||
:type="show ? 'text' : 'password'"
|
||||
:value="modelValue || value"
|
||||
v-bind="$attrs"
|
||||
@keydown.meta.i.prevent="show = !show"
|
||||
@keydown.ctrl.i.prevent="show = !show"
|
||||
>
|
||||
<template #prefix v-if="$slots.prefix">
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ show ? __('Hide Password') : __('Show Password') }}
|
||||
<KeyboardShortcut
|
||||
bg
|
||||
ctrl
|
||||
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
|
||||
>
|
||||
<span class="font-mono leading-none tracking-widest">+I</span>
|
||||
</KeyboardShortcut>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<FeatherIcon
|
||||
v-show="showEye"
|
||||
:name="show ? 'eye-off' : 'eye'"
|
||||
class="h-3 cursor-pointer mr-1"
|
||||
@click="show = !show"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</FormControl>
|
||||
</template>
|
||||
<script setup>
|
||||
import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
|
||||
import { FormControl, Tooltip } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
const show = ref(false)
|
||||
const showEye = computed(() => {
|
||||
let v = props.modelValue || props.value
|
||||
return !v?.includes('*')
|
||||
})
|
||||
</script>
|
||||
@ -60,9 +60,14 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const { getFields } = getMeta(props.doctype)
|
||||
|
||||
const values = defineModel()
|
||||
const values = defineModel({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const valuesRef = ref([])
|
||||
const error = ref(null)
|
||||
@ -109,14 +114,16 @@ const addValue = (value) => {
|
||||
|
||||
if (value) {
|
||||
values.value.push({ [linkField.value.fieldname]: value })
|
||||
emit('change', values.value)
|
||||
!error.value && (query.value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter(
|
||||
let _value = values.value.filter(
|
||||
(row) => row[linkField.value.fieldname] !== value,
|
||||
)
|
||||
emit('change', _value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
@ -125,12 +132,11 @@ const removeLastValue = () => {
|
||||
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
|
||||
if (document.activeElement === valueRef) {
|
||||
values.value.pop()
|
||||
emit('change', values.value)
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
|
||||
valueRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
|
||||
@ -37,8 +37,8 @@ import { isMobileView } from '@/composables/settings'
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Object,
|
||||
required: true,
|
||||
type: [Object, Array, undefined],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
@ -85,7 +85,7 @@ const groupedActions = computed(() => {
|
||||
})
|
||||
}
|
||||
_actions = _actions.concat(
|
||||
props.actions.filter((action) => action.group && !action.buttonLabel)
|
||||
props.actions.filter((action) => action.group && !action.buttonLabel),
|
||||
)
|
||||
return _actions
|
||||
})
|
||||
|
||||
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>
|
||||
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
49
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<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 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>
|
||||
265
frontend/src/components/DeleteLinkedDocModal.vue
Normal file
265
frontend/src/components/DeleteLinkedDocModal.vue
Normal file
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<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>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{
|
||||
linkedDocs?.length == 0
|
||||
? __('Delete')
|
||||
: __('Delete or unlink linked documents')
|
||||
}}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="linkedDocs?.length > 0">
|
||||
<span class="text-ink-gray-5 text-base">
|
||||
{{
|
||||
__(
|
||||
'Delete or unlink these linked documents before deleting this document',
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<LinkedDocsListView
|
||||
class="mt-4"
|
||||
:rows="linkedDocs"
|
||||
:columns="[
|
||||
{
|
||||
label: 'Document',
|
||||
key: 'title',
|
||||
},
|
||||
{
|
||||
label: 'Master',
|
||||
key: 'reference_doctype',
|
||||
width: '30%',
|
||||
},
|
||||
]"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
:linkedDocsResource="linkedDocsResource"
|
||||
:unlinkLinkedDoc="unlinkLinkedDoc"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="linkedDocs?.length == 0" class="text-ink-gray-5 text-base">
|
||||
{{
|
||||
__('Are you sure you want to delete {0} - {1}?', [
|
||||
props.doctype,
|
||||
props.docname,
|
||||
])
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 pb-7 pt-0 sm:px-6">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
v-if="linkedDocs?.length > 0"
|
||||
:label="
|
||||
viewControls?.selections?.length == 0
|
||||
? __('Delete all')
|
||||
: __('Delete {0} item(s)', [viewControls?.selections?.length])
|
||||
"
|
||||
theme="red"
|
||||
variant="solid"
|
||||
icon-left="trash-2"
|
||||
@click="confirmDelete()"
|
||||
/>
|
||||
<Button
|
||||
v-if="linkedDocs?.length > 0"
|
||||
:label="
|
||||
viewControls?.selections?.length == 0
|
||||
? __('Unlink all')
|
||||
: __('Unlink {0} item(s)', [viewControls?.selections?.length])
|
||||
"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
icon-left="unlock"
|
||||
@click="confirmUnlink()"
|
||||
/>
|
||||
<Button
|
||||
v-if="linkedDocs?.length == 0"
|
||||
variant="solid"
|
||||
icon-left="trash-2"
|
||||
:label="__('Delete')"
|
||||
:loading="isDealCreating"
|
||||
@click="deleteDoc()"
|
||||
theme="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #body v-if="confirmDeleteInfo.show">
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-2xl leading-6 text-ink-gray-9 font-semibold">
|
||||
{{ confirmDeleteInfo.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button variant="ghost" icon="x" @click="show = false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-gray-5 text-base">
|
||||
{{ confirmDeleteInfo.message }}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<Button variant="ghost" @click="cancel()">
|
||||
{{ __('Cancel') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="confirmDeleteInfo.title"
|
||||
@click="removeDocLinks()"
|
||||
theme="red"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { createResource, call } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
const router = useRouter()
|
||||
const props = defineProps({
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
reload: {
|
||||
type: Function,
|
||||
},
|
||||
})
|
||||
const viewControls = ref({
|
||||
selections: [],
|
||||
updateSelections: (selections) => {
|
||||
viewControls.value.selections = Array.from(selections || [])
|
||||
},
|
||||
})
|
||||
|
||||
const confirmDeleteInfo = ref({
|
||||
show: false,
|
||||
title: '',
|
||||
})
|
||||
|
||||
const linkedDocsResource = createResource({
|
||||
url: 'crm.api.doc.get_linked_docs_of_document',
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
docname: props.docname,
|
||||
},
|
||||
auto: true,
|
||||
validate(params) {
|
||||
if (!params?.doctype || !params?.docname) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const linkedDocs = computed(() => {
|
||||
return (
|
||||
linkedDocsResource.data?.map((doc) => ({
|
||||
id: doc.reference_docname,
|
||||
...doc,
|
||||
})) || []
|
||||
)
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
confirmDeleteInfo.value.show = false
|
||||
viewControls.value.updateSelections([])
|
||||
}
|
||||
|
||||
const unlinkLinkedDoc = (doc) => {
|
||||
let selectedDocs = []
|
||||
if (viewControls.value.selections.length > 0) {
|
||||
Array.from(viewControls.value.selections).forEach((selection) => {
|
||||
const docData = linkedDocs.value.find((d) => d.id == selection)
|
||||
selectedDocs.push({
|
||||
doctype: docData.reference_doctype,
|
||||
docname: docData.reference_docname,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
selectedDocs = linkedDocs.value.map((doc) => ({
|
||||
doctype: doc.reference_doctype,
|
||||
docname: doc.reference_docname,
|
||||
}))
|
||||
}
|
||||
|
||||
call('crm.api.doc.remove_linked_doc_reference', {
|
||||
items: selectedDocs,
|
||||
remove_contact: props.doctype == 'Contact',
|
||||
delete: doc.delete,
|
||||
}).then(() => {
|
||||
linkedDocsResource.reload()
|
||||
confirmDeleteInfo.value = {
|
||||
show: false,
|
||||
title: '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
const items =
|
||||
viewControls.value.selections.length == 0
|
||||
? 'all'
|
||||
: viewControls.value.selections.length
|
||||
confirmDeleteInfo.value = {
|
||||
show: true,
|
||||
title: __('Delete linked item'),
|
||||
message: __('Are you sure you want to delete {0} linked item(s)?', [items]),
|
||||
delete: true,
|
||||
}
|
||||
}
|
||||
|
||||
const confirmUnlink = () => {
|
||||
const items =
|
||||
viewControls.value.selections.length == 0
|
||||
? 'all'
|
||||
: viewControls.value.selections.length
|
||||
confirmDeleteInfo.value = {
|
||||
show: true,
|
||||
title: __('Unlink linked item'),
|
||||
message: __('Are you sure you want to unlink {0} linked item(s)?', [items]),
|
||||
delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
const removeDocLinks = () => {
|
||||
unlinkLinkedDoc({
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.docname,
|
||||
delete: confirmDeleteInfo.value.delete,
|
||||
})
|
||||
viewControls.value.updateSelections([])
|
||||
}
|
||||
|
||||
const deleteDoc = async () => {
|
||||
await call('frappe.client.delete', {
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
})
|
||||
router.push({ name: props.name })
|
||||
props?.reload?.()
|
||||
}
|
||||
</script>
|
||||
@ -31,7 +31,9 @@
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="option.onClick"
|
||||
>
|
||||
<template #icon>
|
||||
<SuccessIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -43,7 +45,9 @@
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
<template #icon>
|
||||
<EditIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@ -150,7 +150,7 @@
|
||||
@click="showEmailTemplateSelectorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<Email2Icon class="h-4" />
|
||||
<EmailTemplateIcon class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -176,7 +176,7 @@
|
||||
<script setup>
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
|
||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
|
||||
|
||||
24
frontend/src/components/ErrorPage.vue
Normal file
24
frontend/src/components/ErrorPage.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid h-full place-items-center px-4 py-20 text-center text-lg text-ink-gray-5"
|
||||
>
|
||||
<div class="flex flex-col justify-between items-center gap-3">
|
||||
<FeatherIcon name="x-octagon" class="h-12 w-12 text-ink-red-3" />
|
||||
<div class="text-2xl font-semibold">{{ errorTitle }}</div>
|
||||
<div v-html="errorMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
errorTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -7,22 +7,30 @@
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-3"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
v-if="
|
||||
field.read_only &&
|
||||
!['Int', 'Float', 'Currency', 'Percent', 'Check'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:disabled="true"
|
||||
:description="field.description"
|
||||
/>
|
||||
<Grid
|
||||
v-else-if="field.fieldtype === 'Table'"
|
||||
v-model="data[field.fieldname]"
|
||||
v-model:parent="data"
|
||||
:doctype="field.options"
|
||||
:parentDoctype="doctype"
|
||||
:parentFieldname="field.fieldname"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
@ -31,7 +39,9 @@
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.fieldname]"
|
||||
@change="(e) => fieldChange(e.target.value, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:description="field.description"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
@ -42,8 +52,9 @@
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.fieldname]"
|
||||
@change="(e) => (data[field.fieldname] = e.target.checked)"
|
||||
@change="(e) => fieldChange(e.target.checked, field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-ink-gray-5"
|
||||
@ -59,13 +70,18 @@
|
||||
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-1" v-else-if="field.fieldtype === 'Link'">
|
||||
<div
|
||||
class="flex gap-1"
|
||||
v-else-if="['Link', 'Dynamic Link'].includes(field.fieldtype)"
|
||||
>
|
||||
<Link
|
||||
class="form-control flex-1 truncate"
|
||||
:value="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link' ? field.options : data[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
@ -85,6 +101,7 @@
|
||||
v-else-if="field.fieldtype === 'Table MultiSelect'"
|
||||
v-model="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
|
||||
<Link
|
||||
@ -93,7 +110,7 @@
|
||||
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:hideMe="true"
|
||||
>
|
||||
@ -118,80 +135,101 @@
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="data[field.fieldname]"
|
||||
icon-left=""
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
icon-left=""
|
||||
v-model="data[field.fieldname]"
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
|
||||
"
|
||||
type="textarea"
|
||||
:value="data[field.fieldname]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
<Password
|
||||
v-else-if="field.fieldtype === 'Password'"
|
||||
:value="data[field.fieldname]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:value="data[field.fieldname] || '0'"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.fieldname, data)"
|
||||
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:value="getDataValue(data[field.fieldname], field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Password from '@/components/Controls/Password.vue'
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed, provide, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
field: Object,
|
||||
@ -200,10 +238,31 @@ const props = defineProps({
|
||||
const data = inject('data')
|
||||
const doctype = inject('doctype')
|
||||
const preview = inject('preview')
|
||||
const isGridRow = inject('isGridRow')
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(doctype)
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const { users, getUser } = usersStore()
|
||||
|
||||
let triggerOnChange
|
||||
let parentDoc
|
||||
|
||||
if (!isGridRow) {
|
||||
const {
|
||||
triggerOnChange: trigger,
|
||||
triggerOnRowAdd,
|
||||
triggerOnRowRemove,
|
||||
} = useDocument(doctype, data.value.name)
|
||||
triggerOnChange = trigger
|
||||
|
||||
provide('triggerOnChange', triggerOnChange)
|
||||
provide('triggerOnRowAdd', triggerOnRowAdd)
|
||||
provide('triggerOnRowRemove', triggerOnRowRemove)
|
||||
} else {
|
||||
triggerOnChange = inject('triggerOnChange', () => {})
|
||||
parentDoc = inject('parentDoc')
|
||||
}
|
||||
|
||||
const field = computed(() => {
|
||||
let field = props.field
|
||||
@ -219,6 +278,21 @@ const field = computed(() => {
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options === 'User') {
|
||||
field.fieldtype = 'User'
|
||||
field.link_filters = JSON.stringify({
|
||||
...(field.link_filters ? JSON.parse(field.link_filters) : {}),
|
||||
name: ['in', users.data.crmUsers?.map((user) => user.name)],
|
||||
})
|
||||
}
|
||||
|
||||
if (field.fieldtype === 'Link' && field.options !== 'User') {
|
||||
if (!field.create) {
|
||||
field.create = (value, close) => {
|
||||
const callback = (d) => {
|
||||
if (d) fieldChange(d.name, field)
|
||||
}
|
||||
createDocument(field.options, value, close, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _field = {
|
||||
@ -260,6 +334,21 @@ const getPlaceholder = (field) => {
|
||||
return __('Enter {0}', [__(field.label)])
|
||||
}
|
||||
}
|
||||
|
||||
function fieldChange(value, df) {
|
||||
if (isGridRow) {
|
||||
triggerOnChange(df.fieldname, value, data.value)
|
||||
} else {
|
||||
triggerOnChange(df.fieldname, value)
|
||||
}
|
||||
}
|
||||
|
||||
function getDataValue(value, field) {
|
||||
if (field.fieldtype === 'Duration') {
|
||||
return value || 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.form-control.prefix select) {
|
||||
|
||||
@ -34,6 +34,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
isGridRow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -55,6 +59,7 @@ provide(
|
||||
provide('hasTabs', hasTabs)
|
||||
provide('doctype', props.doctype)
|
||||
provide('preview', props.preview)
|
||||
provide('isGridRow', props.isGridRow)
|
||||
</script>
|
||||
<style scoped>
|
||||
.section:not(:has(.field)) {
|
||||
|
||||
@ -277,13 +277,13 @@ const fields = createResource({
|
||||
]
|
||||
let existingFields = []
|
||||
|
||||
for (let tab of props.tabs) {
|
||||
for (let section of tab.sections) {
|
||||
for (let column of section.columns) {
|
||||
props.tabs?.forEach((tab) => {
|
||||
tab.sections?.forEach((section) => {
|
||||
section.columns?.forEach((column) => {
|
||||
existingFields = existingFields.concat(column.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return data.filter((field) => {
|
||||
return (
|
||||
|
||||
@ -104,7 +104,7 @@
|
||||
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
|
||||
import FilesUploadHandler from './filesUploaderHandler'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { createToast } from '@/utils'
|
||||
import { toast } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -165,12 +165,7 @@ function attachFiles() {
|
||||
function uploadViaWebLink() {
|
||||
let fileUrl = filesUploaderArea.value.webLink
|
||||
if (!fileUrl) {
|
||||
createToast({
|
||||
title: __('Error'),
|
||||
title: __('Please enter a valid URL'),
|
||||
icon: 'x',
|
||||
iconClasses: 'text-ink-red-4',
|
||||
})
|
||||
toast.error(__('Please enter a valid URL'))
|
||||
return
|
||||
}
|
||||
fileUrl = decodeURI(fileUrl)
|
||||
|
||||
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