Compare commits
437 Commits
main
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223187c7ea | ||
|
|
40c5c92230 | ||
|
|
6760798f18 | ||
|
|
42ea1ad16e | ||
|
|
96200aebe6 | ||
|
|
bcfe4b6a49 | ||
|
|
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 | ||
|
|
ff657ec34c | ||
|
|
da4698d431 | ||
|
|
20d47ae323 | ||
|
|
f4f799f636 | ||
|
|
cc411f036d | ||
|
|
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"]
|
||||
)
|
||||
contact["phone_nos"] = frappe.get_all(
|
||||
"Contact Phone", filters={"parent": name}, fields=["name", "phone", "is_primary_mobile_no"]
|
||||
)
|
||||
return contact
|
||||
frappe.db.set_value(
|
||||
"CRM Deal",
|
||||
linked_deal.parent,
|
||||
{
|
||||
"email": doc.email_id,
|
||||
"mobile_no": doc.mobile_no,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
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,35 @@ 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.is_manager = "Sales Manager" in frappe.get_roles(user.name)
|
||||
user.is_admin = user.name == "Administrator"
|
||||
|
||||
user.roles = frappe.get_roles(user.name)
|
||||
|
||||
user.role = ""
|
||||
|
||||
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_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name})
|
||||
|
||||
return users
|
||||
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
|
||||
|
||||
@ -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,16 @@
|
||||
"naming_series",
|
||||
"organization",
|
||||
"next_step",
|
||||
"probability",
|
||||
"column_break_ijan",
|
||||
"status",
|
||||
"close_date",
|
||||
"deal_owner",
|
||||
"lost_reason",
|
||||
"lost_notes",
|
||||
"section_break_jgpm",
|
||||
"probability",
|
||||
"deal_value",
|
||||
"column_break_kpxa",
|
||||
"close_date",
|
||||
"contacts_tab",
|
||||
"contacts",
|
||||
"contact",
|
||||
@ -43,6 +48,12 @@
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"gender",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ccbj",
|
||||
"total",
|
||||
"column_break_udbq",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
@ -119,14 +130,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 +252,9 @@
|
||||
{
|
||||
"fieldname": "phone",
|
||||
"fieldtype": "Data",
|
||||
"label": "Phone",
|
||||
"options": "Phone"
|
||||
"label": "Primary Phone",
|
||||
"options": "Phone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "log_tab",
|
||||
@ -334,11 +348,73 @@
|
||||
"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\""
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-11 14:31:41.058895",
|
||||
"modified": "2025-07-05 12:25:05.927806",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
@ -370,10 +446,11 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "organization",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +24,8 @@ class CRMDeal(Document):
|
||||
self.assign_agent(self.deal_owner)
|
||||
if self.has_value_changed("status"):
|
||||
add_status_change_log(self)
|
||||
self.validate_forcasting_fields()
|
||||
self.validate_lost_reason()
|
||||
|
||||
def after_insert(self):
|
||||
if self.deal_owner:
|
||||
@ -133,6 +135,39 @@ 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 == "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)
|
||||
|
||||
@staticmethod
|
||||
def default_list_data():
|
||||
columns = [
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
"field_order": [
|
||||
"deal_status",
|
||||
"color",
|
||||
"position"
|
||||
"position",
|
||||
"probability"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -32,11 +33,18 @@
|
||||
"fieldtype": "Int",
|
||||
"in_list_view": 1,
|
||||
"label": "Position"
|
||||
},
|
||||
{
|
||||
"fieldname": "probability",
|
||||
"fieldtype": "Percent",
|
||||
"in_list_view": 1,
|
||||
"label": "Probability"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-19 21:56:44.552134",
|
||||
"modified": "2025-07-01 12:06:42.937440",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal Status",
|
||||
@ -68,7 +76,8 @@
|
||||
"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": ["close_date", "probability", "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,9 +83,19 @@
|
||||
"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,7 +106,8 @@
|
||||
"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,
|
||||
@ -339,4 +381,4 @@
|
||||
"states": [],
|
||||
"title_field": "lead_name",
|
||||
"track_changes": 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,7 +81,8 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("CRM Lost Reason", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
||||
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "field:lost_reason",
|
||||
"creation": "2025-06-30 16:51:31.082360",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"lost_reason",
|
||||
"description"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "lost_reason",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Lost Reason",
|
||||
"reqd": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-30 16:59:15.094049",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lost Reason",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMLostReason(Document):
|
||||
pass
|
||||
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record dependencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestCRMLostReason(UnitTestCase):
|
||||
"""
|
||||
Unit tests for CRMLostReason.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for CRMLostReason.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
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')
|
||||
}
|
||||
}"""
|
||||
)
|
||||
@ -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,7 @@
|
||||
"field_order": [
|
||||
"defaults_tab",
|
||||
"restore_defaults",
|
||||
"enable_forecasting",
|
||||
"branding_tab",
|
||||
"brand_name",
|
||||
"brand_logo",
|
||||
@ -28,7 +29,7 @@
|
||||
{
|
||||
"fieldname": "defaults_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Defaults"
|
||||
"label": "Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "branding_tab",
|
||||
@ -56,12 +57,19 @@
|
||||
"fieldname": "favicon",
|
||||
"fieldtype": "Attach",
|
||||
"label": "Favicon"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
|
||||
"fieldname": "enable_forecasting",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Forecasting"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-02-20 12:38:38.088477",
|
||||
"modified": "2025-07-01 13:20:48.757603",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Settings",
|
||||
@ -95,7 +103,8 @@
|
||||
"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,7 @@ class FCRMSettings(Document):
|
||||
|
||||
def validate(self):
|
||||
self.do_not_allow_to_delete_if_standard()
|
||||
self.setup_forecasting()
|
||||
|
||||
def do_not_allow_to_delete_if_standard(self):
|
||||
if not self.has_value_changed("dropdown_items"):
|
||||
@ -24,8 +26,43 @@ 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",
|
||||
"close_date",
|
||||
)
|
||||
delete_property_setter(
|
||||
"CRM Deal",
|
||||
"reqd",
|
||||
"deal_value",
|
||||
)
|
||||
else:
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"close_date",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
make_property_setter(
|
||||
"CRM Deal",
|
||||
"deal_value",
|
||||
"reqd",
|
||||
1 if self.enable_forecasting else 0,
|
||||
"Check",
|
||||
)
|
||||
|
||||
|
||||
def get_standard_dropdown_items():
|
||||
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||
|
||||
|
||||
def after_migrate():
|
||||
@ -51,3 +88,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,8 @@ import click
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||
|
||||
|
||||
def before_install():
|
||||
pass
|
||||
@ -18,7 +20,9 @@ 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()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -65,30 +69,37 @@ def add_default_deal_statuses():
|
||||
statuses = {
|
||||
"Qualification": {
|
||||
"color": "gray",
|
||||
"probability": 10,
|
||||
"position": 1,
|
||||
},
|
||||
"Demo/Making": {
|
||||
"color": "orange",
|
||||
"probability": 25,
|
||||
"position": 2,
|
||||
},
|
||||
"Proposal/Quotation": {
|
||||
"color": "blue",
|
||||
"probability": 50,
|
||||
"position": 3,
|
||||
},
|
||||
"Negotiation": {
|
||||
"color": "yellow",
|
||||
"probability": 70,
|
||||
"position": 4,
|
||||
},
|
||||
"Ready to Close": {
|
||||
"color": "purple",
|
||||
"probability": 90,
|
||||
"position": 5,
|
||||
},
|
||||
"Won": {
|
||||
"color": "green",
|
||||
"probability": 100,
|
||||
"position": 6,
|
||||
},
|
||||
"Lost": {
|
||||
"color": "red",
|
||||
"probability": 0,
|
||||
"position": 7,
|
||||
},
|
||||
}
|
||||
@ -100,6 +111,7 @@ def add_default_deal_statuses():
|
||||
doc = frappe.new_doc("CRM Deal Status")
|
||||
doc.deal_status = status
|
||||
doc.color = statuses[status]["color"]
|
||||
doc.probability = statuses[status]["probability"]
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
|
||||
@ -340,6 +352,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 +403,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
|
||||
|
||||
2028
crm/locale/main.pot
2028
crm/locale/main.pot
File diff suppressed because it is too large
Load Diff
@ -11,4 +11,6 @@ crm.patches.v1_0.create_default_fields_layout #22/01/2025
|
||||
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.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||
crm.patches.v1_0.update_deal_status_probabilities
|
||||
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)
|
||||
@ -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")
|
||||
@ -19,4 +20,4 @@ def delete_email_template_custom_fields():
|
||||
for fieldname in fieldnames:
|
||||
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
|
||||
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
from frappe import frappe
|
||||
import phonenumbers
|
||||
from frappe.utils import floor
|
||||
from phonenumbers import NumberParseException
|
||||
from phonenumbers import PhoneNumberFormat as PNF
|
||||
from frappe.model.docstatus import DocStatus
|
||||
from frappe.model.dynamic_links import get_dynamic_link_map
|
||||
|
||||
|
||||
def parse_phone_number(phone_number, default_country="IN"):
|
||||
@ -93,3 +96,129 @@ 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
|
||||
|
||||
@ -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 424288f77af4779dd3bb71dc3d278fc627f95179
|
||||
37
frontend/components.d.ts
vendored
37
frontend/components.d.ts
vendored
@ -8,9 +8,11 @@ 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']
|
||||
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 +31,7 @@ declare module 'vue' {
|
||||
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
|
||||
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
|
||||
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
|
||||
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 +40,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,7 +56,9 @@ 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']
|
||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||
@ -62,6 +68,7 @@ declare module 'vue' {
|
||||
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 +82,30 @@ 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']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
|
||||
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
|
||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||
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 +124,11 @@ 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']
|
||||
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']
|
||||
@ -125,21 +145,29 @@ 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']
|
||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
LucideX: typeof import('~icons/lucide/x')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -149,10 +177,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 +193,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']
|
||||
@ -196,6 +228,7 @@ declare module 'vue' {
|
||||
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
||||
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
||||
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
|
||||
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 +239,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.166",
|
||||
"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>
|
||||
<Layout v-if="session().isLoggedIn">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
<Toasts />
|
||||
<FrappeUIProvider>
|
||||
<Layout v-if="session().isLoggedIn">
|
||||
<router-view />
|
||||
</Layout>
|
||||
<Dialogs />
|
||||
</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)"
|
||||
>
|
||||
<EditIcon class="h-3.5" />
|
||||
<template #icon>
|
||||
<EditIcon class="h-3.5" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-5 w-5 !p-1"
|
||||
@click="removeColumn(element)"
|
||||
>
|
||||
<FeatherIcon name="x" class="h-3.5" />
|
||||
<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>
|
||||
|
||||
@ -159,6 +159,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
|
||||
})
|
||||
|
||||
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"
|
||||
>
|
||||
<SuccessIcon />
|
||||
<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"
|
||||
>
|
||||
<EditIcon />
|
||||
<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)
|
||||
|
||||
@ -126,8 +126,13 @@
|
||||
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
||||
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||
import { createToast, formatDate, convertSize } from '@/utils'
|
||||
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
|
||||
import { formatDate, convertSize } from '@/utils'
|
||||
import {
|
||||
FormControl,
|
||||
CircularProgressBar,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -324,24 +329,18 @@ function checkRestrictions(file) {
|
||||
|
||||
if (!isCorrectType) {
|
||||
console.warn('File skipped because of invalid file type', file)
|
||||
createToast({
|
||||
title: __('File "{0}" was skipped because of invalid file type', [
|
||||
file.name,
|
||||
]),
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-orange-600',
|
||||
})
|
||||
toast.warning(
|
||||
__('File "{0}" was skipped because of invalid file type', [file.name]),
|
||||
)
|
||||
}
|
||||
if (!validFileSize) {
|
||||
console.warn('File skipped because of invalid file size', file.size, file)
|
||||
createToast({
|
||||
title: __('File "{0}" was skipped because size exceeds {1} MB', [
|
||||
toast.warning(
|
||||
__('File "{0}" was skipped because size exceeds {1} MB', [
|
||||
file.name,
|
||||
maxFileSize / (1024 * 1024),
|
||||
]),
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-orange-600',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return isCorrectType && validFileSize
|
||||
@ -363,11 +362,7 @@ function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
|
||||
)
|
||||
}
|
||||
|
||||
createToast({
|
||||
title: message,
|
||||
icon: 'alert-circle',
|
||||
iconClasses: 'text-orange-600',
|
||||
})
|
||||
toast.warning(message)
|
||||
}
|
||||
|
||||
function removeFile(name) {
|
||||
|
||||
@ -126,7 +126,7 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Autocomplete
|
||||
value=""
|
||||
:options="filterableFields.data"
|
||||
:options="availableFilters"
|
||||
@change="(e) => setfilter(e)"
|
||||
:placeholder="__('First name')"
|
||||
>
|
||||
@ -217,6 +217,19 @@ const filters = computed(() => {
|
||||
return convertFilters(filterableFields.data, allFilters)
|
||||
})
|
||||
|
||||
const availableFilters = computed(() => {
|
||||
if (!filterableFields.data) return []
|
||||
|
||||
const selectedFieldNames = new Set()
|
||||
for (const filter of filters.value) {
|
||||
selectedFieldNames.add(filter.fieldname)
|
||||
}
|
||||
|
||||
return filterableFields.data.filter(
|
||||
(field) => !selectedFieldNames.has(field.fieldname),
|
||||
)
|
||||
})
|
||||
|
||||
function removeCommonFilters(commonFilters, allFilters) {
|
||||
for (const key in commonFilters) {
|
||||
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
|
||||
|
||||
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
30
frontend/src/components/Icons/EmailTemplateIcon.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_1191_1930)">
|
||||
<path
|
||||
d="M1.45001 12.1V12.1C1.45001 11.7869 1.60364 11.4936 1.86111 11.3154L6.87595 7.84359C7.52855 7.39178 8.38658 7.36866 9.06257 7.78465L13.5984 10.5759C14.1276 10.9016 14.45 11.4786 14.45 12.1V12.1"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.45 7.60001L11.95 9.60001M4.45001 9.60001L1.45001 7.60001"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4 9V3C4 2.44772 4.44772 2 5 2H11C11.5523 2 12 2.44772 12 3V9"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4 4.49999L2.1786 6C1.71727 6.37992 1.45002 6.94623 1.45002 7.54385L1.45002 12.1C1.45002 13.2046 2.34545 14.1 3.45002 14.1L12.45 14.1C13.5546 14.1 14.45 13.2046 14.45 12.1V7.51988C14.45 6.93603 14.1949 6.38133 13.7516 6.00137L12 4.5"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path d="M6 6H10" stroke="currentColor" stroke-linecap="round" />
|
||||
<path d="M6 4H9" stroke="currentColor" stroke-linecap="round" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
19
frontend/src/components/Icons/SquareAsterisk.vue
Normal file
19
frontend/src/components/Icons/SquareAsterisk.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-square-asterisk-icon lucide-square-asterisk"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="m8.5 14 7-4" />
|
||||
<path d="m8.5 10 7 4" />
|
||||
</svg>
|
||||
</template>
|
||||
21
frontend/src/components/Icons/TelegramIcon.vue
Normal file
21
frontend/src/components/Icons/TelegramIcon.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke-width="3"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<g
|
||||
id="SVGRepo_tracerCarrier"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path
|
||||
d="M26.67,38.57l-.82,11.54A2.88,2.88,0,0,0,28.14,49l5.5-5.26,11.42,8.35c2.08,1.17,3.55.56,4.12-1.92l7.49-35.12h0c.66-3.09-1.08-4.33-3.16-3.55l-44,16.85C6.47,29.55,6.54,31.23,9,32l11.26,3.5L45.59,20.71c1.23-.83,2.36-.37,1.44.44Z"
|
||||
stroke-linecap="round"
|
||||
></path>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
33
frontend/src/components/KeyboardShortcut.vue
Normal file
33
frontend/src/components/KeyboardShortcut.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex items-center gap-0.5 text-sm"
|
||||
:class="{
|
||||
'bg-surface-gray-2 rounded-sm text-ink-gray-5 py-0.5 px-1': bg,
|
||||
'text-ink-gray-4': !bg,
|
||||
}"
|
||||
>
|
||||
<span v-if="ctrl || meta">
|
||||
<LucideCommand v-if="isMac" class="w-3 h-3" />
|
||||
<span v-else>Ctrl</span>
|
||||
</span>
|
||||
<span v-if="shift"><LucideShift class="w-3 h-3" /></span>
|
||||
<span v-if="alt"><LucideAlt class="w-3 h-3" /></span>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import LucideCommand from '~icons/lucide/command'
|
||||
import LucideShift from '~icons/lucide/arrow-big-up'
|
||||
import LucideAlt from '~icons/lucide/option'
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
|
||||
defineProps({
|
||||
meta: Boolean,
|
||||
ctrl: Boolean,
|
||||
shift: Boolean,
|
||||
alt: Boolean,
|
||||
shortcut: String,
|
||||
bg: Boolean,
|
||||
})
|
||||
</script>
|
||||
@ -147,9 +147,9 @@ import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import StepsIcon from '@/components/Icons/StepsIcon.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import PinIcon from '@/components/Icons/PinIcon.vue'
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||
@ -168,7 +168,10 @@ import {
|
||||
unreadNotificationsCount,
|
||||
notificationsStore,
|
||||
} from '@/stores/notifications'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
||||
import { showChangePasswordModal } from '@/composables/modals'
|
||||
import { FeatherIcon, call } from 'frappe-ui'
|
||||
import {
|
||||
SignupBanner,
|
||||
@ -229,11 +232,6 @@ const links = [
|
||||
icon: PhoneIcon,
|
||||
to: 'Call Logs',
|
||||
},
|
||||
{
|
||||
label: 'Email Templates',
|
||||
icon: Email2Icon,
|
||||
to: 'Email Templates',
|
||||
},
|
||||
]
|
||||
|
||||
const allViews = computed(() => {
|
||||
@ -299,16 +297,18 @@ function getIcon(routeName, icon) {
|
||||
}
|
||||
|
||||
// onboarding
|
||||
const { user } = sessionStore()
|
||||
const { users, isManager } = usersStore()
|
||||
const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm')
|
||||
|
||||
async function getFirstLead() {
|
||||
let firstLead = localStorage.getItem('firstLead')
|
||||
let firstLead = localStorage.getItem('firstLead' + user)
|
||||
if (firstLead) return firstLead
|
||||
return await call('crm.api.onboarding.get_first_lead')
|
||||
}
|
||||
|
||||
async function getFirstDeal() {
|
||||
let firstDeal = localStorage.getItem('firstDeal')
|
||||
let firstDeal = localStorage.getItem('firstDeal' + user)
|
||||
if (firstDeal) return firstDeal
|
||||
return await call('crm.api.onboarding.get_first_deal')
|
||||
}
|
||||
@ -317,6 +317,16 @@ const showIntermediateModal = ref(false)
|
||||
const currentStep = ref({})
|
||||
|
||||
const steps = reactive([
|
||||
{
|
||||
name: 'setup_your_password',
|
||||
title: __('Setup your password'),
|
||||
icon: markRaw(SquareAsterisk),
|
||||
completed: false,
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
showChangePasswordModal.value = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'create_first_lead',
|
||||
title: __('Create your first lead'),
|
||||
@ -335,14 +345,16 @@ const steps = reactive([
|
||||
onClick: () => {
|
||||
minimize.value = true
|
||||
showSettings.value = true
|
||||
activeSettingsPage.value = 'Invite Members'
|
||||
activeSettingsPage.value = 'Invite User'
|
||||
},
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
name: 'convert_lead_to_deal',
|
||||
title: __('Convert lead to deal'),
|
||||
icon: markRaw(ConvertIcon),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_lead',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
|
||||
@ -410,6 +422,7 @@ const steps = reactive([
|
||||
title: __('Add your first comment'),
|
||||
icon: markRaw(CommentIcon),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_lead',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let deal = await getFirstDeal()
|
||||
@ -430,6 +443,7 @@ const steps = reactive([
|
||||
title: __('Send email'),
|
||||
icon: markRaw(EmailIcon),
|
||||
completed: false,
|
||||
dependsOn: 'create_first_lead',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
let deal = await getFirstDeal()
|
||||
@ -450,6 +464,7 @@ const steps = reactive([
|
||||
title: __('Change deal status'),
|
||||
icon: markRaw(StepsIcon),
|
||||
completed: false,
|
||||
dependsOn: 'convert_lead_to_deal',
|
||||
onClick: async () => {
|
||||
minimize.value = true
|
||||
|
||||
@ -478,7 +493,18 @@ const steps = reactive([
|
||||
},
|
||||
])
|
||||
|
||||
onMounted(() => setUp(steps))
|
||||
onMounted(async () => {
|
||||
await users.promise
|
||||
|
||||
const filteredSteps = steps.filter((step) => {
|
||||
if (step.condition) {
|
||||
return step.condition()
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
setUp(filteredSteps)
|
||||
})
|
||||
|
||||
// help center
|
||||
const articles = ref([
|
||||
@ -497,7 +523,7 @@ const articles = ref([
|
||||
{ name: 'profile', title: __('Profile') },
|
||||
{ name: 'custom-branding', title: __('Custom branding') },
|
||||
{ name: 'home-actions', title: __('Home actions') },
|
||||
{ name: 'invite-members', title: __('Invite members') },
|
||||
{ name: 'invite-users', title: __('Invite users') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -517,9 +543,7 @@ const articles = ref([
|
||||
{
|
||||
title: __('Capturing leads'),
|
||||
opened: false,
|
||||
subArticles: [
|
||||
{ name: 'web-form', title: __('Web form') },
|
||||
],
|
||||
subArticles: [{ name: 'web-form', title: __('Web form') }],
|
||||
},
|
||||
{
|
||||
title: __('Views'),
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
<AppHeader />
|
||||
<slot />
|
||||
</div>
|
||||
<GlobalModals />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
|
||||
import AppHeader from '@/components/Layouts/AppHeader.vue'
|
||||
import GlobalModals from '@/components/Modals/GlobalModals.vue'
|
||||
</script>
|
||||
|
||||
@ -5,9 +5,11 @@
|
||||
<MobileAppHeader />
|
||||
<slot />
|
||||
</div>
|
||||
<GlobalModals />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import MobileSidebar from '@/components/Mobile/MobileSidebar.vue'
|
||||
import MobileAppHeader from '@/components/Mobile/MobileAppHeader.vue'
|
||||
import GlobalModals from '@/components/Modals/GlobalModals.vue'
|
||||
</script>
|
||||
|
||||
@ -14,15 +14,29 @@
|
||||
:doctype="doctype"
|
||||
@reload="reload"
|
||||
/>
|
||||
<DeleteLinkedDocModal
|
||||
v-if="showDeleteDocModal.showLinkedDocsModal"
|
||||
v-model="showDeleteDocModal.showLinkedDocsModal"
|
||||
:doctype="props.doctype"
|
||||
:docname="showDeleteDocModal.docname"
|
||||
:reload="reload"
|
||||
/>
|
||||
<BulkDeleteLinkedDocModal
|
||||
v-if="showDeleteDocModal.showDeleteModal"
|
||||
v-model="showDeleteDocModal.showDeleteModal"
|
||||
:doctype="props.doctype"
|
||||
:items="showDeleteDocModal.items"
|
||||
:reload="reload"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EditValueModal from '@/components/Modals/EditValueModal.vue'
|
||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||
import { setupListCustomizations, createToast } from '@/utils'
|
||||
import { setupListCustomizations } from '@/utils'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { capture } from '@/telemetry'
|
||||
import { call } from 'frappe-ui'
|
||||
import { call, toast } from 'frappe-ui'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@ -50,7 +64,11 @@ const { $dialog, $socket } = globalStore()
|
||||
const showEditModal = ref(false)
|
||||
const selectedValues = ref([])
|
||||
const unselectAllAction = ref(() => {})
|
||||
|
||||
const showDeleteDocModal = ref({
|
||||
showLinkedDocsModal: false,
|
||||
showDeleteModal: false,
|
||||
docname: null,
|
||||
})
|
||||
function editValues(selections, unselectAll) {
|
||||
selectedValues.value = selections
|
||||
showEditModal.value = true
|
||||
@ -75,11 +93,7 @@ function convertToDeal(selections, unselectAll) {
|
||||
call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||
lead: name,
|
||||
}).then(() => {
|
||||
createToast({
|
||||
title: __('Converted successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
toast.success(__('Converted successfully'))
|
||||
list.value.reload()
|
||||
unselectAll()
|
||||
close()
|
||||
@ -92,37 +106,18 @@ function convertToDeal(selections, unselectAll) {
|
||||
}
|
||||
|
||||
function deleteValues(selections, unselectAll) {
|
||||
$dialog({
|
||||
title: __('Delete'),
|
||||
message: __('Are you sure you want to delete {0} item(s)?', [
|
||||
selections.size,
|
||||
]),
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
actions: [
|
||||
{
|
||||
label: __('Delete'),
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
onClick: (close) => {
|
||||
capture('bulk_delete')
|
||||
call('frappe.desk.reportview.delete_items', {
|
||||
items: JSON.stringify(Array.from(selections)),
|
||||
doctype: props.doctype,
|
||||
}).then(() => {
|
||||
createToast({
|
||||
title: __('Deleted successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
unselectAll()
|
||||
list.value.reload()
|
||||
close()
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const selectedDocs = Array.from(selections)
|
||||
if (selectedDocs.length == 1) {
|
||||
showDeleteDocModal.value = {
|
||||
showLinkedDocsModal: true,
|
||||
docname: selectedDocs[0],
|
||||
}
|
||||
} else {
|
||||
showDeleteDocModal.value = {
|
||||
showDeleteModal: true,
|
||||
items: selectedDocs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showAssignmentModal = ref(false)
|
||||
@ -154,11 +149,7 @@ function clearAssignemnts(selections, unselectAll) {
|
||||
names: JSON.stringify(Array.from(selections)),
|
||||
ignore_permissions: true,
|
||||
}).then(() => {
|
||||
createToast({
|
||||
title: __('Assignment cleared successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
toast.success(__('Assignment cleared successfully'))
|
||||
reload(unselectAll)
|
||||
close()
|
||||
})
|
||||
@ -215,7 +206,8 @@ function bulkActions(selections, unselectAll) {
|
||||
selections,
|
||||
unselectAll,
|
||||
call,
|
||||
createToast,
|
||||
createToast: toast.create,
|
||||
toast,
|
||||
$dialog,
|
||||
router,
|
||||
}),
|
||||
@ -235,7 +227,8 @@ onMounted(async () => {
|
||||
let customization = await setupListCustomizations(list.value.data, {
|
||||
list: list.value,
|
||||
call,
|
||||
createToast,
|
||||
createToast: toast.create,
|
||||
toast,
|
||||
$dialog,
|
||||
$socket,
|
||||
router,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
}"
|
||||
row-key="name"
|
||||
v-bind="$attrs"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -205,6 +206,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="mx-3 sm:mx-5"
|
||||
@ -201,6 +202,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -245,6 +246,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -1,224 +0,0 @@
|
||||
<template>
|
||||
<ListView
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:options="{
|
||||
onRowClick: (row) => emit('showEmailTemplate', row.name),
|
||||
selectable: options.selectable,
|
||||
showTooltip: options.showTooltip,
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@columnWidthUpdated="emit('columnWidthUpdated')"
|
||||
>
|
||||
<ListHeaderItem
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:item="column"
|
||||
@columnWidthUpdated="emit('columnWidthUpdated', column)"
|
||||
>
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
class="!h-4"
|
||||
:class="isLikeFilterApplied ? 'fill-red-500' : 'fill-white'"
|
||||
@click="() => emit('applyLikeFilter')"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</ListHeaderItem>
|
||||
</ListHeader>
|
||||
<ListRows
|
||||
class="mx-3 sm:mx-5"
|
||||
:rows="rows"
|
||||
v-slot="{ idx, column, item }"
|
||||
doctype="Email Template"
|
||||
>
|
||||
<ListRowItem :item="item" :align="column.align">
|
||||
<!-- <template #prefix>
|
||||
|
||||
</template> -->
|
||||
<template #default="{ label }">
|
||||
<div
|
||||
v-if="['modified', 'creation'].includes(column.key)"
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
<Tooltip :text="item.label">
|
||||
<div>{{ item.timeAgo }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'status'" class="truncate text-base">
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="item.color"
|
||||
size="md"
|
||||
:label="item.label"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.type === 'Check'">
|
||||
<FormControl
|
||||
type="checkbox"
|
||||
:modelValue="item"
|
||||
:disabled="true"
|
||||
class="text-ink-gray-9"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === '_liked_by'">
|
||||
<Button
|
||||
v-if="column.key == '_liked_by'"
|
||||
variant="ghosted"
|
||||
:class="isLiked(item) ? 'fill-red-500' : 'fill-white'"
|
||||
@click.stop.prevent="
|
||||
() => emit('likeDoc', { name: row.name, liked: isLiked(item) })
|
||||
"
|
||||
>
|
||||
<HeartIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="truncate text-base"
|
||||
@click="
|
||||
(event) =>
|
||||
emit('applyFilter', {
|
||||
event,
|
||||
idx,
|
||||
column,
|
||||
item,
|
||||
firstColumn: columns[0],
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
</template>
|
||||
</ListRowItem>
|
||||
</ListRows>
|
||||
<ListSelectBanner>
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
<Dropdown
|
||||
:options="listBulkActionsRef.bulkActions(selections, unselectAll)"
|
||||
>
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
<ListFooter
|
||||
class="border-t sm:px-5 px-3 py-2"
|
||||
v-model="pageLengthCount"
|
||||
:options="{
|
||||
rowCount: options.rowCount,
|
||||
totalCount: options.totalCount,
|
||||
}"
|
||||
@loadMore="emit('loadMore')"
|
||||
/>
|
||||
<ListBulkActions
|
||||
ref="listBulkActionsRef"
|
||||
v-model="list"
|
||||
doctype="Email Template"
|
||||
:options="{
|
||||
hideAssign: true,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import HeartIcon from '@/components/Icons/HeartIcon.vue'
|
||||
import ListBulkActions from '@/components/ListBulkActions.vue'
|
||||
import ListRows from '@/components/ListViews/ListRows.vue'
|
||||
import {
|
||||
ListView,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListSelectBanner,
|
||||
ListRowItem,
|
||||
ListFooter,
|
||||
Dropdown,
|
||||
Tooltip,
|
||||
} from 'frappe-ui'
|
||||
import { sessionStore } from '@/stores/session'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
selectable: true,
|
||||
showTooltip: true,
|
||||
resizeColumn: false,
|
||||
totalCount: 0,
|
||||
rowCount: 0,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'loadMore',
|
||||
'updatePageCount',
|
||||
'showEmailTemplate',
|
||||
'columnWidthUpdated',
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
const list = defineModel('list')
|
||||
|
||||
const isLikeFilterApplied = computed(() => {
|
||||
return list.value.params?.filters?._liked_by ? true : false
|
||||
})
|
||||
|
||||
const { user } = sessionStore()
|
||||
|
||||
function isLiked(item) {
|
||||
if (item) {
|
||||
let likedByMe = JSON.parse(item)
|
||||
return likedByMe.includes(user)
|
||||
}
|
||||
}
|
||||
|
||||
watch(pageLengthCount, (val, old_value) => {
|
||||
if (val === old_value) return
|
||||
emit('updatePageCount', val)
|
||||
})
|
||||
|
||||
const listBulkActionsRef = ref(null)
|
||||
|
||||
defineExpose({
|
||||
customListActions: computed(
|
||||
() => listBulkActionsRef.value?.customListActions,
|
||||
),
|
||||
})
|
||||
</script>
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -71,15 +72,6 @@
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'organization'">
|
||||
<Avatar
|
||||
v-if="item"
|
||||
class="flex items-center"
|
||||
:image="item"
|
||||
:label="item"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="column.key === 'lead_owner'">
|
||||
<Avatar
|
||||
v-if="item.full_name"
|
||||
@ -250,7 +242,6 @@ const props = defineProps({
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'loadMore',
|
||||
'updatePageCount',
|
||||
@ -258,6 +249,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
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