Merge pull request #38 from shariquerik/sla
This commit is contained in:
commit
bd51426e12
@ -24,7 +24,17 @@
|
|||||||
"deal_owner",
|
"deal_owner",
|
||||||
"section_break_eepu",
|
"section_break_eepu",
|
||||||
"lead",
|
"lead",
|
||||||
"column_break_bqvs"
|
"column_break_bqvs",
|
||||||
|
"sla_tab",
|
||||||
|
"sla",
|
||||||
|
"sla_creation",
|
||||||
|
"column_break_pfvq",
|
||||||
|
"sla_status",
|
||||||
|
"response_details_section",
|
||||||
|
"response_by",
|
||||||
|
"column_break_hpvj",
|
||||||
|
"first_response_time",
|
||||||
|
"first_responded_on"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -134,11 +144,62 @@
|
|||||||
"fieldname": "organization_tab",
|
"fieldname": "organization_tab",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Organization"
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-29 11:31:46.968519",
|
"modified": "2023-12-11 12:37:51.198228",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Deal",
|
"name": "CRM Deal",
|
||||||
|
|||||||
@ -7,10 +7,16 @@ from frappe.model.document import Document
|
|||||||
|
|
||||||
|
|
||||||
class CRMDeal(Document):
|
class CRMDeal(Document):
|
||||||
|
def before_validate(self):
|
||||||
|
self.set_sla()
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.set_primary_contact()
|
self.set_primary_contact()
|
||||||
self.set_primary_email_mobile_no()
|
self.set_primary_email_mobile_no()
|
||||||
|
|
||||||
|
def before_save(self):
|
||||||
|
self.apply_sla()
|
||||||
|
|
||||||
def set_primary_contact(self, contact=None):
|
def set_primary_contact(self, contact=None):
|
||||||
if not self.contacts:
|
if not self.contacts:
|
||||||
return
|
return
|
||||||
@ -45,6 +51,22 @@ class CRMDeal(Document):
|
|||||||
self.email = ""
|
self.email = ""
|
||||||
self.mobile_no = ""
|
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
|
@staticmethod
|
||||||
def sort_options():
|
def sort_options():
|
||||||
return [
|
return [
|
||||||
@ -113,6 +135,9 @@ class CRMDeal(Document):
|
|||||||
"email",
|
"email",
|
||||||
"mobile_no",
|
"mobile_no",
|
||||||
"deal_owner",
|
"deal_owner",
|
||||||
|
"sla_status",
|
||||||
|
"first_response_time",
|
||||||
|
"first_responded_on",
|
||||||
"modified",
|
"modified",
|
||||||
]
|
]
|
||||||
return {'columns': columns, 'rows': rows}
|
return {'columns': columns, 'rows': rows}
|
||||||
@ -146,3 +171,9 @@ def set_primary_contact(deal, contact):
|
|||||||
deal.set_primary_contact(contact)
|
deal.set_primary_contact(contact)
|
||||||
deal.save()
|
deal.save()
|
||||||
return True
|
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)
|
||||||
|
|||||||
@ -34,7 +34,17 @@
|
|||||||
"no_of_employees",
|
"no_of_employees",
|
||||||
"annual_revenue",
|
"annual_revenue",
|
||||||
"image",
|
"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": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -196,12 +206,63 @@
|
|||||||
"fieldname": "details",
|
"fieldname": "details",
|
||||||
"fieldtype": "Tab Break",
|
"fieldtype": "Tab Break",
|
||||||
"label": "Details"
|
"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": "sla_creation",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "SLA Creation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_ffnp",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "sla_status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"label": "SLA Status",
|
||||||
|
"options": "\nFirst Response Due\nFailed\nFulfilled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "first_response_time",
|
||||||
|
"fieldtype": "Duration",
|
||||||
|
"label": "First Response Time"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "first_responded_on",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "First Responded On"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"image_field": "image",
|
"image_field": "image",
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-29 11:31:08.555096",
|
"modified": "2023-12-10 13:54:53.630114",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Lead",
|
"name": "CRM Lead",
|
||||||
|
|||||||
@ -9,12 +9,18 @@ from frappe.utils import has_gravatar, validate_email_address
|
|||||||
|
|
||||||
|
|
||||||
class CRMLead(Document):
|
class CRMLead(Document):
|
||||||
|
def before_validate(self):
|
||||||
|
self.set_sla()
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.set_full_name()
|
self.set_full_name()
|
||||||
self.set_lead_name()
|
self.set_lead_name()
|
||||||
self.set_title()
|
self.set_title()
|
||||||
self.validate_email()
|
self.validate_email()
|
||||||
|
|
||||||
|
def before_save(self):
|
||||||
|
self.apply_sla()
|
||||||
|
|
||||||
def set_full_name(self):
|
def set_full_name(self):
|
||||||
if self.first_name:
|
if self.first_name:
|
||||||
self.lead_name = " ".join(
|
self.lead_name = " ".join(
|
||||||
@ -121,6 +127,22 @@ class CRMLead(Document):
|
|||||||
deal.insert(ignore_permissions=True)
|
deal.insert(ignore_permissions=True)
|
||||||
return deal.name
|
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
|
@staticmethod
|
||||||
def sort_options():
|
def sort_options():
|
||||||
return [
|
return [
|
||||||
@ -193,6 +215,9 @@ class CRMLead(Document):
|
|||||||
"mobile_no",
|
"mobile_no",
|
||||||
"lead_owner",
|
"lead_owner",
|
||||||
"first_name",
|
"first_name",
|
||||||
|
"sla_status",
|
||||||
|
"first_response_time",
|
||||||
|
"first_responded_on",
|
||||||
"modified",
|
"modified",
|
||||||
"image",
|
"image",
|
||||||
]
|
]
|
||||||
@ -210,3 +235,9 @@ def convert_to_deal(lead):
|
|||||||
deal = lead.create_deal(contact)
|
deal = lead.create_deal(contact)
|
||||||
lead.save()
|
lead.save()
|
||||||
return deal
|
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)
|
||||||
|
|||||||
0
crm/fcrm/doctype/crm_service_day/__init__.py
Normal file
0
crm/fcrm/doctype/crm_service_day/__init__.py
Normal file
59
crm/fcrm/doctype/crm_service_day/crm_service_day.json
Normal file
59
crm/fcrm/doctype/crm_service_day/crm_service_day.json
Normal file
@ -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": []
|
||||||
|
}
|
||||||
9
crm/fcrm/doctype/crm_service_day/crm_service_day.py
Normal file
9
crm/fcrm/doctype/crm_service_day/crm_service_day.py
Normal file
@ -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
|
||||||
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"section_break_rmgo",
|
||||||
|
"working_hours"
|
||||||
|
],
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 16:13:24.638239",
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||||
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
# import frappe
|
||||||
|
from datetime import timedelta
|
||||||
|
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):
|
||||||
|
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_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
|
||||||
|
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_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)
|
||||||
|
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,
|
||||||
|
duration_seconds: int,
|
||||||
|
):
|
||||||
|
res = get_datetime(start_at)
|
||||||
|
time_needed = duration_seconds
|
||||||
|
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_time, end_time) -> 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_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
|
||||||
|
total_seconds += 1
|
||||||
|
current_time += timedelta(seconds=1)
|
||||||
|
|
||||||
|
return total_seconds
|
||||||
|
|
||||||
|
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_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
|
||||||
|
"""
|
||||||
|
res = {}
|
||||||
|
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
|
||||||
@ -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
|
||||||
@ -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) {
|
||||||
|
|
||||||
|
// },
|
||||||
|
// });
|
||||||
@ -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": []
|
||||||
|
}
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"charts": [],
|
"charts": [],
|
||||||
"content": "[{\"id\":\"1nr6UkvDiL\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h5\\\"><b>PORTAL</b></span>\",\"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\":\"<b>SHORTCUTS</b>\",\"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\":\"<b>META</b>\",\"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\":\"<span class=\\\"h5\\\"><b>PORTAL</b></span>\",\"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\":\"<b>SHORTCUTS</b>\",\"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\":\"<b>META</b>\",\"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",
|
"creation": "2023-11-27 13:55:17.090361",
|
||||||
"custom_blocks": [],
|
"custom_blocks": [],
|
||||||
"docstatus": 0,
|
"docstatus": 0,
|
||||||
"doctype": "Workspace",
|
"doctype": "Workspace",
|
||||||
"for_user": "",
|
"for_user": "",
|
||||||
"hide_custom": 0,
|
"hide_custom": 0,
|
||||||
"icon": "crm",
|
"icon": "filter",
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"indicator_color": "green",
|
"indicator_color": "",
|
||||||
"is_hidden": 0,
|
"is_hidden": 0,
|
||||||
"label": "Frappe CRM",
|
"label": "Frappe CRM",
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2023-11-29 13:37:56.731645",
|
"modified": "2023-12-11 13:06:13.532693",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "Frappe CRM",
|
"name": "Frappe CRM",
|
||||||
@ -40,6 +40,14 @@
|
|||||||
"type": "URL",
|
"type": "URL",
|
||||||
"url": "/crm"
|
"url": "/crm"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"color": "Grey",
|
||||||
|
"doc_view": "List",
|
||||||
|
"label": "SLA",
|
||||||
|
"link_to": "CRM Service Level Agreement",
|
||||||
|
"stats_filter": "[]",
|
||||||
|
"type": "DocType"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"color": "Grey",
|
"color": "Grey",
|
||||||
"doc_view": "List",
|
"doc_view": "List",
|
||||||
|
|||||||
@ -44,9 +44,31 @@
|
|||||||
<PhoneIcon class="h-4 w-4" />
|
<PhoneIcon class="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="['modified', 'creation'].includes(column.key)" class="truncate text-base">
|
<div
|
||||||
|
v-if="
|
||||||
|
[
|
||||||
|
'modified',
|
||||||
|
'creation',
|
||||||
|
'first_response_time',
|
||||||
|
'first_responded_on',
|
||||||
|
].includes(column.key)
|
||||||
|
"
|
||||||
|
class="truncate text-base"
|
||||||
|
>
|
||||||
{{ item.timeAgo }}
|
{{ item.timeAgo }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key === 'sla_status'"
|
||||||
|
class="truncate text-base"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-if="item.label"
|
||||||
|
:variant="'subtle'"
|
||||||
|
:theme="item.color"
|
||||||
|
size="md"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-else-if="column.type === 'Check'">
|
<div v-else-if="column.type === 'Check'">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -74,6 +96,7 @@ import {
|
|||||||
ListRowItem,
|
ListRowItem,
|
||||||
ListSelectBanner,
|
ListSelectBanner,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@ -53,9 +53,31 @@
|
|||||||
<PhoneIcon class="h-4 w-4" />
|
<PhoneIcon class="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="['modified', 'creation'].includes(column.key)" class="truncate text-base">
|
<div
|
||||||
|
v-if="
|
||||||
|
[
|
||||||
|
'modified',
|
||||||
|
'creation',
|
||||||
|
'first_response_time',
|
||||||
|
'first_responded_on',
|
||||||
|
].includes(column.key)
|
||||||
|
"
|
||||||
|
class="truncate text-base"
|
||||||
|
>
|
||||||
{{ item.timeAgo }}
|
{{ item.timeAgo }}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="column.key === 'sla_status'"
|
||||||
|
class="truncate text-base"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
v-if="item.label"
|
||||||
|
:variant="'subtle'"
|
||||||
|
:theme="item.color"
|
||||||
|
size="md"
|
||||||
|
:label="item.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div v-else-if="column.type === 'Check'">
|
<div v-else-if="column.type === 'Check'">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -83,6 +105,7 @@ import {
|
|||||||
ListSelectBanner,
|
ListSelectBanner,
|
||||||
ListRowItem,
|
ListRowItem,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
Badge,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@ -153,8 +153,8 @@ const allFields = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deal Owner',
|
label: 'Deal Owner',
|
||||||
name: 'lead_owner',
|
name: 'deal_owner',
|
||||||
type: 'link',
|
type: 'user',
|
||||||
placeholder: 'Deal Owner',
|
placeholder: 'Deal Owner',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -94,6 +94,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="deal.data.sla_status" class="flex flex-col gap-2 border-b p-5">
|
||||||
|
<div
|
||||||
|
v-if="deal.data.sla_status == 'First Response Due'"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Response By</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="dateFormat(deal.data.response_by, 'ddd, MMM D, YYYY h:mm A')"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ timeAgo(deal.data.response_by) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="deal.data.sla_status == 'Fulfilled'"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Fulfilled In</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
dateFormat(
|
||||||
|
deal.data.first_responded_on,
|
||||||
|
'ddd, MMM D, YYYY h:mm A'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ formatTime(deal.data.first_response_time) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
deal.data.sla_status == 'Failed' && deal.data.first_responded_on
|
||||||
|
"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Fulfilled In</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
dateFormat(
|
||||||
|
deal.data.first_responded_on,
|
||||||
|
'ddd, MMM D, YYYY h:mm A'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ formatTime(deal.data.first_response_time) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-base leading-5">
|
||||||
|
<div class="w-[106px] text-gray-600">Status</div>
|
||||||
|
<div class="">
|
||||||
|
<Badge
|
||||||
|
:label="deal.data.sla_status"
|
||||||
|
variant="outline"
|
||||||
|
:theme="
|
||||||
|
deal.data.sla_status === 'Failed'
|
||||||
|
? 'red'
|
||||||
|
: deal.data.sla_status === 'Fulfilled'
|
||||||
|
? 'green'
|
||||||
|
: 'gray'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@ -275,7 +341,14 @@ import ContactModal from '@/components/Modals/ContactModal.vue'
|
|||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import { openWebsite, createToast, activeAgents } from '@/utils'
|
import {
|
||||||
|
openWebsite,
|
||||||
|
createToast,
|
||||||
|
activeAgents,
|
||||||
|
dateFormat,
|
||||||
|
timeAgo,
|
||||||
|
formatTime,
|
||||||
|
} from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
import { organizationsStore } from '@/stores/organizations'
|
||||||
@ -313,6 +386,15 @@ const deal = createResource({
|
|||||||
params: { name: props.dealId },
|
params: { name: props.dealId },
|
||||||
cache: ['deal', props.dealId],
|
cache: ['deal', props.dealId],
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
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)
|
const reload = ref(false)
|
||||||
|
|||||||
@ -71,6 +71,7 @@ import {
|
|||||||
dateTooltipFormat,
|
dateTooltipFormat,
|
||||||
timeAgo,
|
timeAgo,
|
||||||
formatNumberIntoCurrency,
|
formatNumberIntoCurrency,
|
||||||
|
formatTime,
|
||||||
} from '@/utils'
|
} from '@/utils'
|
||||||
import {
|
import {
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
@ -154,6 +155,16 @@ const rows = computed(() => {
|
|||||||
label: deal.status,
|
label: deal.status,
|
||||||
color: getDealStatus(deal.status)?.iconColorClass,
|
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') {
|
} else if (row == 'deal_owner') {
|
||||||
_rows[row] = {
|
_rows[row] = {
|
||||||
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
|
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
|
||||||
@ -164,6 +175,17 @@ const rows = computed(() => {
|
|||||||
label: dateFormat(deal[row], dateTooltipFormat),
|
label: dateFormat(deal[row], dateTooltipFormat),
|
||||||
timeAgo: timeAgo(deal[row]),
|
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
|
return _rows
|
||||||
|
|||||||
@ -134,6 +134,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileUploader>
|
</FileUploader>
|
||||||
|
<div v-if="lead.data.sla_status" class="flex flex-col gap-2 border-b p-5">
|
||||||
|
<div
|
||||||
|
v-if="lead.data.sla_status == 'First Response Due'"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Response By</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="dateFormat(lead.data.response_by, 'ddd, MMM D, YYYY h:mm A')"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ timeAgo(lead.data.response_by) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="lead.data.sla_status == 'Fulfilled'"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Fulfilled In</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
dateFormat(
|
||||||
|
lead.data.first_responded_on,
|
||||||
|
'ddd, MMM D, YYYY h:mm A'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ formatTime(lead.data.first_response_time) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
lead.data.sla_status == 'Failed' && lead.data.first_responded_on
|
||||||
|
"
|
||||||
|
class="flex items-center gap-4 text-base leading-5"
|
||||||
|
>
|
||||||
|
<div class="w-[106px] text-gray-600">Fulfilled In</div>
|
||||||
|
<Tooltip
|
||||||
|
:text="
|
||||||
|
dateFormat(
|
||||||
|
lead.data.first_responded_on,
|
||||||
|
'ddd, MMM D, YYYY h:mm A'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ formatTime(lead.data.first_response_time) }}
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-base leading-5">
|
||||||
|
<div class="w-[106px] text-gray-600">Status</div>
|
||||||
|
<div class="">
|
||||||
|
<Badge
|
||||||
|
:label="lead.data.sla_status"
|
||||||
|
variant="outline"
|
||||||
|
:theme="
|
||||||
|
lead.data.sla_status === 'Failed'
|
||||||
|
? 'red'
|
||||||
|
: lead.data.sla_status === 'Fulfilled'
|
||||||
|
? 'green'
|
||||||
|
: 'gray'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div class="flex flex-col overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
@ -181,7 +247,14 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
|||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import { openWebsite, createToast, activeAgents } from '@/utils'
|
import {
|
||||||
|
openWebsite,
|
||||||
|
createToast,
|
||||||
|
activeAgents,
|
||||||
|
dateFormat,
|
||||||
|
timeAgo,
|
||||||
|
formatTime,
|
||||||
|
} from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
import { organizationsStore } from '@/stores/organizations'
|
import { organizationsStore } from '@/stores/organizations'
|
||||||
@ -197,6 +270,7 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
Tabs,
|
Tabs,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
|
Badge,
|
||||||
call,
|
call,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
@ -220,6 +294,15 @@ const lead = createResource({
|
|||||||
params: { name: props.leadId },
|
params: { name: props.leadId },
|
||||||
cache: ['lead', props.leadId],
|
cache: ['lead', props.leadId],
|
||||||
auto: true,
|
auto: true,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
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)
|
const reload = ref(false)
|
||||||
|
|||||||
@ -65,7 +65,7 @@ import { statusesStore } from '@/stores/statuses'
|
|||||||
import { useOrderBy } from '@/composables/orderby'
|
import { useOrderBy } from '@/composables/orderby'
|
||||||
import { useFilter } from '@/composables/filter'
|
import { useFilter } from '@/composables/filter'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
|
import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils'
|
||||||
import {
|
import {
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -154,6 +154,16 @@ const rows = computed(() => {
|
|||||||
label: lead.status,
|
label: lead.status,
|
||||||
color: getLeadStatus(lead.status)?.iconColorClass,
|
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') {
|
} else if (row == 'lead_owner') {
|
||||||
_rows[row] = {
|
_rows[row] = {
|
||||||
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
|
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
|
||||||
@ -164,6 +174,17 @@ const rows = computed(() => {
|
|||||||
label: dateFormat(lead[row], dateTooltipFormat),
|
label: dateFormat(lead[row], dateTooltipFormat),
|
||||||
timeAgo: timeAgo(lead[row]),
|
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
|
return _rows
|
||||||
|
|||||||
@ -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) {
|
export function dateFormat(date, format) {
|
||||||
const _format = format || 'DD-MM-YYYY HH:mm:ss'
|
const _format = format || 'DD-MM-YYYY HH:mm:ss'
|
||||||
return useDateFormat(date, _format).value
|
return useDateFormat(date, _format).value
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user