Compare commits
221 Commits
main
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
165509f5a0 | ||
|
|
cf91f3f72a | ||
|
|
51b87d0ac6 | ||
|
|
c83d7adddd | ||
|
|
549665bc61 | ||
|
|
43e1309bd8 | ||
|
|
91f7cf05fc | ||
|
|
875431a620 | ||
|
|
db0c0d98bc | ||
|
|
5406f4a11b | ||
|
|
bfdd3273fe | ||
|
|
8798103e7e | ||
|
|
203b5ab1ac | ||
|
|
ed1b26207b | ||
|
|
e0166a08e2 | ||
|
|
8af4e9b5e8 | ||
|
|
900c1d3570 | ||
|
|
b95a17a4e0 | ||
|
|
0f0b012a44 | ||
|
|
b291f82e4d | ||
|
|
86b7222916 | ||
|
|
7a12b80dd2 | ||
|
|
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 }}"
|
||||||
@ -1,9 +1,10 @@
|
|||||||
from bs4 import BeautifulSoup
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.translate import get_all_translations
|
from bs4 import BeautifulSoup
|
||||||
from frappe.utils import validate_email_address, split_emails, cstr
|
|
||||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
|
||||||
from frappe.core.api.file import get_max_file_size
|
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)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@ -63,6 +64,11 @@ def check_app_permission():
|
|||||||
if frappe.session.user == "Administrator":
|
if frappe.session.user == "Administrator":
|
||||||
return True
|
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()
|
roles = frappe.get_roles()
|
||||||
if any(
|
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", "Sales Master Manager"] for role in roles
|
||||||
@ -94,8 +100,13 @@ def accept_invitation(key: str | None = None):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def invite_by_email(emails: str, role: str):
|
def invite_by_email(emails: str, role: str):
|
||||||
frappe.only_for("Sales Manager")
|
frappe.only_for("Sales Manager")
|
||||||
|
|
||||||
|
if role not in ["Sales Manager", "Sales User"]:
|
||||||
|
frappe.throw("Cannot invite for this role")
|
||||||
|
|
||||||
if not emails:
|
if not emails:
|
||||||
return
|
return
|
||||||
|
|
||||||
email_string = validate_email_address(emails, throw=False)
|
email_string = validate_email_address(emails, throw=False)
|
||||||
email_list = split_emails(email_string)
|
email_list = split_emails(email_string)
|
||||||
if not email_list:
|
if not email_list:
|
||||||
@ -112,6 +123,12 @@ def invite_by_email(emails: str, role: str):
|
|||||||
for email in to_invite:
|
for email in to_invite:
|
||||||
frappe.get_doc(doctype="CRM Invitation", email=email, role=role).insert(ignore_permissions=True)
|
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()
|
@frappe.whitelist()
|
||||||
def get_file_uploader_defaults(doctype: str):
|
def get_file_uploader_defaults(doctype: str):
|
||||||
|
|||||||
@ -124,6 +124,7 @@ def get_deal_activities(name):
|
|||||||
activity = {
|
activity = {
|
||||||
"activity_type": "communication",
|
"activity_type": "communication",
|
||||||
"communication_type": communication.communication_type,
|
"communication_type": communication.communication_type,
|
||||||
|
"communication_date": communication.communication_date or communication.creation,
|
||||||
"creation": communication.creation,
|
"creation": communication.creation,
|
||||||
"data": {
|
"data": {
|
||||||
"subject": communication.subject,
|
"subject": communication.subject,
|
||||||
@ -255,6 +256,7 @@ def get_lead_activities(name):
|
|||||||
activity = {
|
activity = {
|
||||||
"activity_type": "communication",
|
"activity_type": "communication",
|
||||||
"communication_type": communication.communication_type,
|
"communication_type": communication.communication_type,
|
||||||
|
"communication_date": communication.communication_date or communication.creation,
|
||||||
"creation": communication.creation,
|
"creation": communication.creation,
|
||||||
"data": {
|
"data": {
|
||||||
"subject": communication.subject,
|
"subject": communication.subject,
|
||||||
|
|||||||
@ -23,22 +23,14 @@ def update_deals_email_mobile_no(doc):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_contact(name):
|
def get_contact(name):
|
||||||
Contact = frappe.qb.DocType("Contact")
|
contact = frappe.get_doc("Contact", name)
|
||||||
|
contact.check_permission("read")
|
||||||
|
|
||||||
query = frappe.qb.from_(Contact).select("*").where(Contact.name == name).limit(1)
|
contact = contact.as_dict()
|
||||||
|
|
||||||
contact = query.run(as_dict=True)
|
|
||||||
if not len(contact):
|
if not len(contact):
|
||||||
frappe.throw(_("Contact not found"), frappe.DoesNotExistError)
|
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
|
return contact
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -418,16 +418,23 @@ def get_data(
|
|||||||
rows.append(field)
|
rows.append(field)
|
||||||
|
|
||||||
for kc in kanban_columns:
|
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")
|
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 = []
|
column_data = []
|
||||||
else:
|
else:
|
||||||
column_filters.update(filters.copy())
|
page_length = kc.get("page_length", 20)
|
||||||
page_length = 20
|
|
||||||
|
|
||||||
if kc.get("page_length"):
|
|
||||||
page_length = kc.get("page_length")
|
|
||||||
|
|
||||||
if order:
|
if order:
|
||||||
column_data = get_records_based_on_order(
|
column_data = get_records_based_on_order(
|
||||||
@ -437,26 +444,20 @@ def get_data(
|
|||||||
column_data = frappe.get_list(
|
column_data = frappe.get_list(
|
||||||
doctype,
|
doctype,
|
||||||
fields=rows,
|
fields=rows,
|
||||||
filters=convert_filter_to_tuple(doctype, column_filters),
|
filters=column_filters,
|
||||||
order_by=order_by,
|
order_by=order_by,
|
||||||
page_length=page_length,
|
page_length=page_length,
|
||||||
)
|
)
|
||||||
|
|
||||||
new_filters = filters.copy()
|
|
||||||
new_filters.update({column_field: kc.get("name")})
|
|
||||||
|
|
||||||
all_count = frappe.get_list(
|
all_count = frappe.get_list(
|
||||||
doctype,
|
doctype,
|
||||||
filters=convert_filter_to_tuple(doctype, new_filters),
|
filters=column_filters,
|
||||||
fields="count(*) as total_count",
|
fields="count(*) as total_count",
|
||||||
)[0].total_count
|
)[0].total_count
|
||||||
|
|
||||||
kc["all_count"] = all_count
|
kc["all_count"] = all_count
|
||||||
kc["count"] = len(column_data)
|
kc["count"] = len(column_data)
|
||||||
|
|
||||||
for d in column_data:
|
|
||||||
getCounts(d, doctype)
|
|
||||||
|
|
||||||
if order:
|
if order:
|
||||||
column_data = sorted(
|
column_data = sorted(
|
||||||
column_data,
|
column_data,
|
||||||
|
|||||||
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -41,13 +41,15 @@
|
|||||||
"fieldname": "from",
|
"fieldname": "from",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "From"
|
"label": "From",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Status",
|
"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",
|
"fieldname": "start_time",
|
||||||
@ -69,13 +71,15 @@
|
|||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Type",
|
"label": "Type",
|
||||||
"options": "Incoming\nOutgoing"
|
"options": "Incoming\nOutgoing",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "to",
|
"fieldname": "to",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "To"
|
"label": "To",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "Call duration in seconds",
|
"description": "Call duration in seconds",
|
||||||
@ -153,7 +157,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-22 17:57:59.289548",
|
"modified": "2025-04-01 16:01:54.479309",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Call Log",
|
"name": "CRM Call Log",
|
||||||
|
|||||||
@ -190,11 +190,20 @@ def get_call_log(name):
|
|||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@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 = frappe.new_doc("CRM Lead")
|
||||||
lead.first_name = "Lead from call " + call_log.get("from")
|
lead_details = frappe.parse_json(lead_details or "{}")
|
||||||
lead.mobile_no = call_log.get("from")
|
|
||||||
lead.lead_owner = frappe.session.user
|
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)
|
lead.save(ignore_permissions=True)
|
||||||
|
|
||||||
# link call log with lead
|
# link call log with lead
|
||||||
|
|||||||
@ -6,7 +6,10 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_deal(name):
|
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["fields_meta"] = get_fields_meta("CRM Deal")
|
||||||
deal["_form_script"] = get_form_script("CRM Deal")
|
deal["_form_script"] = get_form_script("CRM Deal")
|
||||||
|
|||||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Deal", {
|
|||||||
refresh(frm) {
|
refresh(frm) {
|
||||||
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -43,6 +43,12 @@
|
|||||||
"mobile_no",
|
"mobile_no",
|
||||||
"phone",
|
"phone",
|
||||||
"gender",
|
"gender",
|
||||||
|
"products_tab",
|
||||||
|
"products",
|
||||||
|
"section_break_ccbj",
|
||||||
|
"total",
|
||||||
|
"column_break_udbq",
|
||||||
|
"net_total",
|
||||||
"sla_tab",
|
"sla_tab",
|
||||||
"sla",
|
"sla",
|
||||||
"sla_creation",
|
"sla_creation",
|
||||||
@ -334,11 +340,46 @@
|
|||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"label": "Currency",
|
"label": "Currency",
|
||||||
"options": "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
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-12-11 14:31:41.058895",
|
"modified": "2025-05-12 12:30:55.415282",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
@ -370,10 +411,11 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "organization",
|
"title_field": "organization",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
if not tabs and type != "Required Fields":
|
if not tabs and type != "Required Fields":
|
||||||
tabs = get_default_layout(doctype)
|
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:
|
if not has_tabs:
|
||||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||||
@ -47,7 +49,10 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
|
|
||||||
for tab in tabs:
|
for tab in tabs:
|
||||||
for section in tab.get("sections"):
|
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 []:
|
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 []:
|
for field in column.get("fields") if column.get("fields") else []:
|
||||||
field = next((f for f in fields if f.fieldname == field), None)
|
field = next((f for f in fields if f.fieldname == field), None)
|
||||||
if field:
|
if field:
|
||||||
|
|||||||
@ -65,7 +65,7 @@
|
|||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-09-16 19:40:19.340948",
|
"modified": "2025-05-19 17:57:24.610295",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Form Script",
|
"name": "CRM Form Script",
|
||||||
@ -83,9 +83,19 @@
|
|||||||
"role": "Sales Manager",
|
"role": "Sales Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "All",
|
||||||
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ class CRMInvitation(Document):
|
|||||||
if frappe.local.dev_server:
|
if frappe.local.dev_server:
|
||||||
print(f"Invite link for {self.email}: {invite_link}")
|
print(f"Invite link for {self.email}: {invite_link}")
|
||||||
|
|
||||||
title = f"Frappe CRM"
|
title = "Frappe CRM"
|
||||||
template = "crm_invitation"
|
template = "crm_invitation"
|
||||||
|
|
||||||
frappe.sendmail(
|
frappe.sendmail(
|
||||||
@ -44,12 +44,24 @@ class CRMInvitation(Document):
|
|||||||
|
|
||||||
user = self.create_user_if_not_exists()
|
user = self.create_user_if_not_exists()
|
||||||
user.append_roles(self.role)
|
user.append_roles(self.role)
|
||||||
|
if self.role == "Sales User":
|
||||||
|
self.update_module_in_user(user, "FCRM")
|
||||||
user.save(ignore_permissions=True)
|
user.save(ignore_permissions=True)
|
||||||
|
|
||||||
self.status = "Accepted"
|
self.status = "Accepted"
|
||||||
self.accepted_at = frappe.utils.now()
|
self.accepted_at = frappe.utils.now()
|
||||||
self.save(ignore_permissions=True)
|
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):
|
def create_user_if_not_exists(self):
|
||||||
if not frappe.db.exists("User", self.email):
|
if not frappe.db.exists("User", self.email):
|
||||||
first_name = self.email.split("@")[0].title()
|
first_name = self.email.split("@")[0].title()
|
||||||
|
|||||||
@ -6,7 +6,10 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_lead(name):
|
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["fields_meta"] = get_fields_meta("CRM Lead")
|
||||||
lead["_form_script"] = get_form_script("CRM Lead")
|
lead["_form_script"] = get_form_script("CRM Lead")
|
||||||
|
|||||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Lead", {
|
|||||||
refresh(frm) {
|
refresh(frm) {
|
||||||
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
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",
|
"annual_revenue",
|
||||||
"image",
|
"image",
|
||||||
"converted",
|
"converted",
|
||||||
|
"products_tab",
|
||||||
|
"products",
|
||||||
|
"section_break_ggwh",
|
||||||
|
"total",
|
||||||
|
"column_break_uisv",
|
||||||
|
"net_total",
|
||||||
"sla_tab",
|
"sla_tab",
|
||||||
"sla",
|
"sla",
|
||||||
"sla_creation",
|
"sla_creation",
|
||||||
@ -285,12 +291,47 @@
|
|||||||
"fieldtype": "Table",
|
"fieldtype": "Table",
|
||||||
"label": "Status Change Log",
|
"label": "Status Change Log",
|
||||||
"options": "CRM 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",
|
"image_field": "image",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-02 22:14:01.991054",
|
"modified": "2025-05-14 19:51:06.184569",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead",
|
"name": "CRM Lead",
|
||||||
@ -331,6 +372,7 @@
|
|||||||
"share": 1
|
"share": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sender_field": "email",
|
"sender_field": "email",
|
||||||
"sender_name_field": "first_name",
|
"sender_name_field": "first_name",
|
||||||
"show_title_field_in_link": 1,
|
"show_title_field_in_link": 1,
|
||||||
@ -339,4 +381,4 @@
|
|||||||
"states": [],
|
"states": [],
|
||||||
"title_field": "lead_name",
|
"title_field": "lead_name",
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
@frappe.whitelist()
|
||||||
def get_crm_form_script():
|
def get_crm_form_script():
|
||||||
return """
|
return """
|
||||||
async function setupForm({ doc, call, $dialog, updateField, createToast }) {
|
async function setupForm({ doc, call, $dialog, updateField, toast }) {
|
||||||
let actions = [];
|
let actions = [];
|
||||||
let is_erpnext_integration_enabled = await call("frappe.client.get_single_value", {doctype: "ERPNext CRM Settings", field: "enabled"});
|
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) {
|
if (!["Lost", "Won"].includes(doc?.status) && is_erpnext_integration_enabled) {
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"in_standard_filter": 1,
|
"in_standard_filter": 1,
|
||||||
"label": "Title"
|
"label": "Title",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "content",
|
"fieldname": "content",
|
||||||
@ -49,7 +50,7 @@
|
|||||||
"link_fieldname": "note"
|
"link_fieldname": "note"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2024-01-19 21:56:30.123334",
|
"modified": "2025-04-01 15:30:14.742001",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "FCRM Note",
|
"name": "FCRM Note",
|
||||||
|
|||||||
@ -24,9 +24,15 @@ class FCRMSettings(Document):
|
|||||||
standard_old_items = [d.name1 for d in old_items if d.is_standard]
|
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)
|
deleted_standard_items = set(standard_old_items) - set(standard_new_items)
|
||||||
if deleted_standard_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)))
|
frappe.throw(_("Cannot delete standard items {0}").format(", ".join(deleted_standard_items)))
|
||||||
|
|
||||||
|
|
||||||
|
def get_standard_dropdown_items():
|
||||||
|
return [item.get("name1") for item in frappe.get_hooks("standard_dropdown_items")]
|
||||||
|
|
||||||
|
|
||||||
def after_migrate():
|
def after_migrate():
|
||||||
sync_table("dropdown_items", "standard_dropdown_items")
|
sync_table("dropdown_items", "standard_dropdown_items")
|
||||||
|
|||||||
24
crm/hooks.py
24
crm/hooks.py
@ -264,22 +264,6 @@ standard_dropdown_items = [
|
|||||||
"route": "#",
|
"route": "#",
|
||||||
"is_standard": 1,
|
"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",
|
"name1": "toggle_theme",
|
||||||
"label": "Toggle theme",
|
"label": "Toggle theme",
|
||||||
@ -303,6 +287,14 @@ standard_dropdown_items = [
|
|||||||
"route": "#",
|
"route": "#",
|
||||||
"is_standard": 1,
|
"is_standard": 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name1": "about",
|
||||||
|
"label": "About",
|
||||||
|
"type": "Route",
|
||||||
|
"icon": "info",
|
||||||
|
"route": "#",
|
||||||
|
"is_standard": 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name1": "separator",
|
"name1": "separator",
|
||||||
"label": "",
|
"label": "",
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import click
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
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():
|
def before_install():
|
||||||
pass
|
pass
|
||||||
@ -19,6 +21,7 @@ def after_install(force=False):
|
|||||||
add_default_industries()
|
add_default_industries()
|
||||||
add_default_lead_sources()
|
add_default_lead_sources()
|
||||||
add_standard_dropdown_items()
|
add_standard_dropdown_items()
|
||||||
|
add_default_scripts()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
@ -353,3 +356,8 @@ def add_standard_dropdown_items():
|
|||||||
crm_settings.append("dropdown_items", item)
|
crm_settings.append("dropdown_items", item)
|
||||||
|
|
||||||
crm_settings.save()
|
crm_settings.save()
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_scripts():
|
||||||
|
for doctype in ["CRM Lead", "CRM Deal"]:
|
||||||
|
create_product_details_script(doctype)
|
||||||
|
|||||||
@ -110,12 +110,12 @@ def get_contact_by_phone_number(phone_number):
|
|||||||
number = parse_phone_number(phone_number)
|
number = parse_phone_number(phone_number)
|
||||||
|
|
||||||
if number.get("is_valid"):
|
if number.get("is_valid"):
|
||||||
return get_contact(number.get("national_number"))
|
return get_contact(number.get("national_number"), number.get("country"))
|
||||||
else:
|
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:
|
if not phone_number:
|
||||||
return {"mobile_no": phone_number}
|
return {"mobile_no": phone_number}
|
||||||
|
|
||||||
@ -149,11 +149,11 @@ def get_contact(phone_number, exact_match=False):
|
|||||||
deal = frappe.db.get_value(
|
deal = frappe.db.get_value(
|
||||||
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
|
"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
|
contact["deal"] = deal
|
||||||
return contact
|
return contact
|
||||||
# Else, return the first 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]
|
return contacts[0]
|
||||||
|
|
||||||
# Else, Check if the number is associated with a lead
|
# 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):
|
if len(leads):
|
||||||
for lead in 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["lead"] = lead.name
|
||||||
lead["full_name"] = lead.lead_name
|
lead["full_name"] = lead.lead_name
|
||||||
return lead
|
return lead
|
||||||
|
|||||||
1251
crm/locale/main.pot
1251
crm/locale/main.pot
File diff suppressed because it is too large
Load Diff
@ -11,4 +11,5 @@ crm.patches.v1_0.create_default_fields_layout #22/01/2025
|
|||||||
crm.patches.v1_0.create_default_sidebar_fields_layout
|
crm.patches.v1_0.create_default_sidebar_fields_layout
|
||||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||||
crm.patches.v1_0.update_layouts_to_new_format
|
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
|
||||||
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()
|
||||||
@ -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>
|
<p>
|
||||||
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
|
<a class="btn btn-primary" href="{{ invite_link }}">Accept Invitation</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# MIT License. See license.txt
|
# MIT License. See license.txt
|
||||||
from __future__ import unicode_literals
|
|
||||||
import click
|
import click
|
||||||
import frappe
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
def before_uninstall():
|
def before_uninstall():
|
||||||
delete_email_template_custom_fields()
|
delete_email_template_custom_fields()
|
||||||
|
|
||||||
|
|
||||||
def delete_email_template_custom_fields():
|
def delete_email_template_custom_fields():
|
||||||
if frappe.get_meta("Email Template").has_field("enabled"):
|
if frappe.get_meta("Email Template").has_field("enabled"):
|
||||||
click.secho("* Uninstalling Custom Fields from Email Template")
|
click.secho("* Uninstalling Custom Fields from Email Template")
|
||||||
@ -19,4 +20,4 @@ def delete_email_template_custom_fields():
|
|||||||
for fieldname in fieldnames:
|
for fieldname in fieldnames:
|
||||||
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
|
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
|
||||||
|
|
||||||
frappe.clear_cache(doctype="Email Template")
|
frappe.clear_cache(doctype="Email Template")
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# GNU GPLv3 License. See license.txt
|
# GNU GPLv3 License. See license.txt
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe import safe_decode
|
||||||
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
|
from frappe.integrations.frappe_providers.frappecloud_billing import is_fc_site
|
||||||
from frappe.utils import cint, get_system_timezone
|
from frappe.utils import cint, get_system_timezone
|
||||||
from frappe.utils.telemetry import capture
|
from frappe.utils.telemetry import capture
|
||||||
@ -43,9 +45,41 @@ def get_boot():
|
|||||||
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
|
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
|
||||||
or get_system_timezone(),
|
or get_system_timezone(),
|
||||||
},
|
},
|
||||||
|
"app_version": get_app_version(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_default_route():
|
def get_default_route():
|
||||||
return "/crm"
|
return "/crm"
|
||||||
|
|
||||||
|
|
||||||
|
def get_app_version():
|
||||||
|
app = "crm"
|
||||||
|
branch = run_git_command(f"cd ../apps/{app} && git rev-parse --abbrev-ref HEAD")
|
||||||
|
commit = run_git_command(f"git -C ../apps/{app} rev-parse --short=7 HEAD")
|
||||||
|
tag = run_git_command(f"git -C ../apps/{app} describe --tags --abbrev=0")
|
||||||
|
dirty = run_git_command(f"git -C ../apps/{app} diff --quiet || echo 'dirty'") == "dirty"
|
||||||
|
commit_date = run_git_command(f"git -C ../apps/{app} log -1 --format=%cd")
|
||||||
|
commit_message = run_git_command(f"git -C ../apps/{app} log -1 --pretty=%B")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"branch": branch,
|
||||||
|
"commit": commit,
|
||||||
|
"commit_date": commit_date,
|
||||||
|
"commit_message": commit_message,
|
||||||
|
"tag": tag,
|
||||||
|
"dirty": dirty,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 8b615c0e899d75b99c7d36ec6df97b5d0386b2ca
|
||||||
19
frontend/components.d.ts
vendored
19
frontend/components.d.ts
vendored
@ -8,6 +8,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AboutModal: typeof import('./src/components/Modals/AboutModal.vue')['default']
|
||||||
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
|
||||||
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
|
||||||
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
|
||||||
@ -53,6 +54,7 @@ declare module 'vue' {
|
|||||||
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
||||||
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
||||||
CountUpTimer: typeof import('./src/components/CountUpTimer.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']
|
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||||
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
||||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||||
@ -78,16 +80,23 @@ declare module 'vue' {
|
|||||||
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
||||||
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
||||||
Email2Icon: typeof import('./src/components/Icons/Email2Icon.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']
|
EmailArea: typeof import('./src/components/Activities/EmailArea.vue')['default']
|
||||||
EmailAtIcon: typeof import('./src/components/Icons/EmailAtIcon.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']
|
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']
|
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||||
|
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.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']
|
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
|
||||||
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
|
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
|
||||||
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
|
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
|
||||||
@ -106,9 +115,11 @@ declare module 'vue' {
|
|||||||
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
||||||
Filter: typeof import('./src/components/Filter.vue')['default']
|
Filter: typeof import('./src/components/Filter.vue')['default']
|
||||||
FilterIcon: typeof import('./src/components/Icons/FilterIcon.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']
|
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||||
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
||||||
|
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
||||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||||
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
|
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
|
||||||
@ -140,6 +151,10 @@ declare module 'vue' {
|
|||||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||||
|
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
|
||||||
|
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||||
|
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||||
|
LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||||
@ -149,6 +164,7 @@ declare module 'vue' {
|
|||||||
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
|
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
|
||||||
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
|
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
|
||||||
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.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']
|
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||||
@ -162,6 +178,7 @@ declare module 'vue' {
|
|||||||
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
|
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
|
||||||
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
|
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
|
||||||
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.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']
|
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
|
||||||
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
|
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
|
||||||
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
|
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
|
||||||
@ -196,6 +213,7 @@ declare module 'vue' {
|
|||||||
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
|
||||||
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
SortBy: typeof import('./src/components/SortBy.vue')['default']
|
||||||
SortIcon: typeof import('./src/components/Icons/SortIcon.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']
|
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
|
||||||
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
|
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
|
||||||
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
|
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
|
||||||
@ -206,6 +224,7 @@ declare module 'vue' {
|
|||||||
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
|
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
|
||||||
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
|
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
|
||||||
TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.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']
|
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
|
||||||
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
|
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
|
||||||
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
"name": "crm-ui",
|
"name": "crm-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build --base=/assets/crm/frontend/ && yarn copy-html-entry",
|
"build": "vite build --base=/assets/crm/frontend/ && yarn copy-html-entry",
|
||||||
@ -9,9 +10,10 @@
|
|||||||
"serve": "vite preview"
|
"serve": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/extension-paragraph": "^2.12.0",
|
||||||
"@twilio/voice-sdk": "^2.10.2",
|
"@twilio/voice-sdk": "^2.10.2",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
"frappe-ui": "^0.1.121",
|
"frappe-ui": "^0.1.145",
|
||||||
"gemoji": "^8.1.0",
|
"gemoji": "^8.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime": "^4.0.1",
|
"mime": "^4.0.1",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
@ -1,16 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<Layout v-if="session().isLoggedIn">
|
<FrappeUIProvider>
|
||||||
<router-view />
|
<Layout v-if="session().isLoggedIn">
|
||||||
</Layout>
|
<router-view />
|
||||||
<Dialogs />
|
</Layout>
|
||||||
<Toasts />
|
<Dialogs />
|
||||||
|
</FrappeUIProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Dialogs } from '@/utils/dialogs'
|
import { Dialogs } from '@/utils/dialogs'
|
||||||
import { sessionStore as session } from '@/stores/session'
|
import { sessionStore as session } from '@/stores/session'
|
||||||
import { setTheme } from '@/stores/theme'
|
import { setTheme } from '@/stores/theme'
|
||||||
import { Toasts, setConfig } from 'frappe-ui'
|
import { FrappeUIProvider, setConfig } from 'frappe-ui'
|
||||||
import { computed, defineAsyncComponent, onMounted } from 'vue'
|
import { computed, defineAsyncComponent, onMounted } from 'vue'
|
||||||
|
|
||||||
const MobileLayout = defineAsyncComponent(
|
const MobileLayout = defineAsyncComponent(
|
||||||
|
|||||||
@ -373,11 +373,7 @@
|
|||||||
>
|
>
|
||||||
<component :is="emptyTextIcon" class="h-10 w-10" />
|
<component :is="emptyTextIcon" class="h-10 w-10" />
|
||||||
<span>{{ __(emptyText) }}</span>
|
<span>{{ __(emptyText) }}</span>
|
||||||
<Button
|
<MultiActionButton v-if="title == 'Calls'" :options="callActions" />
|
||||||
v-if="title == 'Calls'"
|
|
||||||
:label="__('Make a Call')"
|
|
||||||
@click="makeCall(doc.data.mobile_no)"
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Notes'"
|
v-else-if="title == 'Notes'"
|
||||||
:label="__('Create Note')"
|
:label="__('Create Note')"
|
||||||
@ -470,6 +466,7 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
|||||||
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
||||||
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
||||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||||
|
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||||
import DotIcon from '@/components/Icons/DotIcon.vue'
|
import DotIcon from '@/components/Icons/DotIcon.vue'
|
||||||
@ -487,7 +484,7 @@ import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
|||||||
import { timeAgo, formatDate, startCase } from '@/utils'
|
import { timeAgo, formatDate, startCase } from '@/utils'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { whatsappEnabled } from '@/composables/settings'
|
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||||
import { useElementVisibility } from '@vueuse/core'
|
import { useElementVisibility } from '@vueuse/core'
|
||||||
@ -785,5 +782,23 @@ function scroll(hash) {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 })
|
defineExpose({ emailBox, all_activities })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -26,16 +26,11 @@
|
|||||||
</template>
|
</template>
|
||||||
<span>{{ __('New Comment') }}</span>
|
<span>{{ __('New Comment') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<MultiActionButton
|
||||||
v-else-if="title == 'Calls'"
|
v-else-if="title == 'Calls'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@click="makeCall(doc.data.mobile_no)"
|
:options="callActions"
|
||||||
>
|
/>
|
||||||
<template #prefix>
|
|
||||||
<PhoneIcon class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
<span>{{ __('Make a Call') }}</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
v-else-if="title == 'Notes'"
|
v-else-if="title == 'Notes'"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@ -97,6 +92,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
@ -136,6 +132,11 @@ const defaultActions = computed(() => {
|
|||||||
label: __('New Comment'),
|
label: __('New Comment'),
|
||||||
onClick: () => (props.emailBox.showComment = true),
|
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' }),
|
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||||
label: __('Make a Call'),
|
label: __('Make a Call'),
|
||||||
@ -172,4 +173,24 @@ const defaultActions = computed(() => {
|
|||||||
function getTabIndex(name) {
|
function getTabIndex(name) {
|
||||||
return props.tabs.findIndex((tab) => tab.name === 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>
|
</script>
|
||||||
|
|||||||
@ -15,10 +15,16 @@
|
|||||||
:doc="doc.data?.name"
|
:doc="doc.data?.name"
|
||||||
@after="redirect('notes')"
|
@after="redirect('notes')"
|
||||||
/>
|
/>
|
||||||
|
<CallLogModal
|
||||||
|
v-model="showCallLogModal"
|
||||||
|
v-model:callLog="callLog"
|
||||||
|
:options="{ afterInsert: () => activities.reload() }"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||||
import NoteModal from '@/components/Modals/NoteModal.vue'
|
import NoteModal from '@/components/Modals/NoteModal.vue'
|
||||||
|
import CallLogModal from '@/components/Modals/CallLogModal.vue'
|
||||||
import { call } from 'frappe-ui'
|
import { call } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@ -77,6 +83,22 @@ function showNote(n) {
|
|||||||
showNoteModal.value = true
|
showNoteModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call Logs
|
||||||
|
const showCallLogModal = ref(false)
|
||||||
|
const callLog = ref({})
|
||||||
|
|
||||||
|
function createCallLog() {
|
||||||
|
let doctype = props.doctype
|
||||||
|
let docname = props.doc.data?.name
|
||||||
|
callLog.value = {
|
||||||
|
data: {
|
||||||
|
reference_doctype: doctype,
|
||||||
|
reference_docname: docname,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
showCallLogModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
// common
|
// common
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -95,5 +117,6 @@ defineExpose({
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
updateTaskStatus,
|
updateTaskStatus,
|
||||||
showNote,
|
showNote,
|
||||||
|
createCallLog,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
||||||
{{ __('Data') }}
|
{{ __('Data') }}
|
||||||
<Badge
|
<Badge
|
||||||
v-if="data.isDirty"
|
v-if="document.isDirty"
|
||||||
class="ml-3"
|
class="ml-3"
|
||||||
:label="'Not Saved'"
|
:label="'Not Saved'"
|
||||||
theme="orange"
|
theme="orange"
|
||||||
@ -20,15 +20,15 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
label="Save"
|
label="Save"
|
||||||
:disabled="!data.isDirty"
|
:disabled="!document.isDirty"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:loading="data.save.loading"
|
:loading="document.save.loading"
|
||||||
@click="saveChanges"
|
@click="saveChanges"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="data.get.loading"
|
v-if="document.get.loading"
|
||||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||||
>
|
>
|
||||||
<LoadingIndicator class="h-6 w-6" />
|
<LoadingIndicator class="h-6 w-6" />
|
||||||
@ -38,7 +38,7 @@
|
|||||||
<FieldLayout
|
<FieldLayout
|
||||||
v-if="tabs.data"
|
v-if="tabs.data"
|
||||||
:tabs="tabs.data"
|
:tabs="tabs.data"
|
||||||
:data="data.doc"
|
:data="document.doc"
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -49,7 +49,7 @@
|
|||||||
@reload="
|
@reload="
|
||||||
() => {
|
() => {
|
||||||
tabs.reload()
|
tabs.reload()
|
||||||
data.reload()
|
document.reload()
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
@ -59,12 +59,12 @@
|
|||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
|
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
|
||||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.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 LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||||
import { createToast } from '@/utils'
|
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doctype: {
|
doctype: {
|
||||||
@ -76,33 +76,11 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { isManager } = usersStore()
|
const { isManager } = usersStore()
|
||||||
|
|
||||||
const showDataFieldsModal = ref(false)
|
const showDataFieldsModal = ref(false)
|
||||||
|
|
||||||
const data = createDocumentResource({
|
const { document } = useDocument(props.doctype, props.docname)
|
||||||
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 tabs = createResource({
|
const tabs = createResource({
|
||||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||||
@ -112,6 +90,22 @@ const tabs = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function saveChanges() {
|
function saveChanges() {
|
||||||
data.save.submit()
|
document.save.submit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@ -22,9 +22,9 @@
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
:theme="status.color"
|
:theme="status.color"
|
||||||
/>
|
/>
|
||||||
<Tooltip :text="formatDate(activity.creation)">
|
<Tooltip :text="formatDate(activity.communication_date)">
|
||||||
<div class="text-sm text-ink-gray-5">
|
<div class="text-sm text-ink-gray-5">
|
||||||
{{ __(timeAgo(activity.creation)) }}
|
{{ __(timeAgo(activity.communication_date)) }}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div class="flex gap-0.5">
|
<div class="flex gap-0.5">
|
||||||
|
|||||||
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
|
<div
|
||||||
v-for="field in fields"
|
v-for="field in fields"
|
||||||
class="border-r border-outline-gray-2 p-2 truncate"
|
class="border-r border-outline-gray-2 p-2 truncate"
|
||||||
|
:class="
|
||||||
|
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
|
||||||
|
? 'text-right'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
:key="field.fieldname"
|
:key="field.fieldname"
|
||||||
:title="field.label"
|
:title="field.label"
|
||||||
>
|
>
|
||||||
{{ __(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>
|
</div>
|
||||||
<div class="w-12">
|
<div class="w-12">
|
||||||
@ -93,18 +106,37 @@
|
|||||||
:key="field.fieldname"
|
:key="field.fieldname"
|
||||||
>
|
>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
v-if="
|
||||||
|
field.read_only &&
|
||||||
|
![
|
||||||
|
'Int',
|
||||||
|
'Float',
|
||||||
|
'Currency',
|
||||||
|
'Percent',
|
||||||
|
'Check',
|
||||||
|
].includes(field.fieldtype)
|
||||||
|
"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
v-model="row[field.fieldname]"
|
v-model="row[field.fieldname]"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-else-if="field.fieldtype === 'Link'"
|
v-else-if="
|
||||||
|
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||||
|
"
|
||||||
class="text-sm text-ink-gray-8"
|
class="text-sm text-ink-gray-8"
|
||||||
v-model="row[field.fieldname]"
|
:value="row[field.fieldname]"
|
||||||
:doctype="field.options"
|
:doctype="
|
||||||
|
field.fieldtype == 'Link'
|
||||||
|
? field.options
|
||||||
|
: row[field.options]
|
||||||
|
"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
|
@change="(v) => fieldChange(v, field, row)"
|
||||||
|
:onCreate="
|
||||||
|
(value, close) => field.create(v, field, row, close)
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-else-if="field.fieldtype === 'User'"
|
v-else-if="field.fieldtype === 'User'"
|
||||||
@ -112,7 +144,7 @@
|
|||||||
:value="getUser(row[field.fieldname]).full_name"
|
:value="getUser(row[field.fieldname]).full_name"
|
||||||
:doctype="field.options"
|
:doctype="field.options"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
@change="(v) => (row[field.fieldname] = v)"
|
@change="(v) => fieldChange(v, field, row)"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:hideMe="true"
|
:hideMe="true"
|
||||||
>
|
>
|
||||||
@ -142,23 +174,26 @@
|
|||||||
class="cursor-pointer duration-300"
|
class="cursor-pointer duration-300"
|
||||||
v-model="row[field.fieldname]"
|
v-model="row[field.fieldname]"
|
||||||
:disabled="!gridSettings.editable_grid"
|
:disabled="!gridSettings.editable_grid"
|
||||||
|
@change="(e) => fieldChange(e.target.checked, field, row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-else-if="field.fieldtype === 'Date'"
|
v-else-if="field.fieldtype === 'Date'"
|
||||||
v-model="row[field.fieldname]"
|
:value="row[field.fieldname]"
|
||||||
icon-left=""
|
icon-left=""
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:formatter="(date) => getFormat(date, '', true)"
|
:formatter="(date) => getFormat(date, '', true)"
|
||||||
input-class="border-none text-sm text-ink-gray-8"
|
input-class="border-none text-sm text-ink-gray-8"
|
||||||
|
@change="(v) => fieldChange(v, field, row)"
|
||||||
/>
|
/>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
v-else-if="field.fieldtype === 'Datetime'"
|
v-else-if="field.fieldtype === 'Datetime'"
|
||||||
v-model="row[field.fieldname]"
|
:value="row[field.fieldname]"
|
||||||
icon-left=""
|
icon-left=""
|
||||||
variant="outline"
|
variant="outline"
|
||||||
:formatter="(date) => getFormat(date, '', true, true)"
|
:formatter="(date) => getFormat(date, '', true, true)"
|
||||||
input-class="border-none text-sm text-ink-gray-8"
|
input-class="border-none text-sm text-ink-gray-8"
|
||||||
|
@change="(v) => fieldChange(v, field, row)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="
|
v-else-if="
|
||||||
@ -169,13 +204,8 @@
|
|||||||
rows="1"
|
rows="1"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
v-model="row[field.fieldname]"
|
:value="row[field.fieldname]"
|
||||||
/>
|
@change="fieldChange($event.target.value, field, row)"
|
||||||
<FormControl
|
|
||||||
v-else-if="['Int'].includes(field.fieldtype)"
|
|
||||||
type="number"
|
|
||||||
variant="outline"
|
|
||||||
v-model="row[field.fieldname]"
|
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.fieldtype === 'Select'"
|
v-else-if="field.fieldtype === 'Select'"
|
||||||
@ -184,6 +214,55 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
v-model="row[field.fieldname]"
|
v-model="row[field.fieldname]"
|
||||||
:options="field.options"
|
: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
|
<FormControl
|
||||||
v-else
|
v-else
|
||||||
@ -192,6 +271,7 @@
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
v-model="row[field.fieldname]"
|
v-model="row[field.fieldname]"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
|
@change="fieldChange($event.target.value, field, row)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -252,6 +332,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Password from '@/components/Controls/Password.vue'
|
||||||
|
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||||
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
||||||
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
||||||
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
||||||
@ -259,8 +341,10 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
|||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
|
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
|
||||||
|
import { flt } from '@/utils/numberFormat.js'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { getMeta } from '@/stores/meta'
|
import { getMeta } from '@/stores/meta'
|
||||||
|
import { createDocument } from '@/composables/document'
|
||||||
import {
|
import {
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -268,9 +352,10 @@ import {
|
|||||||
DateTimePicker,
|
DateTimePicker,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
dayjs,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed, inject, provide } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
@ -285,15 +370,32 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
parentFieldname: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { getGridViewSettings, getFields, getGridSettings } = getMeta(
|
const triggerOnChange = inject('triggerOnChange')
|
||||||
props.doctype,
|
const triggerOnRowAdd = inject('triggerOnRowAdd')
|
||||||
)
|
const triggerOnRowRemove = inject('triggerOnRowRemove')
|
||||||
|
|
||||||
|
const {
|
||||||
|
getGridViewSettings,
|
||||||
|
getFields,
|
||||||
|
getFloatWithPrecision,
|
||||||
|
getCurrencyWithPrecision,
|
||||||
|
getFormattedCurrency,
|
||||||
|
getGridSettings,
|
||||||
|
} = getMeta(props.doctype)
|
||||||
getMeta(props.parentDoctype)
|
getMeta(props.parentDoctype)
|
||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
|
|
||||||
const rows = defineModel()
|
const rows = defineModel()
|
||||||
|
const parentDoc = defineModel('parent')
|
||||||
|
|
||||||
|
provide('parentDoc', parentDoc)
|
||||||
|
|
||||||
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
||||||
const selectedRows = reactive(new Set())
|
const selectedRows = reactive(new Set())
|
||||||
|
|
||||||
@ -316,7 +418,22 @@ const fields = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const allFields = computed(() => {
|
||||||
|
return getFields()?.map((f) => getFieldObj(f)) || []
|
||||||
|
})
|
||||||
|
|
||||||
function getFieldObj(field) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...field,
|
...field,
|
||||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||||
@ -361,21 +478,71 @@ const toggleSelectRow = (row) => {
|
|||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
const newRow = {}
|
const newRow = {}
|
||||||
fields.value?.forEach((field) => {
|
allFields.value?.forEach((field) => {
|
||||||
if (field.fieldtype === 'Check') newRow[field.fieldname] = false
|
if (field.fieldtype === 'Check') {
|
||||||
else newRow[field.fieldname] = ''
|
newRow[field.fieldname] = false
|
||||||
|
} else {
|
||||||
|
newRow[field.fieldname] = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.default) {
|
||||||
|
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
newRow.name = getRandom(10)
|
newRow.name = getRandom(10)
|
||||||
showRowList.value.push(false)
|
showRowList.value.push(false)
|
||||||
newRow['__islocal'] = true
|
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)
|
rows.value.push(newRow)
|
||||||
|
triggerOnRowAdd(newRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteRows = () => {
|
const deleteRows = () => {
|
||||||
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
||||||
|
triggerOnRowRemove(selectedRows, rows.value)
|
||||||
|
|
||||||
showRowList.value.pop()
|
showRowList.value.pop()
|
||||||
selectedRows.clear()
|
selectedRows.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldChange(value, field, row) {
|
||||||
|
row[field.fieldname] = value
|
||||||
|
triggerOnChange(field.fieldname, 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -139,9 +139,14 @@ const oldFields = computed(() => {
|
|||||||
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
||||||
|
|
||||||
const dropdownFields = computed(() => {
|
const dropdownFields = computed(() => {
|
||||||
return getFields()?.filter(
|
return getFields()?.filter((field) => {
|
||||||
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
|
return (
|
||||||
)
|
!fields.value.find((f) => f.fieldname === field.fieldname) &&
|
||||||
|
!['Tab Break', 'Section Break', 'Column Break', 'Table'].includes(
|
||||||
|
field.fieldtype,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
|
|||||||
@ -23,7 +23,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -159,6 +159,7 @@ const options = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function reload(val) {
|
function reload(val) {
|
||||||
|
if (!props.doctype) return
|
||||||
if (
|
if (
|
||||||
options.data?.length &&
|
options.data?.length &&
|
||||||
val === options.params?.txt &&
|
val === options.params?.txt &&
|
||||||
|
|||||||
@ -58,6 +58,21 @@
|
|||||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||||
static
|
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 add')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
<ComboboxOption
|
<ComboboxOption
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@ -137,6 +152,10 @@ const props = defineProps({
|
|||||||
type: Function,
|
type: Function,
|
||||||
default: (value) => `${value} is an Invalid value`,
|
default: (value) => `${value} is an Invalid value`,
|
||||||
},
|
},
|
||||||
|
fetchContacts: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel()
|
||||||
@ -191,17 +210,19 @@ const filterOptions = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
let searchedContacts = filterOptions.data || []
|
let searchedContacts = props.fetchContacts ? filterOptions.data : []
|
||||||
if (!searchedContacts.length && query.value) {
|
if (!searchedContacts?.length && query.value) {
|
||||||
searchedContacts.push({
|
searchedContacts.push({
|
||||||
label: query.value,
|
label: query.value,
|
||||||
value: query.value,
|
value: query.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return searchedContacts
|
return searchedContacts || []
|
||||||
})
|
})
|
||||||
|
|
||||||
function reload(val) {
|
function reload(val) {
|
||||||
|
if (!props.fetchContacts) return
|
||||||
|
|
||||||
filterOptions.update({
|
filterOptions.update({
|
||||||
params: { txt: val },
|
params: { txt: val },
|
||||||
})
|
})
|
||||||
|
|||||||
32
frontend/src/components/Controls/Password.vue
Normal file
32
frontend/src/components/Controls/Password.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<FormControl
|
||||||
|
:type="show ? 'text' : 'password'"
|
||||||
|
:value="modelValue || value"
|
||||||
|
v-bind="$attrs"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<Button v-show="showEye" class="!h-4" @click="show = !show">
|
||||||
|
<FeatherIcon :name="show ? 'eye-off' : 'eye'" class="h-3" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</FormControl>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { FormControl } 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 { getFields } = getMeta(props.doctype)
|
||||||
|
|
||||||
const values = defineModel()
|
const values = defineModel({
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
})
|
||||||
|
|
||||||
const valuesRef = ref([])
|
const valuesRef = ref([])
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
@ -109,14 +114,16 @@ const addValue = (value) => {
|
|||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
values.value.push({ [linkField.value.fieldname]: value })
|
values.value.push({ [linkField.value.fieldname]: value })
|
||||||
|
emit('change', values.value)
|
||||||
!error.value && (query.value = '')
|
!error.value && (query.value = '')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeValue = (value) => {
|
const removeValue = (value) => {
|
||||||
values.value = values.value.filter(
|
let _value = values.value.filter(
|
||||||
(row) => row[linkField.value.fieldname] !== value,
|
(row) => row[linkField.value.fieldname] !== value,
|
||||||
)
|
)
|
||||||
|
emit('change', _value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeLastValue = () => {
|
const removeLastValue = () => {
|
||||||
@ -125,12 +132,11 @@ const removeLastValue = () => {
|
|||||||
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
|
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
|
||||||
if (document.activeElement === valueRef) {
|
if (document.activeElement === valueRef) {
|
||||||
values.value.pop()
|
values.value.pop()
|
||||||
|
emit('change', values.value)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (values.value.length) {
|
if (values.value.length) {
|
||||||
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
|
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
|
||||||
valueRef?.focus()
|
valueRef?.focus()
|
||||||
} else {
|
|
||||||
setFocus()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -37,8 +37,8 @@ import { isMobileView } from '@/composables/settings'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
actions: {
|
actions: {
|
||||||
type: Object,
|
type: [Object, Array, undefined],
|
||||||
required: true,
|
default: () => [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ const groupedActions = computed(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
_actions = _actions.concat(
|
_actions = _actions.concat(
|
||||||
props.actions.filter((action) => action.group && !action.buttonLabel)
|
props.actions.filter((action) => action.group && !action.buttonLabel),
|
||||||
)
|
)
|
||||||
return _actions
|
return _actions
|
||||||
})
|
})
|
||||||
|
|||||||
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.reqd ||
|
||||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||||
"
|
"
|
||||||
class="text-ink-red-3"
|
class="text-ink-red-2"
|
||||||
>*</span
|
>*</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
v-if="
|
||||||
|
field.read_only &&
|
||||||
|
!['Int', 'Float', 'Currency', 'Percent', 'Check'].includes(
|
||||||
|
field.fieldtype,
|
||||||
|
)
|
||||||
|
"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
v-model="data[field.fieldname]"
|
v-model="data[field.fieldname]"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
|
:description="field.description"
|
||||||
/>
|
/>
|
||||||
<Grid
|
<Grid
|
||||||
v-else-if="field.fieldtype === 'Table'"
|
v-else-if="field.fieldtype === 'Table'"
|
||||||
v-model="data[field.fieldname]"
|
v-model="data[field.fieldname]"
|
||||||
|
v-model:parent="data"
|
||||||
:doctype="field.options"
|
:doctype="field.options"
|
||||||
:parentDoctype="doctype"
|
:parentDoctype="doctype"
|
||||||
|
:parentFieldname="field.fieldname"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.fieldtype === 'Select'"
|
v-else-if="field.fieldtype === 'Select'"
|
||||||
@ -31,7 +39,9 @@
|
|||||||
:class="field.prefix ? 'prefix' : ''"
|
:class="field.prefix ? 'prefix' : ''"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
v-model="data[field.fieldname]"
|
v-model="data[field.fieldname]"
|
||||||
|
@change="(e) => fieldChange(e.target.value, field)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
|
:description="field.description"
|
||||||
>
|
>
|
||||||
<template v-if="field.prefix" #prefix>
|
<template v-if="field.prefix" #prefix>
|
||||||
<IndicatorIcon :class="field.prefix" />
|
<IndicatorIcon :class="field.prefix" />
|
||||||
@ -42,8 +52,9 @@
|
|||||||
class="form-control"
|
class="form-control"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="data[field.fieldname]"
|
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)"
|
:disabled="Boolean(field.read_only)"
|
||||||
|
:description="field.description"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
class="text-sm text-ink-gray-5"
|
class="text-sm text-ink-gray-5"
|
||||||
@ -59,13 +70,18 @@
|
|||||||
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
|
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<Link
|
||||||
class="form-control flex-1 truncate"
|
class="form-control flex-1 truncate"
|
||||||
:value="data[field.fieldname]"
|
:value="data[field.fieldname]"
|
||||||
:doctype="field.options"
|
:doctype="
|
||||||
|
field.fieldtype == 'Link' ? field.options : data[field.options]
|
||||||
|
"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
@change="(v) => (data[field.fieldname] = v)"
|
@change="(v) => fieldChange(v, field)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
:onCreate="field.create"
|
:onCreate="field.create"
|
||||||
/>
|
/>
|
||||||
@ -85,6 +101,7 @@
|
|||||||
v-else-if="field.fieldtype === 'Table MultiSelect'"
|
v-else-if="field.fieldtype === 'Table MultiSelect'"
|
||||||
v-model="data[field.fieldname]"
|
v-model="data[field.fieldname]"
|
||||||
:doctype="field.options"
|
:doctype="field.options"
|
||||||
|
@change="(v) => fieldChange(v, field)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
@ -93,7 +110,7 @@
|
|||||||
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
|
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
|
||||||
:doctype="field.options"
|
:doctype="field.options"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
@change="(v) => (data[field.fieldname] = v)"
|
@change="(v) => fieldChange(v, field)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
:hideMe="true"
|
:hideMe="true"
|
||||||
>
|
>
|
||||||
@ -118,80 +135,101 @@
|
|||||||
</Link>
|
</Link>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
v-else-if="field.fieldtype === 'Datetime'"
|
v-else-if="field.fieldtype === 'Datetime'"
|
||||||
v-model="data[field.fieldname]"
|
:value="data[field.fieldname]"
|
||||||
icon-left=""
|
|
||||||
:formatter="(date) => getFormat(date, '', true, true)"
|
:formatter="(date) => getFormat(date, '', true, true)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
input-class="border-none"
|
input-class="border-none"
|
||||||
|
@change="(v) => fieldChange(v, field)"
|
||||||
/>
|
/>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-else-if="field.fieldtype === 'Date'"
|
v-else-if="field.fieldtype === 'Date'"
|
||||||
icon-left=""
|
:value="data[field.fieldname]"
|
||||||
v-model="data[field.fieldname]"
|
|
||||||
:formatter="(date) => getFormat(date, '', true)"
|
:formatter="(date) => getFormat(date, '', true)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
input-class="border-none"
|
input-class="border-none"
|
||||||
|
@change="(v) => fieldChange(v, field)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="
|
v-else-if="
|
||||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
|
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
|
||||||
"
|
"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
:value="data[field.fieldname]"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
v-model="data[field.fieldname]"
|
:description="field.description"
|
||||||
|
@change="fieldChange($event.target.value, field)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<Password
|
||||||
v-else-if="['Int'].includes(field.fieldtype)"
|
v-else-if="field.fieldtype === 'Password'"
|
||||||
type="number"
|
:value="data[field.fieldname]"
|
||||||
:placeholder="getPlaceholder(field)"
|
: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'"
|
v-else-if="field.fieldtype === 'Percent'"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedPercent(field.fieldname, data)"
|
:value="getFormattedPercent(field.fieldname, data)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
:disabled="Boolean(field.read_only)"
|
: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'"
|
v-else-if="field.fieldtype === 'Float'"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedFloat(field.fieldname, data)"
|
:value="getFormattedFloat(field.fieldname, data)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
:disabled="Boolean(field.read_only)"
|
: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'"
|
v-else-if="field.fieldtype === 'Currency'"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedCurrency(field.fieldname, data)"
|
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
:disabled="Boolean(field.read_only)"
|
:disabled="Boolean(field.read_only)"
|
||||||
@change="data[field.fieldname] = flt($event.target.value)"
|
:description="field.description"
|
||||||
|
@change="fieldChange(flt($event.target.value), field)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else
|
v-else
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="getPlaceholder(field)"
|
:placeholder="getPlaceholder(field)"
|
||||||
v-model="data[field.fieldname]"
|
:value="getDataValue(data[field.fieldname], field)"
|
||||||
:disabled="Boolean(field.read_only)"
|
:disabled="Boolean(field.read_only)"
|
||||||
|
:description="field.description"
|
||||||
|
@change="fieldChange($event.target.value, field)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Password from '@/components/Controls/Password.vue'
|
||||||
|
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
|
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import Grid from '@/components/Controls/Grid.vue'
|
import Grid from '@/components/Controls/Grid.vue'
|
||||||
|
import { createDocument } from '@/composables/document'
|
||||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||||
import { flt } from '@/utils/numberFormat.js'
|
import { flt } from '@/utils/numberFormat.js'
|
||||||
import { getMeta } from '@/stores/meta'
|
import { getMeta } from '@/stores/meta'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||||
import { computed, inject } from 'vue'
|
import { computed, provide, inject } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
field: Object,
|
field: Object,
|
||||||
@ -200,11 +238,32 @@ const props = defineProps({
|
|||||||
const data = inject('data')
|
const data = inject('data')
|
||||||
const doctype = inject('doctype')
|
const doctype = inject('doctype')
|
||||||
const preview = inject('preview')
|
const preview = inject('preview')
|
||||||
|
const isGridRow = inject('isGridRow')
|
||||||
|
|
||||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||||
getMeta(doctype)
|
getMeta(doctype)
|
||||||
|
|
||||||
const { getUser } = usersStore()
|
const { 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(() => {
|
const field = computed(() => {
|
||||||
let field = props.field
|
let field = props.field
|
||||||
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
|
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
|
||||||
@ -221,6 +280,17 @@ const field = computed(() => {
|
|||||||
field.fieldtype = 'User'
|
field.fieldtype = 'User'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
let _field = {
|
||||||
...field,
|
...field,
|
||||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||||
@ -260,6 +330,23 @@ const getPlaceholder = (field) => {
|
|||||||
return __('Enter {0}', [__(field.label)])
|
return __('Enter {0}', [__(field.label)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldChange(value, df) {
|
||||||
|
data.value[df.fieldname] = value
|
||||||
|
|
||||||
|
if (isGridRow) {
|
||||||
|
triggerOnChange(df.fieldname, data.value)
|
||||||
|
} else {
|
||||||
|
triggerOnChange(df.fieldname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataValue(value, field) {
|
||||||
|
if (field.fieldtype === 'Duration') {
|
||||||
|
return value || 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(.form-control.prefix select) {
|
:deep(.form-control.prefix select) {
|
||||||
|
|||||||
@ -34,6 +34,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'CRM Lead',
|
default: 'CRM Lead',
|
||||||
},
|
},
|
||||||
|
isGridRow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
preview: {
|
preview: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -55,6 +59,7 @@ provide(
|
|||||||
provide('hasTabs', hasTabs)
|
provide('hasTabs', hasTabs)
|
||||||
provide('doctype', props.doctype)
|
provide('doctype', props.doctype)
|
||||||
provide('preview', props.preview)
|
provide('preview', props.preview)
|
||||||
|
provide('isGridRow', props.isGridRow)
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.section:not(:has(.field)) {
|
.section:not(:has(.field)) {
|
||||||
|
|||||||
@ -277,13 +277,13 @@ const fields = createResource({
|
|||||||
]
|
]
|
||||||
let existingFields = []
|
let existingFields = []
|
||||||
|
|
||||||
for (let tab of props.tabs) {
|
props.tabs?.forEach((tab) => {
|
||||||
for (let section of tab.sections) {
|
tab.sections?.forEach((section) => {
|
||||||
for (let column of section.columns) {
|
section.columns?.forEach((column) => {
|
||||||
existingFields = existingFields.concat(column.fields)
|
existingFields = existingFields.concat(column.fields)
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
return data.filter((field) => {
|
return data.filter((field) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
|
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
|
||||||
import FilesUploadHandler from './filesUploaderHandler'
|
import FilesUploadHandler from './filesUploaderHandler'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { createToast } from '@/utils'
|
import { toast } from 'frappe-ui'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -165,12 +165,7 @@ function attachFiles() {
|
|||||||
function uploadViaWebLink() {
|
function uploadViaWebLink() {
|
||||||
let fileUrl = filesUploaderArea.value.webLink
|
let fileUrl = filesUploaderArea.value.webLink
|
||||||
if (!fileUrl) {
|
if (!fileUrl) {
|
||||||
createToast({
|
toast.error(__('Please enter a valid URL'))
|
||||||
title: __('Error'),
|
|
||||||
title: __('Please enter a valid URL'),
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'text-ink-red-4',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fileUrl = decodeURI(fileUrl)
|
fileUrl = decodeURI(fileUrl)
|
||||||
|
|||||||
@ -126,8 +126,13 @@
|
|||||||
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||||
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
||||||
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||||
import { createToast, formatDate, convertSize } from '@/utils'
|
import { formatDate, convertSize } from '@/utils'
|
||||||
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
|
import {
|
||||||
|
FormControl,
|
||||||
|
CircularProgressBar,
|
||||||
|
createResource,
|
||||||
|
toast,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -324,24 +329,18 @@ function checkRestrictions(file) {
|
|||||||
|
|
||||||
if (!isCorrectType) {
|
if (!isCorrectType) {
|
||||||
console.warn('File skipped because of invalid file type', file)
|
console.warn('File skipped because of invalid file type', file)
|
||||||
createToast({
|
toast.warning(
|
||||||
title: __('File "{0}" was skipped because of invalid file type', [
|
__('File "{0}" was skipped because of invalid file type', [file.name]),
|
||||||
file.name,
|
)
|
||||||
]),
|
|
||||||
icon: 'alert-circle',
|
|
||||||
iconClasses: 'text-orange-600',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (!validFileSize) {
|
if (!validFileSize) {
|
||||||
console.warn('File skipped because of invalid file size', file.size, file)
|
console.warn('File skipped because of invalid file size', file.size, file)
|
||||||
createToast({
|
toast.warning(
|
||||||
title: __('File "{0}" was skipped because size exceeds {1} MB', [
|
__('File "{0}" was skipped because size exceeds {1} MB', [
|
||||||
file.name,
|
file.name,
|
||||||
maxFileSize / (1024 * 1024),
|
maxFileSize / (1024 * 1024),
|
||||||
]),
|
]),
|
||||||
icon: 'alert-circle',
|
)
|
||||||
iconClasses: 'text-orange-600',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return isCorrectType && validFileSize
|
return isCorrectType && validFileSize
|
||||||
@ -363,11 +362,7 @@ function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createToast({
|
toast.warning(message)
|
||||||
title: message,
|
|
||||||
icon: 'alert-circle',
|
|
||||||
iconClasses: 'text-orange-600',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(name) {
|
function removeFile(name) {
|
||||||
|
|||||||
@ -126,7 +126,7 @@
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
value=""
|
value=""
|
||||||
:options="filterableFields.data"
|
:options="availableFilters"
|
||||||
@change="(e) => setfilter(e)"
|
@change="(e) => setfilter(e)"
|
||||||
:placeholder="__('First name')"
|
:placeholder="__('First name')"
|
||||||
>
|
>
|
||||||
@ -217,6 +217,19 @@ const filters = computed(() => {
|
|||||||
return convertFilters(filterableFields.data, allFilters)
|
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) {
|
function removeCommonFilters(commonFilters, allFilters) {
|
||||||
for (const key in commonFilters) {
|
for (const key in commonFilters) {
|
||||||
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
|
if (commonFilters.hasOwnProperty(key) && allFilters.hasOwnProperty(key)) {
|
||||||
|
|||||||
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>
|
||||||
@ -150,6 +150,7 @@ import Section from '@/components/Section.vue'
|
|||||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||||
import PinIcon from '@/components/Icons/PinIcon.vue'
|
import PinIcon from '@/components/Icons/PinIcon.vue'
|
||||||
import UserDropdown from '@/components/UserDropdown.vue'
|
import UserDropdown from '@/components/UserDropdown.vue'
|
||||||
|
import SquareAsterisk from '@/components/Icons/SquareAsterisk.vue'
|
||||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||||
@ -168,6 +169,8 @@ import {
|
|||||||
unreadNotificationsCount,
|
unreadNotificationsCount,
|
||||||
notificationsStore,
|
notificationsStore,
|
||||||
} from '@/stores/notifications'
|
} from '@/stores/notifications'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
import { showSettings, activeSettingsPage } from '@/composables/settings'
|
||||||
import { FeatherIcon, call } from 'frappe-ui'
|
import { FeatherIcon, call } from 'frappe-ui'
|
||||||
import {
|
import {
|
||||||
@ -299,16 +302,18 @@ function getIcon(routeName, icon) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// onboarding
|
// onboarding
|
||||||
|
const { user } = sessionStore()
|
||||||
|
const { users, isManager } = usersStore()
|
||||||
const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm')
|
const { isOnboardingStepsCompleted, setUp } = useOnboarding('frappecrm')
|
||||||
|
|
||||||
async function getFirstLead() {
|
async function getFirstLead() {
|
||||||
let firstLead = localStorage.getItem('firstLead')
|
let firstLead = localStorage.getItem('firstLead' + user)
|
||||||
if (firstLead) return firstLead
|
if (firstLead) return firstLead
|
||||||
return await call('crm.api.onboarding.get_first_lead')
|
return await call('crm.api.onboarding.get_first_lead')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFirstDeal() {
|
async function getFirstDeal() {
|
||||||
let firstDeal = localStorage.getItem('firstDeal')
|
let firstDeal = localStorage.getItem('firstDeal' + user)
|
||||||
if (firstDeal) return firstDeal
|
if (firstDeal) return firstDeal
|
||||||
return await call('crm.api.onboarding.get_first_deal')
|
return await call('crm.api.onboarding.get_first_deal')
|
||||||
}
|
}
|
||||||
@ -317,6 +322,17 @@ const showIntermediateModal = ref(false)
|
|||||||
const currentStep = ref({})
|
const currentStep = ref({})
|
||||||
|
|
||||||
const steps = reactive([
|
const steps = reactive([
|
||||||
|
{
|
||||||
|
name: 'setup_your_password',
|
||||||
|
title: __('Setup your password'),
|
||||||
|
icon: markRaw(SquareAsterisk),
|
||||||
|
completed: false,
|
||||||
|
onClick: () => {
|
||||||
|
minimize.value = true
|
||||||
|
showSettings.value = true
|
||||||
|
activeSettingsPage.value = 'Profile'
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'create_first_lead',
|
name: 'create_first_lead',
|
||||||
title: __('Create your first lead'),
|
title: __('Create your first lead'),
|
||||||
@ -337,12 +353,14 @@ const steps = reactive([
|
|||||||
showSettings.value = true
|
showSettings.value = true
|
||||||
activeSettingsPage.value = 'Invite Members'
|
activeSettingsPage.value = 'Invite Members'
|
||||||
},
|
},
|
||||||
|
condition: () => isManager(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'convert_lead_to_deal',
|
name: 'convert_lead_to_deal',
|
||||||
title: __('Convert lead to deal'),
|
title: __('Convert lead to deal'),
|
||||||
icon: markRaw(ConvertIcon),
|
icon: markRaw(ConvertIcon),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_lead',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
|
|
||||||
@ -410,6 +428,7 @@ const steps = reactive([
|
|||||||
title: __('Add your first comment'),
|
title: __('Add your first comment'),
|
||||||
icon: markRaw(CommentIcon),
|
icon: markRaw(CommentIcon),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_lead',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let deal = await getFirstDeal()
|
let deal = await getFirstDeal()
|
||||||
@ -430,6 +449,7 @@ const steps = reactive([
|
|||||||
title: __('Send email'),
|
title: __('Send email'),
|
||||||
icon: markRaw(EmailIcon),
|
icon: markRaw(EmailIcon),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'create_first_lead',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
let deal = await getFirstDeal()
|
let deal = await getFirstDeal()
|
||||||
@ -450,6 +470,7 @@ const steps = reactive([
|
|||||||
title: __('Change deal status'),
|
title: __('Change deal status'),
|
||||||
icon: markRaw(StepsIcon),
|
icon: markRaw(StepsIcon),
|
||||||
completed: false,
|
completed: false,
|
||||||
|
dependsOn: 'convert_lead_to_deal',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
minimize.value = true
|
minimize.value = true
|
||||||
|
|
||||||
@ -478,7 +499,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
|
// help center
|
||||||
const articles = ref([
|
const articles = ref([
|
||||||
@ -517,9 +549,7 @@ const articles = ref([
|
|||||||
{
|
{
|
||||||
title: __('Capturing leads'),
|
title: __('Capturing leads'),
|
||||||
opened: false,
|
opened: false,
|
||||||
subArticles: [
|
subArticles: [{ name: 'web-form', title: __('Web form') }],
|
||||||
{ name: 'web-form', title: __('Web form') },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: __('Views'),
|
title: __('Views'),
|
||||||
|
|||||||
@ -7,9 +7,11 @@
|
|||||||
<AppHeader />
|
<AppHeader />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
<GlobalModals />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
|
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
|
||||||
import AppHeader from '@/components/Layouts/AppHeader.vue'
|
import AppHeader from '@/components/Layouts/AppHeader.vue'
|
||||||
|
import GlobalModals from '@/components/Modals/GlobalModals.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -19,10 +19,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import EditValueModal from '@/components/Modals/EditValueModal.vue'
|
import EditValueModal from '@/components/Modals/EditValueModal.vue'
|
||||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||||
import { setupListCustomizations, createToast } from '@/utils'
|
import { setupListCustomizations } from '@/utils'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { call } from 'frappe-ui'
|
import { call, toast } from 'frappe-ui'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@ -75,11 +75,7 @@ function convertToDeal(selections, unselectAll) {
|
|||||||
call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||||
lead: name,
|
lead: name,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
createToast({
|
toast.success(__('Converted successfully'))
|
||||||
title: __('Converted successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
list.value.reload()
|
list.value.reload()
|
||||||
unselectAll()
|
unselectAll()
|
||||||
close()
|
close()
|
||||||
@ -110,11 +106,7 @@ function deleteValues(selections, unselectAll) {
|
|||||||
items: JSON.stringify(Array.from(selections)),
|
items: JSON.stringify(Array.from(selections)),
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
createToast({
|
toast.success(__('Deleted successfully'))
|
||||||
title: __('Deleted successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
unselectAll()
|
unselectAll()
|
||||||
list.value.reload()
|
list.value.reload()
|
||||||
close()
|
close()
|
||||||
@ -154,11 +146,7 @@ function clearAssignemnts(selections, unselectAll) {
|
|||||||
names: JSON.stringify(Array.from(selections)),
|
names: JSON.stringify(Array.from(selections)),
|
||||||
ignore_permissions: true,
|
ignore_permissions: true,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
createToast({
|
toast.success(__('Assignment cleared successfully'))
|
||||||
title: __('Assignment cleared successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
reload(unselectAll)
|
reload(unselectAll)
|
||||||
close()
|
close()
|
||||||
})
|
})
|
||||||
@ -215,7 +203,8 @@ function bulkActions(selections, unselectAll) {
|
|||||||
selections,
|
selections,
|
||||||
unselectAll,
|
unselectAll,
|
||||||
call,
|
call,
|
||||||
createToast,
|
createToast: toast.create,
|
||||||
|
toast,
|
||||||
$dialog,
|
$dialog,
|
||||||
router,
|
router,
|
||||||
}),
|
}),
|
||||||
@ -235,7 +224,8 @@ onMounted(async () => {
|
|||||||
let customization = await setupListCustomizations(list.value.data, {
|
let customization = await setupListCustomizations(list.value.data, {
|
||||||
list: list.value,
|
list: list.value,
|
||||||
call,
|
call,
|
||||||
createToast,
|
createToast: toast.create,
|
||||||
|
toast,
|
||||||
$dialog,
|
$dialog,
|
||||||
$socket,
|
$socket,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="sm:mx-5 mx-3"
|
class="sm:mx-5 mx-3"
|
||||||
@ -205,6 +206,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const pageLengthCount = defineModel()
|
const pageLengthCount = defineModel()
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mx-3 sm:mx-5"
|
class="mx-3 sm:mx-5"
|
||||||
@ -201,6 +202,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="sm:mx-5 mx-3"
|
class="sm:mx-5 mx-3"
|
||||||
@ -245,6 +246,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="sm:mx-5 mx-3"
|
class="sm:mx-5 mx-3"
|
||||||
@ -191,6 +192,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const pageLengthCount = defineModel()
|
const pageLengthCount = defineModel()
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="sm:mx-5 mx-3"
|
class="sm:mx-5 mx-3"
|
||||||
@ -250,7 +251,6 @@ const props = defineProps({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'loadMore',
|
'loadMore',
|
||||||
'updatePageCount',
|
'updatePageCount',
|
||||||
@ -258,6 +258,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="sm:mx-5 mx-3"
|
class="sm:mx-5 mx-3"
|
||||||
@ -186,6 +187,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
resizeColumn: options.resizeColumn,
|
resizeColumn: options.resizeColumn,
|
||||||
}"
|
}"
|
||||||
row-key="name"
|
row-key="name"
|
||||||
|
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||||
>
|
>
|
||||||
<ListHeader
|
<ListHeader
|
||||||
class="mx-3 sm:mx-5"
|
class="mx-3 sm:mx-5"
|
||||||
@ -207,6 +208,7 @@ const emit = defineEmits([
|
|||||||
'applyFilter',
|
'applyFilter',
|
||||||
'applyLikeFilter',
|
'applyLikeFilter',
|
||||||
'likeDoc',
|
'likeDoc',
|
||||||
|
'selectionsChanged',
|
||||||
])
|
])
|
||||||
|
|
||||||
const pageLengthCount = defineModel()
|
const pageLengthCount = defineModel()
|
||||||
|
|||||||
100
frontend/src/components/Modals/AboutModal.vue
Normal file
100
frontend/src/components/Modals/AboutModal.vue
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="{ size: 'sm' }">
|
||||||
|
<template #body>
|
||||||
|
<div class="p-4 pt-5">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<CRMLogo class="mb-3 size-12" />
|
||||||
|
<h3 class="font-semibold text-xl text-ink-gray-9">Frappe CRM</h3>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<div class="text-base text-ink-gray-6">
|
||||||
|
{{ appVersion.branch != 'main' ? appVersion.branch : '' }}
|
||||||
|
<template v-if="appVersion.branch != 'main'">
|
||||||
|
({{ appVersion.commit }})
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ appVersion.tag }}</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
:text="`${appVersion.commit_message} - ${appVersion.commit_date}`"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<LucideInfo class="size-3.5 text-ink-gray-8 ml-1" />
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="border-t my-3 mx-2" />
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
v-for="link in links"
|
||||||
|
:key="link.label"
|
||||||
|
class="flex py-2 px-2 hover:bg-surface-gray-1 rounded cursor-pointer"
|
||||||
|
target="_blank"
|
||||||
|
:href="link.url"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="link.icon"
|
||||||
|
:is="link.icon"
|
||||||
|
class="size-4 mr-2 text-ink-gray-7"
|
||||||
|
/>
|
||||||
|
<span class="text-base text-ink-gray-8">
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<hr class="border-t my-3 mx-2" />
|
||||||
|
<p class="text-sm text-ink-gray-6 px-2 mt-2">
|
||||||
|
© Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Tooltip } from 'frappe-ui'
|
||||||
|
import CRMLogo from '@/components/Icons/CRMLogo.vue'
|
||||||
|
import LucideGlobe from '~icons/lucide/globe'
|
||||||
|
import LucideGitHub from '~icons/lucide/github'
|
||||||
|
import LucideHeadset from '~icons/lucide/headset'
|
||||||
|
import LucideBug from '~icons/lucide/bug'
|
||||||
|
import LucideBookOpen from '~icons/lucide/book-open'
|
||||||
|
import TelegramIcon from '@/components/Icons/TelegramIcon.vue'
|
||||||
|
|
||||||
|
let show = defineModel()
|
||||||
|
|
||||||
|
let links = [
|
||||||
|
{
|
||||||
|
label: __('Website'),
|
||||||
|
url: 'https://frappe.io/crm',
|
||||||
|
icon: LucideGlobe,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('GitHub Repository'),
|
||||||
|
url: 'https://github.com/frappe/crm',
|
||||||
|
icon: LucideGitHub,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Documentation'),
|
||||||
|
url: 'https://docs.frappe.io/crm',
|
||||||
|
icon: LucideBookOpen,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Telegram Channel'),
|
||||||
|
url: 'https://t.me/frappecrm',
|
||||||
|
icon: TelegramIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Report an Issue'),
|
||||||
|
url: 'https://github.com/frappe/crm/issues',
|
||||||
|
icon: LucideBug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Contact Support'),
|
||||||
|
url: 'https://support.frappe.io',
|
||||||
|
icon: LucideHeadset,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let appVersion = window.app_version
|
||||||
|
</script>
|
||||||
@ -172,8 +172,9 @@ import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
|||||||
import { getCallLogDetail } from '@/utils/callLog'
|
import { getCallLogDetail } from '@/utils/callLog'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
|
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
|
||||||
import { ref, computed, h, nextTick } from 'vue'
|
import { ref, computed, h, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { isManager } = usersStore()
|
const { isManager } = usersStore()
|
||||||
@ -289,9 +290,19 @@ const detailFields = computed(() => {
|
|||||||
.filter((detail) => (detail.condition ? detail.condition() : true))
|
.filter((detail) => (detail.condition ? detail.condition() : true))
|
||||||
})
|
})
|
||||||
|
|
||||||
function createLead() {
|
const d = ref({})
|
||||||
|
const leadDetails = ref({})
|
||||||
|
|
||||||
|
async function createLead() {
|
||||||
|
await d.value.triggerOnCreateLead?.(
|
||||||
|
callLog.value?.data,
|
||||||
|
leadDetails.value,
|
||||||
|
() => (show.value = false),
|
||||||
|
)
|
||||||
|
|
||||||
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
|
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
|
||||||
call_log: callLog.value?.data,
|
call_log: callLog.value?.data,
|
||||||
|
lead_details: leadDetails.value,
|
||||||
}).then((d) => {
|
}).then((d) => {
|
||||||
if (d) {
|
if (d) {
|
||||||
router.push({ name: 'Lead', params: { leadId: d } })
|
router.push({ name: 'Lead', params: { leadId: d } })
|
||||||
@ -351,6 +362,14 @@ async function addTaskToCallLog(_task, insert_mode = false) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => callLog.value?.data?.name,
|
||||||
|
(value) => {
|
||||||
|
if (!value) return
|
||||||
|
d.value = useDocument('CRM Call Log', value)
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,55 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="dialogOptions">
|
<Dialog v-model="show" :options="dialogOptions">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
<div class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
{{ __(dialogOptions.title) || __('Untitled') }}
|
{{ __(dialogOptions.title) || __('Untitled') }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
<Button v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
|
||||||
v-if="isManager() && !isMobileView"
|
<EditIcon class="w-4 h-4" />
|
||||||
variant="ghost"
|
|
||||||
class="w-7"
|
|
||||||
@click="openQuickEntryModal"
|
|
||||||
>
|
|
||||||
<EditIcon class="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" class="w-7" @click="show = false">
|
<Button variant="ghost" class="w-7" @click="show = false">
|
||||||
<FeatherIcon name="x" class="h-4 w-4" />
|
<FeatherIcon name="x" class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tabs.data">
|
<div v-if="tabs.data">
|
||||||
<FieldLayout
|
<FieldLayout :tabs="tabs.data" :data="_callLog" doctype="CRM Call Log" />
|
||||||
:tabs="tabs.data"
|
<ErrorMessage class="mt-8" :message="error" />
|
||||||
:data="_callLog"
|
|
||||||
doctype="CRM Call Log"
|
|
||||||
/>
|
|
||||||
<ErrorMessage class="mt-2" :message="error" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 pb-7 pt-4 sm:px-6">
|
<div class="px-4 pt-4 pb-7 sm:px-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Button
|
<Button class="w-full" v-for="action in dialogOptions.actions" :key="action.label" v-bind="action"
|
||||||
class="w-full"
|
:label="__(action.label)" :loading="loading" />
|
||||||
v-for="action in dialogOptions.actions"
|
|
||||||
:key="action.label"
|
|
||||||
v-bind="action"
|
|
||||||
:label="__(action.label)"
|
|
||||||
:loading="loading"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<QuickEntryModal
|
<QuickEntryModal v-if="showQuickEntryModal" v-model="showQuickEntryModal" doctype="CRM Call Log" />
|
||||||
v-if="showQuickEntryModal"
|
|
||||||
v-model="showQuickEntryModal"
|
|
||||||
doctype="CRM Call Log"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -67,7 +48,7 @@ const props = defineProps({
|
|||||||
options: {
|
options: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: {
|
default: {
|
||||||
afterInsert: () => {},
|
afterInsert: () => { },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -175,6 +156,13 @@ const createCallLog = createResource({
|
|||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
if (err.exc_type == 'MandatoryError') {
|
||||||
|
const errorMessage = err.messages
|
||||||
|
.map(msg => msg.split('Log:')[1].trim())
|
||||||
|
.join(', ')
|
||||||
|
error.value = `These fields are required: ${errorMessage}`
|
||||||
|
return
|
||||||
|
}
|
||||||
error.value = err
|
error.value = err
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
147
frontend/src/components/Modals/CreateDocumentModal.vue
Normal file
147
frontend/src/components/Modals/CreateDocumentModal.vue
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model="show" :options="dialogOptions">
|
||||||
|
<template #body>
|
||||||
|
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||||
|
<div class="mb-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
|
{{ __(dialogOptions.title) || __('Untitled') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="isManager() && !isMobileView"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-7"
|
||||||
|
@click="openQuickEntryModal"
|
||||||
|
>
|
||||||
|
<EditIcon class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" class="w-7" @click="show = false">
|
||||||
|
<FeatherIcon name="x" class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="tabs.data">
|
||||||
|
<FieldLayout :tabs="tabs.data" :data="_data" :doctype="doctype" />
|
||||||
|
<ErrorMessage class="mt-2" :message="error" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 pb-7 pt-4 sm:px-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Button
|
||||||
|
class="w-full"
|
||||||
|
v-for="action in dialogOptions.actions"
|
||||||
|
:key="action.label"
|
||||||
|
v-bind="action"
|
||||||
|
:label="__(action.label)"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||||
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { isMobileView } from '@/composables/settings'
|
||||||
|
import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui'
|
||||||
|
import { ref, nextTick, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doctype: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['showQuickEntryModal', 'callback'])
|
||||||
|
|
||||||
|
const { isManager } = usersStore()
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
let _data = ref({})
|
||||||
|
|
||||||
|
const dialogOptions = computed(() => {
|
||||||
|
let doctype = props.doctype
|
||||||
|
|
||||||
|
if (doctype.startsWith('CRM ') || doctype.startsWith('FCRM ')) {
|
||||||
|
doctype = doctype.replace(/^(CRM |FCRM )/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = __('New {0}', [doctype])
|
||||||
|
let size = 'xl'
|
||||||
|
let actions = [
|
||||||
|
{
|
||||||
|
label: __('Create'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: () => create(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return { title, size, actions }
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabs = createResource({
|
||||||
|
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||||
|
cache: ['QuickEntry', props.doctype],
|
||||||
|
params: { doctype: props.doctype, type: 'Quick Entry' },
|
||||||
|
auto: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
let doc = await call(
|
||||||
|
'frappe.client.insert',
|
||||||
|
{
|
||||||
|
doc: {
|
||||||
|
doctype: props.doctype,
|
||||||
|
..._data.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
loading.value = false
|
||||||
|
if (err.error) {
|
||||||
|
error.value = err.error.messages?.[0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
loading.value = false
|
||||||
|
show.value = false
|
||||||
|
emit('callback', doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => show.value,
|
||||||
|
(value) => {
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
_data.value = { ...props.data }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function openQuickEntryModal() {
|
||||||
|
emit('showQuickEntryModal', props.doctype)
|
||||||
|
nextTick(() => {
|
||||||
|
show.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
37
frontend/src/components/Modals/GlobalModals.vue
Normal file
37
frontend/src/components/Modals/GlobalModals.vue
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<CreateDocumentModal
|
||||||
|
v-if="showCreateDocumentModal"
|
||||||
|
v-model="showCreateDocumentModal"
|
||||||
|
:doctype="createDocumentDoctype"
|
||||||
|
:data="createDocumentData"
|
||||||
|
@showQuickEntryModal="(dt) => openQuickEntryModal(dt)"
|
||||||
|
@callback="(data) => createDocumentCallback(data)"
|
||||||
|
/>
|
||||||
|
<QuickEntryModal
|
||||||
|
v-if="showQuickEntryModal"
|
||||||
|
v-model="showQuickEntryModal"
|
||||||
|
:doctype="quickEntryDoctype"
|
||||||
|
/>
|
||||||
|
<AboutModal v-model="showAboutModal" />
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
|
||||||
|
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||||
|
import AboutModal from '@/components/Modals/AboutModal.vue'
|
||||||
|
import {
|
||||||
|
showCreateDocumentModal,
|
||||||
|
createDocumentDoctype,
|
||||||
|
createDocumentData,
|
||||||
|
createDocumentCallback,
|
||||||
|
} from '@/composables/document'
|
||||||
|
import { showAboutModal } from '@/composables/settings'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const showQuickEntryModal = ref(false)
|
||||||
|
const quickEntryDoctype = ref('')
|
||||||
|
|
||||||
|
function openQuickEntryModal(dt) {
|
||||||
|
showQuickEntryModal.value = true
|
||||||
|
quickEntryDoctype.value = dt
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -46,6 +46,7 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
|||||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { capture } from '@/telemetry'
|
import { capture } from '@/telemetry'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
@ -57,6 +58,7 @@ const props = defineProps({
|
|||||||
defaults: Object,
|
defaults: Object,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { user } = sessionStore()
|
||||||
const { getUser, isManager } = usersStore()
|
const { getUser, isManager } = usersStore()
|
||||||
const { getLeadStatus, statusOptions } = statusesStore()
|
const { getLeadStatus, statusOptions } = statusesStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||||
@ -169,7 +171,7 @@ function createNewLead() {
|
|||||||
show.value = false
|
show.value = false
|
||||||
router.push({ name: 'Lead', params: { leadId: data.name } })
|
router.push({ name: 'Lead', params: { leadId: data.name } })
|
||||||
updateOnboardingStep('create_first_lead', true, false, () => {
|
updateOnboardingStep('create_first_lead', true, false, () => {
|
||||||
localStorage.setItem('firstLead', data.name)
|
localStorage.setItem('firstLead' + user, data.name)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onError(err) {
|
onError(err) {
|
||||||
|
|||||||
@ -1,34 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog v-model="show" :options="{
|
||||||
v-model="show"
|
size: 'xl',
|
||||||
:options="{
|
actions: [
|
||||||
size: 'xl',
|
{
|
||||||
actions: [
|
label: editMode ? __('Update') : __('Create'),
|
||||||
{
|
variant: 'solid',
|
||||||
label: editMode ? __('Update') : __('Create'),
|
onClick: () => updateNote(),
|
||||||
variant: 'solid',
|
},
|
||||||
onClick: () => updateNote(),
|
],
|
||||||
},
|
}">
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-title>
|
<template #body-title>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
{{ editMode ? __('Edit Note') : __('Create Note') }}
|
{{ editMode ? __('Edit Note') : __('Create Note') }}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button v-if="_note?.reference_docname" size="sm" :label="_note.reference_doctype == 'CRM Deal'
|
||||||
v-if="_note?.reference_docname"
|
? __('Open Deal')
|
||||||
size="sm"
|
: __('Open Lead')
|
||||||
:label="
|
" @click="redirect()">
|
||||||
_note.reference_doctype == 'CRM Deal'
|
|
||||||
? __('Open Deal')
|
|
||||||
: __('Open Lead')
|
|
||||||
"
|
|
||||||
@click="redirect()"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<ArrowUpRightIcon class="h-4 w-4" />
|
<ArrowUpRightIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -36,27 +27,17 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl ref="title" :label="__('Title')" v-model="_note.title" :placeholder="__('Call with John Doe')"
|
||||||
ref="title"
|
required />
|
||||||
:label="__('Title')"
|
|
||||||
v-model="_note.title"
|
|
||||||
:placeholder="__('Call with John Doe')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">{{ __('Content') }}</div>
|
<div class="mb-1.5 text-xs text-ink-gray-5">{{ __('Content') }}</div>
|
||||||
<TextEditor
|
<TextEditor variant="outline" ref="content"
|
||||||
variant="outline"
|
|
||||||
ref="content"
|
|
||||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||||
:bubbleMenu="true"
|
:bubbleMenu="true" :content="_note.content" @change="(val) => (_note.content = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
|
||||||
:content="_note.content"
|
" />
|
||||||
@change="(val) => (_note.content = val)"
|
|
||||||
:placeholder="
|
|
||||||
__('Took a call with John Doe and discussed the new project.')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -94,17 +75,12 @@ const router = useRouter()
|
|||||||
|
|
||||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||||
|
|
||||||
|
const error = ref(null)
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
let _note = ref({})
|
let _note = ref({})
|
||||||
|
|
||||||
async function updateNote() {
|
async function updateNote() {
|
||||||
if (
|
|
||||||
props.note.title === _note.value.title &&
|
|
||||||
props.note.content === _note.value.content
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (_note.value.name) {
|
if (_note.value.name) {
|
||||||
let d = await call('frappe.client.set_value', {
|
let d = await call('frappe.client.set_value', {
|
||||||
doctype: 'FCRM Note',
|
doctype: 'FCRM Note',
|
||||||
@ -124,6 +100,12 @@ async function updateNote() {
|
|||||||
reference_doctype: props.doctype,
|
reference_doctype: props.doctype,
|
||||||
reference_docname: props.doc || '',
|
reference_docname: props.doc || '',
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.error.exc_type == 'MandatoryError') {
|
||||||
|
error.value = "Title is mandatory"
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (d.name) {
|
if (d.name) {
|
||||||
updateOnboardingStep('create_first_note')
|
updateOnboardingStep('create_first_note')
|
||||||
|
|||||||
@ -1,43 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
<div class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="flex items-center justify-between mb-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
{{ __('New Organization') }}
|
{{ __('New Organization') }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<Button
|
<Button v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
|
||||||
v-if="isManager() && !isMobileView"
|
<EditIcon class="w-4 h-4" />
|
||||||
variant="ghost"
|
|
||||||
class="w-7"
|
|
||||||
@click="openQuickEntryModal"
|
|
||||||
>
|
|
||||||
<EditIcon class="h-4 w-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" class="w-7" @click="show = false">
|
<Button variant="ghost" class="w-7" @click="show = false">
|
||||||
<FeatherIcon name="x" class="h-4 w-4" />
|
<FeatherIcon name="x" class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FieldLayout
|
<FieldLayout v-if="tabs.data?.length" :tabs="tabs.data" :data="_organization" doctype="CRM Organization" />
|
||||||
v-if="tabs.data?.length"
|
<ErrorMessage class="mt-8" v-if="error" :message="__(error)" />
|
||||||
:tabs="tabs.data"
|
|
||||||
:data="_organization"
|
|
||||||
doctype="CRM Organization"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4 pb-7 pt-4 sm:px-6">
|
<div class="px-4 pt-4 pb-7 sm:px-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Button
|
<Button class="w-full" variant="solid" :label="__('Create')" :loading="loading" @click="createOrganization" />
|
||||||
class="w-full"
|
|
||||||
variant="solid"
|
|
||||||
:label="__('Create')"
|
|
||||||
:loading="loading"
|
|
||||||
@click="createOrganization"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -59,7 +44,7 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: {
|
default: {
|
||||||
redirect: true,
|
redirect: true,
|
||||||
afterInsert: () => {},
|
afterInsert: () => { },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -84,6 +69,7 @@ let _organization = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
let doc = ref({})
|
let doc = ref({})
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
async function createOrganization() {
|
async function createOrganization() {
|
||||||
const doc = await call('frappe.client.insert', {
|
const doc = await call('frappe.client.insert', {
|
||||||
@ -91,6 +77,12 @@ async function createOrganization() {
|
|||||||
doctype: 'CRM Organization',
|
doctype: 'CRM Organization',
|
||||||
..._organization.value,
|
..._organization.value,
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.error.exc_type == 'ValidationError') {
|
||||||
|
error.value = err.error?.messages?.[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (doc.name) {
|
if (doc.name) {
|
||||||
|
|||||||
@ -38,9 +38,9 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="preview" class="flex flex-1 flex-col border rounded">
|
<div v-if="preview" class="flex flex-1 flex-col border rounded">
|
||||||
<SidePanelLayout
|
<SidePanelLayout
|
||||||
v-model="data"
|
|
||||||
:sections="tabs.data[0].sections"
|
:sections="tabs.data[0].sections"
|
||||||
:doctype="_doctype"
|
:doctype="_doctype"
|
||||||
|
docname=""
|
||||||
:preview="true"
|
:preview="true"
|
||||||
v-slot="{ section }"
|
v-slot="{ section }"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,34 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog v-model="show" :options="{
|
||||||
v-model="show"
|
size: 'xl',
|
||||||
:options="{
|
actions: [
|
||||||
size: 'xl',
|
{
|
||||||
actions: [
|
label: editMode ? __('Update') : __('Create'),
|
||||||
{
|
variant: 'solid',
|
||||||
label: editMode ? __('Update') : __('Create'),
|
onClick: () => updateTask(),
|
||||||
variant: 'solid',
|
},
|
||||||
onClick: () => updateTask(),
|
],
|
||||||
},
|
}">
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-title>
|
<template #body-title>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
{{ editMode ? __('Edit Task') : __('Create Task') }}
|
{{ editMode ? __('Edit Task') : __('Create Task') }}
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<Button v-if="task?.reference_docname" size="sm" :label="task.reference_doctype == 'CRM Deal'
|
||||||
v-if="task?.reference_docname"
|
? __('Open Deal')
|
||||||
size="sm"
|
: __('Open Lead')
|
||||||
:label="
|
" @click="redirect()">
|
||||||
task.reference_doctype == 'CRM Deal'
|
|
||||||
? __('Open Deal')
|
|
||||||
: __('Open Lead')
|
|
||||||
"
|
|
||||||
@click="redirect()"
|
|
||||||
>
|
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<ArrowUpRightIcon class="h-4 w-4" />
|
<ArrowUpRightIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -36,74 +27,53 @@
|
|||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<FormControl
|
<FormControl ref="title" :label="__('Title')" v-model="_task.title" :placeholder="__('Call with John Doe')"
|
||||||
ref="title"
|
required />
|
||||||
:label="__('Title')"
|
|
||||||
v-model="_task.title"
|
|
||||||
:placeholder="__('Call with John Doe')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||||
{{ __('Description') }}
|
{{ __('Description') }}
|
||||||
</div>
|
</div>
|
||||||
<TextEditor
|
<TextEditor variant="outline" ref="description"
|
||||||
variant="outline"
|
|
||||||
ref="description"
|
|
||||||
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
editor-class="!prose-sm overflow-auto min-h-[180px] max-h-80 py-1.5 px-2 rounded border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
|
||||||
:bubbleMenu="true"
|
:bubbleMenu="true" :content="_task.description" @change="(val) => (_task.description = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
|
||||||
:content="_task.description"
|
" />
|
||||||
@change="(val) => (_task.description = val)"
|
|
||||||
:placeholder="
|
|
||||||
__('Took a call with John Doe and discussed the new project.')
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
|
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
|
||||||
<Button :label="_task.status" class="w-full justify-between">
|
<Button :label="_task.status" class="justify-between w-full">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<TaskStatusIcon :status="_task.status" />
|
<TaskStatusIcon :status="_task.status" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Link
|
<Link class="form-control" :value="getUser(_task.assigned_to).full_name" doctype="User"
|
||||||
class="form-control"
|
@change="(option) => (_task.assigned_to = option)" :placeholder="__('John Doe')" :hideMe="true">
|
||||||
:value="getUser(_task.assigned_to).full_name"
|
<template #prefix>
|
||||||
doctype="User"
|
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
||||||
@change="(option) => (_task.assigned_to = option)"
|
</template>
|
||||||
:placeholder="__('John Doe')"
|
<template #item-prefix="{ option }">
|
||||||
:hideMe="true"
|
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||||
>
|
</template>
|
||||||
<template #prefix>
|
<template #item-label="{ option }">
|
||||||
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
<Tooltip :text="option.value">
|
||||||
</template>
|
<div class="cursor-pointer text-ink-gray-9">
|
||||||
<template #item-prefix="{ option }">
|
{{ getUser(option.value).full_name }}
|
||||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
</div>
|
||||||
</template>
|
</Tooltip>
|
||||||
<template #item-label="{ option }">
|
</template>
|
||||||
<Tooltip :text="option.value">
|
|
||||||
<div class="cursor-pointer text-ink-gray-9">
|
|
||||||
{{ getUser(option.value).full_name }}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</template>
|
|
||||||
</Link>
|
</Link>
|
||||||
<DateTimePicker
|
<DateTimePicker class="datepicker w-36" v-model="_task.due_date" :placeholder="__('01/04/2024 11:30 PM')"
|
||||||
class="datepicker w-36"
|
:formatter="(date) => getFormat(date, '', true, true)" input-class="border-none" />
|
||||||
v-model="_task.due_date"
|
|
||||||
:placeholder="__('01/04/2024 11:30 PM')"
|
|
||||||
:formatter="(date) => getFormat(date, '', true, true)"
|
|
||||||
input-class="border-none"
|
|
||||||
/>
|
|
||||||
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
|
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
|
||||||
<Button :label="_task.priority" class="w-full justify-between">
|
<Button :label="_task.priority" class="justify-between w-full">
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<TaskPriorityIcon :priority="_task.priority" />
|
<TaskPriorityIcon :priority="_task.priority" />
|
||||||
</template>
|
</template>
|
||||||
</Button>
|
</Button>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -147,6 +117,7 @@ const router = useRouter()
|
|||||||
const { getUser } = usersStore()
|
const { getUser } = usersStore()
|
||||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||||
|
|
||||||
|
const error = ref(null)
|
||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
const _task = ref({
|
const _task = ref({
|
||||||
@ -200,6 +171,12 @@ async function updateTask() {
|
|||||||
reference_docname: props.doc || null,
|
reference_docname: props.doc || null,
|
||||||
..._task.value,
|
..._task.value,
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
onError: (err) => {
|
||||||
|
if (err.error.exc_type == 'MandatoryError') {
|
||||||
|
error.value = "Title is mandatory"
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
if (d.name) {
|
if (d.name) {
|
||||||
updateOnboardingStep('create_first_task')
|
updateOnboardingStep('create_first_task')
|
||||||
|
|||||||
71
frontend/src/components/MultiActionButton.vue
Normal file
71
frontend/src/components/MultiActionButton.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<Button
|
||||||
|
:variant="$attrs.variant"
|
||||||
|
class="border-0"
|
||||||
|
:label="activeButton.label"
|
||||||
|
:size="$attrs.size"
|
||||||
|
:class="[
|
||||||
|
$attrs.class,
|
||||||
|
showDropdown ? 'rounded-br-none rounded-tr-none' : '',
|
||||||
|
]"
|
||||||
|
@click="() => activeButton.onClick()"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<FeatherIcon
|
||||||
|
v-if="activeButton.icon && typeof activeButton.icon === 'string'"
|
||||||
|
:name="activeButton.icon"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
v-else-if="activeButton.icon"
|
||||||
|
:is="activeButton.icon"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
v-show="showDropdown"
|
||||||
|
:options="parsedOptions"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1 [&>div>div>div]:w-full"
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<template v-slot="{ togglePopover }">
|
||||||
|
<Button
|
||||||
|
:variant="$attrs.variant"
|
||||||
|
@click="togglePopover"
|
||||||
|
icon="chevron-down"
|
||||||
|
class="!w-6 justify-start rounded-bl-none rounded-tl-none border-0 pr-0 text-xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { Dropdown } from 'frappe-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDropdown = ref(props.options?.length > 1)
|
||||||
|
const activeButton = ref(props.options?.[0] || {})
|
||||||
|
|
||||||
|
const parsedOptions = computed(() => {
|
||||||
|
return (
|
||||||
|
props.options?.map((option) => {
|
||||||
|
return {
|
||||||
|
label: option.label,
|
||||||
|
onClick: () => {
|
||||||
|
activeButton.value = option
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
50
frontend/src/components/Settings/EmailAccountCard.vue
Normal file
50
frontend/src/components/Settings/EmailAccountCard.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between p-1 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
<!-- avatar and name -->
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-ink-gray-9">
|
||||||
|
{{ emailAccount.email_account_name }}
|
||||||
|
</p>
|
||||||
|
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Badge variant="subtle" :label="badgeTitle" :theme="gray" />
|
||||||
|
</div>
|
||||||
|
<!-- email id -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { emailIcon } from './emailConfig'
|
||||||
|
import EmailProviderIcon from './EmailProviderIcon.vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
emailAccount: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const badgeTitle = computed(() => {
|
||||||
|
if (
|
||||||
|
props.emailAccount.default_incoming &&
|
||||||
|
props.emailAccount.default_outgoing
|
||||||
|
) {
|
||||||
|
return __('Default Sending and Inbox')
|
||||||
|
} else if (props.emailAccount.default_incoming) {
|
||||||
|
return __('Default Inbox')
|
||||||
|
} else if (props.emailAccount.default_outgoing) {
|
||||||
|
return __('Default Sending')
|
||||||
|
} else {
|
||||||
|
return __('Inbox')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
64
frontend/src/components/Settings/EmailAccountList.vue
Normal file
64
frontend/src/components/Settings/EmailAccountList.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- header -->
|
||||||
|
<div class="flex items-center justify-between text-ink-gray-9">
|
||||||
|
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5">
|
||||||
|
{{ __('Email Accounts') }}
|
||||||
|
</h2>
|
||||||
|
<Button
|
||||||
|
:label="__('Add Account')"
|
||||||
|
theme="gray"
|
||||||
|
variant="solid"
|
||||||
|
@click="emit('update:step', 'email-add')"
|
||||||
|
class="mr-8"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<LucidePlus class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<!-- list accounts -->
|
||||||
|
<div
|
||||||
|
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
|
||||||
|
<EmailAccountCard
|
||||||
|
:emailAccount="emailAccount"
|
||||||
|
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- fallback if no email accounts -->
|
||||||
|
<div v-else class="flex items-center justify-center h-64 text-gray-500">
|
||||||
|
{{ __('Please add an email account to continue.') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { createListResource } from 'frappe-ui'
|
||||||
|
import EmailAccountCard from './EmailAccountCard.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:step'])
|
||||||
|
|
||||||
|
const emailAccounts = createListResource({
|
||||||
|
doctype: 'Email Account',
|
||||||
|
cache: true,
|
||||||
|
fields: ['*'],
|
||||||
|
filters: {
|
||||||
|
email_id: ['Not Like', '%example%'],
|
||||||
|
},
|
||||||
|
pageLength: 10,
|
||||||
|
auto: true,
|
||||||
|
onSuccess: (accounts) => {
|
||||||
|
// convert 0 to false to handle boolean fields
|
||||||
|
accounts.forEach((account) => {
|
||||||
|
account.enable_incoming = Boolean(account.enable_incoming)
|
||||||
|
account.enable_outgoing = Boolean(account.enable_outgoing)
|
||||||
|
account.default_incoming = Boolean(account.default_incoming)
|
||||||
|
account.default_outgoing = Boolean(account.default_outgoing)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
158
frontend/src/components/Settings/EmailAdd.vue
Normal file
158
frontend/src/components/Settings/EmailAdd.vue
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full gap-4">
|
||||||
|
<!-- title and desc -->
|
||||||
|
<div role="heading" aria-level="1" class="flex flex-col gap-1">
|
||||||
|
<h2 class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Setup Email') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{ __('Choose the email service provider you want to configure.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- email service provider selection -->
|
||||||
|
<div class="flex flex-wrap items-center">
|
||||||
|
<div
|
||||||
|
v-for="s in services"
|
||||||
|
:key="s.name"
|
||||||
|
class="flex flex-col items-center gap-1 mt-4 w-[70px]"
|
||||||
|
@click="handleSelect(s)"
|
||||||
|
>
|
||||||
|
<EmailProviderIcon
|
||||||
|
:service-name="s.name"
|
||||||
|
:logo="s.icon"
|
||||||
|
:selected="selectedService?.name === s?.name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||||
|
<!-- email service provider info -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700 text-gray-700 dark:text-gray-500"
|
||||||
|
>
|
||||||
|
<CircleAlert class="w-5 h-6 w-min-5 w-max-5 min-h-5 max-w-5" />
|
||||||
|
<div class="text-xs text-wrap">
|
||||||
|
{{ selectedService.info }}
|
||||||
|
<a :href="selectedService.link" target="_blank" class="underline"
|
||||||
|
>here</a
|
||||||
|
>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- service provider fields -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.name"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="state[field.name]"
|
||||||
|
:label="field.label"
|
||||||
|
:name="field.name"
|
||||||
|
:type="field.type"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="field in incomingOutgoingFields"
|
||||||
|
:key="field.name"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="state[field.name]"
|
||||||
|
:label="field.label"
|
||||||
|
:name="field.name"
|
||||||
|
:type="field.type"
|
||||||
|
/>
|
||||||
|
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- action button -->
|
||||||
|
<div v-if="selectedService" class="flex justify-between mt-auto">
|
||||||
|
<Button
|
||||||
|
label="Back"
|
||||||
|
theme="gray"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="addEmailRes.loading"
|
||||||
|
@click="emit('update:step', 'email-list')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Create"
|
||||||
|
variant="solid"
|
||||||
|
:loading="addEmailRes.loading"
|
||||||
|
@click="createEmailAccount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { createResource, toast } from 'frappe-ui'
|
||||||
|
import CircleAlert from '~icons/lucide/circle-alert'
|
||||||
|
import {
|
||||||
|
customProviderFields,
|
||||||
|
popularProviderFields,
|
||||||
|
services,
|
||||||
|
validateInputs,
|
||||||
|
incomingOutgoingFields,
|
||||||
|
} from './emailConfig'
|
||||||
|
import EmailProviderIcon from './EmailProviderIcon.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
service: '',
|
||||||
|
email_account_name: '',
|
||||||
|
email_id: '',
|
||||||
|
password: '',
|
||||||
|
api_key: '',
|
||||||
|
api_secret: '',
|
||||||
|
frappe_mail_site: '',
|
||||||
|
enable_incoming: false,
|
||||||
|
enable_outgoing: false,
|
||||||
|
default_incoming: false,
|
||||||
|
default_outgoing: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedService = ref(null)
|
||||||
|
const fields = computed(() =>
|
||||||
|
selectedService.value.custom ? customProviderFields : popularProviderFields,
|
||||||
|
)
|
||||||
|
|
||||||
|
function handleSelect(service) {
|
||||||
|
selectedService.value = service
|
||||||
|
state.service = service.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const addEmailRes = createResource({
|
||||||
|
url: 'crm.api.settings.create_email_account',
|
||||||
|
makeParams: (val) => {
|
||||||
|
return {
|
||||||
|
...val,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(__('Email account created successfully'))
|
||||||
|
emit('update:step', 'email-list')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
error.value = __('Failed to create email account, Invalid credentials')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = ref()
|
||||||
|
function createEmailAccount() {
|
||||||
|
error.value = validateInputs(state, selectedService.value.custom)
|
||||||
|
if (error.value) return
|
||||||
|
|
||||||
|
addEmailRes.submit({ data: state })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
27
frontend/src/components/Settings/EmailConfig.vue
Normal file
27
frontend/src/components/Settings/EmailConfig.vue
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 p-8">
|
||||||
|
<div v-if="step === 'email-add'" class="h-full">
|
||||||
|
<EmailAdd @update:step="updateStep" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 'email-list'" class="h-full">
|
||||||
|
<EmailAccountList @update:step="updateStep" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 'email-edit'" class="h-full">
|
||||||
|
<EmailEdit :account-data="accountData" @update:step="updateStep" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import EmailAdd from "./EmailAdd.vue";
|
||||||
|
import EmailAccountList from "./EmailAccountList.vue";
|
||||||
|
import EmailEdit from "./EmailEdit.vue";
|
||||||
|
|
||||||
|
const step = ref("email-list");
|
||||||
|
const accountData = ref(null);
|
||||||
|
function updateStep(newStep, data) {
|
||||||
|
step.value = newStep;
|
||||||
|
accountData.value = data;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
215
frontend/src/components/Settings/EmailEdit.vue
Normal file
215
frontend/src/components/Settings/EmailEdit.vue
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-full gap-4">
|
||||||
|
<!-- title and desc -->
|
||||||
|
<div role="heading" aria-level="1" class="flex justify-between gap-1">
|
||||||
|
<h2 class="text-xl font-semibold text-ink-gray-9">
|
||||||
|
{{ __('Edit Email') }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="w-fit">
|
||||||
|
<EmailProviderIcon
|
||||||
|
:logo="emailIcon[accountData.service]"
|
||||||
|
:service-name="accountData.service"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- banner for setting up email account -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 p-2 rounded-md ring-1 ring-gray-400 dark:ring-gray-700"
|
||||||
|
>
|
||||||
|
<CircleAlert
|
||||||
|
class="size-6 text-gray-500 w-min-5 w-max-5 min-h-5 max-w-5"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-700 dark:text-gray-500 text-wrap">
|
||||||
|
{{ info.description }}
|
||||||
|
<a :href="info.link" target="_blank" class="underline">{{
|
||||||
|
__('here')
|
||||||
|
}}</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- fields -->
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="field in fields"
|
||||||
|
:key="field.name"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="state[field.name]"
|
||||||
|
:label="field.label"
|
||||||
|
:name="field.name"
|
||||||
|
:type="field.type"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="field in incomingOutgoingFields"
|
||||||
|
:key="field.name"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
v-model="state[field.name]"
|
||||||
|
:label="field.label"
|
||||||
|
:name="field.name"
|
||||||
|
:type="field.type"
|
||||||
|
/>
|
||||||
|
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||||
|
</div>
|
||||||
|
<!-- action buttons -->
|
||||||
|
<div class="flex justify-between mt-auto">
|
||||||
|
<Button
|
||||||
|
:label="__('Back')"
|
||||||
|
theme="gray"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('update:step', 'email-list')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="__('Update Account')"
|
||||||
|
variant="solid"
|
||||||
|
@click="updateAccount"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { call, toast } from 'frappe-ui'
|
||||||
|
import EmailProviderIcon from './EmailProviderIcon.vue'
|
||||||
|
import {
|
||||||
|
emailIcon,
|
||||||
|
services,
|
||||||
|
popularProviderFields,
|
||||||
|
customProviderFields,
|
||||||
|
validateInputs,
|
||||||
|
incomingOutgoingFields,
|
||||||
|
} from './emailConfig'
|
||||||
|
import CircleAlert from '~icons/lucide/circle-alert'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
accountData: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits()
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
email_account_name: props.accountData.email_account_name || '',
|
||||||
|
service: props.accountData.service || '',
|
||||||
|
email_id: props.accountData.email_id || '',
|
||||||
|
api_key: props.accountData?.api_key || null,
|
||||||
|
api_secret: props.accountData?.api_secret || null,
|
||||||
|
password: props.accountData?.password || null,
|
||||||
|
frappe_mail_site: props.accountData?.frappe_mail_site || '',
|
||||||
|
enable_incoming: props.accountData.enable_incoming || false,
|
||||||
|
enable_outgoing: props.accountData.enable_outgoing || false,
|
||||||
|
default_outgoing: props.accountData.default_outgoing || false,
|
||||||
|
default_incoming: props.accountData.default_incoming || false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
description: __('To know more about setting up email accounts, click'),
|
||||||
|
link: 'https://docs.erpnext.com/docs/user/manual/en/email-account',
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCustomService = computed(() => {
|
||||||
|
return services.find((s) => s.name === props.accountData.service).custom
|
||||||
|
})
|
||||||
|
|
||||||
|
const fields = computed(() => {
|
||||||
|
if (isCustomService.value) {
|
||||||
|
return customProviderFields
|
||||||
|
}
|
||||||
|
return popularProviderFields
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = ref()
|
||||||
|
const loading = ref(false)
|
||||||
|
async function updateAccount() {
|
||||||
|
error.value = validateInputs(state, isCustomService.value)
|
||||||
|
if (error.value) return
|
||||||
|
const old = { ...props.accountData }
|
||||||
|
const updatedEmailAccount = { ...state }
|
||||||
|
|
||||||
|
const nameChanged =
|
||||||
|
old.email_account_name !== updatedEmailAccount.email_account_name
|
||||||
|
delete old.email_account_name
|
||||||
|
delete updatedEmailAccount.email_account_name
|
||||||
|
|
||||||
|
const otherFieldsChanged = isDirty.value
|
||||||
|
const values = updatedEmailAccount
|
||||||
|
|
||||||
|
if (!nameChanged && !otherFieldsChanged) {
|
||||||
|
toast.info(__('No changes made'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameChanged) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await callRenameDoc()
|
||||||
|
succesHandler()
|
||||||
|
} catch (err) {
|
||||||
|
errorHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (otherFieldsChanged) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await callSetValue(values)
|
||||||
|
succesHandler()
|
||||||
|
} catch (err) {
|
||||||
|
errorHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = computed(() => {
|
||||||
|
return (
|
||||||
|
state.email_id !== props.accountData.email_id ||
|
||||||
|
state.api_key !== props.accountData.api_key ||
|
||||||
|
state.api_secret !== props.accountData.api_secret ||
|
||||||
|
state.password !== props.accountData.password ||
|
||||||
|
state.enable_incoming !== props.accountData.enable_incoming ||
|
||||||
|
state.enable_outgoing !== props.accountData.enable_outgoing ||
|
||||||
|
state.default_outgoing !== props.accountData.default_outgoing ||
|
||||||
|
state.default_incoming !== props.accountData.default_incoming ||
|
||||||
|
state.frappe_mail_site !== props.accountData.frappe_mail_site
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function callRenameDoc() {
|
||||||
|
const d = await call('frappe.client.rename_doc', {
|
||||||
|
doctype: 'Email Account',
|
||||||
|
old_name: props.accountData.email_account_name,
|
||||||
|
new_name: state.email_account_name,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callSetValue(values) {
|
||||||
|
const d = await call('frappe.client.set_value', {
|
||||||
|
doctype: 'Email Account',
|
||||||
|
name: state.email_account_name,
|
||||||
|
fieldname: values,
|
||||||
|
})
|
||||||
|
return d.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function succesHandler() {
|
||||||
|
emit('update:step', 'email-list')
|
||||||
|
toast.success(__('Email account updated successfully'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorHandler() {
|
||||||
|
loading.value = false
|
||||||
|
error.value = __('Failed to update email account, Invalid credentials')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
33
frontend/src/components/Settings/EmailProviderIcon.vue
Normal file
33
frontend/src/components/Settings/EmailProviderIcon.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center w-8 h-8 bg-gray-100 cursor-pointer rounded-xl hover:bg-gray-200"
|
||||||
|
:class="{ 'ring-2 ring-gray-500 dark:ring-gray-100': selected }"
|
||||||
|
>
|
||||||
|
<img :src="logo" class="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="serviceName"
|
||||||
|
class="text-xs text-center text-gray-700 dark:text-gray-500 mt-2"
|
||||||
|
>
|
||||||
|
{{ serviceName }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
serviceName: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
selected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@ -101,6 +101,7 @@
|
|||||||
v-model="settings.doc.dropdown_items"
|
v-model="settings.doc.dropdown_items"
|
||||||
doctype="CRM Dropdown Item"
|
doctype="CRM Dropdown Item"
|
||||||
parentDoctype="FCRM Settings"
|
parentDoctype="FCRM Settings"
|
||||||
|
parentFieldname="dropdown_items"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
:error-message="
|
:error-message="
|
||||||
(value) => __('{0} is an invalid email address', [value])
|
(value) => __('{0} is an invalid email address', [value])
|
||||||
"
|
"
|
||||||
|
:fetchContacts="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormControl
|
||||||
@ -127,10 +128,17 @@ const inviteByEmail = createResource({
|
|||||||
role: role.value,
|
role: role.value,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess(data) {
|
||||||
|
if (data?.existing_invites?.length) {
|
||||||
|
error.value = __('Agent with email {0} already exists', [
|
||||||
|
data.existing_invites.join(', '),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
role.value = 'Sales User'
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
invitees.value = []
|
invitees.value = []
|
||||||
role.value = 'Sales User'
|
|
||||||
error.value = null
|
|
||||||
pendingInvitations.reload()
|
pendingInvitations.reload()
|
||||||
updateOnboardingStep('invite_your_team')
|
updateOnboardingStep('invite_your_team')
|
||||||
},
|
},
|
||||||
|
|||||||
@ -57,7 +57,7 @@
|
|||||||
v-model="profile.email"
|
v-model="profile.email"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<Password
|
||||||
class="w-full"
|
class="w-full"
|
||||||
label="Set new password"
|
label="Set new password"
|
||||||
v-model="profile.new_password"
|
v-model="profile.new_password"
|
||||||
@ -77,13 +77,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Password from '@/components/Controls/Password.vue'
|
||||||
import ProfileImageEditor from '@/components/Settings/ProfileImageEditor.vue'
|
import ProfileImageEditor from '@/components/Settings/ProfileImageEditor.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { createToast } from '@/utils'
|
import { Dialog, Avatar, createResource, ErrorMessage, toast } from 'frappe-ui'
|
||||||
import { Dialog, Avatar, createResource, ErrorMessage } from 'frappe-ui'
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
const { getUser, users } = usersStore()
|
const { getUser, users } = usersStore()
|
||||||
|
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||||
|
|
||||||
const user = computed(() => getUser() || {})
|
const user = computed(() => getUser() || {})
|
||||||
|
|
||||||
@ -95,6 +97,13 @@ const error = ref('')
|
|||||||
|
|
||||||
function updateUser() {
|
function updateUser() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
|
let passwordUpdated = false
|
||||||
|
|
||||||
|
if (profile.value.new_password) {
|
||||||
|
passwordUpdated = true
|
||||||
|
}
|
||||||
|
|
||||||
const fieldname = {
|
const fieldname = {
|
||||||
first_name: profile.value.first_name,
|
first_name: profile.value.first_name,
|
||||||
last_name: profile.value.last_name,
|
last_name: profile.value.last_name,
|
||||||
@ -111,15 +120,14 @@ function updateUser() {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
if (passwordUpdated) {
|
||||||
|
updateOnboardingStep('setup_your_password')
|
||||||
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
error.value = ''
|
error.value = ''
|
||||||
profile.value.new_password = ''
|
profile.value.new_password = ''
|
||||||
showEditProfilePhotoModal.value = false
|
showEditProfilePhotoModal.value = false
|
||||||
createToast({
|
toast.success(__('Profile updated successfully'))
|
||||||
title: __('Profile updated successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
users.reload()
|
users.reload()
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||||
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
|
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
|
||||||
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
|
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-9">
|
||||||
{{ __('Settings') }}
|
{{ __('Settings') }}
|
||||||
</h1>
|
</h1>
|
||||||
<div v-for="tab in tabs">
|
<div v-for="tab in tabs">
|
||||||
@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex relative flex-1 flex-col overflow-y-auto bg-surface-modal"
|
class="relative flex flex-col flex-1 overflow-y-auto bg-surface-modal"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
class="absolute right-5 top-5"
|
class="absolute right-5 top-5"
|
||||||
@ -53,12 +53,14 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
|||||||
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
import ERPNextIcon from '@/components/Icons/ERPNextIcon.vue'
|
||||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||||
|
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||||
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
||||||
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
|
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
|
||||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||||
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
|
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
|
||||||
|
import EmailConfig from '@/components/Settings/EmailConfig.vue'
|
||||||
import SidebarLink from '@/components/SidebarLink.vue'
|
import SidebarLink from '@/components/SidebarLink.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import {
|
import {
|
||||||
@ -101,6 +103,12 @@ const tabs = computed(() => {
|
|||||||
component: markRaw(InviteMemberPage),
|
component: markRaw(InviteMemberPage),
|
||||||
condition: () => isManager(),
|
condition: () => isManager(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: __('Email Accounts'),
|
||||||
|
icon: Email2Icon,
|
||||||
|
component: markRaw(EmailConfig),
|
||||||
|
condition: () => isManager(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -42,9 +42,10 @@ import {
|
|||||||
createResource,
|
createResource,
|
||||||
Spinner,
|
Spinner,
|
||||||
Badge,
|
Badge,
|
||||||
|
toast,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { createToast, getRandom } from '@/utils'
|
import { getRandom } from '@/utils'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -79,20 +80,10 @@ const data = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
setValue: {
|
setValue: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
createToast({
|
toast.success(__(props.successMessage))
|
||||||
title: __('Success'),
|
|
||||||
text: __(props.successMessage),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
createToast({
|
toast.error(err.message + ': ' + err.messages[0])
|
||||||
title: __('Error'),
|
|
||||||
text: err.message + ': ' + err.messages[0],
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'text-ink-red-4',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -87,7 +87,8 @@ import {
|
|||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { defaultCallingMedium } from '@/composables/settings'
|
import { defaultCallingMedium } from '@/composables/settings'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { createToast, getRandom } from '@/utils'
|
import { toast } from 'frappe-ui'
|
||||||
|
import { getRandom } from '@/utils'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
const { isManager, isAgent } = usersStore()
|
const { isManager, isAgent } = usersStore()
|
||||||
@ -119,20 +120,10 @@ const twilio = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
setValue: {
|
setValue: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
createToast({
|
toast.success(__('Twilio settings updated successfully'))
|
||||||
title: __('Success'),
|
|
||||||
text: __('Twilio settings updated successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
createToast({
|
toast.error(err.message + ': ' + err.messages[0])
|
||||||
title: __('Error'),
|
|
||||||
text: err.message + ': ' + err.messages[0],
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'text-ink-red-4',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -144,20 +135,10 @@ const exotel = createDocumentResource({
|
|||||||
auto: true,
|
auto: true,
|
||||||
setValue: {
|
setValue: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
createToast({
|
toast.success(__('Exotel settings updated successfully'))
|
||||||
title: __('Success'),
|
|
||||||
text: __('Exotel settings updated successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
createToast({
|
toast.error(err.message + ': ' + err.messages[0])
|
||||||
title: __('Error'),
|
|
||||||
text: err.message + ': ' + err.messages[0],
|
|
||||||
icon: 'x',
|
|
||||||
iconClasses: 'text-ink-red-4',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -294,12 +275,7 @@ async function updateMedium() {
|
|||||||
})
|
})
|
||||||
mediumChanged.value = false
|
mediumChanged.value = false
|
||||||
error.value = ''
|
error.value = ''
|
||||||
createToast({
|
toast.success(__('Default calling medium updated successfully'))
|
||||||
title: __('Success'),
|
|
||||||
text: __('Default calling medium updated successfully'),
|
|
||||||
icon: 'check',
|
|
||||||
iconClasses: 'text-ink-green-3',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|||||||
186
frontend/src/components/Settings/emailConfig.js
Normal file
186
frontend/src/components/Settings/emailConfig.js
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { validateEmail } from '../../utils'
|
||||||
|
|
||||||
|
import LogoGmail from '@/images/gmail.png'
|
||||||
|
import LogoOutlook from '@/images/outlook.png'
|
||||||
|
import LogoSendgrid from '@/images/sendgrid.png'
|
||||||
|
import LogoSparkpost from '@/images/sparkpost.webp'
|
||||||
|
import LogoYahoo from '@/images/yahoo.png'
|
||||||
|
import LogoYandex from '@/images/yandex.png'
|
||||||
|
import LogoFrappeMail from '@/images/frappe-mail.svg'
|
||||||
|
|
||||||
|
const fixedFields = [
|
||||||
|
{
|
||||||
|
label: __('Account Name'),
|
||||||
|
name: 'email_account_name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: __('Support / Sales'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Email ID',
|
||||||
|
name: 'email_id',
|
||||||
|
type: 'email',
|
||||||
|
placeholder: 'johndoe@example.com',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const incomingOutgoingFields = [
|
||||||
|
{
|
||||||
|
label: __('Enable Incoming'),
|
||||||
|
name: 'enable_incoming',
|
||||||
|
type: 'checkbox',
|
||||||
|
description: __(
|
||||||
|
'If enabled, records can be created from the incoming emails on this account.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Enable Outgoing'),
|
||||||
|
name: 'enable_outgoing',
|
||||||
|
type: 'checkbox',
|
||||||
|
description: __(
|
||||||
|
'If enabled, outgoing emails can be sent from this account.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Default Incoming'),
|
||||||
|
name: 'default_incoming',
|
||||||
|
type: 'checkbox',
|
||||||
|
description: __(
|
||||||
|
'If enabled, all replies to your company (eg: replies@yourcomany.com) will come to this account. Note: Only one account can be default incoming.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: __('Default Outgoing'),
|
||||||
|
name: 'default_outgoing',
|
||||||
|
type: 'checkbox',
|
||||||
|
description: __(
|
||||||
|
'If enabled, all outgoing emails will be sent from this account. Note: Only one account can be default outgoing.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const popularProviderFields = [
|
||||||
|
...fixedFields,
|
||||||
|
{
|
||||||
|
label: __('Password'),
|
||||||
|
name: 'password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: '********',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const customProviderFields = [
|
||||||
|
...fixedFields,
|
||||||
|
{
|
||||||
|
label: 'Frappe Mail Site',
|
||||||
|
name: 'frappe_mail_site',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'https://frappemail.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'API Key',
|
||||||
|
name: 'api_key',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: '********',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'API Secret',
|
||||||
|
name: 'api_secret',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: '********',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const services = [
|
||||||
|
{
|
||||||
|
name: 'GMail',
|
||||||
|
icon: LogoGmail,
|
||||||
|
info: __(`Setting up GMail requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more`),
|
||||||
|
link: 'https://support.google.com/accounts/answer/185833',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Outlook',
|
||||||
|
icon: LogoOutlook,
|
||||||
|
info: __(`Setting up Outlook requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more`),
|
||||||
|
link: 'https://support.microsoft.com/en-us/account-billing/how-to-get-and-use-app-passwords-5896ed9b-4263-e681-128a-a6f2979a7944',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sendgrid',
|
||||||
|
icon: LogoSendgrid,
|
||||||
|
info: __(`Setting up Sendgrid requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more `),
|
||||||
|
link: 'https://sendgrid.com/docs/ui/account-and-settings/two-factor-authentication/',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SparkPost',
|
||||||
|
icon: LogoSparkpost,
|
||||||
|
info: __(`Setting up SparkPost requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more `),
|
||||||
|
link: 'https://support.sparkpost.com/docs/my-account-and-profile/enabling-two-factor-authentication',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yahoo',
|
||||||
|
icon: LogoYahoo,
|
||||||
|
info: __(`Setting up Yahoo requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more `),
|
||||||
|
link: 'https://help.yahoo.com/kb/SLN15241.html',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Yandex',
|
||||||
|
icon: LogoYandex,
|
||||||
|
info: __(`Setting up Yandex requires you to enable two factor authentication
|
||||||
|
and app specific passwords. Read more `),
|
||||||
|
link: 'https://yandex.com/support/id/authorization/app-passwords.html',
|
||||||
|
custom: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Frappe Mail',
|
||||||
|
icon: LogoFrappeMail,
|
||||||
|
info: __(
|
||||||
|
`Setting up Frappe Mail requires you to have an API key and API Secret of your email account. Read more `,
|
||||||
|
),
|
||||||
|
link: 'https://github.com/frappe/mail',
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const emailIcon = {
|
||||||
|
GMail: LogoGmail,
|
||||||
|
Outlook: LogoOutlook,
|
||||||
|
Sendgrid: LogoSendgrid,
|
||||||
|
SparkPost: LogoSparkpost,
|
||||||
|
Yahoo: LogoYahoo,
|
||||||
|
Yandex: LogoYandex,
|
||||||
|
'Frappe Mail': LogoFrappeMail,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateInputs(state, isCustom) {
|
||||||
|
if (!state.email_account_name) {
|
||||||
|
return __('Account name is required')
|
||||||
|
}
|
||||||
|
if (!state.email_id) {
|
||||||
|
return __('Email ID is required')
|
||||||
|
}
|
||||||
|
const validEmail = validateEmail(state.email_id)
|
||||||
|
if (!validEmail) {
|
||||||
|
return __('Invalid email ID')
|
||||||
|
}
|
||||||
|
if (!isCustom && !state.password) {
|
||||||
|
return __('Password is required')
|
||||||
|
}
|
||||||
|
if (isCustom) {
|
||||||
|
if (!state.api_key) {
|
||||||
|
return __('API Key is required')
|
||||||
|
}
|
||||||
|
if (!state.api_secret) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sections flex flex-col overflow-y-auto">
|
<div
|
||||||
|
v-if="!document.get?.loading"
|
||||||
|
class="sections flex flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
<template v-for="(section, i) in _sections" :key="section.name">
|
<template v-for="(section, i) in _sections" :key="section.name">
|
||||||
<div v-if="section.visible" class="section flex flex-col">
|
<div v-if="section.visible" class="section flex flex-col">
|
||||||
<div
|
<div
|
||||||
@ -50,7 +53,7 @@
|
|||||||
(field.mandatory_depends_on &&
|
(field.mandatory_depends_on &&
|
||||||
field.mandatory_via_depends_on)
|
field.mandatory_via_depends_on)
|
||||||
"
|
"
|
||||||
class="text-ink-red-3"
|
class="text-ink-red-2"
|
||||||
>*</span
|
>*</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@ -62,26 +65,33 @@
|
|||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
field.read_only &&
|
field.read_only &&
|
||||||
!['Check', 'Dropdown'].includes(field.fieldtype)
|
![
|
||||||
|
'Int',
|
||||||
|
'Float',
|
||||||
|
'Currency',
|
||||||
|
'Percent',
|
||||||
|
'Check',
|
||||||
|
'Dropdown',
|
||||||
|
].includes(field.fieldtype)
|
||||||
"
|
"
|
||||||
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
|
class="flex h-7 cursor-pointer items-center px-2 py-1 text-ink-gray-5"
|
||||||
>
|
>
|
||||||
<Tooltip :text="__(field.tooltip)">
|
<Tooltip :text="__(field.tooltip)">
|
||||||
<div>{{ data[field.fieldname] }}</div>
|
<div>{{ document.doc[field.fieldname] }}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="field.fieldtype === 'Dropdown'">
|
<div v-else-if="field.fieldtype === 'Dropdown'">
|
||||||
<NestedPopover>
|
<NestedPopover>
|
||||||
<template #target="{ open }">
|
<template #target="{ open }">
|
||||||
<Button
|
<Button
|
||||||
:label="data[field.fieldname]"
|
:label="document.doc[field.fieldname]"
|
||||||
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
class="dropdown-button flex w-full items-center justify-between rounded border border-gray-100 bg-surface-gray-2 px-2 py-1.5 text-base text-ink-gray-8 placeholder-ink-gray-4 transition-colors hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:bg-surface-white focus:shadow-sm focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="data[field.fieldname]"
|
v-if="document.doc[field.fieldname]"
|
||||||
class="truncate"
|
class="truncate"
|
||||||
>
|
>
|
||||||
{{ data[field.fieldname] }}
|
{{ document.doc[field.fieldname] }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@ -138,13 +148,9 @@
|
|||||||
v-else-if="field.fieldtype == 'Check'"
|
v-else-if="field.fieldtype == 'Check'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
v-model="data[field.fieldname]"
|
v-model="document.doc[field.fieldname]"
|
||||||
@change.stop="
|
@change.stop="
|
||||||
emit(
|
fieldChange($event.target.checked, field)
|
||||||
'update',
|
|
||||||
field.fieldname,
|
|
||||||
$event.target.checked,
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
:disabled="Boolean(field.read_only)"
|
:disabled="Boolean(field.read_only)"
|
||||||
/>
|
/>
|
||||||
@ -159,43 +165,40 @@
|
|||||||
"
|
"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:value="data[field.fieldname]"
|
:value="document.doc[field.fieldname]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="fieldChange($event.target.value, field)"
|
||||||
emit('update', field.fieldname, $event.target.value)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else-if="field.fieldtype === 'Select'"
|
v-else-if="field.fieldtype === 'Select'"
|
||||||
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
|
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
|
||||||
type="select"
|
type="select"
|
||||||
v-model="data[field.fieldname]"
|
v-model="document.doc[field.fieldname]"
|
||||||
:options="field.options"
|
:options="field.options"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
@change.stop="
|
@change.stop="fieldChange($event.target.value, field)"
|
||||||
emit('update', field.fieldname, $event.target.value)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
v-else-if="field.fieldtype === 'User'"
|
v-else-if="field.fieldtype === 'User'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
:value="
|
:value="
|
||||||
data[field.fieldname] &&
|
document.doc[field.fieldname] &&
|
||||||
getUser(data[field.fieldname]).full_name
|
getUser(document.doc[field.fieldname]).full_name
|
||||||
"
|
"
|
||||||
doctype="User"
|
doctype="User"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
@change="
|
@change="(v) => fieldChange(v, field)"
|
||||||
(data) => emit('update', field.fieldname, data)
|
|
||||||
"
|
|
||||||
:placeholder="'Select' + ' ' + field.label + '...'"
|
:placeholder="'Select' + ' ' + field.label + '...'"
|
||||||
:hideMe="true"
|
:hideMe="true"
|
||||||
>
|
>
|
||||||
<template v-if="data[field.fieldname]" #prefix>
|
<template
|
||||||
|
v-if="document.doc[field.fieldname]"
|
||||||
|
#prefix
|
||||||
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
class="mr-1.5"
|
class="mr-1.5"
|
||||||
:user="data[field.fieldname]"
|
:user="document.doc[field.fieldname]"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -215,15 +218,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
v-else-if="field.fieldtype === 'Link'"
|
v-else-if="
|
||||||
|
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||||
|
"
|
||||||
class="form-control select-text"
|
class="form-control select-text"
|
||||||
:value="data[field.fieldname]"
|
:value="document.doc[field.fieldname]"
|
||||||
:doctype="field.options"
|
:doctype="
|
||||||
|
field.fieldtype == 'Link'
|
||||||
|
? field.options
|
||||||
|
: document.doc[field.options]
|
||||||
|
"
|
||||||
:filters="field.filters"
|
:filters="field.filters"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
@change="
|
@change="(v) => fieldChange(v, field)"
|
||||||
(data) => emit('update', field.fieldname, data)
|
|
||||||
"
|
|
||||||
:onCreate="field.create"
|
:onCreate="field.create"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@ -232,15 +239,13 @@
|
|||||||
>
|
>
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
icon-left=""
|
icon-left=""
|
||||||
:value="data[field.fieldname]"
|
:value="document.doc[field.fieldname]"
|
||||||
:formatter="
|
:formatter="
|
||||||
(date) => getFormat(date, '', true, true)
|
(date) => getFormat(date, '', true, true)
|
||||||
"
|
"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
placement="left-start"
|
placement="left-start"
|
||||||
@change="
|
@change="(v) => fieldChange(v, field)"
|
||||||
(data) => emit('update', field.fieldname, data)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -249,81 +254,82 @@
|
|||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
icon-left=""
|
icon-left=""
|
||||||
:value="data[field.fieldname]"
|
:value="document.doc[field.fieldname]"
|
||||||
:formatter="(date) => getFormat(date, '', true)"
|
:formatter="(date) => getFormat(date, '', true)"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
placement="left-start"
|
placement="left-start"
|
||||||
@change="
|
@change="(v) => fieldChange(v, field)"
|
||||||
(data) => emit('update', field.fieldname, data)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FormControl
|
<FormattedInput
|
||||||
v-else-if="field.fieldtype === 'Percent'"
|
v-else-if="field.fieldtype === 'Percent'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedPercent(field.fieldname, data)"
|
:value="
|
||||||
|
getFormattedPercent(field.fieldname, document.doc)
|
||||||
|
"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="
|
||||||
emit(
|
fieldChange(flt($event.target.value), field)
|
||||||
'update',
|
|
||||||
field.fieldname,
|
|
||||||
flt($event.target.value),
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:disabled="Boolean(field.read_only)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<Password
|
||||||
|
v-else-if="field.fieldtype === 'Password'"
|
||||||
|
class="form-control"
|
||||||
|
:value="document.doc[field.fieldname]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:debounce="500"
|
||||||
|
@change.stop="fieldChange($event.target.value, field)"
|
||||||
|
:disabled="Boolean(field.read_only)"
|
||||||
|
/>
|
||||||
|
<FormattedInput
|
||||||
v-else-if="field.fieldtype === 'Int'"
|
v-else-if="field.fieldtype === 'Int'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="number"
|
type="text"
|
||||||
v-model="data[field.fieldname]"
|
:value="document.doc[field.fieldname] || '0'"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="fieldChange($event.target.value, field)"
|
||||||
emit('update', field.fieldname, $event.target.value)
|
:disabled="Boolean(field.read_only)"
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormattedInput
|
||||||
v-else-if="field.fieldtype === 'Float'"
|
v-else-if="field.fieldtype === 'Float'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedFloat(field.fieldname, data)"
|
:value="
|
||||||
|
getFormattedFloat(field.fieldname, document.doc)
|
||||||
|
"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="
|
||||||
emit(
|
fieldChange(flt($event.target.value), field)
|
||||||
'update',
|
|
||||||
field.fieldname,
|
|
||||||
flt($event.target.value),
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:disabled="Boolean(field.read_only)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormattedInput
|
||||||
v-else-if="field.fieldtype === 'Currency'"
|
v-else-if="field.fieldtype === 'Currency'"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
:value="getFormattedCurrency(field.fieldname, data)"
|
:value="
|
||||||
|
getFormattedCurrency(field.fieldname, document.doc)
|
||||||
|
"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="
|
||||||
emit(
|
fieldChange(flt($event.target.value), field)
|
||||||
'update',
|
|
||||||
field.fieldname,
|
|
||||||
flt($event.target.value),
|
|
||||||
)
|
|
||||||
"
|
"
|
||||||
|
:disabled="Boolean(field.read_only)"
|
||||||
/>
|
/>
|
||||||
<FormControl
|
<FormControl
|
||||||
v-else
|
v-else
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
:value="data[field.fieldname]"
|
:value="document.doc[field.fieldname]"
|
||||||
:placeholder="field.placeholder"
|
:placeholder="field.placeholder"
|
||||||
:debounce="500"
|
:debounce="500"
|
||||||
@change.stop="
|
@change.stop="fieldChange($event.target.value, field)"
|
||||||
emit('update', field.fieldname, $event.target.value)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-1">
|
<div class="ml-1">
|
||||||
@ -331,19 +337,23 @@
|
|||||||
v-if="
|
v-if="
|
||||||
field.fieldtype === 'Link' &&
|
field.fieldtype === 'Link' &&
|
||||||
field.link &&
|
field.link &&
|
||||||
data[field.fieldname]
|
document.doc[field.fieldname]
|
||||||
"
|
"
|
||||||
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||||
@click.stop="field.link(data[field.fieldname])"
|
@click.stop="
|
||||||
|
field.link(document.doc[field.fieldname])
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<EditIcon
|
<EditIcon
|
||||||
v-if="
|
v-if="
|
||||||
field.fieldtype === 'Link' &&
|
field.fieldtype === 'Link' &&
|
||||||
field.edit &&
|
field.edit &&
|
||||||
data[field.fieldname]
|
document.doc[field.fieldname]
|
||||||
"
|
"
|
||||||
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
|
||||||
@click.stop="field.edit(data[field.fieldname])"
|
@click.stop="
|
||||||
|
field.edit(document.doc[field.fieldname])
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -365,6 +375,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import Password from '@/components/Controls/Password.vue'
|
||||||
|
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import NestedPopover from '@/components/NestedPopover.vue'
|
import NestedPopover from '@/components/NestedPopover.vue'
|
||||||
import DropdownItem from '@/components/DropdownItem.vue'
|
import DropdownItem from '@/components/DropdownItem.vue'
|
||||||
@ -380,6 +392,7 @@ import { isMobileView } from '@/composables/settings'
|
|||||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||||
import { flt } from '@/utils/numberFormat.js'
|
import { flt } from '@/utils/numberFormat.js'
|
||||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -389,6 +402,11 @@ const props = defineProps({
|
|||||||
doctype: {
|
doctype: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'CRM Lead',
|
default: 'CRM Lead',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
docname: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -401,13 +419,22 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||||
getMeta(props.doctype)
|
getMeta(props.doctype)
|
||||||
|
|
||||||
const { isManager, getUser } = usersStore()
|
const { isManager, getUser } = usersStore()
|
||||||
|
|
||||||
const emit = defineEmits(['update', 'reload'])
|
const emit = defineEmits(['reload'])
|
||||||
|
|
||||||
const data = defineModel()
|
|
||||||
const showSidePanelModal = ref(false)
|
const showSidePanelModal = ref(false)
|
||||||
|
|
||||||
|
let document = { doc: {} }
|
||||||
|
let triggerOnChange
|
||||||
|
|
||||||
|
if (props.docname) {
|
||||||
|
let d = useDocument(props.doctype, props.docname)
|
||||||
|
document = d.document
|
||||||
|
triggerOnChange = d.triggerOnChange
|
||||||
|
}
|
||||||
|
|
||||||
const _sections = computed(() => {
|
const _sections = computed(() => {
|
||||||
if (!props.sections?.length) return []
|
if (!props.sections?.length) return []
|
||||||
let editButtonAdded = false
|
let editButtonAdded = false
|
||||||
@ -447,11 +474,11 @@ function parsedField(field) {
|
|||||||
placeholder: field.placeholder || field.label,
|
placeholder: field.placeholder || field.label,
|
||||||
display_via_depends_on: evaluateDependsOnValue(
|
display_via_depends_on: evaluateDependsOnValue(
|
||||||
field.depends_on,
|
field.depends_on,
|
||||||
data.value,
|
document.doc,
|
||||||
),
|
),
|
||||||
mandatory_via_depends_on: evaluateDependsOnValue(
|
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||||
field.mandatory_depends_on,
|
field.mandatory_depends_on,
|
||||||
data.value,
|
document.doc,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,6 +486,16 @@ function parsedField(field) {
|
|||||||
return _field
|
return _field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fieldChange(value, df) {
|
||||||
|
if (props.preview) return
|
||||||
|
|
||||||
|
document.doc[df.fieldname] = value
|
||||||
|
|
||||||
|
await triggerOnChange(df.fieldname)
|
||||||
|
|
||||||
|
document.save.submit()
|
||||||
|
}
|
||||||
|
|
||||||
function parsedSection(section, editButtonAdded) {
|
function parsedSection(section, editButtonAdded) {
|
||||||
let isContactSection = section.name == 'contacts_section'
|
let isContactSection = section.name == 'contacts_section'
|
||||||
section.showEditButton = !(
|
section.showEditButton = !(
|
||||||
@ -479,7 +516,7 @@ function isFieldVisible(field) {
|
|||||||
if (props.preview) return true
|
if (props.preview) return true
|
||||||
return (
|
return (
|
||||||
(field.fieldtype == 'Check' ||
|
(field.fieldtype == 'Check' ||
|
||||||
(field.read_only && data.value[field.fieldname]) ||
|
(field.read_only && document.doc?.[field.fieldname]) ||
|
||||||
!field.read_only) &&
|
!field.read_only) &&
|
||||||
(!field.depends_on || field.display_via_depends_on) &&
|
(!field.depends_on || field.display_via_depends_on) &&
|
||||||
!field.hidden
|
!field.hidden
|
||||||
|
|||||||
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