From cd22d1a15d867042e80d47de062dfa87fdced508 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 4 Dec 2023 14:24:44 +0530 Subject: [PATCH 1/9] fix: added sla & sla priority doctype --- .../crm_service_level_agreement/__init__.py | 0 .../crm_service_level_agreement.js | 21 ++++ .../crm_service_level_agreement.json | 96 +++++++++++++++++++ .../crm_service_level_agreement.py | 9 ++ .../test_crm_service_level_agreement.py | 9 ++ .../crm_service_level_priority/__init__.py | 0 .../crm_service_level_priority.js | 8 ++ .../crm_service_level_priority.json | 57 +++++++++++ .../crm_service_level_priority.py | 9 ++ .../test_crm_service_level_priority.py | 9 ++ 10 files changed, 218 insertions(+) create mode 100644 crm/fcrm/doctype/crm_service_level_agreement/__init__.py create mode 100644 crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js create mode 100644 crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json create mode 100644 crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py create mode 100644 crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py create mode 100644 crm/fcrm/doctype/crm_service_level_priority/__init__.py create mode 100644 crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js create mode 100644 crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json create mode 100644 crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py create mode 100644 crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py diff --git a/crm/fcrm/doctype/crm_service_level_agreement/__init__.py b/crm/fcrm/doctype/crm_service_level_agreement/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js new file mode 100644 index 00000000..d1c3549c --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.js @@ -0,0 +1,21 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Service Level Agreement", { +// refresh(frm) { + +// }, +// }); + +frappe.ui.form.on("CRM Service Level Priority", { + priorities_add: function (frm, cdt, cdn) { + if (frm.doc.apply_on == "CRM Deal") { + frappe.model.set_value( + cdt, + cdn, + "reference_doctype", + "CRM Deal Status" + ); + } + }, +}); diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json new file mode 100644 index 00000000..11835677 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json @@ -0,0 +1,96 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:sla_name", + "creation": "2023-12-04 13:07:18.426211", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "sla_name", + "apply_on", + "enabled", + "default", + "column_break_uxua", + "condition", + "section_break_ufaf", + "priorities" + ], + "fields": [ + { + "fieldname": "sla_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "SLA Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "default": "0", + "fieldname": "default", + "fieldtype": "Check", + "label": "Default" + }, + { + "fieldname": "column_break_uxua", + "fieldtype": "Column Break" + }, + { + "description": "Simple Python Expression, Example: doc.status == 'Open' and doc.lead_source == 'Ads'", + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition", + "options": "Python" + }, + { + "fieldname": "apply_on", + "fieldtype": "Link", + "label": "Apply On", + "link_filters": "[[{\"fieldname\":\"apply_on\",\"field_option\":\"DocType\"},\"name\",\"in\",[\"CRM Lead\",\"CRM Deal\"]]]", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "section_break_ufaf", + "fieldtype": "Section Break" + }, + { + "fieldname": "priorities", + "fieldtype": "Table", + "label": "Priorities", + "options": "CRM Service Level Priority", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-12-04 13:49:09.547523", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Level Agreement", + "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 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py new file mode 100644 index 00000000..89895f8a --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMServiceLevelAgreement(Document): + pass diff --git a/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py new file mode 100644 index 00000000..40e2555e --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_agreement/test_crm_service_level_agreement.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestCRMServiceLevelAgreement(FrappeTestCase): + pass diff --git a/crm/fcrm/doctype/crm_service_level_priority/__init__.py b/crm/fcrm/doctype/crm_service_level_priority/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js new file mode 100644 index 00000000..ef808235 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Service Level Priority", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json new file mode 100644 index 00000000..294b94a7 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-12-04 13:18:58.028384", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "default_priority", + "reference_doctype", + "priority", + "first_response_time" + ], + "fields": [ + { + "default": "0", + "fieldname": "default_priority", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Default Priority" + }, + { + "fieldname": "priority", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Priority", + "options": "reference_doctype", + "reqd": 1 + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "First Response TIme", + "reqd": 1 + }, + { + "default": "CRM Lead Status", + "fieldname": "reference_doctype", + "fieldtype": "Link", + "label": "DocType", + "options": "DocType" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-12-04 14:05:42.838493", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Level Priority", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py new file mode 100644 index 00000000..a7210a82 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/crm_service_level_priority.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMServiceLevelPriority(Document): + pass diff --git a/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py b/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py new file mode 100644 index 00000000..f1f5448b --- /dev/null +++ b/crm/fcrm/doctype/crm_service_level_priority/test_crm_service_level_priority.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestCRMServiceLevelPriority(FrappeTestCase): + pass From 338322774e49fb93e649998b9f6adc1f893d8543 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 4 Dec 2023 15:28:10 +0530 Subject: [PATCH 2/9] fix: added sla related fields in lead & deal doctype --- crm/fcrm/doctype/crm_deal/crm_deal.json | 65 ++++++++++++++++++++++++- crm/fcrm/doctype/crm_lead/crm_lead.json | 65 ++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json index 4cf453ea..7b34cd93 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.json +++ b/crm/fcrm/doctype/crm_deal/crm_deal.json @@ -24,7 +24,17 @@ "deal_owner", "section_break_eepu", "lead", - "column_break_bqvs" + "column_break_bqvs", + "sla_tab", + "sla", + "column_break_pfvq", + "sla_status", + "sla_creation", + "response_details_section", + "response_by", + "column_break_hpvj", + "first_response_time", + "first_responded_on" ], "fields": [ { @@ -134,11 +144,62 @@ "fieldname": "organization_tab", "fieldtype": "Tab Break", "label": "Organization" + }, + { + "fieldname": "sla_tab", + "fieldtype": "Tab Break", + "label": "SLA", + "read_only": 1 + }, + { + "fieldname": "sla", + "fieldtype": "Link", + "label": "SLA", + "options": "CRM Service Level Agreement" + }, + { + "fieldname": "response_by", + "fieldtype": "Datetime", + "label": "Response By" + }, + { + "fieldname": "column_break_pfvq", + "fieldtype": "Column Break" + }, + { + "fieldname": "sla_status", + "fieldtype": "Select", + "label": "SLA Status", + "options": "\nFirst Response Due\nFailed\nFulfilled" + }, + { + "fieldname": "sla_creation", + "fieldtype": "Datetime", + "label": "SLA Creation" + }, + { + "fieldname": "response_details_section", + "fieldtype": "Section Break", + "label": "Response Details" + }, + { + "fieldname": "column_break_hpvj", + "fieldtype": "Column Break" + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time" + }, + { + "fieldname": "first_responded_on", + "fieldtype": "Datetime", + "label": "First Responded On" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-11-29 11:31:46.968519", + "modified": "2023-12-04 15:27:30.887589", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Deal", diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.json b/crm/fcrm/doctype/crm_lead/crm_lead.json index 300f9901..d940f653 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.json +++ b/crm/fcrm/doctype/crm_lead/crm_lead.json @@ -34,7 +34,17 @@ "no_of_employees", "annual_revenue", "image", - "converted" + "converted", + "sla_tab", + "sla", + "sla_creation", + "column_break_ffnp", + "sla_status", + "response_details_section", + "response_by", + "column_break_pweh", + "first_response_time", + "first_responded_on" ], "fields": [ { @@ -196,12 +206,63 @@ "fieldname": "details", "fieldtype": "Tab Break", "label": "Details" + }, + { + "fieldname": "sla_tab", + "fieldtype": "Tab Break", + "label": "SLA", + "read_only": 1 + }, + { + "fieldname": "sla", + "fieldtype": "Link", + "label": "SLA", + "options": "CRM Service Level Agreement" + }, + { + "fieldname": "response_by", + "fieldtype": "Datetime", + "label": "Response By" + }, + { + "fieldname": "column_break_ffnp", + "fieldtype": "Column Break" + }, + { + "fieldname": "sla_status", + "fieldtype": "Select", + "label": "SLA Status", + "options": "\nFirst Response Due\nFailed\nFulfilled" + }, + { + "fieldname": "sla_creation", + "fieldtype": "Datetime", + "label": "SLA Creation" + }, + { + "fieldname": "response_details_section", + "fieldtype": "Section Break", + "label": "Response Details" + }, + { + "fieldname": "column_break_pweh", + "fieldtype": "Column Break" + }, + { + "fieldname": "first_response_time", + "fieldtype": "Duration", + "label": "First Response Time" + }, + { + "fieldname": "first_responded_on", + "fieldtype": "Datetime", + "label": "First Responded On" } ], "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2023-11-29 11:31:08.555096", + "modified": "2023-12-04 15:25:07.155734", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Lead", From da53a9eed5a0d5b3df998ba8969baa3955561ec6 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Sun, 10 Dec 2023 14:23:22 +0530 Subject: [PATCH 3/9] fix: set/apply sla and first response logic --- crm/fcrm/doctype/crm_lead/crm_lead.json | 16 +- crm/fcrm/doctype/crm_lead/crm_lead.py | 30 +++- crm/fcrm/doctype/crm_service_day/__init__.py | 0 .../crm_service_day/crm_service_day.json | 59 +++++++ .../crm_service_day/crm_service_day.py | 9 ++ .../crm_service_level_agreement.json | 17 +- .../crm_service_level_agreement.py | 152 +++++++++++++++++- 7 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 crm/fcrm/doctype/crm_service_day/__init__.py create mode 100644 crm/fcrm/doctype/crm_service_day/crm_service_day.json create mode 100644 crm/fcrm/doctype/crm_service_day/crm_service_day.py diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.json b/crm/fcrm/doctype/crm_lead/crm_lead.json index d940f653..c37c8835 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.json +++ b/crm/fcrm/doctype/crm_lead/crm_lead.json @@ -220,9 +220,9 @@ "options": "CRM Service Level Agreement" }, { - "fieldname": "response_by", + "fieldname": "sla_creation", "fieldtype": "Datetime", - "label": "Response By" + "label": "SLA Creation" }, { "fieldname": "column_break_ffnp", @@ -234,16 +234,16 @@ "label": "SLA Status", "options": "\nFirst Response Due\nFailed\nFulfilled" }, - { - "fieldname": "sla_creation", - "fieldtype": "Datetime", - "label": "SLA Creation" - }, { "fieldname": "response_details_section", "fieldtype": "Section Break", "label": "Response Details" }, + { + "fieldname": "response_by", + "fieldtype": "Datetime", + "label": "Response By" + }, { "fieldname": "column_break_pweh", "fieldtype": "Column Break" @@ -262,7 +262,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2023-12-04 15:25:07.155734", + "modified": "2023-12-10 13:54:53.630114", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Lead", diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py index d41cfd3f..99c1b528 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.py +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -9,12 +9,18 @@ from frappe.utils import has_gravatar, validate_email_address class CRMLead(Document): + def before_validate(self): + self.set_sla() + def validate(self): self.set_full_name() self.set_lead_name() self.set_title() self.validate_email() + def before_save(self): + self.apply_sla() + def set_full_name(self): if self.first_name: self.lead_name = " ".join( @@ -121,6 +127,22 @@ class CRMLead(Document): deal.insert(ignore_permissions=True) return deal.name + def set_sla(self): + """ + Find an SLA to apply to the lead. + """ + if sla := get_sla("CRM Lead"): + if not sla: + return + self.sla = sla.name + + def apply_sla(self): + """ + Apply SLA if set. + """ + if sla := frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}): + sla.apply(self) + @staticmethod def sort_options(): return [ @@ -209,4 +231,10 @@ def convert_to_deal(lead): contact = lead.create_contact(False) deal = lead.create_deal(contact) lead.save() - return deal \ No newline at end of file + return deal + +def get_sla(doctype): + sla = frappe.db.exists("CRM Service Level Agreement", {"apply_on": doctype, "enabled": 1}) + if not sla: + return None + return frappe.get_cached_doc("CRM Service Level Agreement", sla) \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_service_day/__init__.py b/crm/fcrm/doctype/crm_service_day/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_service_day/crm_service_day.json b/crm/fcrm/doctype/crm_service_day/crm_service_day.json new file mode 100644 index 00000000..ddca9883 --- /dev/null +++ b/crm/fcrm/doctype/crm_service_day/crm_service_day.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-12-04 16:07:20.400084", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "workday", + "section_break_uegc", + "start_time", + "column_break_maie", + "end_time" + ], + "fields": [ + { + "fieldname": "workday", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Workday", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 + }, + { + "fieldname": "section_break_uegc", + "fieldtype": "Section Break" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "Start Time", + "reqd": 1 + }, + { + "fieldname": "column_break_maie", + "fieldtype": "Column Break" + }, + { + "fieldname": "end_time", + "fieldtype": "Time", + "in_list_view": 1, + "label": "End Time", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-12-04 16:09:22.928308", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Service Day", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_service_day/crm_service_day.py b/crm/fcrm/doctype/crm_service_day/crm_service_day.py new file mode 100644 index 00000000..724a720d --- /dev/null +++ b/crm/fcrm/doctype/crm_service_day/crm_service_day.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CRMServiceDay(Document): + pass diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json index 11835677..59603dd6 100644 --- a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.json @@ -13,7 +13,9 @@ "column_break_uxua", "condition", "section_break_ufaf", - "priorities" + "priorities", + "section_break_rmgo", + "working_hours" ], "fields": [ { @@ -66,11 +68,22 @@ "label": "Priorities", "options": "CRM Service Level Priority", "reqd": 1 + }, + { + "fieldname": "section_break_rmgo", + "fieldtype": "Section Break" + }, + { + "fieldname": "working_hours", + "fieldtype": "Table", + "label": "Working Hours", + "options": "CRM Service Day", + "reqd": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-12-04 13:49:09.547523", + "modified": "2023-12-04 16:13:24.638239", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Service Level Agreement", diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py index 89895f8a..31efbfdb 100644 --- a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py @@ -1,9 +1,159 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt +from typing import Literal # import frappe from frappe.model.document import Document +from frappe.utils import ( + add_to_date, + get_datetime, + get_weekdays, + getdate, + now_datetime, + time_diff_in_seconds, +) class CRMServiceLevelAgreement(Document): - pass + def apply(self, doc: Document): + self.handle_new(doc) + self.handle_status(doc) + self.handle_targets(doc) + self.handle_sla_status(doc) + + def handle_new(self, doc: Document): + if not doc.is_new(): + return + creation = doc.sla_creation or now_datetime() + doc.sla_creation = creation + + def handle_status(self, doc: Document): + if doc.is_new() or not doc.has_value_changed("status"): + return + self.set_first_response_time(doc) + + def set_first_response_time(self, doc: Document): + start_at = doc.sla_creation + end_at = doc.first_responded_on + if not start_at or not end_at: + return + doc.first_response_time = self.calc_elapsed_time(start_at, end_at) + + def handle_targets(self, doc: Document): + self.set_response_by(doc) + + def set_response_by(self, doc: Document): + start = doc.sla_creation + doc.response_by = self.calc_time(start, doc.status, "first_response_time") + + def handle_sla_status(self, doc: Document): + is_failed = self.is_first_response_failed(doc) + options = { + "Fulfilled": True, + "First Response Due": not doc.first_responded_on, + "Failed": is_failed, + } + for status in options: + if options[status]: + doc.sla_status = status + + def is_first_response_failed(self, doc: Document): + if not doc.first_responded_on: + return get_datetime(doc.response_by) < now_datetime() + return get_datetime(doc.response_by) < get_datetime(doc.first_responded_on) + + def calc_time( + self, + start_at: str, + priority: str, + target: Literal["first_response_time"], + ): + res = get_datetime(start_at) + priority = self.get_priorities()[priority] + time_needed = priority.get(target, 0) + holidays = [] + weekdays = get_weekdays() + workdays = self.get_workdays() + while time_needed: + today = res + today_day = getdate(today) + today_weekday = weekdays[today.weekday()] + is_workday = today_weekday in workdays + is_holiday = today_day in holidays + if is_holiday or not is_workday: + res = add_to_date(res, days=1, as_datetime=True) + continue + today_workday = workdays[today_weekday] + now_in_seconds = time_diff_in_seconds(today, today_day) + start_time = max(today_workday.start_time.total_seconds(), now_in_seconds) + till_start_time = max(start_time - now_in_seconds, 0) + end_time = max(today_workday.end_time.total_seconds(), now_in_seconds) + time_left = max(end_time - start_time, 0) + if not time_left: + res = getdate(add_to_date(res, days=1, as_datetime=True)) + continue + time_taken = min(time_needed, time_left) + time_needed -= time_taken + time_required = till_start_time + time_taken + res = add_to_date(res, seconds=time_required, as_datetime=True) + return res + + + def calc_elapsed_time(self, start_at, end_at) -> float: + """ + Get took from start to end, excluding non-working hours + + :param start_at: Date at which calculation starts + :param end_at: Date at which calculation ends + :return: Number of seconds + """ + start_at = getdate(start_at) + end_at = getdate(end_at) + time_took = 0 + holidays = [] + weekdays = get_weekdays() + workdays = self.get_workdays() + while getdate(start_at) <= getdate(end_at): + today = start_at + today_day = getdate(today) + today_weekday = weekdays[today.weekday()] + is_workday = today_weekday in workdays + is_holiday = today_day in holidays + if is_holiday or not is_workday: + start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) + continue + today_workday = workdays[today_weekday] + is_today = getdate(start_at) == getdate(end_at) + if not is_today: + working_start = today_workday.start_time + working_end = today_workday.end_time + working_time = time_diff_in_seconds(working_start, working_end) + time_took += working_time + start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) + continue + now_in_seconds = time_diff_in_seconds(today, today_day) + start_time = max(today_workday.start_time.total_seconds(), now_in_seconds) + end_at_seconds = time_diff_in_seconds(getdate(end_at), end_at) + end_time = max(today_workday.end_time.total_seconds(), end_at_seconds) + time_taken = end_time - start_time + time_took += time_taken + start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) + return time_took + + def get_priorities(self): + """ + Return priorities related info as a dict. With `priority` as key + """ + res = {} + for row in self.priorities: + res[row.priority] = row + return res + + def get_workdays(self) -> dict[str, dict]: + """ + Return workdays related info as a dict. With `workday` as key + """ + res = {} + for row in self.working_hours: + res[row.workday] = row + return res From 08c766c4cdcc585cab5df672c4f60944ccb7a6a0 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 11:34:58 +0530 Subject: [PATCH 4/9] fix: show sla details on lead page fixed some time calculation logic and some more fixes --- crm/fcrm/doctype/crm_lead/crm_lead.py | 2 +- .../crm_service_level_agreement.py | 109 +++++++++++------- frontend/src/pages/Lead.vue | 85 +++++++++++++- frontend/src/utils/index.js | 25 ++++ 4 files changed, 179 insertions(+), 42 deletions(-) diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py index 99c1b528..5965f502 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.py +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -237,4 +237,4 @@ def get_sla(doctype): sla = frappe.db.exists("CRM Service Level Agreement", {"apply_on": doctype, "enabled": 1}) if not sla: return None - return frappe.get_cached_doc("CRM Service Level Agreement", sla) \ No newline at end of file + return frappe.get_cached_doc("CRM Service Level Agreement", sla) diff --git a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py index 31efbfdb..2debf7f1 100644 --- a/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py +++ b/crm/fcrm/doctype/crm_service_level_agreement/crm_service_level_agreement.py @@ -1,8 +1,8 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -from typing import Literal # import frappe +from datetime import timedelta from frappe.model.document import Document from frappe.utils import ( add_to_date, @@ -30,8 +30,15 @@ class CRMServiceLevelAgreement(Document): def handle_status(self, doc: Document): if doc.is_new() or not doc.has_value_changed("status"): return + self.set_first_responded_on(doc) self.set_first_response_time(doc) + def set_first_responded_on(self, doc: Document): + if doc.status != self.get_default_priority(): + doc.first_responded_on = ( + doc.first_responded_on or now_datetime() + ) + def set_first_response_time(self, doc: Document): start_at = doc.sla_creation end_at = doc.first_responded_on @@ -43,8 +50,18 @@ class CRMServiceLevelAgreement(Document): self.set_response_by(doc) def set_response_by(self, doc: Document): - start = doc.sla_creation - doc.response_by = self.calc_time(start, doc.status, "first_response_time") + start_time = doc.sla_creation + status = doc.status + + priorities = self.get_priorities() + priority = priorities.get(status) + if not priority or doc.response_by: + return + + first_response_time = priority.get("first_response_time", 0) + end_time = self.calc_time(start_time, first_response_time) + if end_time: + doc.response_by = end_time def handle_sla_status(self, doc: Document): is_failed = self.is_first_response_failed(doc) @@ -65,12 +82,10 @@ class CRMServiceLevelAgreement(Document): def calc_time( self, start_at: str, - priority: str, - target: Literal["first_response_time"], + duration_seconds: int, ): res = get_datetime(start_at) - priority = self.get_priorities()[priority] - time_needed = priority.get(target, 0) + time_needed = duration_seconds holidays = [] weekdays = get_weekdays() workdays = self.get_workdays() @@ -98,8 +113,7 @@ class CRMServiceLevelAgreement(Document): res = add_to_date(res, seconds=time_required, as_datetime=True) return res - - def calc_elapsed_time(self, start_at, end_at) -> float: + def calc_elapsed_time(self, start_time, end_time) -> float: """ Get took from start to end, excluding non-working hours @@ -107,38 +121,25 @@ class CRMServiceLevelAgreement(Document): :param end_at: Date at which calculation ends :return: Number of seconds """ - start_at = getdate(start_at) - end_at = getdate(end_at) - time_took = 0 - holidays = [] - weekdays = get_weekdays() - workdays = self.get_workdays() - while getdate(start_at) <= getdate(end_at): - today = start_at - today_day = getdate(today) - today_weekday = weekdays[today.weekday()] - is_workday = today_weekday in workdays - is_holiday = today_day in holidays - if is_holiday or not is_workday: - start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) + start_time = get_datetime(start_time) + end_time = get_datetime(end_time) + holiday_list = [] + working_day_list = self.get_working_days() + working_hours = self.get_working_hours() + + total_seconds = 0 + current_time = start_time + + while current_time < end_time: + in_holiday_list = current_time.date() in holiday_list + not_in_working_day_list = get_weekdays()[current_time.weekday()] not in working_day_list + if in_holiday_list or not_in_working_day_list or not self.is_working_time(current_time, working_hours): + current_time += timedelta(seconds=1) continue - today_workday = workdays[today_weekday] - is_today = getdate(start_at) == getdate(end_at) - if not is_today: - working_start = today_workday.start_time - working_end = today_workday.end_time - working_time = time_diff_in_seconds(working_start, working_end) - time_took += working_time - start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) - continue - now_in_seconds = time_diff_in_seconds(today, today_day) - start_time = max(today_workday.start_time.total_seconds(), now_in_seconds) - end_at_seconds = time_diff_in_seconds(getdate(end_at), end_at) - end_time = max(today_workday.end_time.total_seconds(), end_at_seconds) - time_taken = end_time - start_time - time_took += time_taken - start_at = getdate(add_to_date(start_at, days=1, as_datetime=True)) - return time_took + total_seconds += 1 + current_time += timedelta(seconds=1) + + return total_seconds def get_priorities(self): """ @@ -149,6 +150,16 @@ class CRMServiceLevelAgreement(Document): res[row.priority] = row return res + def get_default_priority(self): + """ + Return default priority + """ + for row in self.priorities: + if row.default_priority: + return row.priority + + return self.priorities[0].priority + def get_workdays(self) -> dict[str, dict]: """ Return workdays related info as a dict. With `workday` as key @@ -157,3 +168,21 @@ class CRMServiceLevelAgreement(Document): for row in self.working_hours: res[row.workday] = row return res + + def get_working_days(self) -> dict[str, dict]: + workdays = [] + for row in self.working_hours: + workdays.append(row.workday) + return workdays + + def get_working_hours(self) -> dict[str, dict]: + res = {} + for row in self.working_hours: + res[row.workday] = (row.start_time, row.end_time) + return res + + def is_working_time(self, date_time, working_hours): + day_of_week = get_weekdays()[date_time.weekday()] + start_time, end_time = working_hours.get(day_of_week, (0, 0)) + date_time = timedelta(hours=date_time.hour, minutes=date_time.minute, seconds=date_time.second) + return start_time <= date_time < end_time diff --git a/frontend/src/pages/Lead.vue b/frontend/src/pages/Lead.vue index 6d42c3c7..7be4f549 100644 --- a/frontend/src/pages/Lead.vue +++ b/frontend/src/pages/Lead.vue @@ -134,6 +134,72 @@ +
+
+
Response By
+ + {{ timeAgo(lead.data.response_by) }} + +
+
+
Fulfilled In
+ + {{ formatTime(lead.data.first_response_time) }} + +
+
+
Fulfilled In
+ + {{ formatTime(lead.data.first_response_time) }} + +
+
+
Status
+
+ +
+
+
{ + if ( + data.response_by && + data.sla_status == 'First Response Due' && + new Date(data.response_by) < new Date() + ) { + updateField('sla_status', 'Failed') + } + }, }) const reload = ref(false) diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 2397f0fd..546b7066 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -12,6 +12,31 @@ export function createToast(options) { }) } +export function formatTime(seconds) { + const days = Math.floor(seconds / (3600 * 24)) + const hours = Math.floor((seconds % (3600 * 24)) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const remainingSeconds = seconds % 60 + + let formattedTime = '' + + if (days > 0) { + formattedTime += `${days}d ` + } + + if (hours > 0 || days > 0) { + formattedTime += `${hours}h ` + } + + if (minutes > 0 || hours > 0 || days > 0) { + formattedTime += `${minutes}m ` + } + + formattedTime += `${remainingSeconds}s` + + return formattedTime.trim() +} + export function dateFormat(date, format) { const _format = format || 'DD-MM-YYYY HH:mm:ss' return useDateFormat(date, _format).value From ab92d96221e837a6c3e227d0c37b06126a975953 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 12:35:35 +0530 Subject: [PATCH 5/9] fix: show sla details on lead list view --- crm/fcrm/doctype/crm_lead/crm_lead.py | 3 +++ .../components/ListViews/LeadsListView.vue | 25 ++++++++++++++++++- frontend/src/pages/Leads.vue | 23 ++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crm/fcrm/doctype/crm_lead/crm_lead.py b/crm/fcrm/doctype/crm_lead/crm_lead.py index 5965f502..e9e20487 100644 --- a/crm/fcrm/doctype/crm_lead/crm_lead.py +++ b/crm/fcrm/doctype/crm_lead/crm_lead.py @@ -215,6 +215,9 @@ class CRMLead(Document): "mobile_no", "lead_owner", "first_name", + "sla_status", + "first_response_time", + "first_responded_on", "modified", "image", ] diff --git a/frontend/src/components/ListViews/LeadsListView.vue b/frontend/src/components/ListViews/LeadsListView.vue index 83601dde..01eae1e0 100644 --- a/frontend/src/components/ListViews/LeadsListView.vue +++ b/frontend/src/components/ListViews/LeadsListView.vue @@ -53,9 +53,31 @@
-
+
{{ item.timeAgo }}
+
+ +
{ label: lead.status, color: getLeadStatus(lead.status)?.iconColorClass, } + } else if (row == 'sla_status') { + _rows[row] = { + label: lead.sla_status, + color: + lead.sla_status === 'Failed' + ? 'red' + : lead.sla_status === 'Fulfilled' + ? 'green' + : 'gray', + } } else if (row == 'lead_owner') { _rows[row] = { label: lead.lead_owner && getUser(lead.lead_owner).full_name, @@ -164,6 +174,17 @@ const rows = computed(() => { label: dateFormat(lead[row], dateTooltipFormat), timeAgo: timeAgo(lead[row]), } + } else if (['first_response_time', 'first_responded_on'].includes(row)) { + _rows[row] = { + label: lead.first_responded_on + ? dateFormat(lead.first_responded_on, dateTooltipFormat) + : '', + timeAgo: lead[row] + ? row == 'first_responded_on' + ? timeAgo(lead[row]) + : formatTime(lead[row]) + : '', + } } }) return _rows From 548b71b4036a118d21b2f9e42e82e7f8ab4007f6 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 12:44:43 +0530 Subject: [PATCH 6/9] fix: changed lead_owner to deal_owner in newDeal component --- crm/fcrm/doctype/crm_deal/crm_deal.json | 4 ++-- frontend/src/components/NewDeal.vue | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.json b/crm/fcrm/doctype/crm_deal/crm_deal.json index 7b34cd93..64b8829c 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.json +++ b/crm/fcrm/doctype/crm_deal/crm_deal.json @@ -27,9 +27,9 @@ "column_break_bqvs", "sla_tab", "sla", + "sla_creation", "column_break_pfvq", "sla_status", - "sla_creation", "response_details_section", "response_by", "column_break_hpvj", @@ -199,7 +199,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-12-04 15:27:30.887589", + "modified": "2023-12-11 12:37:51.198228", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Deal", diff --git a/frontend/src/components/NewDeal.vue b/frontend/src/components/NewDeal.vue index 95f6d225..bf3ca111 100644 --- a/frontend/src/components/NewDeal.vue +++ b/frontend/src/components/NewDeal.vue @@ -153,8 +153,8 @@ const allFields = [ }, { label: 'Deal Owner', - name: 'lead_owner', - type: 'link', + name: 'deal_owner', + type: 'user', placeholder: 'Deal Owner', }, ], From 78b935373523750d72b10b1d88a61c3450277b0d Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 12:57:00 +0530 Subject: [PATCH 7/9] fix: show sla details on deal page --- crm/fcrm/doctype/crm_deal/crm_deal.py | 30 +++++++++- frontend/src/pages/Deal.vue | 84 ++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index 78032c55..f9f3ee7c 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -7,10 +7,16 @@ from frappe.model.document import Document class CRMDeal(Document): + def before_validate(self): + self.set_sla() + def validate(self): self.set_primary_contact() self.set_primary_email_mobile_no() + def before_save(self): + self.apply_sla() + def set_primary_contact(self, contact=None): if not self.contacts: return @@ -45,6 +51,22 @@ class CRMDeal(Document): self.email = "" self.mobile_no = "" + def set_sla(self): + """ + Find an SLA to apply to the deal. + """ + if sla := get_sla("CRM Deal"): + if not sla: + return + self.sla = sla.name + + def apply_sla(self): + """ + Apply SLA if set. + """ + if sla := frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}): + sla.apply(self) + @staticmethod def sort_options(): return [ @@ -145,4 +167,10 @@ def set_primary_contact(deal, contact): deal = frappe.get_cached_doc("CRM Deal", deal) deal.set_primary_contact(contact) deal.save() - return True \ No newline at end of file + return True + +def get_sla(doctype): + sla = frappe.db.exists("CRM Service Level Agreement", {"apply_on": doctype, "enabled": 1}) + if not sla: + return None + return frappe.get_cached_doc("CRM Service Level Agreement", sla) diff --git a/frontend/src/pages/Deal.vue b/frontend/src/pages/Deal.vue index 007c321b..2b122b11 100644 --- a/frontend/src/pages/Deal.vue +++ b/frontend/src/pages/Deal.vue @@ -94,6 +94,72 @@
+
+
+
Response By
+ + {{ timeAgo(deal.data.response_by) }} + +
+
+
Fulfilled In
+ + {{ formatTime(deal.data.first_response_time) }} + +
+
+
Fulfilled In
+ + {{ formatTime(deal.data.first_response_time) }} + +
+
+
Status
+
+ +
+
+
{ + if ( + data.response_by && + data.sla_status == 'First Response Due' && + new Date(data.response_by) < new Date() + ) { + updateField('sla_status', 'Failed') + } + }, }) const reload = ref(false) From 562200cd4c8ad4b3b1ec671ed84253f5a21ff8c0 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 13:01:58 +0530 Subject: [PATCH 8/9] fix: show sla details on deal list view --- crm/fcrm/doctype/crm_deal/crm_deal.py | 3 +++ .../components/ListViews/DealsListView.vue | 25 ++++++++++++++++++- frontend/src/pages/Deals.vue | 22 ++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crm/fcrm/doctype/crm_deal/crm_deal.py b/crm/fcrm/doctype/crm_deal/crm_deal.py index f9f3ee7c..87cdfb72 100644 --- a/crm/fcrm/doctype/crm_deal/crm_deal.py +++ b/crm/fcrm/doctype/crm_deal/crm_deal.py @@ -135,6 +135,9 @@ class CRMDeal(Document): "email", "mobile_no", "deal_owner", + "sla_status", + "first_response_time", + "first_responded_on", "modified", ] return {'columns': columns, 'rows': rows} diff --git a/frontend/src/components/ListViews/DealsListView.vue b/frontend/src/components/ListViews/DealsListView.vue index 1b5618b4..72efab98 100644 --- a/frontend/src/components/ListViews/DealsListView.vue +++ b/frontend/src/components/ListViews/DealsListView.vue @@ -44,9 +44,31 @@
-
+
{{ item.timeAgo }}
+
+ +
{ label: deal.status, color: getDealStatus(deal.status)?.iconColorClass, } + } else if (row == 'sla_status') { + _rows[row] = { + label: deal.sla_status, + color: + deal.sla_status === 'Failed' + ? 'red' + : deal.sla_status === 'Fulfilled' + ? 'green' + : 'gray', + } } else if (row == 'deal_owner') { _rows[row] = { label: deal.deal_owner && getUser(deal.deal_owner).full_name, @@ -164,6 +175,17 @@ const rows = computed(() => { label: dateFormat(deal[row], dateTooltipFormat), timeAgo: timeAgo(deal[row]), } + } else if (['first_response_time', 'first_responded_on'].includes(row)) { + _rows[row] = { + label: deal.first_responded_on + ? dateFormat(deal.first_responded_on, dateTooltipFormat) + : '', + timeAgo: deal[row] + ? row == 'first_responded_on' + ? timeAgo(deal[row]) + : formatTime(deal[row]) + : '', + } } }) return _rows From 5ab2ae83a9f36ce5f977eb4adae70e5af0975969 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 11 Dec 2023 13:05:29 +0530 Subject: [PATCH 9/9] fix: add SLA shortcut on CRM workspace --- crm/fcrm/workspace/frappe_crm/frappe_crm.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crm/fcrm/workspace/frappe_crm/frappe_crm.json b/crm/fcrm/workspace/frappe_crm/frappe_crm.json index 6c56ef5a..164fa444 100644 --- a/crm/fcrm/workspace/frappe_crm/frappe_crm.json +++ b/crm/fcrm/workspace/frappe_crm/frappe_crm.json @@ -1,19 +1,19 @@ { "charts": [], - "content": "[{\"id\":\"1nr6UkvDiL\",\"type\":\"header\",\"data\":{\"text\":\"PORTAL\",\"col\":12}},{\"id\":\"1hyi8SysUY\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"CRM Portal Page\",\"col\":3}},{\"id\":\"ktENiGaqXQ\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"VgeWLYOuAS\",\"type\":\"paragraph\",\"data\":{\"text\":\"SHORTCUTS\",\"col\":12}},{\"id\":\"A66FpG-K3T\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leads\",\"col\":3}},{\"id\":\"n9b6N5ebOj\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deals\",\"col\":3}},{\"id\":\"sGHTXrludH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Organizations\",\"col\":3}},{\"id\":\"uXZNCdqxy0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Contacts\",\"col\":3}},{\"id\":\"TZ7cULX3Tk\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"zpySv0nGVQ\",\"type\":\"paragraph\",\"data\":{\"text\":\"META\",\"col\":12}},{\"id\":\"fa-uKzobpp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Statuses\",\"col\":3}},{\"id\":\"hxoZghUHP2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deal Statuses\",\"col\":3}},{\"id\":\"HbgghUpc8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Sources\",\"col\":3}},{\"id\":\"8cPs7Fohb4\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Industries\",\"col\":3}}]", + "content": "[{\"id\":\"1nr6UkvDiL\",\"type\":\"header\",\"data\":{\"text\":\"PORTAL\",\"col\":12}},{\"id\":\"1hyi8SysUY\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"CRM Portal Page\",\"col\":3}},{\"id\":\"ktENiGaqXQ\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"VgeWLYOuAS\",\"type\":\"paragraph\",\"data\":{\"text\":\"SHORTCUTS\",\"col\":12}},{\"id\":\"A66FpG-K3T\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leads\",\"col\":3}},{\"id\":\"n9b6N5ebOj\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deals\",\"col\":3}},{\"id\":\"sGHTXrludH\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Organizations\",\"col\":3}},{\"id\":\"uXZNCdqxy0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Contacts\",\"col\":3}},{\"id\":\"v1kkMwlntf\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"SLA\",\"col\":3}},{\"id\":\"TZ7cULX3Tk\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"zpySv0nGVQ\",\"type\":\"paragraph\",\"data\":{\"text\":\"META\",\"col\":12}},{\"id\":\"fa-uKzobpp\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Statuses\",\"col\":3}},{\"id\":\"hxoZghUHP2\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Deal Statuses\",\"col\":3}},{\"id\":\"HbgghUpc8N\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Lead Sources\",\"col\":3}},{\"id\":\"8cPs7Fohb4\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Industries\",\"col\":3}}]", "creation": "2023-11-27 13:55:17.090361", "custom_blocks": [], "docstatus": 0, "doctype": "Workspace", "for_user": "", "hide_custom": 0, - "icon": "crm", + "icon": "filter", "idx": 0, - "indicator_color": "green", + "indicator_color": "", "is_hidden": 0, "label": "Frappe CRM", "links": [], - "modified": "2023-11-29 13:37:56.731645", + "modified": "2023-12-11 13:06:13.532693", "modified_by": "Administrator", "module": "FCRM", "name": "Frappe CRM", @@ -40,6 +40,14 @@ "type": "URL", "url": "/crm" }, + { + "color": "Grey", + "doc_view": "List", + "label": "SLA", + "link_to": "CRM Service Level Agreement", + "stats_filter": "[]", + "type": "DocType" + }, { "color": "Grey", "doc_view": "List",