Merge branch 'develop' into dashboard
This commit is contained in:
commit
cb92e5e68d
@ -14,6 +14,8 @@
|
|||||||
"column_break_ijan",
|
"column_break_ijan",
|
||||||
"status",
|
"status",
|
||||||
"deal_owner",
|
"deal_owner",
|
||||||
|
"lost_reason",
|
||||||
|
"lost_notes",
|
||||||
"section_break_jgpm",
|
"section_break_jgpm",
|
||||||
"probability",
|
"probability",
|
||||||
"deal_value",
|
"deal_value",
|
||||||
@ -391,12 +393,25 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "column_break_kpxa",
|
"fieldname": "column_break_kpxa",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lost_reason",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"label": "Lost Reason",
|
||||||
|
"mandatory_depends_on": "eval: doc.status == \"Lost\"",
|
||||||
|
"options": "CRM Lost Reason"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "lost_notes",
|
||||||
|
"fieldtype": "Text",
|
||||||
|
"label": "Lost Notes",
|
||||||
|
"mandatory_depends_on": "eval: doc.lost_reason == \"Other\""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-06-16 11:42:49.413483",
|
"modified": "2025-07-02 11:07:50.192089",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
|
|||||||
@ -24,7 +24,8 @@ class CRMDeal(Document):
|
|||||||
self.assign_agent(self.deal_owner)
|
self.assign_agent(self.deal_owner)
|
||||||
if self.has_value_changed("status"):
|
if self.has_value_changed("status"):
|
||||||
add_status_change_log(self)
|
add_status_change_log(self)
|
||||||
self.update_close_date()
|
self.validate_forcasting_fields()
|
||||||
|
self.validate_lost_reason()
|
||||||
|
|
||||||
def after_insert(self):
|
def after_insert(self):
|
||||||
if self.deal_owner:
|
if self.deal_owner:
|
||||||
@ -141,6 +142,32 @@ class CRMDeal(Document):
|
|||||||
if self.status == "Won" and not self.close_date:
|
if self.status == "Won" and not self.close_date:
|
||||||
self.close_date = frappe.utils.nowdate()
|
self.close_date = frappe.utils.nowdate()
|
||||||
|
|
||||||
|
def update_default_probability(self):
|
||||||
|
"""
|
||||||
|
Update the default probability based on the status.
|
||||||
|
"""
|
||||||
|
if not self.probability or self.probability == 0:
|
||||||
|
self.probability = frappe.db.get_value("CRM Deal Status", self.status, "probability") or 0
|
||||||
|
|
||||||
|
def validate_forcasting_fields(self):
|
||||||
|
self.update_close_date()
|
||||||
|
self.update_default_probability()
|
||||||
|
if frappe.db.get_single_value("FCRM Settings", "enable_forecasting"):
|
||||||
|
if not self.deal_value or self.deal_value == 0:
|
||||||
|
frappe.throw(_("Deal Value is required."), frappe.MandatoryError)
|
||||||
|
if not self.close_date:
|
||||||
|
frappe.throw(_("Close Date is required."), frappe.MandatoryError)
|
||||||
|
|
||||||
|
def validate_lost_reason(self):
|
||||||
|
"""
|
||||||
|
Validate the lost reason if the status is set to "Lost".
|
||||||
|
"""
|
||||||
|
if self.status == "Lost":
|
||||||
|
if not self.lost_reason:
|
||||||
|
frappe.throw(_("Please specify a reason for losing the deal."), frappe.ValidationError)
|
||||||
|
elif self.lost_reason == "Other" and not self.lost_notes:
|
||||||
|
frappe.throw(_("Please specify the reason for losing the deal."), frappe.ValidationError)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_list_data():
|
def default_list_data():
|
||||||
columns = [
|
columns = [
|
||||||
|
|||||||
@ -37,13 +37,14 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "probability",
|
"fieldname": "probability",
|
||||||
"fieldtype": "Percent",
|
"fieldtype": "Percent",
|
||||||
|
"in_list_view": 1,
|
||||||
"label": "Probability"
|
"label": "Probability"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-06-11 13:00:34.518808",
|
"modified": "2025-07-01 12:06:42.937440",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal Status",
|
"name": "CRM Deal Status",
|
||||||
|
|||||||
@ -47,6 +47,13 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
fields = frappe.get_meta(doctype).fields
|
fields = frappe.get_meta(doctype).fields
|
||||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||||
|
|
||||||
|
required_fields = []
|
||||||
|
|
||||||
|
if type == "Required Fields":
|
||||||
|
required_fields = [
|
||||||
|
field for field in frappe.get_meta(doctype, False).fields if field.reqd and not field.default
|
||||||
|
]
|
||||||
|
|
||||||
for tab in tabs:
|
for tab in tabs:
|
||||||
for section in tab.get("sections"):
|
for section in tab.get("sections"):
|
||||||
if section.get("columns"):
|
if section.get("columns"):
|
||||||
@ -60,6 +67,32 @@ def get_fields_layout(doctype: str, type: str, parent_doctype: str | None = None
|
|||||||
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
handle_perm_level_restrictions(field, doctype, parent_doctype)
|
||||||
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
column["fields"][column.get("fields").index(field["fieldname"])] = field
|
||||||
|
|
||||||
|
# remove field from required_fields if it is already present
|
||||||
|
if (
|
||||||
|
type == "Required Fields"
|
||||||
|
and field.reqd
|
||||||
|
and any(f.get("fieldname") == field.get("fieldname") for f in required_fields)
|
||||||
|
):
|
||||||
|
required_fields = [
|
||||||
|
f for f in required_fields if f.get("fieldname") != field.get("fieldname")
|
||||||
|
]
|
||||||
|
|
||||||
|
if type == "Required Fields" and required_fields and tabs:
|
||||||
|
tabs[-1].get("sections").append(
|
||||||
|
{
|
||||||
|
"label": "Required Fields",
|
||||||
|
"name": "required_fields_section_" + str(random_string(4)),
|
||||||
|
"opened": True,
|
||||||
|
"hideLabel": True,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "required_fields_column_" + str(random_string(4)),
|
||||||
|
"fields": [field.as_dict() for field in required_fields],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return tabs or []
|
return tabs or []
|
||||||
|
|
||||||
|
|
||||||
@ -83,6 +116,8 @@ def get_sidepanel_sections(doctype):
|
|||||||
fields = frappe.get_meta(doctype).fields
|
fields = frappe.get_meta(doctype).fields
|
||||||
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
fields = [field for field in fields if field.fieldtype not in not_allowed_fieldtypes]
|
||||||
|
|
||||||
|
add_forecasting_section(layout, doctype)
|
||||||
|
|
||||||
for section in layout:
|
for section in layout:
|
||||||
section["name"] = section.get("name") or section.get("label")
|
section["name"] = section.get("name") or section.get("label")
|
||||||
for column in section.get("columns") if section.get("columns") else []:
|
for column in section.get("columns") if section.get("columns") else []:
|
||||||
@ -100,6 +135,38 @@ def get_sidepanel_sections(doctype):
|
|||||||
return layout
|
return layout
|
||||||
|
|
||||||
|
|
||||||
|
def add_forecasting_section(layout, doctype):
|
||||||
|
if (
|
||||||
|
doctype == "CRM Deal"
|
||||||
|
and frappe.db.get_single_value("FCRM Settings", "enable_forecasting")
|
||||||
|
and not any(section.get("name") == "forecasted_sales_section" for section in layout)
|
||||||
|
):
|
||||||
|
contacts_section_index = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, section in enumerate(layout)
|
||||||
|
if section.get("name") == "contacts_section" or section.get("label") == "Contacts"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if contacts_section_index is not None:
|
||||||
|
layout.insert(
|
||||||
|
contacts_section_index + 1,
|
||||||
|
{
|
||||||
|
"name": "forecasted_sales_section",
|
||||||
|
"label": "Forecasted Sales",
|
||||||
|
"opened": True,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "column_" + str(random_string(4)),
|
||||||
|
"fields": ["close_date", "probability", "deal_value"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
def handle_perm_level_restrictions(field, doctype, parent_doctype=None):
|
||||||
if field.permlevel == 0:
|
if field.permlevel == 0:
|
||||||
return
|
return
|
||||||
|
|||||||
@ -27,9 +27,10 @@
|
|||||||
"label": "Details"
|
"label": "Details"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-02 22:13:30.498404",
|
"modified": "2025-06-30 16:53:51.721752",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead Source",
|
"name": "CRM Lead Source",
|
||||||
@ -44,7 +45,7 @@
|
|||||||
"print": 1,
|
"print": 1,
|
||||||
"read": 1,
|
"read": 1,
|
||||||
"report": 1,
|
"report": 1,
|
||||||
"role": "Sales User",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
@ -60,6 +61,15 @@
|
|||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"email": 1,
|
"email": 1,
|
||||||
"export": 1,
|
"export": 1,
|
||||||
@ -71,7 +81,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"quick_entry": 1,
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
0
crm/fcrm/doctype/crm_lost_reason/__init__.py
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
8
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
// For license information, please see license.txt
|
||||||
|
|
||||||
|
// frappe.ui.form.on("CRM Lost Reason", {
|
||||||
|
// refresh(frm) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
79
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.json
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"actions": [],
|
||||||
|
"allow_rename": 1,
|
||||||
|
"autoname": "field:lost_reason",
|
||||||
|
"creation": "2025-06-30 16:51:31.082360",
|
||||||
|
"doctype": "DocType",
|
||||||
|
"engine": "InnoDB",
|
||||||
|
"field_order": [
|
||||||
|
"lost_reason",
|
||||||
|
"description"
|
||||||
|
],
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldname": "lost_reason",
|
||||||
|
"fieldtype": "Data",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Lost Reason",
|
||||||
|
"reqd": 1,
|
||||||
|
"unique": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "description",
|
||||||
|
"fieldtype": "Text Editor",
|
||||||
|
"label": "Description"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"grid_page_length": 50,
|
||||||
|
"index_web_pages_for_search": 1,
|
||||||
|
"links": [],
|
||||||
|
"modified": "2025-06-30 16:59:15.094049",
|
||||||
|
"modified_by": "Administrator",
|
||||||
|
"module": "FCRM",
|
||||||
|
"name": "CRM Lost Reason",
|
||||||
|
"naming_rule": "By fieldname",
|
||||||
|
"owner": "Administrator",
|
||||||
|
"permissions": [
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "System Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales Manager",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"create": 1,
|
||||||
|
"delete": 1,
|
||||||
|
"email": 1,
|
||||||
|
"export": 1,
|
||||||
|
"print": 1,
|
||||||
|
"read": 1,
|
||||||
|
"report": 1,
|
||||||
|
"role": "Sales User",
|
||||||
|
"share": 1,
|
||||||
|
"write": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
"sort_order": "DESC",
|
||||||
|
"states": []
|
||||||
|
}
|
||||||
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
9
crm/fcrm/doctype/crm_lost_reason/crm_lost_reason.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.model.document import Document
|
||||||
|
|
||||||
|
|
||||||
|
class CRMLostReason(Document):
|
||||||
|
pass
|
||||||
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
30
crm/fcrm/doctype/crm_lost_reason/test_crm_lost_reason.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
# See license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||||
|
|
||||||
|
|
||||||
|
# On IntegrationTestCase, the doctype test records and all
|
||||||
|
# link-field test record dependencies are recursively loaded
|
||||||
|
# Use these module variables to add/remove to/from that list
|
||||||
|
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||||
|
|
||||||
|
|
||||||
|
class UnitTestCRMLostReason(UnitTestCase):
|
||||||
|
"""
|
||||||
|
Unit tests for CRMLostReason.
|
||||||
|
Use this class for testing individual functions and methods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationTestCRMLostReason(IntegrationTestCase):
|
||||||
|
"""
|
||||||
|
Integration tests for CRMLostReason.
|
||||||
|
Use this class for testing interactions between multiple components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
pass
|
||||||
@ -60,7 +60,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"description": "It will make deal's \"Expected Closure Date\" mandatory to get accurate forecasting insights",
|
"description": "It will make deal's \"Close Date\" & \"Deal Value\" mandatory to get accurate forecasting insights",
|
||||||
"fieldname": "enable_forecasting",
|
"fieldname": "enable_forecasting",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Enable Forecasting"
|
"label": "Enable Forecasting"
|
||||||
@ -69,7 +69,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-06-11 19:12:16.762499",
|
"modified": "2025-07-01 13:20:48.757603",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "FCRM Settings",
|
"name": "FCRM Settings",
|
||||||
|
|||||||
@ -39,6 +39,11 @@ class FCRMSettings(Document):
|
|||||||
"reqd",
|
"reqd",
|
||||||
"close_date",
|
"close_date",
|
||||||
)
|
)
|
||||||
|
delete_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"reqd",
|
||||||
|
"deal_value",
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
make_property_setter(
|
make_property_setter(
|
||||||
"CRM Deal",
|
"CRM Deal",
|
||||||
@ -47,6 +52,13 @@ class FCRMSettings(Document):
|
|||||||
1 if self.enable_forecasting else 0,
|
1 if self.enable_forecasting else 0,
|
||||||
"Check",
|
"Check",
|
||||||
)
|
)
|
||||||
|
make_property_setter(
|
||||||
|
"CRM Deal",
|
||||||
|
"deal_value",
|
||||||
|
"reqd",
|
||||||
|
1 if self.enable_forecasting else 0,
|
||||||
|
"Check",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_standard_dropdown_items():
|
def get_standard_dropdown_items():
|
||||||
|
|||||||
@ -20,6 +20,7 @@ def after_install(force=False):
|
|||||||
add_email_template_custom_fields()
|
add_email_template_custom_fields()
|
||||||
add_default_industries()
|
add_default_industries()
|
||||||
add_default_lead_sources()
|
add_default_lead_sources()
|
||||||
|
add_default_lost_reasons()
|
||||||
add_standard_dropdown_items()
|
add_standard_dropdown_items()
|
||||||
add_default_scripts()
|
add_default_scripts()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
@ -68,30 +69,37 @@ def add_default_deal_statuses():
|
|||||||
statuses = {
|
statuses = {
|
||||||
"Qualification": {
|
"Qualification": {
|
||||||
"color": "gray",
|
"color": "gray",
|
||||||
|
"probability": 10,
|
||||||
"position": 1,
|
"position": 1,
|
||||||
},
|
},
|
||||||
"Demo/Making": {
|
"Demo/Making": {
|
||||||
"color": "orange",
|
"color": "orange",
|
||||||
|
"probability": 25,
|
||||||
"position": 2,
|
"position": 2,
|
||||||
},
|
},
|
||||||
"Proposal/Quotation": {
|
"Proposal/Quotation": {
|
||||||
"color": "blue",
|
"color": "blue",
|
||||||
|
"probability": 50,
|
||||||
"position": 3,
|
"position": 3,
|
||||||
},
|
},
|
||||||
"Negotiation": {
|
"Negotiation": {
|
||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
|
"probability": 70,
|
||||||
"position": 4,
|
"position": 4,
|
||||||
},
|
},
|
||||||
"Ready to Close": {
|
"Ready to Close": {
|
||||||
"color": "purple",
|
"color": "purple",
|
||||||
|
"probability": 90,
|
||||||
"position": 5,
|
"position": 5,
|
||||||
},
|
},
|
||||||
"Won": {
|
"Won": {
|
||||||
"color": "green",
|
"color": "green",
|
||||||
|
"probability": 100,
|
||||||
"position": 6,
|
"position": 6,
|
||||||
},
|
},
|
||||||
"Lost": {
|
"Lost": {
|
||||||
"color": "red",
|
"color": "red",
|
||||||
|
"probability": 0,
|
||||||
"position": 7,
|
"position": 7,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -103,6 +111,7 @@ def add_default_deal_statuses():
|
|||||||
doc = frappe.new_doc("CRM Deal Status")
|
doc = frappe.new_doc("CRM Deal Status")
|
||||||
doc.deal_status = status
|
doc.deal_status = status
|
||||||
doc.color = statuses[status]["color"]
|
doc.color = statuses[status]["color"]
|
||||||
|
doc.probability = statuses[status]["probability"]
|
||||||
doc.position = statuses[status]["position"]
|
doc.position = statuses[status]["position"]
|
||||||
doc.insert()
|
doc.insert()
|
||||||
|
|
||||||
@ -343,6 +352,44 @@ def add_default_lead_sources():
|
|||||||
doc.insert()
|
doc.insert()
|
||||||
|
|
||||||
|
|
||||||
|
def add_default_lost_reasons():
|
||||||
|
lost_reasons = [
|
||||||
|
{
|
||||||
|
"reason": "Pricing",
|
||||||
|
"description": "The prospect found the pricing to be too high or not competitive.",
|
||||||
|
},
|
||||||
|
{"reason": "Competition", "description": "The prospect chose a competitor's product or service."},
|
||||||
|
{
|
||||||
|
"reason": "Budget Constraints",
|
||||||
|
"description": "The prospect did not have the budget to proceed with the purchase.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Missing Features",
|
||||||
|
"description": "The prospect felt that the product or service was missing key features they needed.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "Long Sales Cycle",
|
||||||
|
"description": "The sales process took too long, leading to loss of interest.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reason": "No Decision-Maker",
|
||||||
|
"description": "The prospect was not the decision-maker and could not proceed.",
|
||||||
|
},
|
||||||
|
{"reason": "Unresponsive Prospect", "description": "The prospect did not respond to follow-ups."},
|
||||||
|
{"reason": "Poor Fit", "description": "The prospect was not a good fit for the product or service."},
|
||||||
|
{"reason": "Other", "description": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
for reason in lost_reasons:
|
||||||
|
if frappe.db.exists("CRM Lost Reason", reason["reason"]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
doc = frappe.new_doc("CRM Lost Reason")
|
||||||
|
doc.lost_reason = reason["reason"]
|
||||||
|
doc.description = reason["description"]
|
||||||
|
doc.insert()
|
||||||
|
|
||||||
|
|
||||||
def add_standard_dropdown_items():
|
def add_standard_dropdown_items():
|
||||||
crm_settings = frappe.get_single("FCRM Settings")
|
crm_settings = frappe.get_single("FCRM Settings")
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,5 @@ crm.patches.v1_0.create_default_sidebar_fields_layout
|
|||||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||||
crm.patches.v1_0.update_layouts_to_new_format
|
crm.patches.v1_0.update_layouts_to_new_format
|
||||||
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
crm.patches.v1_0.move_twilio_agent_to_telephony_agent
|
||||||
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
crm.patches.v1_0.create_default_scripts # 13-06-2025
|
||||||
|
crm.patches.v1_0.update_deal_status_probabilities
|
||||||
24
crm/patches/v1_0/update_deal_status_probabilities.py
Normal file
24
crm/patches/v1_0/update_deal_status_probabilities.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import frappe
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
deal_statuses = frappe.get_all("CRM Deal Status", fields=["name", "probability", "deal_status"])
|
||||||
|
|
||||||
|
for status in deal_statuses:
|
||||||
|
if status.probability is None or status.probability == 0:
|
||||||
|
if status.deal_status == "Qualification":
|
||||||
|
probability = 10
|
||||||
|
elif status.deal_status == "Demo/Making":
|
||||||
|
probability = 25
|
||||||
|
elif status.deal_status == "Proposal/Quotation":
|
||||||
|
probability = 50
|
||||||
|
elif status.deal_status == "Negotiation":
|
||||||
|
probability = 70
|
||||||
|
elif status.deal_status == "Ready to Close":
|
||||||
|
probability = 90
|
||||||
|
elif status.deal_status == "Won":
|
||||||
|
probability = 100
|
||||||
|
else:
|
||||||
|
probability = 0
|
||||||
|
|
||||||
|
frappe.db.set_value("CRM Deal Status", status.name, "probability", probability)
|
||||||
2
frontend/components.d.ts
vendored
2
frontend/components.d.ts
vendored
@ -56,6 +56,7 @@ declare module 'vue' {
|
|||||||
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
|
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
|
||||||
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
|
||||||
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
|
||||||
|
ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.vue')['default']
|
||||||
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
|
||||||
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
|
||||||
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
|
||||||
@ -161,6 +162,7 @@ declare module 'vue' {
|
|||||||
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
|
||||||
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
|
||||||
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
|
||||||
|
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
|
||||||
LucideInfo: typeof import('~icons/lucide/info')['default']
|
LucideInfo: typeof import('~icons/lucide/info')['default']
|
||||||
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
LucideMoreHorizontal: typeof import('~icons/lucide/more-horizontal')['default']
|
||||||
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
LucidePlus: typeof import('~icons/lucide/plus')['default']
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
"@tiptap/extension-paragraph": "^2.12.0",
|
"@tiptap/extension-paragraph": "^2.12.0",
|
||||||
"@twilio/voice-sdk": "^2.10.2",
|
"@twilio/voice-sdk": "^2.10.2",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
"frappe-ui": "^0.1.162",
|
"frappe-ui": "^0.1.166",
|
||||||
"gemoji": "^8.1.0",
|
"gemoji": "^8.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime": "^4.0.1",
|
"mime": "^4.0.1",
|
||||||
|
|||||||
@ -368,6 +368,7 @@
|
|||||||
<DataFields
|
<DataFields
|
||||||
:doctype="doctype"
|
:doctype="doctype"
|
||||||
:docname="doc.data.name"
|
:docname="doc.data.name"
|
||||||
|
@beforeSave="(data) => emit('beforeSave', data)"
|
||||||
@afterSave="(data) => emit('afterSave', data)"
|
@afterSave="(data) => emit('afterSave', data)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -518,7 +519,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['afterSave'])
|
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
v-if="showCallLogModal"
|
v-if="showCallLogModal"
|
||||||
v-model="showCallLogModal"
|
v-model="showCallLogModal"
|
||||||
:data="callLog"
|
:data="callLog"
|
||||||
|
:referenceDoc="referenceDoc"
|
||||||
:options="{ afterInsert: () => activities.reload() }"
|
:options="{ afterInsert: () => activities.reload() }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -87,10 +88,12 @@ function showNote(n) {
|
|||||||
// Call Logs
|
// Call Logs
|
||||||
const showCallLogModal = ref(false)
|
const showCallLogModal = ref(false)
|
||||||
const callLog = ref({})
|
const callLog = ref({})
|
||||||
|
const referenceDoc = ref({})
|
||||||
|
|
||||||
function createCallLog() {
|
function createCallLog() {
|
||||||
let doctype = props.doctype
|
let doctype = props.doctype
|
||||||
let docname = props.doc.data?.name
|
let docname = props.doc.data?.name
|
||||||
|
referenceDoc.value = { ...props.doc.data }
|
||||||
callLog.value = {
|
callLog.value = {
|
||||||
reference_doctype: doctype,
|
reference_doctype: doctype,
|
||||||
reference_docname: docname,
|
reference_docname: docname,
|
||||||
|
|||||||
@ -66,7 +66,7 @@ import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
|||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { useDocument } from '@/data/document'
|
import { useDocument } from '@/data/document'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doctype: {
|
doctype: {
|
||||||
@ -79,10 +79,13 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['afterSave'])
|
const emit = defineEmits(['beforeSave', 'afterSave'])
|
||||||
|
|
||||||
const { isManager } = usersStore()
|
const { isManager } = usersStore()
|
||||||
|
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
const attrs = instance?.vnode?.props ?? {}
|
||||||
|
|
||||||
const showDataFieldsModal = ref(false)
|
const showDataFieldsModal = ref(false)
|
||||||
|
|
||||||
const { document } = useDocument(props.doctype, props.docname)
|
const { document } = useDocument(props.doctype, props.docname)
|
||||||
@ -107,9 +110,15 @@ function saveChanges() {
|
|||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
document.save.submit(null, {
|
const hasListener = attrs['onBeforeSave'] !== undefined
|
||||||
onSuccess: () => emit('afterSave', changes),
|
|
||||||
})
|
if (hasListener) {
|
||||||
|
emit('beforeSave', changes)
|
||||||
|
} else {
|
||||||
|
document.save.submit(null, {
|
||||||
|
onSuccess: () => emit('afterSave', changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@ -84,7 +84,10 @@ const error = ref(null)
|
|||||||
const title = ref(null)
|
const title = ref(null)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
|
|
||||||
const { document: _address } = useDocument('Address', props.address || '')
|
const { document: _address, triggerOnBeforeCreate } = useDocument(
|
||||||
|
'Address',
|
||||||
|
props.address || '',
|
||||||
|
)
|
||||||
|
|
||||||
const dialogOptions = computed(() => {
|
const dialogOptions = computed(() => {
|
||||||
let title = !editMode.value
|
let title = !editMode.value
|
||||||
@ -95,8 +98,7 @@ const dialogOptions = computed(() => {
|
|||||||
{
|
{
|
||||||
label: editMode.value ? __('Save') : __('Create'),
|
label: editMode.value ? __('Save') : __('Create'),
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: () =>
|
onClick: () => (editMode.value ? updateAddress() : createAddress()),
|
||||||
editMode.value ? updateAddress() : createAddress.submit(),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -119,7 +121,10 @@ const callBacks = {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
if (err.exc_type == 'MandatoryError') {
|
if (err.exc_type == 'MandatoryError') {
|
||||||
const errorMessage = err.messages
|
const errorMessage = err.messages
|
||||||
.map((msg) => msg.split(': ')[2].trim())
|
.map((msg) => {
|
||||||
|
let arr = msg.split(': ')
|
||||||
|
return arr[arr.length - 1].trim()
|
||||||
|
})
|
||||||
.join(', ')
|
.join(', ')
|
||||||
error.value = __('These fields are required: {0}', [errorMessage])
|
error.value = __('These fields are required: {0}', [errorMessage])
|
||||||
return
|
return
|
||||||
@ -133,16 +138,22 @@ async function updateAddress() {
|
|||||||
await _address.save.submit(null, callBacks)
|
await _address.save.submit(null, callBacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAddress = createResource({
|
async function createAddress() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.()
|
||||||
|
|
||||||
|
await _createAddress.submit({
|
||||||
|
doc: {
|
||||||
|
doctype: 'Address',
|
||||||
|
..._address.doc,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const _createAddress = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'Address',
|
|
||||||
..._address.doc,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(doc) {
|
onSuccess(doc) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (doc.name) {
|
if (doc.name) {
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Button
|
<Button
|
||||||
v-if="isManager() && !isMobileView"
|
v-if="!isMobileView"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-7"
|
class="w-7"
|
||||||
@click="openCallLogModal"
|
@click="openCallLogModal"
|
||||||
@ -174,14 +174,12 @@ import NoteModal from '@/components/Modals/NoteModal.vue'
|
|||||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||||
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
|
||||||
import { getCallLogDetail } from '@/utils/callLog'
|
import { getCallLogDetail } from '@/utils/callLog'
|
||||||
import { usersStore } from '@/stores/users'
|
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { useDocument } from '@/data/document'
|
import { useDocument } from '@/data/document'
|
||||||
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
|
import { FeatherIcon, Dropdown, Avatar, Tooltip, call } from 'frappe-ui'
|
||||||
import { ref, computed, h, nextTick, watch } from 'vue'
|
import { ref, computed, h, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const { isManager } = usersStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
|
|||||||
@ -69,6 +69,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
referenceDoc: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
options: {
|
options: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: {
|
default: {
|
||||||
@ -85,7 +89,7 @@ const loading = ref(false)
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const editMode = ref(false)
|
const editMode = ref(false)
|
||||||
|
|
||||||
const { document: callLog } = useDocument(
|
const { document: callLog, triggerOnBeforeCreate } = useDocument(
|
||||||
'CRM Call Log',
|
'CRM Call Log',
|
||||||
props.data?.name || '',
|
props.data?.name || '',
|
||||||
)
|
)
|
||||||
@ -97,8 +101,7 @@ const dialogOptions = computed(() => {
|
|||||||
{
|
{
|
||||||
label: editMode.value ? __('Save') : __('Create'),
|
label: editMode.value ? __('Save') : __('Create'),
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
onClick: () =>
|
onClick: () => (editMode.value ? updateCallLog() : createCallLog()),
|
||||||
editMode.value ? updateCallLog() : createCallLog.submit(),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -121,7 +124,10 @@ const callBacks = {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
if (err.exc_type == 'MandatoryError') {
|
if (err.exc_type == 'MandatoryError') {
|
||||||
const errorMessage = err.messages
|
const errorMessage = err.messages
|
||||||
.map((msg) => msg.split(': ')[2].trim())
|
.map((msg) => {
|
||||||
|
let arr = msg.split(': ')
|
||||||
|
return arr[arr.length - 1].trim()
|
||||||
|
})
|
||||||
.join(', ')
|
.join(', ')
|
||||||
error.value = __('These fields are required: {0}', [errorMessage])
|
error.value = __('These fields are required: {0}', [errorMessage])
|
||||||
return
|
return
|
||||||
@ -135,18 +141,21 @@ async function updateCallLog() {
|
|||||||
await callLog.save.submit(null, callBacks)
|
await callLog.save.submit(null, callBacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const createCallLog = createResource({
|
async function createCallLog() {
|
||||||
|
Object.assign(callLog.doc, {
|
||||||
|
doctype: 'CRM Call Log',
|
||||||
|
id: getRandom(6),
|
||||||
|
telephony_medium: 'Manual',
|
||||||
|
})
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.(props.referenceDoc)
|
||||||
|
await _createCallLog.submit({
|
||||||
|
doc: callLog.doc,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const _createCallLog = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams() {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'CRM Call Log',
|
|
||||||
id: getRandom(6),
|
|
||||||
telephony_medium: 'Manual',
|
|
||||||
...callLog.doc,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSuccess(doc) {
|
onSuccess(doc) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (doc.name) {
|
if (doc.name) {
|
||||||
|
|||||||
@ -86,7 +86,7 @@ const show = defineModel()
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const { document: _contact } = useDocument('Contact')
|
const { document: _contact, triggerOnBeforeCreate } = useDocument('Contact')
|
||||||
|
|
||||||
async function createContact() {
|
async function createContact() {
|
||||||
if (_contact.doc.email_id) {
|
if (_contact.doc.email_id) {
|
||||||
@ -99,6 +99,8 @@ async function createContact() {
|
|||||||
delete _contact.doc.mobile_no
|
delete _contact.doc.mobile_no
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.()
|
||||||
|
|
||||||
const doc = await call('frappe.client.insert', {
|
const doc = await call('frappe.client.insert', {
|
||||||
doc: {
|
doc: {
|
||||||
doctype: 'Contact',
|
doctype: 'Contact',
|
||||||
|
|||||||
247
frontend/src/components/Modals/ConvertToDealModal.vue
Normal file
247
frontend/src/components/Modals/ConvertToDealModal.vue
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{
|
||||||
|
size: 'xl',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Convert'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: convertToDeal,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body-header>
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
||||||
|
{{ __('Convert to Deal') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="isManager() && !isMobileView"
|
||||||
|
variant="ghost"
|
||||||
|
@click="openQuickEntryModal"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EditIcon class="h-4 w-4" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<Button icon="x" variant="ghost" @click="show = false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="mb-4 flex items-center gap-2 text-ink-gray-5">
|
||||||
|
<OrganizationsIcon class="h-4 w-4" />
|
||||||
|
<label class="block text-base">{{ __('Organization') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="ml-6 text-ink-gray-9">
|
||||||
|
<div class="flex items-center justify-between text-base">
|
||||||
|
<div>{{ __('Choose Existing') }}</div>
|
||||||
|
<Switch v-model="existingOrganizationChecked" />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
v-if="existingOrganizationChecked"
|
||||||
|
class="form-control mt-2.5"
|
||||||
|
size="md"
|
||||||
|
:value="existingOrganization"
|
||||||
|
doctype="CRM Organization"
|
||||||
|
@change="(data) => (existingOrganization = data)"
|
||||||
|
/>
|
||||||
|
<div v-else class="mt-2.5 text-base">
|
||||||
|
{{
|
||||||
|
__(
|
||||||
|
'New organization will be created based on the data in details section',
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4 mt-6 flex items-center gap-2 text-ink-gray-5">
|
||||||
|
<ContactsIcon class="h-4 w-4" />
|
||||||
|
<label class="block text-base">{{ __('Contact') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="ml-6 text-ink-gray-9">
|
||||||
|
<div class="flex items-center justify-between text-base">
|
||||||
|
<div>{{ __('Choose Existing') }}</div>
|
||||||
|
<Switch v-model="existingContactChecked" />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
v-if="existingContactChecked"
|
||||||
|
class="form-control mt-2.5"
|
||||||
|
size="md"
|
||||||
|
:value="existingContact"
|
||||||
|
doctype="Contact"
|
||||||
|
@change="(data) => (existingContact = data)"
|
||||||
|
/>
|
||||||
|
<div v-else class="mt-2.5 text-base">
|
||||||
|
{{ __("New contact will be created based on the person's details") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="dealTabs.data?.length" class="h-px w-full border-t my-6" />
|
||||||
|
|
||||||
|
<FieldLayout
|
||||||
|
v-if="dealTabs.data?.length"
|
||||||
|
:tabs="dealTabs.data"
|
||||||
|
:data="deal.doc"
|
||||||
|
doctype="CRM Deal"
|
||||||
|
/>
|
||||||
|
<ErrorMessage class="mt-4" :message="error" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
||||||
|
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
||||||
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
|
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { sessionStore } from '@/stores/session'
|
||||||
|
import { statusesStore } from '@/stores/statuses'
|
||||||
|
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
|
||||||
|
import { isMobileView } from '@/composables/settings'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
|
import { useOnboarding } from 'frappe-ui/frappe'
|
||||||
|
import { Switch, Dialog, createResource, call } from 'frappe-ui'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
lead: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { statusOptions, getDealStatus } = statusesStore()
|
||||||
|
const { isManager } = usersStore()
|
||||||
|
const { user } = sessionStore()
|
||||||
|
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
||||||
|
|
||||||
|
const existingContactChecked = ref(false)
|
||||||
|
const existingOrganizationChecked = ref(false)
|
||||||
|
|
||||||
|
const existingContact = ref('')
|
||||||
|
const existingOrganization = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const { triggerConvertToDeal } = useDocument('CRM Lead', props.lead.name)
|
||||||
|
const { document: deal } = useDocument('CRM Deal')
|
||||||
|
|
||||||
|
async function convertToDeal() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (existingContactChecked.value && !existingContact.value) {
|
||||||
|
error.value = __('Please select an existing contact')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingOrganizationChecked.value && !existingOrganization.value) {
|
||||||
|
error.value = __('Please select an existing organization')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingContactChecked.value && existingContact.value) {
|
||||||
|
existingContact.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingOrganizationChecked.value && existingOrganization.value) {
|
||||||
|
existingOrganization.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
await triggerConvertToDeal?.(props.lead, deal.doc, () => (show.value = false))
|
||||||
|
|
||||||
|
let _deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||||
|
lead: props.lead.name,
|
||||||
|
deal: deal.doc,
|
||||||
|
existing_contact: existingContact.value,
|
||||||
|
existing_organization: existingOrganization.value,
|
||||||
|
}).catch((err) => {
|
||||||
|
if (err.exc_type == 'MandatoryError') {
|
||||||
|
const errorMessage = err.messages
|
||||||
|
.map((msg) => {
|
||||||
|
let arr = msg.split(': ')
|
||||||
|
return arr[arr.length - 1].trim()
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
|
||||||
|
if (errorMessage.toLowerCase().includes('required')) {
|
||||||
|
error.value = __(errorMessage)
|
||||||
|
} else {
|
||||||
|
error.value = __('{0} is required', [errorMessage])
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = __('Error converting to deal: {0}', [err.messages?.[0]])
|
||||||
|
})
|
||||||
|
if (_deal) {
|
||||||
|
show.value = false
|
||||||
|
existingContactChecked.value = false
|
||||||
|
existingOrganizationChecked.value = false
|
||||||
|
existingContact.value = ''
|
||||||
|
existingOrganization.value = ''
|
||||||
|
error.value = ''
|
||||||
|
updateOnboardingStep('convert_lead_to_deal', true, false, () => {
|
||||||
|
localStorage.setItem('firstDeal' + user, _deal)
|
||||||
|
})
|
||||||
|
capture('convert_lead_to_deal')
|
||||||
|
router.push({ name: 'Deal', params: { dealId: _deal } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dealStatuses = computed(() => {
|
||||||
|
let statuses = statusOptions('deal')
|
||||||
|
if (!deal.doc?.status) {
|
||||||
|
deal.doc.status = statuses[0].value
|
||||||
|
}
|
||||||
|
return statuses
|
||||||
|
})
|
||||||
|
|
||||||
|
const dealTabs = createResource({
|
||||||
|
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
||||||
|
cache: ['RequiredFields', 'CRM Deal'],
|
||||||
|
params: { doctype: 'CRM Deal', type: 'Required Fields' },
|
||||||
|
auto: true,
|
||||||
|
transform: (_tabs) => {
|
||||||
|
let hasFields = false
|
||||||
|
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'
|
||||||
|
field.options = dealStatuses.value
|
||||||
|
field.prefix = getDealStatus(deal.doc.status).color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.fieldtype === 'Table') {
|
||||||
|
deal.doc[field.fieldname] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return hasFields ? parsedTabs : []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function openQuickEntryModal() {
|
||||||
|
showQuickEntryModal.value = true
|
||||||
|
quickEntryProps.value = {
|
||||||
|
doctype: 'CRM Deal',
|
||||||
|
onlyRequired: true,
|
||||||
|
}
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tabs.data">
|
<div v-if="tabs.data">
|
||||||
<FieldLayout :tabs="tabs.data" :data="_data" :doctype="doctype" />
|
<FieldLayout :tabs="tabs.data" :data="_data.doc" :doctype="doctype" />
|
||||||
<ErrorMessage class="mt-2" :message="error" />
|
<ErrorMessage class="mt-2" :message="error" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,6 +51,7 @@
|
|||||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { useDocument } from '@/data/document'
|
||||||
import { isMobileView } from '@/composables/settings'
|
import { isMobileView } from '@/composables/settings'
|
||||||
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
|
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
|
||||||
import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui'
|
import { FeatherIcon, createResource, ErrorMessage, call } from 'frappe-ui'
|
||||||
@ -76,7 +77,7 @@ const show = defineModel()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
let _data = ref({})
|
const { document: _data, triggerOnBeforeCreate } = useDocument(props.doctype)
|
||||||
|
|
||||||
const dialogOptions = computed(() => {
|
const dialogOptions = computed(() => {
|
||||||
let doctype = props.doctype
|
let doctype = props.doctype
|
||||||
@ -109,12 +110,14 @@ async function create() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.()
|
||||||
|
|
||||||
let doc = await call(
|
let doc = await call(
|
||||||
'frappe.client.insert',
|
'frappe.client.insert',
|
||||||
{
|
{
|
||||||
doc: {
|
doc: {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
..._data.value,
|
..._data.doc,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -138,7 +141,7 @@ watch(
|
|||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
_data.value = { ...props.data }
|
_data.doc = { ...props.data }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -98,7 +98,7 @@ const show = defineModel()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
const { document: deal, triggerOnChange } = useDocument('CRM Deal')
|
const { document: deal, triggerOnBeforeCreate } = useDocument('CRM Deal')
|
||||||
|
|
||||||
const hasOrganizationSections = ref(true)
|
const hasOrganizationSections = ref(true)
|
||||||
const hasContactSections = ref(true)
|
const hasContactSections = ref(true)
|
||||||
@ -168,14 +168,14 @@ const tabs = createResource({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dealStatuses = computed(() => {
|
const dealStatuses = computed(() => {
|
||||||
let statuses = statusOptions('deal', null, [], triggerOnChange)
|
let statuses = statusOptions('deal')
|
||||||
if (!deal.doc.status) {
|
if (!deal.doc.status) {
|
||||||
deal.doc.status = statuses[0].value
|
deal.doc.status = statuses[0].value
|
||||||
}
|
}
|
||||||
return statuses
|
return statuses
|
||||||
})
|
})
|
||||||
|
|
||||||
function createDeal() {
|
async function createDeal() {
|
||||||
if (deal.doc.website && !deal.doc.website.startsWith('http')) {
|
if (deal.doc.website && !deal.doc.website.startsWith('http')) {
|
||||||
deal.doc.website = 'https://' + deal.doc.website
|
deal.doc.website = 'https://' + deal.doc.website
|
||||||
}
|
}
|
||||||
@ -186,6 +186,8 @@ function createDeal() {
|
|||||||
deal.doc['mobile_no'] = null
|
deal.doc['mobile_no'] = null
|
||||||
} else deal.doc['contact'] = null
|
} else deal.doc['contact'] = null
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.()
|
||||||
|
|
||||||
createResource({
|
createResource({
|
||||||
url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal',
|
url: 'crm.fcrm.doctype.crm_deal.crm_deal.create_deal',
|
||||||
params: { args: deal.doc },
|
params: { args: deal.doc },
|
||||||
|
|||||||
@ -74,10 +74,10 @@ const router = useRouter()
|
|||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const isLeadCreating = ref(false)
|
const isLeadCreating = ref(false)
|
||||||
|
|
||||||
const { document: lead, triggerOnChange } = useDocument('CRM Lead')
|
const { document: lead, triggerOnBeforeCreate } = useDocument('CRM Lead')
|
||||||
|
|
||||||
const leadStatuses = computed(() => {
|
const leadStatuses = computed(() => {
|
||||||
let statuses = statusOptions('lead', null, [], triggerOnChange)
|
let statuses = statusOptions('lead')
|
||||||
if (!lead.doc.status) {
|
if (!lead.doc.status) {
|
||||||
lead.doc.status = statuses?.[0]?.value
|
lead.doc.status = statuses?.[0]?.value
|
||||||
}
|
}
|
||||||
@ -112,71 +112,73 @@ const tabs = createResource({
|
|||||||
|
|
||||||
const createLead = createResource({
|
const createLead = createResource({
|
||||||
url: 'frappe.client.insert',
|
url: 'frappe.client.insert',
|
||||||
makeParams(values) {
|
|
||||||
return {
|
|
||||||
doc: {
|
|
||||||
doctype: 'CRM Lead',
|
|
||||||
...values,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function createNewLead() {
|
async function createNewLead() {
|
||||||
if (lead.doc.website && !lead.doc.website.startsWith('http')) {
|
if (lead.doc.website && !lead.doc.website.startsWith('http')) {
|
||||||
lead.doc.website = 'https://' + lead.doc.website
|
lead.doc.website = 'https://' + lead.doc.website
|
||||||
}
|
}
|
||||||
|
|
||||||
createLead.submit(lead.doc, {
|
await triggerOnBeforeCreate?.()
|
||||||
validate() {
|
|
||||||
error.value = null
|
createLead.submit(
|
||||||
if (!lead.doc.first_name) {
|
{
|
||||||
error.value = __('First Name is mandatory')
|
doc: {
|
||||||
return error.value
|
doctype: 'CRM Lead',
|
||||||
}
|
...lead.doc,
|
||||||
if (lead.doc.annual_revenue) {
|
},
|
||||||
if (typeof lead.doc.annual_revenue === 'string') {
|
},
|
||||||
lead.doc.annual_revenue = lead.doc.annual_revenue.replace(/,/g, '')
|
{
|
||||||
} else if (isNaN(lead.doc.annual_revenue)) {
|
validate() {
|
||||||
error.value = __('Annual Revenue should be a number')
|
error.value = null
|
||||||
|
if (!lead.doc.first_name) {
|
||||||
|
error.value = __('First Name is mandatory')
|
||||||
return error.value
|
return error.value
|
||||||
}
|
}
|
||||||
}
|
if (lead.doc.annual_revenue) {
|
||||||
if (
|
if (typeof lead.doc.annual_revenue === 'string') {
|
||||||
lead.doc.mobile_no &&
|
lead.doc.annual_revenue = lead.doc.annual_revenue.replace(/,/g, '')
|
||||||
isNaN(lead.doc.mobile_no.replace(/[-+() ]/g, ''))
|
} else if (isNaN(lead.doc.annual_revenue)) {
|
||||||
) {
|
error.value = __('Annual Revenue should be a number')
|
||||||
error.value = __('Mobile No should be a number')
|
return error.value
|
||||||
return error.value
|
}
|
||||||
}
|
}
|
||||||
if (lead.doc.email && !lead.doc.email.includes('@')) {
|
if (
|
||||||
error.value = __('Invalid Email')
|
lead.doc.mobile_no &&
|
||||||
return error.value
|
isNaN(lead.doc.mobile_no.replace(/[-+() ]/g, ''))
|
||||||
}
|
) {
|
||||||
if (!lead.doc.status) {
|
error.value = __('Mobile No should be a number')
|
||||||
error.value = __('Status is required')
|
return error.value
|
||||||
return error.value
|
}
|
||||||
}
|
if (lead.doc.email && !lead.doc.email.includes('@')) {
|
||||||
isLeadCreating.value = true
|
error.value = __('Invalid Email')
|
||||||
|
return error.value
|
||||||
|
}
|
||||||
|
if (!lead.doc.status) {
|
||||||
|
error.value = __('Status is required')
|
||||||
|
return error.value
|
||||||
|
}
|
||||||
|
isLeadCreating.value = true
|
||||||
|
},
|
||||||
|
onSuccess(data) {
|
||||||
|
capture('lead_created')
|
||||||
|
isLeadCreating.value = false
|
||||||
|
show.value = false
|
||||||
|
router.push({ name: 'Lead', params: { leadId: data.name } })
|
||||||
|
updateOnboardingStep('create_first_lead', true, false, () => {
|
||||||
|
localStorage.setItem('firstLead' + user, data.name)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError(err) {
|
||||||
|
isLeadCreating.value = false
|
||||||
|
if (!err.messages) {
|
||||||
|
error.value = err.message
|
||||||
|
return
|
||||||
|
}
|
||||||
|
error.value = err.messages.join('\n')
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
)
|
||||||
capture('lead_created')
|
|
||||||
isLeadCreating.value = false
|
|
||||||
show.value = false
|
|
||||||
router.push({ name: 'Lead', params: { leadId: data.name } })
|
|
||||||
updateOnboardingStep('create_first_lead', true, false, () => {
|
|
||||||
localStorage.setItem('firstLead' + user, data.name)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onError(err) {
|
|
||||||
isLeadCreating.value = false
|
|
||||||
if (!err.messages) {
|
|
||||||
error.value = err.message
|
|
||||||
return
|
|
||||||
}
|
|
||||||
error.value = err.messages.join('\n')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openQuickEntryModal() {
|
function openQuickEntryModal() {
|
||||||
|
|||||||
98
frontend/src/components/Modals/LostReasonModal.vue
Normal file
98
frontend/src/components/Modals/LostReasonModal.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model="show"
|
||||||
|
:options="{ title: __('Lost reason') }"
|
||||||
|
@close="cancel"
|
||||||
|
>
|
||||||
|
<template #body-content>
|
||||||
|
<div class="-mt-3 mb-4 text-p-base text-ink-gray-7">
|
||||||
|
{{ __('Please provide a reason for marking this deal as lost') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-ink-gray-5">
|
||||||
|
{{ __('Lost reason') }}
|
||||||
|
<span class="text-ink-red-2">*</span>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
class="form-control flex-1 truncate"
|
||||||
|
:value="lostReason"
|
||||||
|
doctype="CRM Lost Reason"
|
||||||
|
@change="(v) => (lostReason = v)"
|
||||||
|
:onCreate="onCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 text-sm text-ink-gray-5">
|
||||||
|
{{ __('Lost notes') }}
|
||||||
|
<span v-if="lostReason == 'Other'" class="text-ink-red-2">*</span>
|
||||||
|
</div>
|
||||||
|
<FormControl
|
||||||
|
class="form-control flex-1 truncate"
|
||||||
|
type="textarea"
|
||||||
|
:value="lostNotes"
|
||||||
|
@change="(e) => (lostNotes = e.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex justify-between items-center gap-2">
|
||||||
|
<div><ErrorMessage :message="error" /></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button :label="__('Cancel')" @click="cancel" />
|
||||||
|
<Button variant="solid" :label="__('Save')" @click="save" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import Link from '@/components/Controls/Link.vue'
|
||||||
|
import { createDocument } from '@/composables/document'
|
||||||
|
import { Dialog } from 'frappe-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
deal: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const show = defineModel()
|
||||||
|
|
||||||
|
const lostReason = ref(props.deal.doc.lost_reason || '')
|
||||||
|
const lostNotes = ref(props.deal.doc.lost_notes || '')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
show.value = false
|
||||||
|
error.value = ''
|
||||||
|
lostReason.value = ''
|
||||||
|
lostNotes.value = ''
|
||||||
|
props.deal.doc.status = props.deal.originalDoc.status
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!lostReason.value) {
|
||||||
|
error.value = __('Lost reason is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (lostReason.value === 'Other' && !lostNotes.value) {
|
||||||
|
error.value = __('Lost notes are required when lost reason is "Other"')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
show.value = false
|
||||||
|
|
||||||
|
props.deal.doc.lost_reason = lostReason.value
|
||||||
|
props.deal.doc.lost_notes = lostNotes.value
|
||||||
|
props.deal.save.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreate(value, close) {
|
||||||
|
createDocument('CRM Lost Reason', value, close)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -88,9 +88,15 @@ const show = defineModel()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
|
|
||||||
const { document: organization } = useDocument('CRM Organization')
|
const { document: organization, triggerOnBeforeCreate } =
|
||||||
|
useDocument('CRM Organization')
|
||||||
|
|
||||||
async function createOrganization() {
|
async function createOrganization() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
await triggerOnBeforeCreate?.()
|
||||||
|
|
||||||
const doc = await call(
|
const doc = await call(
|
||||||
'frappe.client.insert',
|
'frappe.client.insert',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -400,7 +400,7 @@ import { getFormat, evaluateDependsOnValue } from '@/utils'
|
|||||||
import { flt } from '@/utils/numberFormat.js'
|
import { flt } from '@/utils/numberFormat.js'
|
||||||
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
import { Tooltip, DateTimePicker, DatePicker } from 'frappe-ui'
|
||||||
import { useDocument } from '@/data/document'
|
import { useDocument } from '@/data/document'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, getCurrentInstance } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
sections: {
|
sections: {
|
||||||
@ -424,7 +424,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['afterFieldChange', 'reload'])
|
const emit = defineEmits(['beforeFieldChange', 'afterFieldChange', 'reload'])
|
||||||
|
|
||||||
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
|
||||||
getMeta(props.doctype)
|
getMeta(props.doctype)
|
||||||
@ -496,18 +496,23 @@ function parsedField(field) {
|
|||||||
return _field
|
return _field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
const attrs = instance?.vnode?.props ?? {}
|
||||||
|
|
||||||
async function fieldChange(value, df) {
|
async function fieldChange(value, df) {
|
||||||
if (props.preview) return
|
if (props.preview) return
|
||||||
|
|
||||||
await triggerOnChange(df.fieldname, value)
|
await triggerOnChange(df.fieldname, value)
|
||||||
|
|
||||||
document.save.submit(null, {
|
const hasListener = attrs['onBeforeFieldChange'] !== undefined
|
||||||
onSuccess: () => {
|
|
||||||
emit('afterFieldChange', {
|
if (hasListener) {
|
||||||
[df.fieldname]: value,
|
emit('beforeFieldChange', { [df.fieldname]: value })
|
||||||
})
|
} else {
|
||||||
},
|
document.save.submit(null, {
|
||||||
})
|
onSuccess: () => emit('afterFieldChange', { [df.fieldname]: value }),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedSection(section, editButtonAdded) {
|
function parsedSection(section, editButtonAdded) {
|
||||||
|
|||||||
@ -26,7 +26,10 @@ export function useDocument(doctype, docname) {
|
|||||||
let errorMessage = __('Error updating document')
|
let errorMessage = __('Error updating document')
|
||||||
if (err.exc_type == 'MandatoryError') {
|
if (err.exc_type == 'MandatoryError') {
|
||||||
const fieldName = err.messages
|
const fieldName = err.messages
|
||||||
.map((msg) => msg.split(': ')[2].trim())
|
.map((msg) => {
|
||||||
|
let arr = msg.split(': ')
|
||||||
|
return arr[arr.length - 1].trim()
|
||||||
|
})
|
||||||
.join(', ')
|
.join(', ')
|
||||||
errorMessage = __('Mandatory field error: {0}', [fieldName])
|
errorMessage = __('Mandatory field error: {0}', [fieldName])
|
||||||
}
|
}
|
||||||
@ -110,6 +113,14 @@ export function useDocument(doctype, docname) {
|
|||||||
await trigger(handler)
|
await trigger(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function triggerOnBeforeCreate() {
|
||||||
|
const args = Array.from(arguments)
|
||||||
|
const handler = async function () {
|
||||||
|
await (this.onBeforeCreate?.(...args) || this.on_before_create?.(...args))
|
||||||
|
}
|
||||||
|
await trigger(handler)
|
||||||
|
}
|
||||||
|
|
||||||
async function triggerOnSave() {
|
async function triggerOnSave() {
|
||||||
const handler = async function () {
|
const handler = async function () {
|
||||||
await (this.onSave?.() || this.on_save?.())
|
await (this.onSave?.() || this.on_save?.())
|
||||||
@ -125,8 +136,14 @@ export function useDocument(doctype, docname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function triggerOnChange(fieldname, value, row) {
|
async function triggerOnChange(fieldname, value, row) {
|
||||||
const oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
|
let oldValue = null
|
||||||
documentsCache[doctype][docname || ''].doc[fieldname] = value
|
if (row) {
|
||||||
|
oldValue = row[fieldname]
|
||||||
|
row[fieldname] = value
|
||||||
|
} else {
|
||||||
|
oldValue = documentsCache[doctype][docname || ''].doc[fieldname]
|
||||||
|
documentsCache[doctype][docname || ''].doc[fieldname] = value
|
||||||
|
}
|
||||||
|
|
||||||
const handler = async function () {
|
const handler = async function () {
|
||||||
this.value = value
|
this.value = value
|
||||||
@ -140,7 +157,11 @@ export function useDocument(doctype, docname) {
|
|||||||
try {
|
try {
|
||||||
await trigger(handler, row)
|
await trigger(handler, row)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
|
if (row) {
|
||||||
|
row[fieldname] = oldValue
|
||||||
|
} else {
|
||||||
|
documentsCache[doctype][docname || ''].doc[fieldname] = oldValue
|
||||||
|
}
|
||||||
console.error(handler)
|
console.error(handler)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@ -185,8 +206,7 @@ export function useDocument(doctype, docname) {
|
|||||||
async function triggerConvertToDeal() {
|
async function triggerConvertToDeal() {
|
||||||
const args = Array.from(arguments)
|
const args = Array.from(arguments)
|
||||||
const handler = async function () {
|
const handler = async function () {
|
||||||
await (this.convertToDeal?.(...args) ||
|
await (this.convertToDeal?.(...args) || this.convert_to_deal?.(...args))
|
||||||
this.on_convert_to_deal?.(...args))
|
|
||||||
}
|
}
|
||||||
await trigger(handler)
|
await trigger(handler)
|
||||||
}
|
}
|
||||||
@ -202,26 +222,12 @@ export function useDocument(doctype, docname) {
|
|||||||
await runSequentially(tasks)
|
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 {
|
return {
|
||||||
document: documentsCache[doctype][docname || ''],
|
document: documentsCache[doctype][docname || ''],
|
||||||
assignees,
|
assignees,
|
||||||
getControllers,
|
getControllers,
|
||||||
triggerOnLoad,
|
triggerOnLoad,
|
||||||
|
triggerOnBeforeCreate,
|
||||||
triggerOnSave,
|
triggerOnSave,
|
||||||
triggerOnRefresh,
|
triggerOnRefresh,
|
||||||
triggerOnChange,
|
triggerOnChange,
|
||||||
|
|||||||
@ -26,9 +26,10 @@
|
|||||||
:options="
|
:options="
|
||||||
statusOptions(
|
statusOptions(
|
||||||
'deal',
|
'deal',
|
||||||
document,
|
document.statuses?.length
|
||||||
deal.data._customStatuses,
|
? document.statuses
|
||||||
triggerOnChange,
|
: deal.data._customStatuses,
|
||||||
|
triggerStatusChange,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -60,6 +61,7 @@
|
|||||||
v-model:reload="reload"
|
v-model:reload="reload"
|
||||||
v-model:tabIndex="tabIndex"
|
v-model:tabIndex="tabIndex"
|
||||||
v-model="deal"
|
v-model="deal"
|
||||||
|
@beforeSave="beforeStatusChange"
|
||||||
@afterSave="reloadAssignees"
|
@afterSave="reloadAssignees"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@ -157,6 +159,7 @@
|
|||||||
doctype="CRM Deal"
|
doctype="CRM Deal"
|
||||||
:docname="deal.data.name"
|
:docname="deal.data.name"
|
||||||
@reload="sections.reload"
|
@reload="sections.reload"
|
||||||
|
@beforeFieldChange="beforeStatusChange"
|
||||||
@afterFieldChange="reloadAssignees"
|
@afterFieldChange="reloadAssignees"
|
||||||
>
|
>
|
||||||
<template #actions="{ section }">
|
<template #actions="{ section }">
|
||||||
@ -336,6 +339,11 @@
|
|||||||
:docname="props.dealId"
|
:docname="props.dealId"
|
||||||
name="Deals"
|
name="Deals"
|
||||||
/>
|
/>
|
||||||
|
<LostReasonModal
|
||||||
|
v-if="showLostReasonModal"
|
||||||
|
v-model="showLostReasonModal"
|
||||||
|
:deal="document"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import ErrorPage from '@/components/ErrorPage.vue'
|
import ErrorPage from '@/components/ErrorPage.vue'
|
||||||
@ -359,6 +367,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
|||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Activities from '@/components/Activities/Activities.vue'
|
import Activities from '@/components/Activities/Activities.vue'
|
||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
|
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
|
||||||
import AssignTo from '@/components/AssignTo.vue'
|
import AssignTo from '@/components/AssignTo.vue'
|
||||||
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||||
@ -765,6 +774,36 @@ const { assignees, document, triggerOnChange } = useDocument(
|
|||||||
props.dealId,
|
props.dealId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function triggerStatusChange(value) {
|
||||||
|
await triggerOnChange('status', value)
|
||||||
|
setLostReason()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLostReasonModal = ref(false)
|
||||||
|
|
||||||
|
function setLostReason() {
|
||||||
|
if (
|
||||||
|
document.doc.status !== 'Lost' ||
|
||||||
|
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
|
||||||
|
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
|
||||||
|
) {
|
||||||
|
document.save.submit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showLostReasonModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeStatusChange(data) {
|
||||||
|
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
|
||||||
|
setLostReason()
|
||||||
|
} else {
|
||||||
|
document.save.submit(null, {
|
||||||
|
onSuccess: () => reloadAssignees(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reloadAssignees(data) {
|
function reloadAssignees(data) {
|
||||||
if (data?.hasOwnProperty('deal_owner')) {
|
if (data?.hasOwnProperty('deal_owner')) {
|
||||||
assignees.reload()
|
assignees.reload()
|
||||||
|
|||||||
@ -26,9 +26,10 @@
|
|||||||
:options="
|
:options="
|
||||||
statusOptions(
|
statusOptions(
|
||||||
'lead',
|
'lead',
|
||||||
document,
|
document.statuses?.length
|
||||||
lead.data._customStatuses,
|
? document.statuses
|
||||||
triggerOnChange,
|
: lead.data._customStatuses,
|
||||||
|
triggerStatusChange,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -226,108 +227,11 @@
|
|||||||
:errorTitle="errorTitle"
|
:errorTitle="errorTitle"
|
||||||
:errorMessage="errorMessage"
|
:errorMessage="errorMessage"
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<ConvertToDealModal
|
||||||
|
v-if="showConvertToDealModal"
|
||||||
v-model="showConvertToDealModal"
|
v-model="showConvertToDealModal"
|
||||||
:options="{
|
:lead="lead.data"
|
||||||
size: 'xl',
|
/>
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: __('Convert'),
|
|
||||||
variant: 'solid',
|
|
||||||
onClick: convertToDeal,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #body-header>
|
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
|
|
||||||
{{ __('Convert to Deal') }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
v-if="isManager() && !isMobileView"
|
|
||||||
variant="ghost"
|
|
||||||
class="w-7"
|
|
||||||
@click="openQuickEntryModal"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<EditIcon class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
class="w-7"
|
|
||||||
@click="showConvertToDealModal = false"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<FeatherIcon name="x" class="h-4 w-4" />
|
|
||||||
</template>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #body-content>
|
|
||||||
<div class="mb-4 flex items-center gap-2 text-ink-gray-5">
|
|
||||||
<OrganizationsIcon class="h-4 w-4" />
|
|
||||||
<label class="block text-base">{{ __('Organization') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="ml-6 text-ink-gray-9">
|
|
||||||
<div class="flex items-center justify-between text-base">
|
|
||||||
<div>{{ __('Choose Existing') }}</div>
|
|
||||||
<Switch v-model="existingOrganizationChecked" />
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
v-if="existingOrganizationChecked"
|
|
||||||
class="form-control mt-2.5"
|
|
||||||
size="md"
|
|
||||||
:value="existingOrganization"
|
|
||||||
doctype="CRM Organization"
|
|
||||||
@change="(data) => (existingOrganization = data)"
|
|
||||||
/>
|
|
||||||
<div v-else class="mt-2.5 text-base">
|
|
||||||
{{
|
|
||||||
__(
|
|
||||||
'New organization will be created based on the data in details section',
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4 mt-6 flex items-center gap-2 text-ink-gray-5">
|
|
||||||
<ContactsIcon class="h-4 w-4" />
|
|
||||||
<label class="block text-base">{{ __('Contact') }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="ml-6 text-ink-gray-9">
|
|
||||||
<div class="flex items-center justify-between text-base">
|
|
||||||
<div>{{ __('Choose Existing') }}</div>
|
|
||||||
<Switch v-model="existingContactChecked" />
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
v-if="existingContactChecked"
|
|
||||||
class="form-control mt-2.5"
|
|
||||||
size="md"
|
|
||||||
:value="existingContact"
|
|
||||||
doctype="Contact"
|
|
||||||
@change="(data) => (existingContact = data)"
|
|
||||||
/>
|
|
||||||
<div v-else class="mt-2.5 text-base">
|
|
||||||
{{ __("New contact will be created based on the person's details") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="dealTabs.data?.length" class="h-px w-full border-t my-6" />
|
|
||||||
|
|
||||||
<FieldLayout
|
|
||||||
v-if="dealTabs.data?.length"
|
|
||||||
:tabs="dealTabs.data"
|
|
||||||
:data="deal"
|
|
||||||
doctype="CRM Deal"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
<FilesUploader
|
<FilesUploader
|
||||||
v-if="lead.data?.name"
|
v-if="lead.data?.name"
|
||||||
v-model="showFilesUploader"
|
v-model="showFilesUploader"
|
||||||
@ -364,40 +268,28 @@ import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
|||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||||
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
|
||||||
import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
|
|
||||||
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Activities from '@/components/Activities/Activities.vue'
|
import Activities from '@/components/Activities/Activities.vue'
|
||||||
import AssignTo from '@/components/AssignTo.vue'
|
import AssignTo from '@/components/AssignTo.vue'
|
||||||
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
|
||||||
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
import SidePanelLayout from '@/components/SidePanelLayout.vue'
|
||||||
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
|
|
||||||
import SLASection from '@/components/SLASection.vue'
|
import SLASection from '@/components/SLASection.vue'
|
||||||
import CustomActions from '@/components/CustomActions.vue'
|
import CustomActions from '@/components/CustomActions.vue'
|
||||||
|
import ConvertToDealModal from '@/components/Modals/ConvertToDealModal.vue'
|
||||||
import {
|
import {
|
||||||
openWebsite,
|
openWebsite,
|
||||||
setupCustomizations,
|
setupCustomizations,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
validateIsImageFile,
|
validateIsImageFile,
|
||||||
} from '@/utils'
|
} from '@/utils'
|
||||||
import { showQuickEntryModal, quickEntryProps } from '@/composables/modals'
|
|
||||||
import { getView } from '@/utils/view'
|
import { getView } from '@/utils/view'
|
||||||
import { getSettings } from '@/stores/settings'
|
import { getSettings } from '@/stores/settings'
|
||||||
import { sessionStore } from '@/stores/session'
|
|
||||||
import { usersStore } from '@/stores/users'
|
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
import { getMeta } from '@/stores/meta'
|
import { getMeta } from '@/stores/meta'
|
||||||
import { useDocument } from '@/data/document'
|
import { useDocument } from '@/data/document'
|
||||||
import {
|
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||||
whatsappEnabled,
|
|
||||||
callEnabled,
|
|
||||||
isMobileView,
|
|
||||||
} from '@/composables/settings'
|
|
||||||
import { capture } from '@/telemetry'
|
|
||||||
import {
|
import {
|
||||||
createResource,
|
createResource,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
@ -405,26 +297,20 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Avatar,
|
Avatar,
|
||||||
Tabs,
|
Tabs,
|
||||||
Switch,
|
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
call,
|
call,
|
||||||
usePageMeta,
|
usePageMeta,
|
||||||
toast,
|
toast,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { useOnboarding } from 'frappe-ui/frappe'
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useActiveTabManager } from '@/composables/useActiveTabManager'
|
import { useActiveTabManager } from '@/composables/useActiveTabManager'
|
||||||
|
|
||||||
const { brand } = getSettings()
|
const { brand } = getSettings()
|
||||||
const { user } = sessionStore()
|
|
||||||
const { isManager } = usersStore()
|
|
||||||
const { $dialog, $socket, makeCall } = globalStore()
|
const { $dialog, $socket, makeCall } = globalStore()
|
||||||
const { statusOptions, getLeadStatus, getDealStatus } = statusesStore()
|
const { statusOptions, getLeadStatus } = statusesStore()
|
||||||
const { doctypeMeta } = getMeta('CRM Lead')
|
const { doctypeMeta } = getMeta('CRM Lead')
|
||||||
|
|
||||||
const { updateOnboardingStep } = useOnboarding('frappecrm')
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -438,6 +324,17 @@ const props = defineProps({
|
|||||||
const errorTitle = ref('')
|
const errorTitle = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const showDeleteLinkedDocModal = ref(false)
|
const showDeleteLinkedDocModal = ref(false)
|
||||||
|
const showConvertToDealModal = ref(false)
|
||||||
|
|
||||||
|
const { triggerOnChange, assignees, document } = useDocument(
|
||||||
|
'CRM Lead',
|
||||||
|
props.leadId,
|
||||||
|
)
|
||||||
|
|
||||||
|
async function triggerStatusChange(value) {
|
||||||
|
await triggerOnChange('status', value)
|
||||||
|
document.save.submit()
|
||||||
|
}
|
||||||
|
|
||||||
const lead = createResource({
|
const lead = createResource({
|
||||||
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
|
url: 'crm.fcrm.doctype.crm_lead.api.get_lead',
|
||||||
@ -640,64 +537,6 @@ async function deleteLeadWithModal(name) {
|
|||||||
showDeleteLinkedDocModal.value = true
|
showDeleteLinkedDocModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Deal
|
|
||||||
const showConvertToDealModal = ref(false)
|
|
||||||
const existingContactChecked = ref(false)
|
|
||||||
const existingOrganizationChecked = ref(false)
|
|
||||||
|
|
||||||
const existingContact = ref('')
|
|
||||||
const existingOrganization = ref('')
|
|
||||||
|
|
||||||
const { triggerConvertToDeal, triggerOnChange, assignees, document } =
|
|
||||||
useDocument('CRM Lead', props.leadId)
|
|
||||||
|
|
||||||
async function convertToDeal() {
|
|
||||||
if (existingContactChecked.value && !existingContact.value) {
|
|
||||||
toast.error(__('Please select an existing contact'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingOrganizationChecked.value && !existingOrganization.value) {
|
|
||||||
toast.error(__('Please select an existing organization'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingContactChecked.value && existingContact.value) {
|
|
||||||
existingContact.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existingOrganizationChecked.value && existingOrganization.value) {
|
|
||||||
existingOrganization.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
await triggerConvertToDeal?.(
|
|
||||||
lead.data,
|
|
||||||
deal,
|
|
||||||
() => (showConvertToDealModal.value = false),
|
|
||||||
)
|
|
||||||
|
|
||||||
let _deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
|
||||||
lead: lead.data.name,
|
|
||||||
deal,
|
|
||||||
existing_contact: existingContact.value,
|
|
||||||
existing_organization: existingOrganization.value,
|
|
||||||
}).catch((err) => {
|
|
||||||
toast.error(__('Error converting to deal: {0}', [err.messages?.[0]]))
|
|
||||||
})
|
|
||||||
if (_deal) {
|
|
||||||
showConvertToDealModal.value = false
|
|
||||||
existingContactChecked.value = false
|
|
||||||
existingOrganizationChecked.value = false
|
|
||||||
existingContact.value = ''
|
|
||||||
existingOrganization.value = ''
|
|
||||||
updateOnboardingStep('convert_lead_to_deal', true, false, () => {
|
|
||||||
localStorage.setItem('firstDeal' + user, _deal)
|
|
||||||
})
|
|
||||||
capture('convert_lead_to_deal')
|
|
||||||
router.push({ name: 'Deal', params: { dealId: _deal } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activities = ref(null)
|
const activities = ref(null)
|
||||||
|
|
||||||
function openEmailBox() {
|
function openEmailBox() {
|
||||||
@ -708,54 +547,6 @@ function openEmailBox() {
|
|||||||
nextTick(() => (activities.value.emailBox.show = true))
|
nextTick(() => (activities.value.emailBox.show = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
const deal = reactive({})
|
|
||||||
|
|
||||||
const dealStatuses = computed(() => {
|
|
||||||
let statuses = statusOptions('deal')
|
|
||||||
if (!deal.status) {
|
|
||||||
deal.status = statuses[0].value
|
|
||||||
}
|
|
||||||
return statuses
|
|
||||||
})
|
|
||||||
|
|
||||||
const dealTabs = createResource({
|
|
||||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
|
||||||
cache: ['RequiredFields', 'CRM Deal'],
|
|
||||||
params: { doctype: 'CRM Deal', type: 'Required Fields' },
|
|
||||||
auto: true,
|
|
||||||
transform: (_tabs) => {
|
|
||||||
let hasFields = false
|
|
||||||
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'
|
|
||||||
field.options = dealStatuses.value
|
|
||||||
field.prefix = getDealStatus(deal.status).color
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.fieldtype === 'Table') {
|
|
||||||
deal[field.fieldname] = []
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return hasFields ? parsedTabs : []
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function openQuickEntryModal() {
|
|
||||||
showQuickEntryModal.value = true
|
|
||||||
quickEntryProps.value = {
|
|
||||||
doctype: 'CRM Deal',
|
|
||||||
onlyRequired: true,
|
|
||||||
}
|
|
||||||
showConvertToDealModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function reloadAssignees(data) {
|
function reloadAssignees(data) {
|
||||||
if (data?.hasOwnProperty('lead_owner')) {
|
if (data?.hasOwnProperty('lead_owner')) {
|
||||||
assignees.reload()
|
assignees.reload()
|
||||||
|
|||||||
@ -14,9 +14,10 @@
|
|||||||
:options="
|
:options="
|
||||||
statusOptions(
|
statusOptions(
|
||||||
'deal',
|
'deal',
|
||||||
document,
|
document.statuses?.length
|
||||||
deal.data._customStatuses,
|
? document.statuses
|
||||||
triggerOnChange,
|
: deal.data._customStatuses,
|
||||||
|
triggerStatusChange,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -78,6 +79,7 @@
|
|||||||
doctype="CRM Deal"
|
doctype="CRM Deal"
|
||||||
:docname="deal.data.name"
|
:docname="deal.data.name"
|
||||||
@reload="sections.reload"
|
@reload="sections.reload"
|
||||||
|
@beforeFieldChange="beforeStatusChange"
|
||||||
@afterFieldChange="reloadAssignees"
|
@afterFieldChange="reloadAssignees"
|
||||||
>
|
>
|
||||||
<template #actions="{ section }">
|
<template #actions="{ section }">
|
||||||
@ -222,6 +224,8 @@
|
|||||||
v-model:reload="reload"
|
v-model:reload="reload"
|
||||||
v-model:tabIndex="tabIndex"
|
v-model:tabIndex="tabIndex"
|
||||||
v-model="deal"
|
v-model="deal"
|
||||||
|
@beforeSave="beforeStatusChange"
|
||||||
|
@afterSave="reloadAssignees"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@ -244,6 +248,11 @@
|
|||||||
afterInsert: (doc) => addContact(doc.name),
|
afterInsert: (doc) => addContact(doc.name),
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
<LostReasonModal
|
||||||
|
v-if="showLostReasonModal"
|
||||||
|
v-model="showLostReasonModal"
|
||||||
|
:deal="document"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Icon from '@/components/Icon.vue'
|
import Icon from '@/components/Icon.vue'
|
||||||
@ -264,6 +273,7 @@ import SuccessIcon from '@/components/Icons/SuccessIcon.vue'
|
|||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import Activities from '@/components/Activities/Activities.vue'
|
import Activities from '@/components/Activities/Activities.vue'
|
||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
|
import LostReasonModal from '@/components/Modals/LostReasonModal.vue'
|
||||||
import AssignTo from '@/components/AssignTo.vue'
|
import AssignTo from '@/components/AssignTo.vue'
|
||||||
import ContactModal from '@/components/Modals/ContactModal.vue'
|
import ContactModal from '@/components/Modals/ContactModal.vue'
|
||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
@ -624,6 +634,36 @@ const { assignees, document, triggerOnChange } = useDocument(
|
|||||||
props.dealId,
|
props.dealId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function triggerStatusChange(value) {
|
||||||
|
await triggerOnChange('status', value)
|
||||||
|
setLostReason()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLostReasonModal = ref(false)
|
||||||
|
|
||||||
|
function setLostReason() {
|
||||||
|
if (
|
||||||
|
document.doc.status !== 'Lost' ||
|
||||||
|
(document.doc.lost_reason && document.doc.lost_reason !== 'Other') ||
|
||||||
|
(document.doc.lost_reason === 'Other' && document.doc.lost_notes)
|
||||||
|
) {
|
||||||
|
document.save.submit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showLostReasonModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeStatusChange(data) {
|
||||||
|
if (data?.hasOwnProperty('status') && data.status == 'Lost') {
|
||||||
|
setLostReason()
|
||||||
|
} else {
|
||||||
|
document.save.submit(null, {
|
||||||
|
onSuccess: () => reloadAssignees(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function reloadAssignees(data) {
|
function reloadAssignees(data) {
|
||||||
if (data?.hasOwnProperty('deal_owner')) {
|
if (data?.hasOwnProperty('deal_owner')) {
|
||||||
assignees.reload()
|
assignees.reload()
|
||||||
|
|||||||
@ -14,9 +14,10 @@
|
|||||||
:options="
|
:options="
|
||||||
statusOptions(
|
statusOptions(
|
||||||
'lead',
|
'lead',
|
||||||
document,
|
document.statuses?.length
|
||||||
lead.data._customStatuses,
|
? document.statuses
|
||||||
triggerOnChange,
|
: lead.data._customStatuses,
|
||||||
|
triggerStatusChange,
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -473,6 +474,11 @@ const { assignees, document, triggerOnChange } = useDocument(
|
|||||||
props.leadId,
|
props.leadId,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function triggerStatusChange(value) {
|
||||||
|
await triggerOnChange('status', value)
|
||||||
|
document.save.submit()
|
||||||
|
}
|
||||||
|
|
||||||
function reloadAssignees(data) {
|
function reloadAssignees(data) {
|
||||||
if (data?.hasOwnProperty('lead_owner')) {
|
if (data?.hasOwnProperty('lead_owner')) {
|
||||||
assignees.reload()
|
assignees.reload()
|
||||||
|
|||||||
@ -77,19 +77,10 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
return communicationStatuses[name]
|
return communicationStatuses[name]
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusOptions(
|
function statusOptions(doctype, statuses = [], triggerStatusChange = null) {
|
||||||
doctype,
|
|
||||||
document,
|
|
||||||
statuses = [],
|
|
||||||
triggerOnChange = null,
|
|
||||||
) {
|
|
||||||
let statusesByName =
|
let statusesByName =
|
||||||
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
|
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
|
||||||
|
|
||||||
if (document?.statuses?.length) {
|
|
||||||
statuses = document.statuses
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statuses?.length) {
|
if (statuses?.length) {
|
||||||
statusesByName = statuses.reduce((acc, status) => {
|
statusesByName = statuses.reduce((acc, status) => {
|
||||||
acc[status] = statusesByName[status]
|
acc[status] = statusesByName[status]
|
||||||
@ -104,11 +95,8 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
value: statusesByName[status]?.name,
|
value: statusesByName[status]?.name,
|
||||||
icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }),
|
icon: () => h(IndicatorIcon, { class: statusesByName[status]?.color }),
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
|
await triggerStatusChange?.(statusesByName[status]?.name)
|
||||||
capture('status_changed', { doctype, status })
|
capture('status_changed', { doctype, status })
|
||||||
if (document) {
|
|
||||||
await triggerOnChange?.('status', statusesByName[status]?.name)
|
|
||||||
document.save.submit()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,7 +85,7 @@ export function prettyDate(date, mini = false) {
|
|||||||
let nowDatetime = dayjs().tz(localTimezone || systemTimezone)
|
let nowDatetime = dayjs().tz(localTimezone || systemTimezone)
|
||||||
let diff = nowDatetime.diff(date, 'seconds')
|
let diff = nowDatetime.diff(date, 'seconds')
|
||||||
|
|
||||||
let dayDiff = Math.floor(diff / 86400)
|
let dayDiff = diff / 86400
|
||||||
|
|
||||||
if (isNaN(dayDiff)) return ''
|
if (isNaN(dayDiff)) return ''
|
||||||
|
|
||||||
@ -93,18 +93,18 @@ export function prettyDate(date, mini = false) {
|
|||||||
// Return short format of time difference
|
// Return short format of time difference
|
||||||
if (dayDiff < 0) {
|
if (dayDiff < 0) {
|
||||||
if (Math.abs(dayDiff) < 1) {
|
if (Math.abs(dayDiff) < 1) {
|
||||||
if (diff < 60) {
|
if (Math.abs(diff) < 60) {
|
||||||
return __('now')
|
return __('now')
|
||||||
} else if (diff < 3600) {
|
} else if (Math.abs(diff) < 3600) {
|
||||||
return __('in {0} m', [Math.floor(diff / 60)])
|
return __('in {0} m', [Math.floor(Math.abs(diff) / 60)])
|
||||||
} else if (diff < 86400) {
|
} else if (Math.abs(diff) < 86400) {
|
||||||
return __('in {0} h', [Math.floor(diff / 3600)])
|
return __('in {0} h', [Math.floor(Math.abs(diff) / 3600)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Math.abs(dayDiff) == 1) {
|
if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) {
|
||||||
return __('tomorrow')
|
return __('tomorrow')
|
||||||
} else if (Math.abs(dayDiff) < 7) {
|
} else if (Math.abs(dayDiff) < 7) {
|
||||||
return __('in {0} d', [Math.abs(dayDiff)])
|
return __('in {0} d', [Math.floor(Math.abs(dayDiff))])
|
||||||
} else if (Math.abs(dayDiff) < 31) {
|
} else if (Math.abs(dayDiff) < 31) {
|
||||||
return __('in {0} w', [Math.floor(Math.abs(dayDiff) / 7)])
|
return __('in {0} w', [Math.floor(Math.abs(dayDiff) / 7)])
|
||||||
} else if (Math.abs(dayDiff) < 365) {
|
} else if (Math.abs(dayDiff) < 365) {
|
||||||
@ -112,7 +112,7 @@ export function prettyDate(date, mini = false) {
|
|||||||
} else {
|
} else {
|
||||||
return __('in {0} y', [Math.floor(Math.abs(dayDiff) / 365)])
|
return __('in {0} y', [Math.floor(Math.abs(dayDiff) / 365)])
|
||||||
}
|
}
|
||||||
} else if (dayDiff == 0) {
|
} else if (dayDiff >= 0 && dayDiff < 1) {
|
||||||
if (diff < 60) {
|
if (diff < 60) {
|
||||||
return __('now')
|
return __('now')
|
||||||
} else if (diff < 3600) {
|
} else if (diff < 3600) {
|
||||||
@ -121,6 +121,7 @@ export function prettyDate(date, mini = false) {
|
|||||||
return __('{0} h', [Math.floor(diff / 3600)])
|
return __('{0} h', [Math.floor(diff / 3600)])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
dayDiff = Math.floor(dayDiff)
|
||||||
if (dayDiff < 7) {
|
if (dayDiff < 7) {
|
||||||
return __('{0} d', [dayDiff])
|
return __('{0} d', [dayDiff])
|
||||||
} else if (dayDiff < 31) {
|
} else if (dayDiff < 31) {
|
||||||
@ -135,22 +136,22 @@ export function prettyDate(date, mini = false) {
|
|||||||
// Return long format of time difference
|
// Return long format of time difference
|
||||||
if (dayDiff < 0) {
|
if (dayDiff < 0) {
|
||||||
if (Math.abs(dayDiff) < 1) {
|
if (Math.abs(dayDiff) < 1) {
|
||||||
if (diff < 60) {
|
if (Math.abs(diff) < 60) {
|
||||||
return __('just now')
|
return __('just now')
|
||||||
} else if (diff < 120) {
|
} else if (Math.abs(diff) < 120) {
|
||||||
return __('in 1 minute')
|
return __('in 1 minute')
|
||||||
} else if (diff < 3600) {
|
} else if (Math.abs(diff) < 3600) {
|
||||||
return __('in {0} minutes', [Math.floor(diff / 60)])
|
return __('in {0} minutes', [Math.floor(Math.abs(diff) / 60)])
|
||||||
} else if (diff < 7200) {
|
} else if (Math.abs(diff) < 7200) {
|
||||||
return __('in 1 hour')
|
return __('in 1 hour')
|
||||||
} else if (diff < 86400) {
|
} else if (Math.abs(diff) < 86400) {
|
||||||
return __('in {0} hours', [Math.floor(diff / 3600)])
|
return __('in {0} hours', [Math.floor(Math.abs(diff) / 3600)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Math.abs(dayDiff) == 1) {
|
if (Math.abs(dayDiff) >= 1 && Math.abs(dayDiff) < 1.5) {
|
||||||
return __('tomorrow')
|
return __('tomorrow')
|
||||||
} else if (Math.abs(dayDiff) < 7) {
|
} else if (Math.abs(dayDiff) < 7) {
|
||||||
return __('in {0} days', [Math.abs(dayDiff)])
|
return __('in {0} days', [Math.floor(Math.abs(dayDiff))])
|
||||||
} else if (Math.abs(dayDiff) < 31) {
|
} else if (Math.abs(dayDiff) < 31) {
|
||||||
return __('in {0} weeks', [Math.floor(Math.abs(dayDiff) / 7)])
|
return __('in {0} weeks', [Math.floor(Math.abs(dayDiff) / 7)])
|
||||||
} else if (Math.abs(dayDiff) < 365) {
|
} else if (Math.abs(dayDiff) < 365) {
|
||||||
@ -160,7 +161,7 @@ export function prettyDate(date, mini = false) {
|
|||||||
} else {
|
} else {
|
||||||
return __('in {0} years', [Math.floor(Math.abs(dayDiff) / 365)])
|
return __('in {0} years', [Math.floor(Math.abs(dayDiff) / 365)])
|
||||||
}
|
}
|
||||||
} else if (dayDiff == 0) {
|
} else if (dayDiff >= 0 && dayDiff < 1) {
|
||||||
if (diff < 60) {
|
if (diff < 60) {
|
||||||
return __('just now')
|
return __('just now')
|
||||||
} else if (diff < 120) {
|
} else if (diff < 120) {
|
||||||
@ -173,6 +174,7 @@ export function prettyDate(date, mini = false) {
|
|||||||
return __('{0} hours ago', [Math.floor(diff / 3600)])
|
return __('{0} hours ago', [Math.floor(diff / 3600)])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
dayDiff = Math.floor(dayDiff)
|
||||||
if (dayDiff == 1) {
|
if (dayDiff == 1) {
|
||||||
return __('yesterday')
|
return __('yesterday')
|
||||||
} else if (dayDiff < 7) {
|
} else if (dayDiff < 7) {
|
||||||
|
|||||||
37
yarn.lock
37
yarn.lock
@ -1562,7 +1562,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/trusted-types@^2.0.2":
|
"@types/trusted-types@^2.0.2", "@types/trusted-types@^2.0.7":
|
||||||
version "2.0.7"
|
version "2.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
|
||||||
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
|
||||||
@ -2094,11 +2094,6 @@ commander@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
|
||||||
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
|
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
|
||||||
|
|
||||||
commander@^9.0.0:
|
|
||||||
version "9.5.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30"
|
|
||||||
integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==
|
|
||||||
|
|
||||||
common-tags@^1.8.0:
|
common-tags@^1.8.0:
|
||||||
version "1.8.2"
|
version "1.8.2"
|
||||||
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
|
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.2.tgz#94ebb3c076d26032745fd54face7f688ef5ac9c6"
|
||||||
@ -2273,6 +2268,13 @@ dlv@^1.1.3:
|
|||||||
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
|
resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79"
|
||||||
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==
|
||||||
|
|
||||||
|
dompurify@^3.2.6:
|
||||||
|
version "3.2.6"
|
||||||
|
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
|
||||||
|
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
|
||||||
|
optionalDependencies:
|
||||||
|
"@types/trusted-types" "^2.0.7"
|
||||||
|
|
||||||
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
dunder-proto@^1.0.0, dunder-proto@^1.0.1:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
|
||||||
@ -2570,10 +2572,10 @@ fraction.js@^4.3.7:
|
|||||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||||
|
|
||||||
frappe-ui@^0.1.162:
|
frappe-ui@^0.1.166:
|
||||||
version "0.1.162"
|
version "0.1.166"
|
||||||
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.162.tgz#01a2f06e9db70b1bce6e0b0f2089a9cc1cb8dd51"
|
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.166.tgz#2664d9373b4751a39144c283be67f219c5eb99e3"
|
||||||
integrity sha512-LdlEQ1I8oMj2TAmx0FGuJl+AwQ6/jqtwEy3lei3mH6SVArfGnoVDqLm8aeJTwAB6KUjgCj+ffWe6vN7HmZXIcg==
|
integrity sha512-VSv2OE/JHa4ReOW0/9SafRzvQ6Dkxa1Bz6u58UU8FvagqpJVorQJlm2854LXuCk1IDV+uulPCr7uxiC8kwcjFw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@floating-ui/vue" "^1.1.6"
|
"@floating-ui/vue" "^1.1.6"
|
||||||
"@headlessui/vue" "^1.7.14"
|
"@headlessui/vue" "^1.7.14"
|
||||||
@ -2603,12 +2605,14 @@ frappe-ui@^0.1.162:
|
|||||||
"@tiptap/vue-3" "^2.0.3"
|
"@tiptap/vue-3" "^2.0.3"
|
||||||
"@vueuse/core" "^10.4.1"
|
"@vueuse/core" "^10.4.1"
|
||||||
dayjs "^1.11.13"
|
dayjs "^1.11.13"
|
||||||
|
dompurify "^3.2.6"
|
||||||
echarts "^5.6.0"
|
echarts "^5.6.0"
|
||||||
feather-icons "^4.28.0"
|
feather-icons "^4.28.0"
|
||||||
highlight.js "^11.11.1"
|
highlight.js "^11.11.1"
|
||||||
idb-keyval "^6.2.0"
|
idb-keyval "^6.2.0"
|
||||||
lowlight "^3.3.0"
|
lowlight "^3.3.0"
|
||||||
lucide-static "^0.479.0"
|
lucide-static "^0.479.0"
|
||||||
|
marked "^15.0.12"
|
||||||
ora "5.4.1"
|
ora "5.4.1"
|
||||||
prettier "^3.3.2"
|
prettier "^3.3.2"
|
||||||
prosemirror-model "^1.25.1"
|
prosemirror-model "^1.25.1"
|
||||||
@ -2616,7 +2620,6 @@ frappe-ui@^0.1.162:
|
|||||||
prosemirror-view "^1.39.2"
|
prosemirror-view "^1.39.2"
|
||||||
radix-vue "^1.5.3"
|
radix-vue "^1.5.3"
|
||||||
reka-ui "^2.0.2"
|
reka-ui "^2.0.2"
|
||||||
showdown "^2.1.0"
|
|
||||||
socket.io-client "^4.5.1"
|
socket.io-client "^4.5.1"
|
||||||
tippy.js "^6.3.7"
|
tippy.js "^6.3.7"
|
||||||
typescript "^5.0.2"
|
typescript "^5.0.2"
|
||||||
@ -3305,6 +3308,11 @@ markdown-it@^14.0.0:
|
|||||||
punycode.js "^2.3.1"
|
punycode.js "^2.3.1"
|
||||||
uc.micro "^2.1.0"
|
uc.micro "^2.1.0"
|
||||||
|
|
||||||
|
marked@^15.0.12:
|
||||||
|
version "15.0.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e"
|
||||||
|
integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==
|
||||||
|
|
||||||
math-intrinsics@^1.0.0:
|
math-intrinsics@^1.0.0:
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||||
@ -4146,13 +4154,6 @@ shebang-regex@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||||
|
|
||||||
showdown@^2.1.0:
|
|
||||||
version "2.1.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/showdown/-/showdown-2.1.0.tgz#1251f5ed8f773f0c0c7bfc8e6fd23581f9e545c5"
|
|
||||||
integrity sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==
|
|
||||||
dependencies:
|
|
||||||
commander "^9.0.0"
|
|
||||||
|
|
||||||
side-channel-list@^1.0.0:
|
side-channel-list@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
|
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user