Compare commits
150 Commits
main
...
pot_develo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@ -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
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import validate_email_address, split_emails, cstr
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
from bs4 import BeautifulSoup
|
||||
from frappe.core.api.file import get_max_file_size
|
||||
from frappe.translate import get_all_translations
|
||||
from frappe.utils import cstr, split_emails, validate_email_address
|
||||
from frappe.utils.modules import get_modules_from_all_apps_for_user
|
||||
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
@ -63,6 +64,11 @@ def check_app_permission():
|
||||
if frappe.session.user == "Administrator":
|
||||
return True
|
||||
|
||||
allowed_modules = get_modules_from_all_apps_for_user()
|
||||
allowed_modules = [x["module_name"] for x in allowed_modules]
|
||||
if "FCRM" not in allowed_modules:
|
||||
return False
|
||||
|
||||
roles = frappe.get_roles()
|
||||
if any(
|
||||
role in ["System Manager", "Sales User", "Sales Manager", "Sales Master Manager"] for role in roles
|
||||
@ -94,8 +100,13 @@ def accept_invitation(key: str | None = None):
|
||||
@frappe.whitelist()
|
||||
def invite_by_email(emails: str, role: str):
|
||||
frappe.only_for("Sales Manager")
|
||||
|
||||
if role not in ["Sales Manager", "Sales User"]:
|
||||
frappe.throw("Cannot invite for this role")
|
||||
|
||||
if not emails:
|
||||
return
|
||||
|
||||
email_string = validate_email_address(emails, throw=False)
|
||||
email_list = split_emails(email_string)
|
||||
if not email_list:
|
||||
|
||||
@ -23,22 +23,14 @@ def update_deals_email_mobile_no(doc):
|
||||
|
||||
@frappe.whitelist()
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
|
||||
@ -418,16 +418,23 @@ def get_data(
|
||||
rows.append(field)
|
||||
|
||||
for kc in kanban_columns:
|
||||
column_filters = {column_field: kc.get("name")}
|
||||
# Start with base filters
|
||||
column_filters = []
|
||||
|
||||
# Convert and add the main filters first
|
||||
if filters:
|
||||
base_filters = convert_filter_to_tuple(doctype, filters)
|
||||
column_filters.extend(base_filters)
|
||||
|
||||
# Add the column-specific filter
|
||||
if column_field and kc.get("name"):
|
||||
column_filters.append([doctype, column_field, "=", kc.get("name")])
|
||||
|
||||
order = kc.get("order")
|
||||
if (column_field in filters and filters.get(column_field) != kc.get("name")) or kc.get("delete"):
|
||||
if kc.get("delete"):
|
||||
column_data = []
|
||||
else:
|
||||
column_filters.update(filters.copy())
|
||||
page_length = 20
|
||||
|
||||
if kc.get("page_length"):
|
||||
page_length = kc.get("page_length")
|
||||
page_length = kc.get("page_length", 20)
|
||||
|
||||
if order:
|
||||
column_data = get_records_based_on_order(
|
||||
@ -437,26 +444,20 @@ def get_data(
|
||||
column_data = frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
filters=convert_filter_to_tuple(doctype, column_filters),
|
||||
filters=column_filters,
|
||||
order_by=order_by,
|
||||
page_length=page_length,
|
||||
)
|
||||
|
||||
new_filters = filters.copy()
|
||||
new_filters.update({column_field: kc.get("name")})
|
||||
|
||||
all_count = frappe.get_list(
|
||||
doctype,
|
||||
filters=convert_filter_to_tuple(doctype, new_filters),
|
||||
filters=column_filters,
|
||||
fields="count(*) as total_count",
|
||||
)[0].total_count
|
||||
|
||||
kc["all_count"] = all_count
|
||||
kc["count"] = len(column_data)
|
||||
|
||||
for d in column_data:
|
||||
getCounts(d, doctype)
|
||||
|
||||
if order:
|
||||
column_data = sorted(
|
||||
column_data,
|
||||
|
||||
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",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "From"
|
||||
"label": "From",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled"
|
||||
"options": "Initiated\nRinging\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "start_time",
|
||||
@ -69,13 +71,15 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Incoming\nOutgoing"
|
||||
"options": "Incoming\nOutgoing",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "to",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "To"
|
||||
"label": "To",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "Call duration in seconds",
|
||||
@ -153,7 +157,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-22 17:57:59.289548",
|
||||
"modified": "2025-04-01 16:01:54.479309",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Call Log",
|
||||
|
||||
@ -6,7 +6,10 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal(name):
|
||||
deal = frappe.get_doc("CRM Deal", name).as_dict()
|
||||
deal = frappe.get_doc("CRM Deal", name)
|
||||
deal.check_permission("read")
|
||||
|
||||
deal = deal.as_dict()
|
||||
|
||||
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
||||
deal["_form_script"] = get_form_script("CRM Deal")
|
||||
|
||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Deal", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/deals/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"discount_amount",
|
||||
discount_amount
|
||||
);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
}
|
||||
});
|
||||
|
||||
@ -43,6 +43,12 @@
|
||||
"mobile_no",
|
||||
"phone",
|
||||
"gender",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ccbj",
|
||||
"total",
|
||||
"column_break_udbq",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
@ -334,11 +340,46 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Currency",
|
||||
"options": "Currency"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ccbj",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_udbq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-11 14:31:41.058895",
|
||||
"modified": "2025-05-12 12:30:55.415282",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
@ -370,10 +411,11 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "organization",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,9 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
||||
if not tabs and type != "Required Fields":
|
||||
tabs = get_default_layout(doctype)
|
||||
|
||||
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
|
||||
has_tabs = False
|
||||
if isinstance(tabs, list) and len(tabs) > 0 and isinstance(tabs[0], dict):
|
||||
has_tabs = any("sections" in tab for tab in tabs)
|
||||
|
||||
if not has_tabs:
|
||||
tabs = [{"name": "first_tab", "sections": tabs}]
|
||||
@ -47,7 +49,10 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
||||
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if section.get("columns"):
|
||||
section["columns"] = [column for column in section.get("columns") if column]
|
||||
for column in section.get("columns") if section.get("columns") else []:
|
||||
column["fields"] = [field for field in column.get("fields") if field]
|
||||
for field in column.get("fields") if column.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
|
||||
@ -6,7 +6,10 @@ from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lead(name):
|
||||
lead = frappe.get_doc("CRM Lead", name).as_dict()
|
||||
lead = frappe.get_doc("CRM Lead", name)
|
||||
lead.check_permission("read")
|
||||
|
||||
lead = lead.as_dict()
|
||||
|
||||
lead["fields_meta"] = get_fields_meta("CRM Lead")
|
||||
lead["_form_script"] = get_form_script("CRM Lead")
|
||||
|
||||
@ -5,4 +5,68 @@ frappe.ui.form.on("CRM Lead", {
|
||||
refresh(frm) {
|
||||
frm.add_web_link(`/crm/leads/${frm.doc.name}`, __("Open in Portal"));
|
||||
},
|
||||
update_total: function (frm) {
|
||||
let total = 0;
|
||||
let total_qty = 0;
|
||||
let net_total = 0;
|
||||
frm.doc.products.forEach((d) => {
|
||||
total += d.amount;
|
||||
total_qty += d.qty;
|
||||
net_total += d.net_amount;
|
||||
});
|
||||
|
||||
frappe.model.set_value(frm.doctype, frm.docname, "total", total);
|
||||
frappe.model.set_value(
|
||||
frm.doctype,
|
||||
frm.docname,
|
||||
"net_total",
|
||||
net_total || total
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("CRM Products", {
|
||||
products_add: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
products_remove: function (frm, cdt, cdn) {
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
product_code: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.set_value(cdt, cdn, "product_name", d.product_code);
|
||||
},
|
||||
rate: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
qty: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.rate && d.qty) {
|
||||
frappe.model.set_value(cdt, cdn, "amount", d.rate * d.qty);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
},
|
||||
discount_percentage: function (frm, cdt, cdn) {
|
||||
let d = frappe.get_doc(cdt, cdn);
|
||||
if (d.discount_percentage && d.amount) {
|
||||
discount_amount = (d.discount_percentage / 100) * d.amount;
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"discount_amount",
|
||||
discount_amount
|
||||
);
|
||||
frappe.model.set_value(
|
||||
cdt,
|
||||
cdn,
|
||||
"net_amount",
|
||||
d.amount - discount_amount
|
||||
);
|
||||
}
|
||||
frm.trigger("update_total");
|
||||
}
|
||||
});
|
||||
@ -37,6 +37,12 @@
|
||||
"annual_revenue",
|
||||
"image",
|
||||
"converted",
|
||||
"products_tab",
|
||||
"products",
|
||||
"section_break_ggwh",
|
||||
"total",
|
||||
"column_break_uisv",
|
||||
"net_total",
|
||||
"sla_tab",
|
||||
"sla",
|
||||
"sla_creation",
|
||||
@ -285,12 +291,47 @@
|
||||
"fieldtype": "Table",
|
||||
"label": "Status Change Log",
|
||||
"options": "CRM Status Change Log"
|
||||
},
|
||||
{
|
||||
"fieldname": "products_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "products",
|
||||
"fieldtype": "Table",
|
||||
"label": "Products",
|
||||
"options": "CRM Products"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ggwh",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_uisv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Total after discount",
|
||||
"fieldname": "net_total",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Total",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-01-02 22:14:01.991054",
|
||||
"modified": "2025-05-14 19:51:06.184569",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Lead",
|
||||
@ -331,6 +372,7 @@
|
||||
"share": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sender_field": "email",
|
||||
"sender_name_field": "first_name",
|
||||
"show_title_field_in_link": 1,
|
||||
@ -339,4 +381,4 @@
|
||||
"states": [],
|
||||
"title_field": "lead_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
0
crm/fcrm/doctype/crm_product/__init__.py
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
@ -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
@ -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
@ -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
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
@ -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')
|
||||
}
|
||||
}"""
|
||||
)
|
||||
@ -19,7 +19,8 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Title"
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "content",
|
||||
@ -49,7 +50,7 @@
|
||||
"link_fieldname": "note"
|
||||
}
|
||||
],
|
||||
"modified": "2024-01-19 21:56:30.123334",
|
||||
"modified": "2025-04-01 15:30:14.742001",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "FCRM Note",
|
||||
|
||||
@ -4,6 +4,8 @@ import click
|
||||
import frappe
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
from crm.fcrm.doctype.crm_products.crm_products import create_product_details_script
|
||||
|
||||
|
||||
def before_install():
|
||||
pass
|
||||
@ -19,6 +21,7 @@ def after_install(force=False):
|
||||
add_default_industries()
|
||||
add_default_lead_sources()
|
||||
add_standard_dropdown_items()
|
||||
add_default_scripts()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
@ -353,3 +356,8 @@ def add_standard_dropdown_items():
|
||||
crm_settings.append("dropdown_items", item)
|
||||
|
||||
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)
|
||||
|
||||
if number.get("is_valid"):
|
||||
return get_contact(number.get("national_number"))
|
||||
return get_contact(number.get("national_number"), number.get("country"))
|
||||
else:
|
||||
return get_contact(phone_number, exact_match=True)
|
||||
return get_contact(phone_number, number.get("country"), exact_match=True)
|
||||
|
||||
|
||||
def get_contact(phone_number, exact_match=False):
|
||||
def get_contact(phone_number, country="IN", exact_match=False):
|
||||
if not phone_number:
|
||||
return {"mobile_no": phone_number}
|
||||
|
||||
@ -149,11 +149,11 @@ def get_contact(phone_number, exact_match=False):
|
||||
deal = frappe.db.get_value(
|
||||
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
|
||||
)
|
||||
if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(contact.mobile_no, phone_number, country, validate=not exact_match):
|
||||
contact["deal"] = deal
|
||||
return contact
|
||||
# Else, return the first contact
|
||||
if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(contacts[0].mobile_no, phone_number, country, validate=not exact_match):
|
||||
return contacts[0]
|
||||
|
||||
# Else, Check if the number is associated with a lead
|
||||
@ -173,7 +173,7 @@ def get_contact(phone_number, exact_match=False):
|
||||
|
||||
if len(leads):
|
||||
for lead in leads:
|
||||
if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
|
||||
if are_same_phone_number(lead.mobile_no, phone_number, country, validate=not exact_match):
|
||||
lead["lead"] = lead.name
|
||||
lead["full_name"] = lead.lead_name
|
||||
return lead
|
||||
|
||||
@ -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.update_deal_quick_entry_layout
|
||||
crm.patches.v1_0.update_layouts_to_new_format
|
||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||
crm.patches.v1_0.create_default_scripts
|
||||
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,12 +1,13 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
import click
|
||||
import frappe
|
||||
|
||||
|
||||
def before_uninstall():
|
||||
delete_email_template_custom_fields()
|
||||
|
||||
|
||||
def delete_email_template_custom_fields():
|
||||
if frappe.get_meta("Email Template").has_field("enabled"):
|
||||
click.secho("* Uninstalling Custom Fields from Email Template")
|
||||
@ -19,4 +20,4 @@ def delete_email_template_custom_fields():
|
||||
for fieldname in fieldnames:
|
||||
frappe.db.delete("Custom Field", {"name": "Email Template-" + fieldname})
|
||||
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
frappe.clear_cache(doctype="Email Template")
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 3423aa5b5c38d3a1b143ae8ab08cbde7360f9a7c
|
||||
Subproject commit 29307e4fffaacdbb3d9c5d95c5270b2f245a5607
|
||||
12
frontend/components.d.ts
vendored
@ -53,6 +53,7 @@ declare module 'vue' {
|
||||
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
||||
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
||||
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
|
||||
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
|
||||
@ -78,16 +79,23 @@ declare module 'vue' {
|
||||
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
|
||||
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
|
||||
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
|
||||
EmailAccountCard: typeof import('./src/components/Settings/EmailAccountCard.vue')['default']
|
||||
EmailAccountList: typeof import('./src/components/Settings/EmailAccountList.vue')['default']
|
||||
EmailAdd: typeof import('./src/components/Settings/EmailAdd.vue')['default']
|
||||
EmailArea: typeof import('./src/components/Activities/EmailArea.vue')['default']
|
||||
EmailAtIcon: typeof import('./src/components/Icons/EmailAtIcon.vue')['default']
|
||||
EmailConfig: typeof import('./src/components/Settings/EmailConfig.vue')['default']
|
||||
EmailContent: typeof import('./src/components/Activities/EmailContent.vue')['default']
|
||||
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
|
||||
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
|
||||
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
|
||||
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
|
||||
EmailTemplateModal: typeof import('./src/components/Modals/EmailTemplateModal.vue')['default']
|
||||
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
|
||||
EmailTemplatesListView: typeof import('./src/components/ListViews/EmailTemplatesListView.vue')['default']
|
||||
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
|
||||
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
|
||||
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
|
||||
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
|
||||
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
|
||||
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
|
||||
@ -106,9 +114,11 @@ declare module 'vue' {
|
||||
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
|
||||
Filter: typeof import('./src/components/Filter.vue')['default']
|
||||
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
|
||||
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
|
||||
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
|
||||
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
|
||||
GeneralSettings: typeof import('./src/components/Settings/GeneralSettings.vue')['default']
|
||||
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
|
||||
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
|
||||
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
|
||||
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
|
||||
@ -140,6 +150,7 @@ declare module 'vue' {
|
||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
|
||||
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
|
||||
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
|
||||
@ -149,6 +160,7 @@ declare module 'vue' {
|
||||
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
|
||||
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
|
||||
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
|
||||
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
|
||||
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
|
||||
MultiSelectEmailInput: typeof import('./src/components/Controls/MultiSelectEmailInput.vue')['default']
|
||||
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"dependencies": {
|
||||
"@twilio/voice-sdk": "^2.10.2",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"frappe-ui": "^0.1.121",
|
||||
"frappe-ui": "^0.1.123",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
|
||||
@ -373,11 +373,7 @@
|
||||
>
|
||||
<component :is="emptyTextIcon" class="h-10 w-10" />
|
||||
<span>{{ __(emptyText) }}</span>
|
||||
<Button
|
||||
v-if="title == 'Calls'"
|
||||
:label="__('Make a Call')"
|
||||
@click="makeCall(doc.data.mobile_no)"
|
||||
/>
|
||||
<MultiActionButton v-if="title == 'Calls'" :options="callActions" />
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
:label="__('Create Note')"
|
||||
@ -470,6 +466,7 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
|
||||
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
|
||||
import DealsIcon from '@/components/Icons/DealsIcon.vue'
|
||||
import DotIcon from '@/components/Icons/DotIcon.vue'
|
||||
@ -487,7 +484,7 @@ import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||
import { timeAgo, formatDate, startCase } from '@/utils'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { whatsappEnabled } from '@/composables/settings'
|
||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
@ -785,5 +782,23 @@ function scroll(hash) {
|
||||
}, 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 })
|
||||
</script>
|
||||
|
||||
@ -26,16 +26,11 @@
|
||||
</template>
|
||||
<span>{{ __('New Comment') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
<MultiActionButton
|
||||
v-else-if="title == 'Calls'"
|
||||
variant="solid"
|
||||
@click="makeCall(doc.data.mobile_no)"
|
||||
>
|
||||
<template #prefix>
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('Make a Call') }}</span>
|
||||
</Button>
|
||||
:options="callActions"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="title == 'Notes'"
|
||||
variant="solid"
|
||||
@ -97,6 +92,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import MultiActionButton from '@/components/MultiActionButton.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
@ -136,6 +132,11 @@ const defaultActions = computed(() => {
|
||||
label: __('New Comment'),
|
||||
onClick: () => (props.emailBox.showComment = true),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Create Call Log'),
|
||||
onClick: () => props.modalRef.createCallLog(),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Make a Call'),
|
||||
@ -172,4 +173,24 @@ const defaultActions = computed(() => {
|
||||
function getTabIndex(name) {
|
||||
return props.tabs.findIndex((tab) => tab.name === name)
|
||||
}
|
||||
|
||||
const callActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
label: __('Create Call Log'),
|
||||
icon: 'plus',
|
||||
onClick: () => props.modalRef.createCallLog(),
|
||||
},
|
||||
{
|
||||
label: __('Make a Call'),
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
onClick: () => makeCall(props.doc.data.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
]
|
||||
|
||||
return actions.filter((action) =>
|
||||
action.condition ? action.condition() : true,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -15,10 +15,16 @@
|
||||
:doc="doc.data?.name"
|
||||
@after="redirect('notes')"
|
||||
/>
|
||||
<CallLogModal
|
||||
v-model="showCallLogModal"
|
||||
v-model:callLog="callLog"
|
||||
:options="{ afterInsert: () => activities.reload() }"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||
import NoteModal from '@/components/Modals/NoteModal.vue'
|
||||
import CallLogModal from '@/components/Modals/CallLogModal.vue'
|
||||
import { call } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@ -77,6 +83,22 @@ function showNote(n) {
|
||||
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
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -95,5 +117,6 @@ defineExpose({
|
||||
deleteTask,
|
||||
updateTaskStatus,
|
||||
showNote,
|
||||
createCallLog,
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
||||
{{ __('Data') }}
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
v-if="document.isDirty"
|
||||
class="ml-3"
|
||||
:label="'Not Saved'"
|
||||
theme="orange"
|
||||
@ -20,15 +20,15 @@
|
||||
</Button>
|
||||
<Button
|
||||
label="Save"
|
||||
:disabled="!data.isDirty"
|
||||
:disabled="!document.isDirty"
|
||||
variant="solid"
|
||||
:loading="data.save.loading"
|
||||
:loading="document.save.loading"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.get.loading"
|
||||
v-if="document.get.loading"
|
||||
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" />
|
||||
@ -38,7 +38,7 @@
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
:data="data.doc"
|
||||
:data="document.doc"
|
||||
:doctype="doctype"
|
||||
/>
|
||||
</div>
|
||||
@ -49,7 +49,7 @@
|
||||
@reload="
|
||||
() => {
|
||||
tabs.reload()
|
||||
data.reload()
|
||||
document.reload()
|
||||
}
|
||||
"
|
||||
/>
|
||||
@ -59,12 +59,12 @@
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
|
||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||
import { Badge, createResource, createDocumentResource } from 'frappe-ui'
|
||||
import { Badge, createResource } from 'frappe-ui'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import { createToast } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { isMobileView } from '@/composables/settings'
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
@ -76,33 +76,11 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const showDataFieldsModal = ref(false)
|
||||
|
||||
const data = createDocumentResource({
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
setValue: {
|
||||
onSuccess: () => {
|
||||
data.reload()
|
||||
createToast({
|
||||
title: 'Data Updated',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages[0],
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const { document } = useDocument(props.doctype, props.docname)
|
||||
|
||||
const tabs = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||
@ -112,6 +90,22 @@ const tabs = createResource({
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
58
frontend/src/components/Controls/FormattedInput.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<TextInput
|
||||
ref="inputRef"
|
||||
:value="displayValue"
|
||||
@focus="handleFocus"
|
||||
@blur="isFocused = false"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<slot name="description">
|
||||
<p v-if="attrs.description" class="mt-1.5" :class="descriptionClasses">
|
||||
{{ attrs.description }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TextInput } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, useAttrs } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
formattedValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const isFocused = ref(false)
|
||||
const inputRef = ref(null)
|
||||
|
||||
function handleFocus() {
|
||||
isFocused.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.el?.select()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const displayValue = computed(() => {
|
||||
return isFocused.value ? props.value : props.formattedValue || props.value
|
||||
})
|
||||
|
||||
const descriptionClasses = computed(() => {
|
||||
return [
|
||||
{
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@ -33,10 +33,23 @@
|
||||
<div
|
||||
v-for="field in fields"
|
||||
class="border-r border-outline-gray-2 p-2 truncate"
|
||||
:class="
|
||||
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
|
||||
? 'text-right'
|
||||
: ''
|
||||
"
|
||||
:key="field.fieldname"
|
||||
:title="field.label"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
v-if="
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-12">
|
||||
@ -93,18 +106,37 @@
|
||||
:key="field.fieldname"
|
||||
>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
v-if="
|
||||
field.read_only &&
|
||||
![
|
||||
'Int',
|
||||
'Float',
|
||||
'Currency',
|
||||
'Percent',
|
||||
'Check',
|
||||
].includes(field.fieldtype)
|
||||
"
|
||||
type="text"
|
||||
:placeholder="field.placeholder"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
v-else-if="
|
||||
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||
"
|
||||
class="text-sm text-ink-gray-8"
|
||||
v-model="row[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:value="row[field.fieldname]"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link'
|
||||
? field.options
|
||||
: row[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
:onCreate="
|
||||
(value, close) => field.create(v, field, row, close)
|
||||
"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
@ -112,7 +144,7 @@
|
||||
:value="getUser(row[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (row[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
:placeholder="field.placeholder"
|
||||
:hideMe="true"
|
||||
>
|
||||
@ -142,23 +174,26 @@
|
||||
class="cursor-pointer duration-300"
|
||||
v-model="row[field.fieldname]"
|
||||
:disabled="!gridSettings.editable_grid"
|
||||
@change="(e) => fieldChange(e.target.checked, field, row)"
|
||||
/>
|
||||
</div>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
icon-left=""
|
||||
variant="outline"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
input-class="border-none text-sm text-ink-gray-8"
|
||||
@change="(v) => fieldChange(v, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
@ -169,13 +204,8 @@
|
||||
rows="1"
|
||||
type="textarea"
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:value="row[field.fieldname]"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
@ -184,6 +214,48 @@
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
@change="(e) => fieldChange(e.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="row[field.fieldname] || '0'"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="(row[field.fieldname] || '0') + '%'"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getFloatWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="row[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
class="[&_input]:text-right"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:value="getCurrencyWithPrecision(field.fieldname, row)"
|
||||
:formattedValue="
|
||||
getFormattedCurrency(field.fieldname, row, parentDoc)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="fieldChange(flt($event.target.value), field, row)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
@ -192,6 +264,7 @@
|
||||
variant="outline"
|
||||
v-model="row[field.fieldname]"
|
||||
:options="field.options"
|
||||
@change="fieldChange($event.target.value, field, row)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -252,6 +325,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
|
||||
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
|
||||
import GridRowModal from '@/components/Controls/GridRowModal.vue'
|
||||
@ -259,8 +333,10 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { getRandom, getFormat, isTouchScreenDevice } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import {
|
||||
FeatherIcon,
|
||||
FormControl,
|
||||
@ -268,9 +344,10 @@ import {
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Tooltip,
|
||||
dayjs,
|
||||
} from 'frappe-ui'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, reactive, computed, inject, provide } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
@ -285,15 +362,32 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
parentFieldname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { getGridViewSettings, getFields, getGridSettings } = getMeta(
|
||||
props.doctype,
|
||||
)
|
||||
const triggerOnChange = inject('triggerOnChange')
|
||||
const triggerOnRowAdd = inject('triggerOnRowAdd')
|
||||
const triggerOnRowRemove = inject('triggerOnRowRemove')
|
||||
|
||||
const {
|
||||
getGridViewSettings,
|
||||
getFields,
|
||||
getFloatWithPrecision,
|
||||
getCurrencyWithPrecision,
|
||||
getFormattedCurrency,
|
||||
getGridSettings,
|
||||
} = getMeta(props.doctype)
|
||||
getMeta(props.parentDoctype)
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const rows = defineModel()
|
||||
const parentDoc = defineModel('parent')
|
||||
|
||||
provide('parentDoc', parentDoc)
|
||||
|
||||
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
|
||||
const selectedRows = reactive(new Set())
|
||||
|
||||
@ -316,7 +410,22 @@ const fields = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const allFields = computed(() => {
|
||||
return getFields()?.map((f) => getFieldObj(f)) || []
|
||||
})
|
||||
|
||||
function getFieldObj(field) {
|
||||
if (field.fieldtype === 'Link' && field.options !== 'User') {
|
||||
if (!field.create) {
|
||||
field.create = (value, field, row, close) => {
|
||||
const callback = (d) => {
|
||||
if (d) fieldChange(d.name, field, row)
|
||||
}
|
||||
createDocument(field.options, value, close, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
@ -361,21 +470,71 @@ const toggleSelectRow = (row) => {
|
||||
|
||||
const addRow = () => {
|
||||
const newRow = {}
|
||||
fields.value?.forEach((field) => {
|
||||
if (field.fieldtype === 'Check') newRow[field.fieldname] = false
|
||||
else newRow[field.fieldname] = ''
|
||||
allFields.value?.forEach((field) => {
|
||||
if (field.fieldtype === 'Check') {
|
||||
newRow[field.fieldname] = false
|
||||
} else {
|
||||
newRow[field.fieldname] = ''
|
||||
}
|
||||
|
||||
if (field.default) {
|
||||
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
|
||||
}
|
||||
})
|
||||
newRow.name = getRandom(10)
|
||||
showRowList.value.push(false)
|
||||
newRow['__islocal'] = true
|
||||
newRow['idx'] = rows.value.length + 1
|
||||
newRow['doctype'] = props.doctype
|
||||
newRow['parentfield'] = props.parentFieldname
|
||||
newRow['parenttype'] = props.parentDoctype
|
||||
rows.value.push(newRow)
|
||||
triggerOnRowAdd(newRow)
|
||||
}
|
||||
|
||||
const deleteRows = () => {
|
||||
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
|
||||
triggerOnRowRemove(selectedRows, rows.value)
|
||||
|
||||
showRowList.value.pop()
|
||||
selectedRows.clear()
|
||||
}
|
||||
|
||||
function fieldChange(value, field, row) {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -139,9 +139,14 @@ const oldFields = computed(() => {
|
||||
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
|
||||
|
||||
const dropdownFields = computed(() => {
|
||||
return getFields()?.filter(
|
||||
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
|
||||
)
|
||||
return getFields()?.filter((field) => {
|
||||
return (
|
||||
!fields.value.find((f) => f.fieldname === field.fieldname) &&
|
||||
!['Tab Break', 'Section Break', 'Column Break', 'Table'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function reset() {
|
||||
|
||||
@ -23,7 +23,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data" />
|
||||
<FieldLayout
|
||||
v-if="tabs.data"
|
||||
:tabs="tabs.data"
|
||||
:data="data"
|
||||
:doctype="doctype"
|
||||
:isGridRow="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -159,6 +159,7 @@ const options = createResource({
|
||||
})
|
||||
|
||||
function reload(val) {
|
||||
if (!props.doctype) return
|
||||
if (
|
||||
options.data?.length &&
|
||||
val === options.params?.txt &&
|
||||
|
||||
@ -60,6 +60,8 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const { getFields } = getMeta(props.doctype)
|
||||
|
||||
const values = defineModel()
|
||||
@ -109,14 +111,16 @@ const addValue = (value) => {
|
||||
|
||||
if (value) {
|
||||
values.value.push({ [linkField.value.fieldname]: value })
|
||||
emit('change', values.value)
|
||||
!error.value && (query.value = '')
|
||||
}
|
||||
}
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter(
|
||||
let _value = values.value.filter(
|
||||
(row) => row[linkField.value.fieldname] !== value,
|
||||
)
|
||||
emit('change', _value)
|
||||
}
|
||||
|
||||
const removeLastValue = () => {
|
||||
@ -125,12 +129,11 @@ const removeLastValue = () => {
|
||||
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
|
||||
if (document.activeElement === valueRef) {
|
||||
values.value.pop()
|
||||
emit('change', values.value)
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
|
||||
valueRef?.focus()
|
||||
} else {
|
||||
setFocus()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
|
||||
@ -37,8 +37,8 @@ import { isMobileView } from '@/composables/settings'
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Object,
|
||||
required: true,
|
||||
type: [Object, Array, undefined],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
@ -85,7 +85,7 @@ const groupedActions = computed(() => {
|
||||
})
|
||||
}
|
||||
_actions = _actions.concat(
|
||||
props.actions.filter((action) => action.group && !action.buttonLabel)
|
||||
props.actions.filter((action) => action.group && !action.buttonLabel),
|
||||
)
|
||||
return _actions
|
||||
})
|
||||
|
||||
24
frontend/src/components/ErrorPage.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid h-full place-items-center px-4 py-20 text-center text-lg text-ink-gray-5"
|
||||
>
|
||||
<div class="flex flex-col justify-between items-center gap-3">
|
||||
<FeatherIcon name="x-octagon" class="h-12 w-12 text-ink-red-3" />
|
||||
<div class="text-2xl font-semibold">{{ errorTitle }}</div>
|
||||
<div v-html="errorMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
errorTitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@ -7,22 +7,30 @@
|
||||
field.reqd ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-3"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.fieldtype !== 'Check'"
|
||||
v-if="
|
||||
field.read_only &&
|
||||
!['Int', 'Float', 'Currency', 'Percent', 'Check'].includes(
|
||||
field.fieldtype,
|
||||
)
|
||||
"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:disabled="true"
|
||||
:description="field.description"
|
||||
/>
|
||||
<Grid
|
||||
v-else-if="field.fieldtype === 'Table'"
|
||||
v-model="data[field.fieldname]"
|
||||
v-model:parent="data"
|
||||
:doctype="field.options"
|
||||
:parentDoctype="doctype"
|
||||
:parentFieldname="field.fieldname"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
@ -31,7 +39,9 @@
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.fieldname]"
|
||||
@change="(e) => fieldChange(e.target.value, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:description="field.description"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
@ -42,8 +52,9 @@
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.fieldname]"
|
||||
@change="(e) => (data[field.fieldname] = e.target.checked)"
|
||||
@change="(e) => fieldChange(e.target.checked, field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-ink-gray-5"
|
||||
@ -59,13 +70,18 @@
|
||||
<span class="text-ink-red-3" v-if="field.mandatory">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-1" v-else-if="field.fieldtype === 'Link'">
|
||||
<div
|
||||
class="flex gap-1"
|
||||
v-else-if="['Link', 'Dynamic Link'].includes(field.fieldtype)"
|
||||
>
|
||||
<Link
|
||||
class="form-control flex-1 truncate"
|
||||
:value="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link' ? field.options : data[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
@ -85,6 +101,7 @@
|
||||
v-else-if="field.fieldtype === 'Table MultiSelect'"
|
||||
v-model="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
|
||||
<Link
|
||||
@ -93,7 +110,7 @@
|
||||
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.fieldname] = v)"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:hideMe="true"
|
||||
>
|
||||
@ -118,80 +135,95 @@
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.fieldtype === 'Datetime'"
|
||||
v-model="data[field.fieldname]"
|
||||
:value="data[field.fieldname]"
|
||||
icon-left=""
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.fieldtype === 'Date'"
|
||||
icon-left=""
|
||||
v-model="data[field.fieldname]"
|
||||
:value="data[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
|
||||
"
|
||||
type="textarea"
|
||||
:value="data[field.fieldname]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="['Int'].includes(field.fieldtype)"
|
||||
type="number"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:value="data[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.fieldname, data)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.fieldname, data)"
|
||||
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
@change="data[field.fieldname] = flt($event.target.value)"
|
||||
:description="field.description"
|
||||
@change="fieldChange(flt($event.target.value), field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.fieldname]"
|
||||
:value="data[field.fieldname]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
:description="field.description"
|
||||
@change="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import Grid from '@/components/Controls/Grid.vue'
|
||||
import { createDocument } from '@/composables/document'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed, provide, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
field: Object,
|
||||
@ -200,11 +232,32 @@ const props = defineProps({
|
||||
const data = inject('data')
|
||||
const doctype = inject('doctype')
|
||||
const preview = inject('preview')
|
||||
const isGridRow = inject('isGridRow')
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(doctype)
|
||||
|
||||
const { getUser } = usersStore()
|
||||
|
||||
let triggerOnChange
|
||||
let parentDoc
|
||||
|
||||
if (!isGridRow) {
|
||||
const {
|
||||
triggerOnChange: trigger,
|
||||
triggerOnRowAdd,
|
||||
triggerOnRowRemove,
|
||||
} = useDocument(doctype, data.value.name)
|
||||
triggerOnChange = trigger
|
||||
|
||||
provide('triggerOnChange', triggerOnChange)
|
||||
provide('triggerOnRowAdd', triggerOnRowAdd)
|
||||
provide('triggerOnRowRemove', triggerOnRowRemove)
|
||||
} else {
|
||||
triggerOnChange = inject('triggerOnChange')
|
||||
parentDoc = inject('parentDoc')
|
||||
}
|
||||
|
||||
const field = computed(() => {
|
||||
let field = props.field
|
||||
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
|
||||
@ -221,6 +274,17 @@ const field = computed(() => {
|
||||
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 = {
|
||||
...field,
|
||||
filters: field.link_filters && JSON.parse(field.link_filters),
|
||||
@ -260,6 +324,16 @@ const getPlaceholder = (field) => {
|
||||
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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
:deep(.form-control.prefix select) {
|
||||
|
||||
@ -34,6 +34,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
},
|
||||
isGridRow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -55,6 +59,7 @@ provide(
|
||||
provide('hasTabs', hasTabs)
|
||||
provide('doctype', props.doctype)
|
||||
provide('preview', props.preview)
|
||||
provide('isGridRow', props.isGridRow)
|
||||
</script>
|
||||
<style scoped>
|
||||
.section:not(:has(.field)) {
|
||||
|
||||
@ -277,13 +277,13 @@ const fields = createResource({
|
||||
]
|
||||
let existingFields = []
|
||||
|
||||
for (let tab of props.tabs) {
|
||||
for (let section of tab.sections) {
|
||||
for (let column of section.columns) {
|
||||
props.tabs?.forEach((tab) => {
|
||||
tab.sections?.forEach((section) => {
|
||||
section.columns?.forEach((column) => {
|
||||
existingFields = existingFields.concat(column.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return data.filter((field) => {
|
||||
return (
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
<AppHeader />
|
||||
<slot />
|
||||
</div>
|
||||
<GlobalModals />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppSidebar from '@/components/Layouts/AppSidebar.vue'
|
||||
import AppHeader from '@/components/Layouts/AppHeader.vue'
|
||||
import GlobalModals from '@/components/Modals/GlobalModals.vue'
|
||||
</script>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
}"
|
||||
row-key="name"
|
||||
v-bind="$attrs"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -205,6 +206,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="mx-3 sm:mx-5"
|
||||
@ -201,6 +202,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -245,6 +246,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -191,6 +192,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -250,7 +251,6 @@ const props = defineProps({
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'loadMore',
|
||||
'updatePageCount',
|
||||
@ -258,6 +258,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="sm:mx-5 mx-3"
|
||||
@ -186,6 +187,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
resizeColumn: options.resizeColumn,
|
||||
}"
|
||||
row-key="name"
|
||||
@update:selections="(selections) => emit('selectionsChanged', selections)"
|
||||
>
|
||||
<ListHeader
|
||||
class="mx-3 sm:mx-5"
|
||||
@ -207,6 +208,7 @@ const emit = defineEmits([
|
||||
'applyFilter',
|
||||
'applyLikeFilter',
|
||||
'likeDoc',
|
||||
'selectionsChanged',
|
||||
])
|
||||
|
||||
const pageLengthCount = defineModel()
|
||||
|
||||
@ -1,55 +1,36 @@
|
||||
<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 class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<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 v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
|
||||
<EditIcon class="w-4 h-4" />
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tabs.data">
|
||||
<FieldLayout
|
||||
:tabs="tabs.data"
|
||||
:data="_callLog"
|
||||
doctype="CRM Call Log"
|
||||
/>
|
||||
<ErrorMessage class="mt-2" :message="error" />
|
||||
<FieldLayout :tabs="tabs.data" :data="_callLog" doctype="CRM Call Log" />
|
||||
<ErrorMessage class="mt-8" :message="error" />
|
||||
</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">
|
||||
<Button
|
||||
class="w-full"
|
||||
v-for="action in dialogOptions.actions"
|
||||
:key="action.label"
|
||||
v-bind="action"
|
||||
:label="__(action.label)"
|
||||
:loading="loading"
|
||||
/>
|
||||
<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>
|
||||
<QuickEntryModal
|
||||
v-if="showQuickEntryModal"
|
||||
v-model="showQuickEntryModal"
|
||||
doctype="CRM Call Log"
|
||||
/>
|
||||
<QuickEntryModal v-if="showQuickEntryModal" v-model="showQuickEntryModal" doctype="CRM Call Log" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -67,7 +48,7 @@ const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
default: {
|
||||
afterInsert: () => {},
|
||||
afterInsert: () => { },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -175,6 +156,13 @@ const createCallLog = createResource({
|
||||
},
|
||||
onError(err) {
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
34
frontend/src/components/Modals/GlobalModals.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import CreateDocumentModal from '@/components/Modals/CreateDocumentModal.vue'
|
||||
import QuickEntryModal from '@/components/Modals/QuickEntryModal.vue'
|
||||
import {
|
||||
showCreateDocumentModal,
|
||||
createDocumentDoctype,
|
||||
createDocumentData,
|
||||
createDocumentCallback,
|
||||
} from '@/composables/document'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showQuickEntryModal = ref(false)
|
||||
const quickEntryDoctype = ref('')
|
||||
|
||||
function openQuickEntryModal(dt) {
|
||||
showQuickEntryModal.value = true
|
||||
quickEntryDoctype.value = dt
|
||||
}
|
||||
</script>
|
||||
@ -1,34 +1,25 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateNote(),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<Dialog v-model="show" :options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateNote(),
|
||||
},
|
||||
],
|
||||
}">
|
||||
<template #body-title>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ editMode ? __('Edit Note') : __('Create Note') }}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="_note?.reference_docname"
|
||||
size="sm"
|
||||
:label="
|
||||
_note.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
"
|
||||
@click="redirect()"
|
||||
>
|
||||
<Button v-if="_note?.reference_docname" size="sm" :label="_note.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
" @click="redirect()">
|
||||
<template #suffix>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
<ArrowUpRightIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -36,27 +27,17 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormControl
|
||||
ref="title"
|
||||
:label="__('Title')"
|
||||
v-model="_note.title"
|
||||
:placeholder="__('Call with John Doe')"
|
||||
/>
|
||||
<FormControl ref="title" :label="__('Title')" v-model="_note.title" :placeholder="__('Call with John Doe')"
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">{{ __('Content') }}</div>
|
||||
<TextEditor
|
||||
variant="outline"
|
||||
ref="content"
|
||||
<TextEditor 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"
|
||||
:bubbleMenu="true"
|
||||
:content="_note.content"
|
||||
@change="(val) => (_note.content = val)"
|
||||
:placeholder="
|
||||
__('Took a call with John Doe and discussed the new project.')
|
||||
"
|
||||
/>
|
||||
:bubbleMenu="true" :content="_note.content" @change="(val) => (_note.content = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
|
||||
" />
|
||||
</div>
|
||||
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@ -94,17 +75,12 @@ const router = useRouter()
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
|
||||
const error = ref(null)
|
||||
const title = ref(null)
|
||||
const editMode = ref(false)
|
||||
let _note = ref({})
|
||||
|
||||
async function updateNote() {
|
||||
if (
|
||||
props.note.title === _note.value.title &&
|
||||
props.note.content === _note.value.content
|
||||
)
|
||||
return
|
||||
|
||||
if (_note.value.name) {
|
||||
let d = await call('frappe.client.set_value', {
|
||||
doctype: 'FCRM Note',
|
||||
@ -124,6 +100,12 @@ async function updateNote() {
|
||||
reference_doctype: props.doctype,
|
||||
reference_docname: props.doc || '',
|
||||
},
|
||||
}, {
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'MandatoryError') {
|
||||
error.value = "Title is mandatory"
|
||||
}
|
||||
}
|
||||
})
|
||||
if (d.name) {
|
||||
updateOnboardingStep('create_first_note')
|
||||
|
||||
@ -1,43 +1,28 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ size: 'xl' }">
|
||||
<template #body>
|
||||
<div class="bg-surface-modal px-4 pb-6 pt-5 sm:px-6">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="px-4 pt-5 pb-6 bg-surface-modal sm:px-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ __('New Organization') }}
|
||||
</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 v-if="isManager() && !isMobileView" variant="ghost" class="w-7" @click="openQuickEntryModal">
|
||||
<EditIcon class="w-4 h-4" />
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<FieldLayout
|
||||
v-if="tabs.data?.length"
|
||||
:tabs="tabs.data"
|
||||
:data="_organization"
|
||||
doctype="CRM Organization"
|
||||
/>
|
||||
<FieldLayout v-if="tabs.data?.length" :tabs="tabs.data" :data="_organization" doctype="CRM Organization" />
|
||||
<ErrorMessage class="mt-8" v-if="error" :message="__(error)" />
|
||||
</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">
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="solid"
|
||||
:label="__('Create')"
|
||||
:loading="loading"
|
||||
@click="createOrganization"
|
||||
/>
|
||||
<Button class="w-full" variant="solid" :label="__('Create')" :loading="loading" @click="createOrganization" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -59,7 +44,7 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: {
|
||||
redirect: true,
|
||||
afterInsert: () => {},
|
||||
afterInsert: () => { },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -84,6 +69,7 @@ let _organization = ref({
|
||||
})
|
||||
|
||||
let doc = ref({})
|
||||
const error = ref(null)
|
||||
|
||||
async function createOrganization() {
|
||||
const doc = await call('frappe.client.insert', {
|
||||
@ -91,6 +77,12 @@ async function createOrganization() {
|
||||
doctype: 'CRM Organization',
|
||||
..._organization.value,
|
||||
},
|
||||
}, {
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'ValidationError') {
|
||||
error.value = err.error?.messages?.[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
loading.value = false
|
||||
if (doc.name) {
|
||||
|
||||
@ -38,9 +38,9 @@
|
||||
/>
|
||||
<div v-if="preview" class="flex flex-1 flex-col border rounded">
|
||||
<SidePanelLayout
|
||||
v-model="data"
|
||||
:sections="tabs.data[0].sections"
|
||||
:doctype="_doctype"
|
||||
docname=""
|
||||
:preview="true"
|
||||
v-slot="{ section }"
|
||||
>
|
||||
|
||||
@ -1,34 +1,25 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateTask(),
|
||||
},
|
||||
],
|
||||
}"
|
||||
>
|
||||
<Dialog v-model="show" :options="{
|
||||
size: 'xl',
|
||||
actions: [
|
||||
{
|
||||
label: editMode ? __('Update') : __('Create'),
|
||||
variant: 'solid',
|
||||
onClick: () => updateTask(),
|
||||
},
|
||||
],
|
||||
}">
|
||||
<template #body-title>
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||
{{ editMode ? __('Edit Task') : __('Create Task') }}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="task?.reference_docname"
|
||||
size="sm"
|
||||
:label="
|
||||
task.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
"
|
||||
@click="redirect()"
|
||||
>
|
||||
<Button v-if="task?.reference_docname" size="sm" :label="task.reference_doctype == 'CRM Deal'
|
||||
? __('Open Deal')
|
||||
: __('Open Lead')
|
||||
" @click="redirect()">
|
||||
<template #suffix>
|
||||
<ArrowUpRightIcon class="h-4 w-4" />
|
||||
<ArrowUpRightIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@ -36,74 +27,53 @@
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<FormControl
|
||||
ref="title"
|
||||
:label="__('Title')"
|
||||
v-model="_task.title"
|
||||
:placeholder="__('Call with John Doe')"
|
||||
/>
|
||||
<FormControl ref="title" :label="__('Title')" v-model="_task.title" :placeholder="__('Call with John Doe')"
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1.5 text-xs text-ink-gray-5">
|
||||
{{ __('Description') }}
|
||||
</div>
|
||||
<TextEditor
|
||||
variant="outline"
|
||||
ref="description"
|
||||
<TextEditor 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"
|
||||
:bubbleMenu="true"
|
||||
:content="_task.description"
|
||||
@change="(val) => (_task.description = val)"
|
||||
:placeholder="
|
||||
__('Took a call with John Doe and discussed the new project.')
|
||||
"
|
||||
/>
|
||||
:bubbleMenu="true" :content="_task.description" @change="(val) => (_task.description = val)" :placeholder="__('Took a call with John Doe and discussed the new project.')
|
||||
" />
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
|
||||
<Button :label="_task.status" class="w-full justify-between">
|
||||
<Button :label="_task.status" class="justify-between w-full">
|
||||
<template #prefix>
|
||||
<TaskStatusIcon :status="_task.status" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Link
|
||||
class="form-control"
|
||||
:value="getUser(_task.assigned_to).full_name"
|
||||
doctype="User"
|
||||
@change="(option) => (_task.assigned_to = option)"
|
||||
:placeholder="__('John Doe')"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<Link class="form-control" :value="getUser(_task.assigned_to).full_name" doctype="User"
|
||||
@change="(option) => (_task.assigned_to = option)" :placeholder="__('John Doe')" :hideMe="true">
|
||||
<template #prefix>
|
||||
<UserAvatar class="mr-2 !h-4 !w-4" :user="_task.assigned_to" />
|
||||
</template>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :user="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
<div class="cursor-pointer text-ink-gray-9">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
class="datepicker w-36"
|
||||
v-model="_task.due_date"
|
||||
:placeholder="__('01/04/2024 11:30 PM')"
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<DateTimePicker class="datepicker w-36" 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)">
|
||||
<Button :label="_task.priority" class="w-full justify-between">
|
||||
<Button :label="_task.priority" class="justify-between w-full">
|
||||
<template #prefix>
|
||||
<TaskPriorityIcon :priority="_task.priority" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
@ -147,6 +117,7 @@ const router = useRouter()
|
||||
const { getUser } = usersStore()
|
||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||
|
||||
const error = ref(null)
|
||||
const title = ref(null)
|
||||
const editMode = ref(false)
|
||||
const _task = ref({
|
||||
@ -200,6 +171,12 @@ async function updateTask() {
|
||||
reference_docname: props.doc || null,
|
||||
..._task.value,
|
||||
},
|
||||
}, {
|
||||
onError: (err) => {
|
||||
if (err.error.exc_type == 'MandatoryError') {
|
||||
error.value = "Title is mandatory"
|
||||
}
|
||||
}
|
||||
})
|
||||
if (d.name) {
|
||||
updateOnboardingStep('create_first_task')
|
||||
|
||||
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
@ -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
@ -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>
|
||||
163
frontend/src/components/Settings/EmailAdd.vue
Normal file
@ -0,0 +1,163 @@
|
||||
<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 } from 'frappe-ui'
|
||||
import CircleAlert from '~icons/lucide/circle-alert'
|
||||
import { createToast } from '@/utils'
|
||||
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: () => {
|
||||
createToast({
|
||||
title: __('Email account created successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
})
|
||||
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
@ -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>
|
||||
224
frontend/src/components/Settings/EmailEdit.vue
Normal file
@ -0,0 +1,224 @@
|
||||
<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 } from 'frappe-ui'
|
||||
import EmailProviderIcon from './EmailProviderIcon.vue'
|
||||
import {
|
||||
emailIcon,
|
||||
services,
|
||||
popularProviderFields,
|
||||
customProviderFields,
|
||||
validateInputs,
|
||||
incomingOutgoingFields,
|
||||
} from './emailConfig'
|
||||
import { createToast } from '@/utils'
|
||||
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) {
|
||||
createToast({
|
||||
title: __('No changes made'),
|
||||
icon: 'info',
|
||||
iconClasses: 'text-blue-600',
|
||||
})
|
||||
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')
|
||||
createToast({
|
||||
title: __('Email account updated successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
})
|
||||
}
|
||||
|
||||
function errorHandler() {
|
||||
loading.value = false
|
||||
error.value = __('Failed to update email account, Invalid credentials')
|
||||
}
|
||||
</script>
|
||||
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"
|
||||
doctype="CRM Dropdown Item"
|
||||
parentDoctype="FCRM Settings"
|
||||
parentFieldname="dropdown_items"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex h-[calc(100vh_-_8rem)]">
|
||||
<div class="flex w-52 shrink-0 flex-col bg-surface-gray-2 p-2">
|
||||
<h1 class="mb-3 px-2 pt-2 text-lg font-semibold text-ink-gray-9">
|
||||
<div class="flex flex-col p-2 w-52 shrink-0 bg-surface-gray-2">
|
||||
<h1 class="px-2 pt-2 mb-3 text-lg font-semibold text-ink-gray-9">
|
||||
{{ __('Settings') }}
|
||||
</h1>
|
||||
<div v-for="tab in tabs">
|
||||
@ -34,7 +34,7 @@
|
||||
</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
|
||||
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 PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import InviteIcon from '@/components/Icons/InviteIcon.vue'
|
||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||
import GeneralSettings from '@/components/Settings/GeneralSettings.vue'
|
||||
import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
|
||||
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
|
||||
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
|
||||
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
|
||||
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
|
||||
import EmailConfig from '@/components/Settings/EmailConfig.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import {
|
||||
@ -101,6 +103,12 @@ const tabs = computed(() => {
|
||||
component: markRaw(InviteMemberPage),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
{
|
||||
label: __('Email Accounts'),
|
||||
icon: Email2Icon,
|
||||
component: markRaw(EmailConfig),
|
||||
condition: () => isManager(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
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>
|
||||
<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">
|
||||
<div v-if="section.visible" class="section flex flex-col">
|
||||
<div
|
||||
@ -50,7 +53,7 @@
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
class="text-ink-red-3"
|
||||
class="text-ink-red-2"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
@ -62,26 +65,33 @@
|
||||
<div
|
||||
v-if="
|
||||
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"
|
||||
>
|
||||
<Tooltip :text="__(field.tooltip)">
|
||||
<div>{{ data[field.fieldname] }}</div>
|
||||
<div>{{ document.doc[field.fieldname] }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-else-if="field.fieldtype === 'Dropdown'">
|
||||
<NestedPopover>
|
||||
<template #target="{ open }">
|
||||
<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"
|
||||
>
|
||||
<div
|
||||
v-if="data[field.fieldname]"
|
||||
v-if="document.doc[field.fieldname]"
|
||||
class="truncate"
|
||||
>
|
||||
{{ data[field.fieldname] }}
|
||||
{{ document.doc[field.fieldname] }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@ -138,13 +148,9 @@
|
||||
v-else-if="field.fieldtype == 'Check'"
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.fieldname]"
|
||||
v-model="document.doc[field.fieldname]"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
$event.target.checked,
|
||||
)
|
||||
fieldChange($event.target.checked, field)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
@ -159,43 +165,40 @@
|
||||
"
|
||||
class="form-control"
|
||||
type="textarea"
|
||||
:value="data[field.fieldname]"
|
||||
:value="document.doc[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
@change.stop="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.fieldtype === 'Select'"
|
||||
class="form-control cursor-pointer [&_select]:cursor-pointer truncate"
|
||||
type="select"
|
||||
v-model="data[field.fieldname]"
|
||||
v-model="document.doc[field.fieldname]"
|
||||
:options="field.options"
|
||||
:placeholder="field.placeholder"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
@change.stop="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'User'"
|
||||
class="form-control"
|
||||
:value="
|
||||
data[field.fieldname] &&
|
||||
getUser(data[field.fieldname]).full_name
|
||||
document.doc[field.fieldname] &&
|
||||
getUser(document.doc[field.fieldname]).full_name
|
||||
"
|
||||
doctype="User"
|
||||
:filters="field.filters"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:placeholder="'Select' + ' ' + field.label + '...'"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template v-if="data[field.fieldname]" #prefix>
|
||||
<template
|
||||
v-if="document.doc[field.fieldname]"
|
||||
#prefix
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-1.5"
|
||||
:user="data[field.fieldname]"
|
||||
:user="document.doc[field.fieldname]"
|
||||
size="sm"
|
||||
/>
|
||||
</template>
|
||||
@ -215,15 +218,19 @@
|
||||
</template>
|
||||
</Link>
|
||||
<Link
|
||||
v-else-if="field.fieldtype === 'Link'"
|
||||
v-else-if="
|
||||
['Link', 'Dynamic Link'].includes(field.fieldtype)
|
||||
"
|
||||
class="form-control select-text"
|
||||
:value="data[field.fieldname]"
|
||||
:doctype="field.options"
|
||||
:value="document.doc[field.fieldname]"
|
||||
:doctype="
|
||||
field.fieldtype == 'Link'
|
||||
? field.options
|
||||
: document.doc[field.options]
|
||||
"
|
||||
:filters="field.filters"
|
||||
:placeholder="field.placeholder"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<div
|
||||
@ -232,15 +239,13 @@
|
||||
>
|
||||
<DateTimePicker
|
||||
icon-left=""
|
||||
:value="data[field.fieldname]"
|
||||
:value="document.doc[field.fieldname]"
|
||||
:formatter="
|
||||
(date) => getFormat(date, '', true, true)
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@ -249,81 +254,73 @@
|
||||
>
|
||||
<DatePicker
|
||||
icon-left=""
|
||||
:value="data[field.fieldname]"
|
||||
:value="document.doc[field.fieldname]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="field.placeholder"
|
||||
placement="left-start"
|
||||
@change="
|
||||
(data) => emit('update', field.fieldname, data)
|
||||
"
|
||||
@change="(v) => fieldChange(v, field)"
|
||||
/>
|
||||
</div>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Percent'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedPercent(field.fieldname, data)"
|
||||
:value="
|
||||
getFormattedPercent(field.fieldname, document.doc)
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
fieldChange(flt($event.target.value), field)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Int'"
|
||||
class="form-control"
|
||||
type="number"
|
||||
v-model="data[field.fieldname]"
|
||||
type="text"
|
||||
v-model="document.doc[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
@change.stop="fieldChange($event.target.value, field)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Float'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedFloat(field.fieldname, data)"
|
||||
:value="
|
||||
getFormattedFloat(field.fieldname, document.doc)
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
fieldChange(flt($event.target.value), field)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
<FormattedInput
|
||||
v-else-if="field.fieldtype === 'Currency'"
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="getFormattedCurrency(field.fieldname, data)"
|
||||
:value="
|
||||
getFormattedCurrency(field.fieldname, document.doc)
|
||||
"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit(
|
||||
'update',
|
||||
field.fieldname,
|
||||
flt($event.target.value),
|
||||
)
|
||||
fieldChange(flt($event.target.value), field)
|
||||
"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
class="form-control"
|
||||
type="text"
|
||||
:value="data[field.fieldname]"
|
||||
:value="document.doc[field.fieldname]"
|
||||
:placeholder="field.placeholder"
|
||||
:debounce="500"
|
||||
@change.stop="
|
||||
emit('update', field.fieldname, $event.target.value)
|
||||
"
|
||||
@change.stop="fieldChange($event.target.value, field)"
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-1">
|
||||
@ -331,19 +328,23 @@
|
||||
v-if="
|
||||
field.fieldtype === '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"
|
||||
@click.stop="field.link(data[field.fieldname])"
|
||||
@click.stop="
|
||||
field.link(document.doc[field.fieldname])
|
||||
"
|
||||
/>
|
||||
<EditIcon
|
||||
v-if="
|
||||
field.fieldtype === 'Link' &&
|
||||
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"
|
||||
@click.stop="field.edit(data[field.fieldname])"
|
||||
@click.stop="
|
||||
field.edit(document.doc[field.fieldname])
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -365,6 +366,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import FormattedInput from '@/components/Controls/FormattedInput.vue'
|
||||
import Section from '@/components/Section.vue'
|
||||
import NestedPopover from '@/components/NestedPopover.vue'
|
||||
import DropdownItem from '@/components/DropdownItem.vue'
|
||||
@ -380,6 +382,7 @@ import { isMobileView } from '@/composables/settings'
|
||||
import { getFormat, evaluateDependsOnValue } from '@/utils'
|
||||
import { flt } from '@/utils/numberFormat.js'
|
||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||
import { useDocument } from '@/data/document'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
@ -389,6 +392,11 @@ const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: 'CRM Lead',
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
preview: {
|
||||
type: Boolean,
|
||||
@ -401,13 +409,22 @@ const props = defineProps({
|
||||
|
||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||
getMeta(props.doctype)
|
||||
|
||||
const { isManager, getUser } = usersStore()
|
||||
|
||||
const emit = defineEmits(['update', 'reload'])
|
||||
const emit = defineEmits(['reload'])
|
||||
|
||||
const data = defineModel()
|
||||
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(() => {
|
||||
if (!props.sections?.length) return []
|
||||
let editButtonAdded = false
|
||||
@ -447,11 +464,11 @@ function parsedField(field) {
|
||||
placeholder: field.placeholder || field.label,
|
||||
display_via_depends_on: evaluateDependsOnValue(
|
||||
field.depends_on,
|
||||
data.value,
|
||||
document.doc,
|
||||
),
|
||||
mandatory_via_depends_on: evaluateDependsOnValue(
|
||||
field.mandatory_depends_on,
|
||||
data.value,
|
||||
document.doc,
|
||||
),
|
||||
}
|
||||
|
||||
@ -459,6 +476,16 @@ function parsedField(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) {
|
||||
let isContactSection = section.name == 'contacts_section'
|
||||
section.showEditButton = !(
|
||||
@ -479,7 +506,7 @@ function isFieldVisible(field) {
|
||||
if (props.preview) return true
|
||||
return (
|
||||
(field.fieldtype == 'Check' ||
|
||||
(field.read_only && data.value[field.fieldname]) ||
|
||||
(field.read_only && document.doc?.[field.fieldname]) ||
|
||||
!field.read_only) &&
|
||||
(!field.depends_on || field.display_via_depends_on) &&
|
||||
!field.hidden
|
||||
|
||||
@ -545,6 +545,11 @@ function reload() {
|
||||
const showExportDialog = ref(false)
|
||||
const export_type = ref('Excel')
|
||||
const export_all = ref(false)
|
||||
const selectedRows = ref([])
|
||||
|
||||
function updateSelections(selections) {
|
||||
selectedRows.value = Array.from(selections)
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
let fields = JSON.stringify(list.value.data.columns.map((f) => f.key))
|
||||
@ -560,7 +565,15 @@ async function exportRows() {
|
||||
page_length = list.value.data.total_count
|
||||
}
|
||||
|
||||
window.location.href = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${filters}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
|
||||
let url = `/api/method/frappe.desk.reportview.export_query?file_format_type=${export_type.value}&title=${props.doctype}&doctype=${props.doctype}&fields=${fields}&filters=${filters}&order_by=${order_by}&page_length=${page_length}&start=0&view=Report&with_comment_count=1`
|
||||
|
||||
// Add selected items parameter if rows are selected
|
||||
if (selectedRows.value?.length && !export_all.value) {
|
||||
url += `&selected_items=${JSON.stringify(selectedRows.value)}`
|
||||
}
|
||||
|
||||
window.location.href = url
|
||||
|
||||
showExportDialog.value = false
|
||||
export_all.value = false
|
||||
export_type.value = 'Excel'
|
||||
@ -1336,6 +1349,7 @@ defineExpose({
|
||||
viewActions,
|
||||
viewsDropdownOptions,
|
||||
currentView,
|
||||
updateSelections,
|
||||
})
|
||||
|
||||
// Watchers
|
||||
|
||||
16
frontend/src/composables/document.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const showCreateDocumentModal = ref(false)
|
||||
export const createDocumentDoctype = ref('')
|
||||
export const createDocumentData = ref({})
|
||||
export const createDocumentCallback = ref(null)
|
||||
|
||||
export function createDocument(doctype, obj, close, callback) {
|
||||
if (doctype) {
|
||||
close?.()
|
||||
createDocumentDoctype.value = doctype
|
||||
createDocumentData.value = obj || {}
|
||||
createDocumentCallback.value = callback || null
|
||||
showCreateDocumentModal.value = true
|
||||
}
|
||||
}
|
||||
142
frontend/src/data/document.js
Normal file
@ -0,0 +1,142 @@
|
||||
import { getScript } from '@/data/script'
|
||||
import { createToast, runSequentially } from '@/utils'
|
||||
import { createDocumentResource } from 'frappe-ui'
|
||||
|
||||
const documentsCache = {}
|
||||
const controllersCache = {}
|
||||
|
||||
export function useDocument(doctype, docname) {
|
||||
const { setupScript } = getScript(doctype)
|
||||
|
||||
documentsCache[doctype] = documentsCache[doctype] || {}
|
||||
|
||||
if (!documentsCache[doctype][docname]) {
|
||||
documentsCache[doctype][docname] = createDocumentResource({
|
||||
doctype: doctype,
|
||||
name: docname,
|
||||
onSuccess: () => setupFormScript(),
|
||||
setValue: {
|
||||
onSuccess: () => {
|
||||
createToast({
|
||||
title: __('Document updated successfully'),
|
||||
icon: 'check',
|
||||
iconClasses: 'text-ink-green-3',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
createToast({
|
||||
title: __('Error updating document'),
|
||||
text: err.messages[0],
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function setupFormScript() {
|
||||
if (controllersCache[doctype]?.[docname]) return
|
||||
|
||||
if (!controllersCache[doctype]) {
|
||||
controllersCache[doctype] = {}
|
||||
}
|
||||
|
||||
controllersCache[doctype][docname] = setupScript(
|
||||
documentsCache[doctype][docname],
|
||||
)
|
||||
}
|
||||
|
||||
function getControllers(row = null) {
|
||||
const _doctype = row?.doctype || doctype
|
||||
return (controllersCache[doctype]?.[docname] || []).filter(
|
||||
(c) => c.constructor.name === _doctype.replace(/\s+/g, ''),
|
||||
)
|
||||
}
|
||||
|
||||
async function triggerOnRefresh() {
|
||||
const handler = async function () {
|
||||
await this.refresh()
|
||||
}
|
||||
await trigger(handler)
|
||||
}
|
||||
|
||||
async function triggerOnChange(fieldname, row) {
|
||||
const handler = async function () {
|
||||
if (row) {
|
||||
this.currentRowIdx = row.idx
|
||||
this.value = row[fieldname]
|
||||
this.oldValue = getOldValue(fieldname, row)
|
||||
} else {
|
||||
this.value = documentsCache[doctype][docname].doc[fieldname]
|
||||
this.oldValue = getOldValue(fieldname)
|
||||
}
|
||||
await this[fieldname]?.()
|
||||
}
|
||||
|
||||
await trigger(handler, row)
|
||||
}
|
||||
|
||||
async function triggerOnRowAdd(row) {
|
||||
const handler = async function () {
|
||||
this.currentRowIdx = row.idx
|
||||
this.value = row
|
||||
await this[row.parentfield + '_add']?.()
|
||||
}
|
||||
|
||||
await trigger(handler, row)
|
||||
}
|
||||
|
||||
async function triggerOnRowRemove(selectedRows, rows) {
|
||||
const handler = async function () {
|
||||
if (selectedRows.size === 1) {
|
||||
const selectedRow = Array.from(selectedRows)[0]
|
||||
this.currentRowIdx = rows.find((r) => r.name === selectedRow).idx
|
||||
} else {
|
||||
delete this.currentRowIdx
|
||||
}
|
||||
|
||||
this.selectedRows = Array.from(selectedRows)
|
||||
this.rows = rows
|
||||
|
||||
await this[rows[0].parentfield + '_remove']?.()
|
||||
}
|
||||
|
||||
await trigger(handler, rows[0])
|
||||
}
|
||||
|
||||
async function trigger(taskFn, row = null) {
|
||||
const controllers = getControllers(row)
|
||||
if (!controllers.length) return
|
||||
|
||||
const tasks = controllers.map(
|
||||
(controller) => async () => await taskFn.call(controller),
|
||||
)
|
||||
|
||||
await runSequentially(tasks)
|
||||
}
|
||||
|
||||
function getOldValue(fieldname, row) {
|
||||
if (!documentsCache[doctype][docname]) return ''
|
||||
|
||||
const document = documentsCache[doctype][docname]
|
||||
const oldDoc = document.originalDoc
|
||||
|
||||
if (row?.name) {
|
||||
return oldDoc?.[row.parentfield]?.find((r) => r.name === row.name)?.[
|
||||
fieldname
|
||||
]
|
||||
}
|
||||
|
||||
return oldDoc?.[fieldname] || document.doc[fieldname]
|
||||
}
|
||||
|
||||
return {
|
||||
document: documentsCache[doctype][docname],
|
||||
triggerOnChange,
|
||||
triggerOnRowAdd,
|
||||
triggerOnRowRemove,
|
||||
triggerOnRefresh,
|
||||
setupFormScript,
|
||||
}
|
||||
}
|
||||
271
frontend/src/data/script.js
Normal file
@ -0,0 +1,271 @@
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { getMeta } from '@/stores/meta'
|
||||
import { createToast } from '@/utils'
|
||||
import { call, createListResource } from 'frappe-ui'
|
||||
import { reactive } from 'vue'
|
||||
import router from '@/router'
|
||||
|
||||
const doctypeScripts = reactive({})
|
||||
|
||||
export function getScript(doctype, view = 'Form') {
|
||||
const scripts = createListResource({
|
||||
doctype: 'CRM Form Script',
|
||||
cache: ['Form Scripts', doctype, view],
|
||||
fields: ['name', 'dt', 'view', 'script'],
|
||||
filters: { view, dt: doctype, enabled: 1 },
|
||||
onSuccess: (_scripts) => {
|
||||
for (let script of _scripts) {
|
||||
if (!doctypeScripts[doctype]) {
|
||||
doctypeScripts[doctype] = {}
|
||||
}
|
||||
doctypeScripts[doctype][script.name] = script || {}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!doctypeScripts[doctype] && !scripts.loading) {
|
||||
scripts.fetch()
|
||||
}
|
||||
|
||||
function setupScript(document, helpers = {}) {
|
||||
let scripts = doctypeScripts[doctype]
|
||||
if (!scripts) return null
|
||||
|
||||
const { $dialog, $socket, makeCall } = globalStore()
|
||||
|
||||
helpers.createDialog = $dialog
|
||||
helpers.createToast = createToast
|
||||
helpers.socket = $socket
|
||||
helpers.router = router
|
||||
helpers.call = call
|
||||
|
||||
helpers.crm = {
|
||||
makePhoneCall: makeCall,
|
||||
}
|
||||
|
||||
return setupMultipleFormControllers(scripts, document, helpers)
|
||||
}
|
||||
|
||||
function setupMultipleFormControllers(scriptStrings, document, helpers) {
|
||||
const controllers = []
|
||||
let parentInstanceIdx = null
|
||||
|
||||
for (let scriptName in scriptStrings) {
|
||||
let script = scriptStrings[scriptName]?.script
|
||||
if (!script) continue
|
||||
try {
|
||||
const classNames = getClassNames(script)
|
||||
if (!classNames) continue
|
||||
|
||||
classNames.forEach((className) => {
|
||||
const FormClass = evaluateFormClass(script, className, helpers)
|
||||
if (!FormClass) return
|
||||
|
||||
let parentInstance = null
|
||||
let doctypeName = doctype.replace(/\s+/g, '')
|
||||
|
||||
let { doctypeMeta } = getMeta(doctype)
|
||||
|
||||
// if className is not doctype name, then it is a child doctype
|
||||
let isChildDoctype = className !== doctypeName
|
||||
|
||||
if (isChildDoctype) {
|
||||
if (!controllers.length) {
|
||||
console.error(
|
||||
__(
|
||||
'⚠️ No class found for doctype: {0}, it is mandatory to have a class for the parent doctype. it can be empty, but it should be present.',
|
||||
[doctype],
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
parentInstance = controllers[parentInstanceIdx]
|
||||
} else {
|
||||
parentInstanceIdx = controllers.length || 0
|
||||
}
|
||||
|
||||
const instance = setupFormController(
|
||||
FormClass,
|
||||
doctypeMeta,
|
||||
document,
|
||||
parentInstance,
|
||||
isChildDoctype,
|
||||
)
|
||||
|
||||
controllers.push(instance)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(__('Failed to load form controller: {0}', [err]))
|
||||
}
|
||||
}
|
||||
|
||||
return controllers
|
||||
}
|
||||
|
||||
function setupFormController(
|
||||
FormClass,
|
||||
meta,
|
||||
document,
|
||||
parentInstance = null,
|
||||
isChildDoctype = false,
|
||||
) {
|
||||
let instance = new FormClass()
|
||||
|
||||
instance._isChildDoctype = isChildDoctype
|
||||
|
||||
for (const key in document) {
|
||||
if (document.hasOwnProperty(key)) {
|
||||
instance[key] = document[key]
|
||||
}
|
||||
}
|
||||
|
||||
instance.getMeta = async (doctype) => {
|
||||
if (!meta[doctype]) {
|
||||
await getMeta(doctype)
|
||||
return meta[doctype]
|
||||
}
|
||||
return meta[doctype]
|
||||
}
|
||||
|
||||
setupHelperMethods(FormClass, document)
|
||||
|
||||
if (isChildDoctype) {
|
||||
instance.doc = createDocProxy(document.doc, parentInstance, instance)
|
||||
|
||||
if (!parentInstance._childInstances) {
|
||||
parentInstance._childInstances = []
|
||||
}
|
||||
|
||||
parentInstance._childInstances.push(instance)
|
||||
} else {
|
||||
instance.doc = createDocProxy(document.doc, instance)
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
function setupHelperMethods(FormClass, document) {
|
||||
if (typeof FormClass.prototype.getRow !== 'function') {
|
||||
FormClass.prototype.getRow = function (parentField, idx) {
|
||||
let data = document.doc
|
||||
idx = idx || this.currentRowIdx
|
||||
|
||||
let dt = null
|
||||
|
||||
if (this instanceof Array) {
|
||||
const { getFields } = getMeta(data.doctype)
|
||||
let fields = getFields()
|
||||
let field = fields.find((f) => f.fieldname === parentField)
|
||||
dt = field?.options?.replace(/\s+/g, '')
|
||||
|
||||
if (!idx && dt) {
|
||||
idx = this.find((r) => r.constructor.name === dt)?.currentRowIdx
|
||||
}
|
||||
}
|
||||
|
||||
if (!data[parentField]) {
|
||||
console.warn(
|
||||
__('⚠️ No data found for parent field: {0}', [parentField]),
|
||||
)
|
||||
return null
|
||||
}
|
||||
const row = data[parentField].find((r) => r.idx === idx)
|
||||
|
||||
if (!row) {
|
||||
console.warn(
|
||||
__('⚠️ No row found for idx: {0} in parent field: {1}', [
|
||||
idx,
|
||||
parentField,
|
||||
]),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
row.parent = row.parent || data.name
|
||||
|
||||
if (this instanceof Array && dt) {
|
||||
return createDocProxy(
|
||||
row,
|
||||
this.find((r) => r.constructor.name === dt),
|
||||
)
|
||||
}
|
||||
|
||||
return createDocProxy(row, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// utility function to setup a form controller
|
||||
function getClassNames(script) {
|
||||
const withoutComments = script
|
||||
.replace(/\/\/.*$/gm, '') // Remove single-line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // Remove multi-line comments
|
||||
|
||||
// Match class declarations
|
||||
return (
|
||||
[...withoutComments.matchAll(/class\s+([A-Za-z0-9_]+)/g)].map(
|
||||
(match) => match[1],
|
||||
) || []
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateFormClass(script, className, helpers = {}) {
|
||||
const helperKeys = Object.keys(helpers)
|
||||
const helperValues = Object.values(helpers)
|
||||
|
||||
const wrappedScript = `
|
||||
${script}
|
||||
return ${className};
|
||||
`
|
||||
|
||||
const FormClass = new Function(...helperKeys, wrappedScript)(
|
||||
...helperValues,
|
||||
)
|
||||
return FormClass
|
||||
}
|
||||
|
||||
function createDocProxy(data, instance, childInstance = null) {
|
||||
return new Proxy(data, {
|
||||
get(target, prop) {
|
||||
if (prop === 'trigger') {
|
||||
if ('trigger' in data) {
|
||||
console.warn(
|
||||
__(
|
||||
'⚠️ Avoid using "trigger" as a field name — it conflicts with the built-in trigger() method.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return (methodName, ...args) => {
|
||||
const method = instance[methodName]
|
||||
if (typeof method === 'function') {
|
||||
return method.apply(instance, args)
|
||||
} else {
|
||||
console.warn(
|
||||
__('⚠️ Method "{0}" not found in class.', [methodName]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (prop === 'getRow') {
|
||||
return instance.getRow.bind(
|
||||
childInstance || instance._childInstances || instance,
|
||||
)
|
||||
}
|
||||
|
||||
return target[prop]
|
||||
},
|
||||
set(target, prop, value) {
|
||||
target[prop] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
scripts,
|
||||
setupScript,
|
||||
setupFormController,
|
||||
}
|
||||
}
|
||||
4
frontend/src/images/frappe-mail.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5714 44L31.4286 44C38.3716 44 44 38.3716 44 31.4286L44 12.5714C44 5.62842 38.3716 0 31.4286 0L12.5714 0C5.62842 0 0 5.62842 0 12.5714L0 31.4286C0 38.3716 5.62842 44 12.5714 44Z" fill="#0466DC"/>
|
||||
<path d="M9.42859 12.5715V14.8972L12.5714 17.4587L18.5743 22.3458C19.5329 23.1315 20.7586 23.5715 22 23.5715C23.2414 23.5715 24.4672 23.1315 25.4257 22.3458L31.4286 17.443V28.2701H12.5714V21.5287L9.42859 18.9672V27.4844C9.42859 29.653 11.1886 31.413 13.3572 31.413H30.6429C32.8115 31.413 34.5715 29.653 34.5715 27.4844V12.5715H9.42859ZM23.4457 19.9101C22.6286 20.5701 21.3714 20.5701 20.57 19.9101L15.4157 15.7144H28.6L23.4457 19.9101Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 763 B |
BIN
frontend/src/images/gmail.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
frontend/src/images/outlook.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
frontend/src/images/sendgrid.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/images/sparkpost.webp
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
frontend/src/images/yahoo.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/src/images/yandex.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@ -41,6 +41,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-else-if="callLogs.data"
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</Breadcrumbs>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div ref="parentRef" class="flex h-full">
|
||||
<div v-if="contact.data" ref="parentRef" class="flex h-full">
|
||||
<Resizer
|
||||
v-if="contact.data"
|
||||
:parent="$refs.parentRef"
|
||||
@ -121,10 +121,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="contact.data"
|
||||
:sections="sections.data"
|
||||
doctype="Contact"
|
||||
@update="updateField"
|
||||
:docname="contact.data.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
@ -168,10 +167,16 @@
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ErrorPage
|
||||
v-else-if="errorTitle"
|
||||
:errorTitle="errorTitle"
|
||||
:errorMessage="errorMessage"
|
||||
/>
|
||||
<AddressModal v-model="showAddressModal" v-model:address="_address" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
@ -202,6 +207,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { errorMessage as _errorMessage } from '../utils'
|
||||
|
||||
const { brand } = getSettings()
|
||||
const { $dialog, makeCall } = globalStore()
|
||||
@ -225,6 +231,9 @@ const showAddressModal = ref(false)
|
||||
const _contact = ref({})
|
||||
const _address = ref({})
|
||||
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const contact = createResource({
|
||||
url: 'crm.api.contact.get_contact',
|
||||
cache: ['contact', props.contactId],
|
||||
@ -237,6 +246,18 @@ const contact = createResource({
|
||||
mobile_no: data.mobile_no,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
errorTitle.value = ''
|
||||
errorMessage.value = ''
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.messages?.[0]) {
|
||||
errorTitle.value = __('Not permitted')
|
||||
errorMessage.value = __(err.messages?.[0])
|
||||
} else {
|
||||
router.push({ name: 'Contacts' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
|
||||
@ -44,6 +44,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-else-if="contacts.data"
|
||||
|
||||
@ -89,7 +89,7 @@
|
||||
@click="
|
||||
deal.data.email
|
||||
? openEmailBox()
|
||||
: errorMessage(__('No email set'))
|
||||
: _errorMessage(__('No email set'))
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
@ -103,7 +103,7 @@
|
||||
@click="
|
||||
deal.data.website
|
||||
? openWebsite(deal.data.website)
|
||||
: errorMessage(__('No website set'))
|
||||
: _errorMessage(__('No website set'))
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
@ -129,11 +129,10 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="deal.data"
|
||||
:sections="sections.data"
|
||||
:addContact="addContact"
|
||||
doctype="CRM Deal"
|
||||
@update="updateField"
|
||||
:docname="deal.data.name"
|
||||
@reload="sections.reload"
|
||||
>
|
||||
<template #actions="{ section }">
|
||||
@ -267,6 +266,11 @@
|
||||
</div>
|
||||
</Resizer>
|
||||
</div>
|
||||
<ErrorPage
|
||||
v-else-if="errorTitle"
|
||||
:errorTitle="errorTitle"
|
||||
:errorMessage="errorMessage"
|
||||
/>
|
||||
<OrganizationModal
|
||||
v-model="showOrganizationModal"
|
||||
v-model:organization="_organization"
|
||||
@ -297,6 +301,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
@ -330,7 +335,7 @@ import {
|
||||
createToast,
|
||||
setupAssignees,
|
||||
setupCustomizations,
|
||||
errorMessage,
|
||||
errorMessage as _errorMessage,
|
||||
copyToClipboard,
|
||||
} from '@/utils'
|
||||
import { getView } from '@/utils/view'
|
||||
@ -372,11 +377,17 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const deal = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_deal.api.get_deal',
|
||||
params: { name: props.dealId },
|
||||
cache: ['deal', props.dealId],
|
||||
onSuccess: (data) => {
|
||||
errorTitle.value = ''
|
||||
errorMessage.value = ''
|
||||
|
||||
if (data.organization) {
|
||||
organization.update({
|
||||
params: { doctype: 'CRM Organization', name: data.organization },
|
||||
@ -401,6 +412,14 @@ const deal = createResource({
|
||||
call,
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.messages?.[0]) {
|
||||
errorTitle.value = __('Not permitted')
|
||||
errorMessage.value = __(err.messages?.[0])
|
||||
} else {
|
||||
router.push({ name: 'Deals' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const organization = createResource({
|
||||
@ -545,7 +564,6 @@ const tabs = computed(() => {
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
@ -699,12 +717,12 @@ function triggerCall() {
|
||||
let mobile_no = primaryContact.mobile_no || null
|
||||
|
||||
if (!primaryContact) {
|
||||
errorMessage(__('No primary contact set'))
|
||||
_errorMessage(__('No primary contact set'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!mobile_no) {
|
||||
errorMessage(__('No mobile number set'))
|
||||
_errorMessage(__('No mobile number set'))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -223,6 +223,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div v-else-if="deals.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
@ -457,9 +460,6 @@ function parseRows(rows, columns = []) {
|
||||
}
|
||||
} else if (row == '_assign') {
|
||||
let assignees = JSON.parse(deal._assign || '[]')
|
||||
if (!assignees.length && deal.deal_owner) {
|
||||
assignees = [deal.deal_owner]
|
||||
}
|
||||
_rows[row] = assignees.map((user) => ({
|
||||
name: user,
|
||||
image: getUser(user).user_image,
|
||||
|
||||
@ -45,6 +45,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-else-if="emailTemplates.data"
|
||||
|
||||
@ -124,7 +124,7 @@
|
||||
() =>
|
||||
lead.data.mobile_no
|
||||
? makeCall(lead.data.mobile_no)
|
||||
: errorMessage(__('No phone number set'))
|
||||
: _errorMessage(__('No phone number set'))
|
||||
"
|
||||
>
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
@ -139,7 +139,7 @@
|
||||
@click="
|
||||
lead.data.email
|
||||
? openEmailBox()
|
||||
: errorMessage(__('No email set'))
|
||||
: _errorMessage(__('No email set'))
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
@ -153,7 +153,7 @@
|
||||
@click="
|
||||
lead.data.website
|
||||
? openWebsite(lead.data.website)
|
||||
: errorMessage(__('No website set'))
|
||||
: _errorMessage(__('No website set'))
|
||||
"
|
||||
/>
|
||||
</Button>
|
||||
@ -182,15 +182,19 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="lead.data"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Lead"
|
||||
@update="updateField"
|
||||
:docname="lead.data.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
</Resizer>
|
||||
</div>
|
||||
<ErrorPage
|
||||
v-else-if="errorTitle"
|
||||
:errorTitle="errorTitle"
|
||||
:errorMessage="errorMessage"
|
||||
/>
|
||||
<Dialog
|
||||
v-model="showConvertToDealModal"
|
||||
:options="{
|
||||
@ -309,6 +313,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
|
||||
@ -342,7 +347,7 @@ import {
|
||||
createToast,
|
||||
setupAssignees,
|
||||
setupCustomizations,
|
||||
errorMessage,
|
||||
errorMessage as _errorMessage,
|
||||
copyToClipboard,
|
||||
} from '@/utils'
|
||||
import { getView } from '@/utils/view'
|
||||
@ -392,11 +397,16 @@ const props = defineProps({
|
||||
},
|
||||
})
|
||||
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const lead = createResource({
|
||||
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
|
||||
params: { name: props.leadId },
|
||||
cache: ['lead', props.leadId],
|
||||
onSuccess: (data) => {
|
||||
errorTitle.value = ''
|
||||
errorMessage.value = ''
|
||||
setupAssignees(lead)
|
||||
setupCustomizations(lead, {
|
||||
doc: data,
|
||||
@ -410,6 +420,14 @@ const lead = createResource({
|
||||
call,
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.messages?.[0]) {
|
||||
errorTitle.value = __('Not permitted')
|
||||
errorMessage.value = __(err.messages?.[0])
|
||||
} else {
|
||||
router.push({ name: 'Leads' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@ -532,7 +550,6 @@ const tabs = computed(() => {
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
@ -688,10 +705,10 @@ const dealTabs = createResource({
|
||||
auto: true,
|
||||
transform: (_tabs) => {
|
||||
let hasFields = false
|
||||
let parsedTabs = _tabs.forEach((tab) => {
|
||||
tab.sections.forEach((section) => {
|
||||
section.columns.forEach((column) => {
|
||||
column.fields.forEach((field) => {
|
||||
let parsedTabs = _tabs?.forEach((tab) => {
|
||||
tab.sections?.forEach((section) => {
|
||||
section.columns?.forEach((column) => {
|
||||
column.fields?.forEach((field) => {
|
||||
hasFields = true
|
||||
if (field.fieldname == 'status') {
|
||||
field.fieldtype = 'Select'
|
||||
|
||||
@ -249,6 +249,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div v-else-if="leads.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
@ -480,9 +483,6 @@ function parseRows(rows, columns = []) {
|
||||
}
|
||||
} else if (row == '_assign') {
|
||||
let assignees = JSON.parse(lead._assign || '[]')
|
||||
if (!assignees.length && lead.lead_owner) {
|
||||
assignees = [lead.lead_owner]
|
||||
}
|
||||
_rows[row] = assignees.map((user) => ({
|
||||
name: user,
|
||||
image: getUser(user).user_image,
|
||||
|
||||
@ -130,10 +130,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="contact.data"
|
||||
:sections="sections.data"
|
||||
doctype="Contact"
|
||||
@update="updateField"
|
||||
:docname="contact.data.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -62,10 +62,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="deal.data"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Deal"
|
||||
@update="updateField"
|
||||
:docname="deal.data.name"
|
||||
@reload="sections.reload"
|
||||
>
|
||||
<template #actions="{ section }">
|
||||
|
||||
@ -67,10 +67,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="lead.data"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Lead"
|
||||
@update="updateField"
|
||||
:docname="lead.data.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -112,10 +112,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="organization.doc"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Organization"
|
||||
@update="updateField"
|
||||
:docname="organization.doc.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
</Breadcrumbs>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
<div ref="parentRef" class="flex h-full">
|
||||
<div v-if="organization.doc" ref="parentRef" class="flex h-full">
|
||||
<Resizer
|
||||
v-if="organization.doc"
|
||||
:parent="$refs.parentRef"
|
||||
@ -106,10 +106,9 @@
|
||||
class="flex flex-1 flex-col justify-between overflow-hidden"
|
||||
>
|
||||
<SidePanelLayout
|
||||
v-model="organization.doc"
|
||||
:sections="sections.data"
|
||||
doctype="CRM Organization"
|
||||
@update="updateField"
|
||||
:docname="organization.doc.name"
|
||||
@reload="sections.reload"
|
||||
/>
|
||||
</div>
|
||||
@ -160,6 +159,11 @@
|
||||
</template>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ErrorPage
|
||||
v-else-if="errorTitle"
|
||||
:errorTitle="errorTitle"
|
||||
:errorMessage="errorMessage"
|
||||
/>
|
||||
<QuickEntryModal
|
||||
v-if="showQuickEntryModal"
|
||||
v-model="showQuickEntryModal"
|
||||
@ -169,6 +173,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ErrorPage from '@/components/ErrorPage.vue'
|
||||
import Resizer from '@/components/Resizer.vue'
|
||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||
import Icon from '@/components/Icon.vue'
|
||||
@ -221,12 +226,27 @@ const showQuickEntryModal = ref(false)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const errorTitle = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const organization = createDocumentResource({
|
||||
doctype: 'CRM Organization',
|
||||
name: props.organizationId,
|
||||
cache: ['organization', props.organizationId],
|
||||
fields: ['*'],
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
errorTitle.value = ''
|
||||
errorMessage.value = ''
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err.messages?.[0]) {
|
||||
errorTitle.value = __('Not permitted')
|
||||
errorMessage.value = __(err.messages?.[0])
|
||||
} else {
|
||||
router.push({ name: 'Organizations' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function updateField(fieldname, value) {
|
||||
|
||||
@ -44,6 +44,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div
|
||||
v-else-if="organizations.data"
|
||||
|
||||
@ -172,6 +172,9 @@
|
||||
@applyFilter="(data) => viewControls.applyFilter(data)"
|
||||
@applyLikeFilter="(data) => viewControls.applyLikeFilter(data)"
|
||||
@likeDoc="(data) => viewControls.likeDoc(data)"
|
||||
@selectionsChanged="
|
||||
(selections) => viewControls.updateSelections(selections)
|
||||
"
|
||||
/>
|
||||
<div v-else-if="tasks.data" class="flex h-full items-center justify-center">
|
||||
<div
|
||||
|
||||
@ -39,7 +39,19 @@ export function getMeta(doctype) {
|
||||
return formatNumber(doc[fieldname], '', precision)
|
||||
}
|
||||
|
||||
function getFormattedCurrency(fieldname, doc) {
|
||||
function getFloatWithPrecision(fieldname, doc) {
|
||||
let df = doctypeMeta[doctype]?.fields.find((f) => f.fieldname == fieldname)
|
||||
let precision = df?.precision || null
|
||||
return formatNumber(doc[fieldname], '', precision)
|
||||
}
|
||||
|
||||
function getCurrencyWithPrecision(fieldname, doc) {
|
||||
let df = doctypeMeta[doctype]?.fields.find((f) => f.fieldname == fieldname)
|
||||
let precision = df?.precision || null
|
||||
return formatCurrency(doc[fieldname], '', '', precision)
|
||||
}
|
||||
|
||||
function getFormattedCurrency(fieldname, doc, parentDoc = null) {
|
||||
let currency = window.sysdefaults.currency || 'USD'
|
||||
let df = doctypeMeta[doctype]?.fields.find((f) => f.fieldname == fieldname)
|
||||
let precision = df?.precision || null
|
||||
@ -47,8 +59,11 @@ export function getMeta(doctype) {
|
||||
if (df && df.options) {
|
||||
if (df.options.indexOf(':') != -1) {
|
||||
currency = currency
|
||||
// TODO: Handle this case
|
||||
} else if (doc && doc[df.options]) {
|
||||
currency = doc[df.options]
|
||||
} else if (parentDoc && parentDoc[df.options]) {
|
||||
currency = parentDoc[df.options]
|
||||
}
|
||||
}
|
||||
|
||||
@ -126,6 +141,8 @@ export function getMeta(doctype) {
|
||||
getGridSettings,
|
||||
getGridViewSettings,
|
||||
saveUserSettings,
|
||||
getFloatWithPrecision,
|
||||
getCurrencyWithPrecision,
|
||||
getFormattedFloat,
|
||||
getFormattedPercent,
|
||||
getFormattedCurrency,
|
||||
|
||||
13
frontend/src/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export interface EmailAccount {
|
||||
email_account_name: string
|
||||
email_id: string
|
||||
service: string
|
||||
api_key?: string
|
||||
api_secret?: string
|
||||
password?: string
|
||||
frappe_mail_site?: string
|
||||
enable_outgoing?: boolean
|
||||
enable_incoming?: boolean
|
||||
default_outgoing?: boolean
|
||||
default_incoming?: boolean
|
||||
}
|
||||