Merge pull request #39 from shariquerik/sla-1

This commit is contained in:
Shariq Ansari 2023-12-15 12:33:42 +05:30 committed by GitHub
commit 0c08ba2300
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 976 additions and 573 deletions

View File

@ -125,7 +125,6 @@ def get_list_data(doctype: str, filters: dict, order_by: str):
}
@frappe.whitelist()
def get_doctype_fields(doctype):
not_allowed_fieldtypes = [
"Section Break",

View File

@ -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 Communication Status", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,47 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:status",
"creation": "2023-12-13 13:25:07.213100",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"status"
],
"fields": [
{
"fieldname": "status",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Status",
"reqd": 1,
"unique": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-13 13:28:38.746199",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Communication Status",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View 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 CRMCommunicationStatus(Document):
pass

View File

@ -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 TestCRMCommunicationStatus(FrappeTestCase):
pass

View File

@ -1,6 +1,7 @@
import frappe
from frappe import _
from crm.api.doc import get_doctype_fields
@frappe.whitelist()
def get_deal(name):
@ -25,4 +26,5 @@ def get_deal(name):
fields=["contact", "is_primary"],
)
deal["doctype_fields"] = get_doctype_fields("CRM Deal")
return deal

View File

@ -30,6 +30,7 @@
"sla_creation",
"column_break_pfvq",
"sla_status",
"communication_status",
"response_details_section",
"response_by",
"column_break_hpvj",
@ -195,11 +196,18 @@
"fieldname": "first_responded_on",
"fieldtype": "Datetime",
"label": "First Responded On"
},
{
"default": "Open",
"fieldname": "communication_status",
"fieldtype": "Link",
"label": "Communication Status",
"options": "CRM Communication Status"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-11 12:37:51.198228",
"modified": "2023-12-13 13:50:55.235109",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -5,6 +5,8 @@ import frappe
from frappe import _
from frappe.model.document import Document
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
class CRMDeal(Document):
def before_validate(self):
@ -55,7 +57,7 @@ class CRMDeal(Document):
"""
Find an SLA to apply to the deal.
"""
sla = get_sla("CRM Deal")
sla = get_sla(self)
if not sla:
return
self.sla = sla.name
@ -139,6 +141,7 @@ class CRMDeal(Document):
"mobile_no",
"deal_owner",
"sla_status",
"response_by",
"first_response_time",
"first_responded_on",
"modified",
@ -175,8 +178,3 @@ def set_primary_contact(deal, contact):
deal.save()
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)

View File

View File

@ -0,0 +1,57 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2023-12-14 11:16:15.476366",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"date",
"column_break_xzyo",
"weekly_off",
"section_break_zenz",
"description"
],
"fields": [
{
"fieldname": "date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Date",
"reqd": 1
},
{
"fieldname": "column_break_xzyo",
"fieldtype": "Column Break"
},
{
"default": "0",
"fieldname": "weekly_off",
"fieldtype": "Check",
"label": "Weekly Off"
},
{
"fieldname": "section_break_zenz",
"fieldtype": "Section Break"
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Description",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-12-14 11:17:41.745419",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Holiday",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View 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 CRMHoliday(Document):
pass

View File

@ -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 Holiday List", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,111 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:holiday_list_name",
"creation": "2023-12-14 11:09:12.876640",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"holiday_list_name",
"from_date",
"to_date",
"column_break_qwqc",
"total_holidays",
"add_weekly_holidays_section",
"weekly_off",
"add_to_holidays",
"holidays_section",
"holidays",
"clear_table"
],
"fields": [
{
"fieldname": "holiday_list_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Holiday List Name",
"reqd": 1,
"unique": 1
},
{
"fieldname": "from_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "From Date",
"reqd": 1
},
{
"fieldname": "to_date",
"fieldtype": "Date",
"in_list_view": 1,
"label": "To Date",
"reqd": 1
},
{
"fieldname": "column_break_qwqc",
"fieldtype": "Column Break"
},
{
"fieldname": "total_holidays",
"fieldtype": "Int",
"label": "Total Holidays"
},
{
"fieldname": "add_weekly_holidays_section",
"fieldtype": "Section Break",
"label": "Add Weekly Holidays"
},
{
"fieldname": "weekly_off",
"fieldtype": "Select",
"label": "Weekly Off",
"options": "\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday"
},
{
"fieldname": "add_to_holidays",
"fieldtype": "Button",
"label": "Add to Holidays"
},
{
"fieldname": "holidays_section",
"fieldtype": "Section Break",
"label": "Holidays"
},
{
"fieldname": "clear_table",
"fieldtype": "Button",
"label": "Clear Table"
},
{
"fieldname": "holidays",
"fieldtype": "Table",
"label": "Holidays",
"options": "CRM Holiday"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-14 11:18:27.236817",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Holiday List",
"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": []
}

View 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 CRMHolidayList(Document):
pass

View File

@ -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 TestCRMHolidayList(FrappeTestCase):
pass

View File

@ -1,6 +1,7 @@
import frappe
from frappe import _
from crm.api.doc import get_doctype_fields
@frappe.whitelist()
def get_lead(name):
@ -13,4 +14,5 @@ def get_lead(name):
frappe.throw(_("Lead not found"), frappe.DoesNotExistError)
lead = lead.pop()
lead["doctype_fields"] = get_doctype_fields("CRM Lead")
return lead

View File

@ -40,6 +40,7 @@
"sla_creation",
"column_break_ffnp",
"sla_status",
"communication_status",
"response_details_section",
"response_by",
"column_break_pweh",
@ -257,12 +258,19 @@
"fieldname": "first_responded_on",
"fieldtype": "Datetime",
"label": "First Responded On"
},
{
"default": "Open",
"fieldname": "communication_status",
"fieldtype": "Link",
"label": "Communication Status",
"options": "CRM Communication Status"
}
],
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-10 13:54:53.630114",
"modified": "2023-12-13 13:50:40.055487",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead",

View File

@ -6,6 +6,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import has_gravatar, validate_email_address
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_sla
class CRMLead(Document):
@ -124,6 +125,19 @@ class CRMLead(Document):
"contacts": [{"contact": contact}],
}
)
if self.first_responded_on:
deal.update(
{
"sla_creation": self.sla_creation,
"response_by": self.response_by,
"sla_status": self.sla_status,
"communication_status": self.communication_status,
"first_response_time": self.first_response_time,
"first_responded_on": self.first_responded_on
}
)
deal.insert(ignore_permissions=True)
return deal.name
@ -131,7 +145,7 @@ class CRMLead(Document):
"""
Find an SLA to apply to the lead.
"""
sla = get_sla("CRM Lead")
sla = get_sla(self)
if not sla:
return
self.sla = sla.name
@ -219,6 +233,7 @@ class CRMLead(Document):
"lead_owner",
"first_name",
"sla_status",
"response_by",
"first_response_time",
"first_responded_on",
"modified",
@ -239,8 +254,3 @@ def convert_to_deal(lead):
lead.save()
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)

View File

@ -6,16 +6,3 @@
// },
// });
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"
);
}
},
});

View File

@ -6,15 +6,20 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"sla_name",
"apply_on",
"column_break_uxua",
"sla_name",
"enabled",
"default",
"column_break_uxua",
"section_break_nevd",
"start_date",
"end_date",
"column_break_pzjg",
"condition",
"section_break_ufaf",
"priorities",
"section_break_rmgo",
"holiday_list",
"working_hours"
],
"fields": [
@ -60,7 +65,8 @@
},
{
"fieldname": "section_break_ufaf",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Response and Follow Up"
},
{
"fieldname": "priorities",
@ -71,7 +77,8 @@
},
{
"fieldname": "section_break_rmgo",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"label": "Working Hours"
},
{
"fieldname": "working_hours",
@ -79,11 +86,36 @@
"label": "Working Hours",
"options": "CRM Service Day",
"reqd": 1
},
{
"fieldname": "section_break_nevd",
"fieldtype": "Section Break",
"label": "Validity"
},
{
"fieldname": "start_date",
"fieldtype": "Date",
"label": "Start Date"
},
{
"fieldname": "column_break_pzjg",
"fieldtype": "Column Break"
},
{
"fieldname": "end_date",
"fieldtype": "Date",
"label": "End Date"
},
{
"fieldname": "holiday_list",
"fieldtype": "Link",
"label": "Holiday List",
"options": "CRM Holiday List"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-12-04 16:13:24.638239",
"modified": "2023-12-15 11:50:29.956775",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Service Level Agreement",

View File

@ -1,7 +1,8 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe import _
from datetime import timedelta
from frappe.model.document import Document
from frappe.utils import (
@ -12,12 +13,27 @@ from frappe.utils import (
now_datetime,
time_diff_in_seconds,
)
from crm.fcrm.doctype.crm_service_level_agreement.utils import get_context
class CRMServiceLevelAgreement(Document):
def validate(self):
self.validate_condition()
def validate_condition(self):
if not self.condition:
return
try:
temp_doc = frappe.new_doc(self.apply_on)
frappe.safe_eval(self.condition, None, get_context(temp_doc))
except Exception as e:
frappe.throw(
_("The Condition '{0}' is invalid: {1}").format(self.condition, str(e))
)
def apply(self, doc: Document):
self.handle_new(doc)
self.handle_status(doc)
self.handle_communication_status(doc)
self.handle_targets(doc)
self.handle_sla_status(doc)
@ -27,14 +43,14 @@ class CRMServiceLevelAgreement(Document):
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"):
def handle_communication_status(self, doc: Document):
if doc.is_new() or not doc.has_value_changed("communication_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():
if doc.communication_status != self.get_default_priority():
doc.first_responded_on = (
doc.first_responded_on or now_datetime()
)
@ -51,10 +67,10 @@ class CRMServiceLevelAgreement(Document):
def set_response_by(self, doc: Document):
start_time = doc.sla_creation
status = doc.status
communication_status = doc.communication_status
priorities = self.get_priorities()
priority = priorities.get(status)
priority = priorities.get(communication_status)
if not priority or doc.response_by:
return

View File

@ -0,0 +1,62 @@
import frappe
from frappe.model.document import Document
from frappe.query_builder import JoinType
from frappe.utils.safe_exec import get_safe_globals
DOCTYPE = "CRM Service Level Agreement"
def get_sla(doc: Document) -> Document:
"""
Get Service Level Agreement for `doc`
:param doc: Lead/Deal to use
:return: Applicable SLA
"""
check_permissions(DOCTYPE, None)
SLA = frappe.qb.DocType(DOCTYPE)
Priority = frappe.qb.DocType("CRM Service Level Priority")
priority = doc.communication_status
q = (
frappe.qb.from_(SLA)
.select(SLA.name, SLA.condition)
.where(SLA.apply_on == doc.doctype)
.where(SLA.enabled == True)
)
if priority:
q = (
q.join(Priority, JoinType.inner)
.on(Priority.parent == SLA.name)
.where(Priority.priority == priority)
)
sla_list = q.run(as_dict=True)
res = None
for sla in sla_list:
cond = sla.get("condition")
if not cond or frappe.safe_eval(cond, None, get_context(doc)):
res = sla
break
return res
def check_permissions(doctype, parent):
user = frappe.session.user
permissions = ("select", "read")
has_select_permission, has_read_permission = [
frappe.has_permission(doctype, perm, user=user, parent_doctype=parent)
for perm in permissions
]
if not has_select_permission and not has_read_permission:
frappe.throw(f"Insufficient Permission for {doctype}", frappe.PermissionError)
def get_context(d: Document) -> dict:
"""
Get safe context for `safe_eval`
:param doc: `Document` to add in context
:return: Context with `doc` and safe variables
"""
utils = get_safe_globals().get("frappe").get("utils")
return {
"doc": d.as_dict(),
"frappe": frappe._dict(utils=utils),
}

View File

@ -7,9 +7,11 @@
"engine": "InnoDB",
"field_order": [
"default_priority",
"reference_doctype",
"column_break_grod",
"priority",
"first_response_time"
"section_break_anyl",
"first_response_time",
"column_break_bwgs"
],
"fields": [
{
@ -21,10 +23,10 @@
},
{
"fieldname": "priority",
"fieldtype": "Dynamic Link",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Priority",
"options": "reference_doctype",
"options": "CRM Communication Status",
"reqd": 1
},
{
@ -35,17 +37,22 @@
"reqd": 1
},
{
"default": "CRM Lead Status",
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "DocType",
"options": "DocType"
"fieldname": "column_break_grod",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_anyl",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_bwgs",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-12-04 14:05:42.838493",
"modified": "2023-12-15 11:49:54.424029",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Service Level Priority",

View File

@ -1,6 +1,6 @@
{
"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\":\"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}}]",
"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}},{\"id\":\"ApHOcISpiJ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Communication Statuses\",\"col\":3}}]",
"creation": "2023-11-27 13:55:17.090361",
"custom_blocks": [],
"docstatus": 0,
@ -13,7 +13,7 @@
"is_hidden": 0,
"label": "Frappe CRM",
"links": [],
"modified": "2023-12-11 13:06:13.532693",
"modified": "2023-12-13 19:59:33.129412",
"modified_by": "Administrator",
"module": "FCRM",
"name": "Frappe CRM",
@ -25,6 +25,14 @@
"roles": [],
"sequence_id": 1.0,
"shortcuts": [
{
"color": "Grey",
"doc_view": "List",
"label": "Communication Statuses",
"link_to": "CRM Communication Status",
"stats_filter": "[]",
"type": "DocType"
},
{
"color": "Grey",
"doc_view": "List",

View File

@ -10,11 +10,12 @@ def before_install():
def after_install():
add_default_lead_statuses()
add_default_deal_statuses()
add_default_communication_statuses()
frappe.db.commit()
def add_default_lead_statuses():
statuses = {
"Open": {
"New": {
"color": "gray",
"position": 1,
},
@ -90,4 +91,15 @@ def add_default_deal_statuses():
doc.deal_status = status
doc.color = statuses[status]["color"]
doc.position = statuses[status]["position"]
doc.insert()
doc.insert()
def add_default_communication_statuses():
statuses = ["Open", "Replied"]
for status in statuses:
if frappe.db.exists("CRM Communication Status", status):
continue
doc = frappe.new_doc("CRM Communication Status")
doc.status = status
doc.insert()

View File

@ -51,6 +51,7 @@
'creation',
'first_response_time',
'first_responded_on',
'response_by',
].includes(column.key)
"
class="truncate text-base"
@ -62,11 +63,11 @@
class="truncate text-base"
>
<Badge
v-if="item.label"
v-if="item.value"
:variant="'subtle'"
:theme="item.color"
size="md"
:label="item.label"
:label="item.value"
/>
</div>
<div v-else-if="column.type === 'Check'">

View File

@ -60,6 +60,7 @@
'creation',
'first_response_time',
'first_responded_on',
'response_by',
].includes(column.key)
"
class="truncate text-base"
@ -71,11 +72,11 @@
class="truncate text-base"
>
<Badge
v-if="item.label"
v-if="item.value"
:variant="'subtle'"
:theme="item.color"
size="md"
:label="item.label"
:label="item.value"
/>
</div>
<div v-else-if="column.type === 'Check'">

View File

@ -338,7 +338,7 @@ const sections = computed(() => {
fields: [
{
label: 'Email',
type: 'dropdown',
type: props.contact.name ? 'dropdown' : 'data',
name: 'email_id',
options: props.contact?.email_ids?.map((email) => {
return {
@ -374,7 +374,7 @@ const sections = computed(() => {
fields: [
{
label: 'Mobile No.',
type: 'dropdown',
type: props.contact.name ? 'dropdown' : 'data',
name: 'mobile_no',
options: props.contact?.phone_nos?.map((phone) => {
return {

View File

@ -8,7 +8,7 @@
{
label: editMode ? 'Update' : 'Create',
variant: 'solid',
onClick: ({ close }) => updateNote(close),
onClick: () => updateNote(),
},
],
}"
@ -67,7 +67,7 @@ const title = ref(null)
const editMode = ref(false)
let _note = ref({})
async function updateNote(close) {
async function updateNote() {
if (
props.note.title === _note.value.title &&
props.note.content === _note.value.content
@ -97,7 +97,7 @@ async function updateNote(close) {
notes.value.reload()
}
}
close()
show.value = false
}
watch(

View File

@ -8,7 +8,7 @@
{
label: editMode ? 'Update' : 'Create',
variant: 'solid',
onClick: ({ close }) => updateTask(close),
onClick: () => updateTask(),
},
],
}"
@ -142,7 +142,7 @@ function updateTaskPriority(priority) {
_task.value.priority = priority
}
async function updateTask(close) {
async function updateTask() {
if (!_task.value.assigned_to) {
_task.value.assigned_to = getUser().email
}
@ -168,7 +168,7 @@ async function updateTask(close) {
tasks.value.reload()
}
}
close()
show.value = false
}
watch(

View File

@ -10,10 +10,8 @@
:options="field.options"
v-model="newDeal[field.name]"
>
<template v-if="field.name == 'status'" #prefix>
<IndicatorIcon
:class="getDealStatus(newDeal[field.name]).iconColorClass"
/>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<FormControl
@ -73,7 +71,7 @@ import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { FormControl, Tooltip } from 'frappe-ui'
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
const { getUser } = usersStore()
const { getDealStatus, statusOptions } = statusesStore()
@ -88,82 +86,83 @@ const props = defineProps({
const showOrganizationModal = ref(false)
const _organization = ref({})
const allFields = [
{
section: 'Deal Details',
fields: [
{
label: 'Salutation',
name: 'salutation',
type: 'select',
options: [
{
label: 'Mr',
value: 'Mr',
},
{
label: 'Ms',
value: 'Ms',
},
{
label: 'Mrs',
value: 'Mrs',
},
],
},
{
label: 'First Name',
name: 'first_name',
type: 'data',
},
{
label: 'Last Name',
name: 'last_name',
type: 'data',
},
{
label: 'Email',
name: 'email',
type: 'data',
},
{
label: 'Mobile No',
name: 'mobile_no',
type: 'data',
},
],
},
{
section: 'Other Details',
fields: [
{
label: 'Organization',
name: 'organization',
type: 'link',
placeholder: 'Organization',
doctype: 'CRM Organization',
change: (data) => (props.newDeal.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
const allFields = computed(() => {
return [
{
section: 'Deal Details',
fields: [
{
label: 'Salutation',
name: 'salutation',
type: 'link',
doctype: 'Salutation',
placeholder: 'Salutation',
change: (data) => (props.newDeal.salutation = data),
},
},
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions('deal'),
},
{
label: 'Deal Owner',
name: 'deal_owner',
type: 'user',
placeholder: 'Deal Owner',
doctype: 'User',
change: (data) => (props.newDeal.deal_owner = data),
},
],
},
]
{
label: 'First Name',
name: 'first_name',
type: 'data',
},
{
label: 'Last Name',
name: 'last_name',
type: 'data',
},
{
label: 'Email',
name: 'email',
type: 'data',
},
{
label: 'Mobile No',
name: 'mobile_no',
type: 'data',
},
],
},
{
section: 'Other Details',
fields: [
{
label: 'Organization',
name: 'organization',
type: 'link',
placeholder: 'Organization',
doctype: 'CRM Organization',
change: (data) => (props.newDeal.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
},
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions(
'deal',
(field, value) => (props.newDeal[field] = value)
),
prefix: getDealStatus(props.newDeal.status).iconColorClass,
},
{
label: 'Deal Owner',
name: 'deal_owner',
type: 'user',
placeholder: 'Deal Owner',
doctype: 'User',
change: (data) => (props.newDeal.deal_owner = data),
},
],
},
]
})
onMounted(() => {
if (!props.newDeal.status) {
props.newDeal.status = getDealStatus(props.newDeal.status).name
}
})
</script>

View File

@ -10,10 +10,8 @@
:options="field.options"
v-model="newLead[field.name]"
>
<template v-if="field.name == 'status'" #prefix>
<IndicatorIcon
:class="getLeadStatus(newLead[field.name]).iconColorClass"
/>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<FormControl
@ -73,7 +71,7 @@ import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { statusesStore } from '@/stores/statuses'
import { FormControl, Tooltip } from 'frappe-ui'
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
const { getUser } = usersStore()
const { getLeadStatus, statusOptions } = statusesStore()
@ -88,82 +86,83 @@ const props = defineProps({
const showOrganizationModal = ref(false)
const _organization = ref({})
const allFields = [
{
section: 'Lead Details',
fields: [
{
label: 'Salutation',
name: 'salutation',
type: 'select',
options: [
{
label: 'Mr',
value: 'Mr',
},
{
label: 'Ms',
value: 'Ms',
},
{
label: 'Mrs',
value: 'Mrs',
},
],
},
{
label: 'First Name',
name: 'first_name',
type: 'data',
},
{
label: 'Last Name',
name: 'last_name',
type: 'data',
},
{
label: 'Email',
name: 'email',
type: 'data',
},
{
label: 'Mobile No',
name: 'mobile_no',
type: 'data',
},
],
},
{
section: 'Other Details',
fields: [
{
label: 'Organization',
name: 'organization',
type: 'link',
placeholder: 'Organization',
doctype: 'CRM Organization',
change: (data) => (props.newLead.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
const allFields = computed(() => {
return [
{
section: 'Lead Details',
fields: [
{
label: 'Salutation',
name: 'salutation',
type: 'link',
placeholder: 'Salutation',
doctype: 'Salutation',
change: (data) => (props.newLead.salutation = data),
},
},
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions('lead'),
},
{
label: 'Lead Owner',
name: 'lead_owner',
type: 'user',
placeholder: 'Lead Owner',
doctype: 'User',
change: (data) => (props.newLead.lead_owner = data),
},
],
},
]
{
label: 'First Name',
name: 'first_name',
type: 'data',
},
{
label: 'Last Name',
name: 'last_name',
type: 'data',
},
{
label: 'Email',
name: 'email',
type: 'data',
},
{
label: 'Mobile No',
name: 'mobile_no',
type: 'data',
},
],
},
{
section: 'Other Details',
fields: [
{
label: 'Organization',
name: 'organization',
type: 'link',
placeholder: 'Organization',
doctype: 'CRM Organization',
change: (data) => (props.newLead.organization = data),
create: (value, close) => {
_organization.value.organization_name = value
showOrganizationModal.value = true
close()
},
},
{
label: 'Status',
name: 'status',
type: 'select',
options: statusOptions(
'lead',
(field, value) => (props.newLead[field] = value)
),
prefix: getLeadStatus(props.newLead.status).iconColorClass,
},
{
label: 'Lead Owner',
name: 'lead_owner',
type: 'user',
placeholder: 'Lead Owner',
doctype: 'User',
change: (data) => (props.newLead.lead_owner = data),
},
],
},
]
})
onMounted(() => {
if (!props.newLead.status) {
props.newLead.status = getLeadStatus(props.newLead.status).name
}
})
</script>

View File

@ -0,0 +1,114 @@
<template>
<div class="flex flex-col gap-1.5 border-b px-6 py-3">
<div
v-for="s in slaSection"
:key="s.label"
class="flex items-center gap-2 text-base leading-5"
>
<div class="w-[106px] text-sm text-gray-600">{{ s.label }}</div>
<div class="grid min-h-[28px] items-center">
<Tooltip
v-if="s.tooltipText"
:text="s.tooltipText"
class="ml-2 cursor-pointer"
>
<Badge
v-if="s.type == 'Badge'"
class="-ml-1"
:label="s.value"
variant="subtle"
:theme="s.color"
/>
<div v-else>{{ s.value }}</div>
</Tooltip>
<Dropdown
class="form-control"
v-if="s.type == 'Select'"
:options="s.options"
>
<template #default="{ open }">
<Button :label="s.value">
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
</div>
</div>
</template>
<script setup>
import { Dropdown, Badge, Tooltip, FeatherIcon } from 'frappe-ui'
import { timeAgo, dateFormat, formatTime, dateTooltipFormat } from '@/utils'
import { statusesStore } from '@/stores/statuses'
import { computed, defineModel } from 'vue'
const data = defineModel()
const emit = defineEmits(['updateField'])
const { communicationStatuses } = statusesStore()
let slaSection = computed(() => {
let sections = []
if (data.value.first_responded_on) {
sections.push({
label: 'Fulfilled In',
type: 'Duration',
value: formatTime(data.value.first_response_time),
tooltipText: dateFormat(data.value.first_responded_on, dateTooltipFormat),
})
}
let status = data.value.sla_status
let tooltipText = status
let color =
data.value.sla_status == 'Failed'
? 'red'
: data.value.sla_status == 'Fulfilled'
? 'green'
: 'orange'
if (status == 'First Response Due') {
status = timeAgo(data.value.response_by)
tooltipText = dateFormat(data.value.response_by, dateTooltipFormat)
if (new Date(data.value.response_by) < new Date()) {
color = 'red'
}
}
sections.push(
...[
{
label: 'SLA',
type: 'Badge',
value: status,
tooltipText: tooltipText,
color: color,
},
{
label: 'Status',
value: data.value.communication_status,
type: 'Select',
options: communicationStatuses.data?.map((status) => ({
label: status.name,
value: status.name,
onClick: () =>
emit('updateField', 'communication_status', status.name),
})),
},
]
)
return sections
})
</script>
<style scoped>
:deep(.form-control button) {
border-color: transparent;
background: white;
}
</style>

View File

@ -3,12 +3,12 @@
<div
v-for="field in fields"
:key="field.label"
class="flex items-center gap-2 px-3 text-base leading-5 first:mt-3"
class="flex items-center gap-2 px-3 leading-5 first:mt-3"
>
<div class="w-[106px] shrink-0 text-gray-600">
<div class="w-[106px] shrink-0 text-sm text-gray-600">
{{ field.label }}
</div>
<div class="grid min-h-[28px] flex-1 items-center overflow-hidden">
<div class="grid min-h-[28px] flex-1 text-base items-center overflow-hidden">
<FormControl
v-if="
[

View File

@ -175,16 +175,9 @@
</button>
</template>
<template #default="{ tab }">
<LeadsListView
class="mt-4"
v-if="tab.label === 'Leads' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
/>
<DealsListView
class="mt-4"
v-else-if="tab.label === 'Deals' && rows.length"
v-if="tab.label === 'Deals' && rows.length"
:rows="rows"
:columns="columns"
:options="{ selectable: false }"
@ -227,9 +220,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import CameraIcon from '@/components/Icons/CameraIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import LeadsListView from '@/components/ListViews/LeadsListView.vue'
import DealsListView from '@/components/ListViews/DealsListView.vue'
import ContactModal from '@/components/Modals/ContactModal.vue'
import {
@ -248,7 +239,7 @@ import { useRouter } from 'vue-router'
const { getContactByName, contacts } = contactsStore()
const { getUser } = usersStore()
const { getOrganization } = organizationsStore()
const { getLeadStatus, getDealStatus } = statusesStore()
const { getDealStatus } = statusesStore()
const props = defineProps({
contactId: {
@ -314,11 +305,6 @@ async function deleteContact() {
const tabIndex = ref(0)
const tabs = [
{
label: 'Leads',
icon: h(LeadsIcon, { class: 'h-4 w-4' }),
count: computed(() => leads.data?.length),
},
{
label: 'Deals',
icon: h(DealsIcon, { class: 'h-4 w-4' }),
@ -326,31 +312,6 @@ const tabs = [
},
]
const leads = createListResource({
type: 'list',
doctype: 'CRM Lead',
cache: ['leads', props.contactId],
fields: [
'name',
'first_name',
'lead_name',
'image',
'organization',
'status',
'email',
'mobile_no',
'lead_owner',
'modified',
],
filters: {
email: contact.value?.email_id,
converted: 0,
},
orderBy: 'modified desc',
pageLength: 20,
auto: true,
})
const deals = createResource({
url: 'crm.api.contact.get_linked_deals',
cache: ['deals', props.contactId],
@ -361,48 +322,12 @@ const deals = createResource({
})
const rows = computed(() => {
let list = []
list = tabIndex.value ? deals : leads
if (!deals.data || deals.data == []) return []
if (!list.data) return []
return list.data.map((row) => {
return tabIndex.value ? getDealRowObject(row) : getLeadRowObject(row)
})
return deals.data.map((row) => getDealRowObject(row))
})
const columns = computed(() => {
return tabIndex.value ? dealColumns : leadColumns
})
function getLeadRowObject(lead) {
return {
name: lead.name,
lead_name: {
label: lead.lead_name,
image: lead.image,
image_label: lead.first_name,
},
organization: {
label: lead.organization,
logo: getOrganization(lead.organization)?.organization_logo,
},
status: {
label: lead.status,
color: getLeadStatus(lead.status)?.iconColorClass,
},
email: lead.email,
mobile_no: lead.mobile_no,
lead_owner: {
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
...(lead.lead_owner && getUser(lead.lead_owner)),
},
modified: {
label: dateFormat(lead.modified, dateTooltipFormat),
timeAgo: timeAgo(lead.modified),
},
}
}
const columns = computed(() => dealColumns)
function getDealRowObject(deal) {
return {
@ -429,44 +354,6 @@ function getDealRowObject(deal) {
}
}
const leadColumns = [
{
label: 'Name',
key: 'lead_name',
width: '12rem',
},
{
label: 'Organization',
key: 'organization',
width: '10rem',
},
{
label: 'Status',
key: 'status',
width: '8rem',
},
{
label: 'Email',
key: 'email',
width: '12rem',
},
{
label: 'Mobile no',
key: 'mobile_no',
width: '11rem',
},
{
label: 'Lead owner',
key: 'lead_owner',
width: '10rem',
},
{
label: 'Last modified',
key: 'modified',
width: '8rem',
},
]
const dealColumns = [
{
label: 'Organization',

View File

@ -4,6 +4,33 @@
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Dropdown
:options="[
{
icon: 'trash-2',
label: 'Delete',
onClick: () =>
$dialog({
title: 'Delete Deal',
message: 'Are you sure you want to delete this deal?',
actions: [
{
label: 'Delete',
theme: 'red',
variant: 'solid',
onClick(close) {
deleteDeal(deal.data.name)
close()
},
},
],
}),
},
]"
@click.stop
>
<Button icon="more-horizontal" />
</Dropdown>
<Link
class="form-control"
:value="getUser(deal.data.deal_owner).full_name"
@ -99,79 +126,21 @@
</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">
<SLASection
v-if="deal.data.sla_status"
v-model="deal.data"
@updateField="updateField"
/>
<div
v-if="detailSections.length"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections.data"
v-for="(section, i) in detailSections"
:key="section.label"
class="flex flex-col p-3"
:class="{ 'border-b': i !== detailSections.data.length - 1 }"
:class="{ 'border-b': i !== detailSections.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<template #actions>
@ -346,13 +315,8 @@ import ContactModal from '@/components/Modals/ContactModal.vue'
import Link from '@/components/Controls/Link.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import {
openWebsite,
createToast,
dateFormat,
timeAgo,
formatTime,
} from '@/utils'
import SLASection from '@/components/SLASection.vue'
import { openWebsite, createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
@ -389,15 +353,6 @@ const deal = createResource({
params: { name: props.dealId },
cache: ['deal', props.dealId],
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)
@ -475,17 +430,13 @@ const tabs = [
},
]
const detailSections = createResource({
url: 'crm.api.doc.get_doctype_fields',
params: { doctype: 'CRM Deal' },
cache: 'dealFields',
auto: true,
transform: (data) => {
return getParsedFields(data)
},
const detailSections = computed(() => {
let data = deal.data
if (!data) return []
return getParsedFields(data.doctype_fields, data.contacts)
})
function getParsedFields(sections) {
function getParsedFields(sections, contacts) {
sections.forEach((section) => {
section.fields.forEach((field) => {
if (['website', 'annual_revenue'].includes(field.name)) {
@ -510,15 +461,14 @@ function getParsedFields(sections) {
let contactSection = {
label: 'Contacts',
opened: true,
contacts: computed(() =>
deal.data?.contacts.map((contact) => {
contacts:
contacts?.map((contact) => {
return {
name: contact.contact,
is_primary: contact.is_primary,
opened: false,
}
})
),
}) || [],
}
return [...sections, contactSection]
@ -599,4 +549,12 @@ function updateField(name, value, callback) {
callback?.()
})
}
async function deleteDeal(name) {
await call('frappe.client.delete', {
doctype: 'CRM Deal',
name,
})
router.push({ name: 'Deals' })
}
</script>

View File

@ -156,14 +156,25 @@ const rows = computed(() => {
color: getDealStatus(deal.status)?.iconColorClass,
}
} else if (row == 'sla_status') {
let value = deal.sla_status
let tooltipText = value
let color =
deal.sla_status == 'Failed'
? 'red'
: deal.sla_status == 'Fulfilled'
? 'green'
: 'orange'
if (value == 'First Response Due') {
value = timeAgo(deal.response_by)
tooltipText = dateFormat(deal.response_by, dateTooltipFormat)
if (new Date(deal.response_by) < new Date()) {
color = 'red'
}
}
_rows[row] = {
label: deal.sla_status,
color:
deal.sla_status === 'Failed'
? 'red'
: deal.sla_status === 'Fulfilled'
? 'green'
: 'gray',
label: tooltipText,
value: value,
color: color,
}
} else if (row == 'deal_owner') {
_rows[row] = {
@ -175,15 +186,18 @@ const rows = computed(() => {
label: dateFormat(deal[row], dateTooltipFormat),
timeAgo: timeAgo(deal[row]),
}
} else if (['first_response_time', 'first_responded_on'].includes(row)) {
} else if (
['first_response_time', 'first_responded_on', 'response_by'].includes(
row
)
) {
let field = row == 'response_by' ? 'response_by' : 'first_responded_on'
_rows[row] = {
label: deal.first_responded_on
? dateFormat(deal.first_responded_on, dateTooltipFormat)
: '',
label: deal[field] ? dateFormat(deal[field], dateTooltipFormat) : '',
timeAgo: deal[row]
? row == 'first_responded_on'
? timeAgo(deal[row])
: formatTime(deal[row])
? row == 'first_response_time'
? formatTime(deal[row])
: timeAgo(deal[row])
: '',
}
}
@ -239,7 +253,7 @@ const showNewDialog = ref(false)
let newDeal = reactive({
organization: '',
status: 'Qualification',
status: '',
email: '',
mobile_no: '',
deal_owner: '',

View File

@ -4,6 +4,33 @@
<Breadcrumbs :items="breadcrumbs" />
</template>
<template #right-header>
<Dropdown
:options="[
{
icon: 'trash-2',
label: 'Delete',
onClick: () =>
$dialog({
title: 'Delete Lead',
message: 'Are you sure you want to delete this lead?',
actions: [
{
label: 'Delete',
theme: 'red',
variant: 'solid',
onClick(close) {
deleteLead(lead.data.name)
close()
},
},
],
}),
},
]"
@click.stop
>
<Button icon="more-horizontal" />
</Dropdown>
<Link
class="form-control"
:value="getUser(lead.data.lead_owner).full_name"
@ -139,79 +166,21 @@
</div>
</template>
</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">
<SLASection
v-if="lead.data.sla_status"
v-model="lead.data"
@updateField="updateField"
/>
<div
v-if="detailSections.length"
class="flex flex-1 flex-col justify-between overflow-hidden"
>
<div class="flex flex-col overflow-y-auto">
<div
v-for="(section, i) in detailSections.data"
v-for="(section, i) in detailSections"
:key="section.label"
class="flex flex-col p-3"
:class="{ 'border-b': i !== detailSections.data.length - 1 }"
:class="{ 'border-b': i !== detailSections.length - 1 }"
>
<Section :is-opened="section.opened" :label="section.label">
<SectionFields
@ -252,14 +221,9 @@ import UserAvatar from '@/components/UserAvatar.vue'
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
import Section from '@/components/Section.vue'
import SectionFields from '@/components/SectionFields.vue'
import SLASection from '@/components/SLASection.vue'
import Link from '@/components/Controls/Link.vue'
import {
openWebsite,
createToast,
dateFormat,
timeAgo,
formatTime,
} from '@/utils'
import { openWebsite, createToast } from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
@ -274,7 +238,6 @@ import {
Avatar,
Tabs,
Breadcrumbs,
Badge,
call,
} from 'frappe-ui'
import { ref, computed } from 'vue'
@ -298,15 +261,6 @@ const lead = createResource({
params: { name: props.leadId },
cache: ['lead', props.leadId],
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)
@ -390,14 +344,10 @@ function validateFile(file) {
}
}
const detailSections = createResource({
url: 'crm.api.doc.get_doctype_fields',
params: { doctype: 'CRM Lead' },
cache: 'leadFields',
auto: true,
transform: (data) => {
return getParsedFields(data)
},
const detailSections = computed(() => {
let data = lead.data
if (!data) return []
return getParsedFields(data.doctype_fields, data.contacts)
})
function getParsedFields(sections) {
@ -426,6 +376,7 @@ function getParsedFields(sections) {
}
async function convertToDeal() {
updateField('communication_status', 'Replied')
let deal = await call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
lead: lead.data.name,
})
@ -441,4 +392,12 @@ function updateField(name, value, callback) {
callback?.()
})
}
async function deleteLead(name) {
await call('frappe.client.delete', {
doctype: 'CRM Lead',
name,
})
router.push({ name: 'Leads' })
}
</script>

View File

@ -155,14 +155,25 @@ const rows = computed(() => {
color: getLeadStatus(lead.status)?.iconColorClass,
}
} else if (row == 'sla_status') {
let value = lead.sla_status
let tooltipText = value
let color =
lead.sla_status == 'Failed'
? 'red'
: lead.sla_status == 'Fulfilled'
? 'green'
: 'orange'
if (value == 'First Response Due') {
value = timeAgo(lead.response_by)
tooltipText = dateFormat(lead.response_by, dateTooltipFormat)
if (new Date(lead.response_by) < new Date()) {
color = 'red'
}
}
_rows[row] = {
label: lead.sla_status,
color:
lead.sla_status === 'Failed'
? 'red'
: lead.sla_status === 'Fulfilled'
? 'green'
: 'gray',
label: tooltipText,
value: value,
color: color,
}
} else if (row == 'lead_owner') {
_rows[row] = {
@ -174,15 +185,18 @@ const rows = computed(() => {
label: dateFormat(lead[row], dateTooltipFormat),
timeAgo: timeAgo(lead[row]),
}
} else if (['first_response_time', 'first_responded_on'].includes(row)) {
} else if (
['first_response_time', 'first_responded_on', 'response_by'].includes(
row
)
) {
let field = row == 'response_by' ? 'response_by' : 'first_responded_on'
_rows[row] = {
label: lead.first_responded_on
? dateFormat(lead.first_responded_on, dateTooltipFormat)
: '',
label: lead[field] ? dateFormat(lead[field], dateTooltipFormat) : '',
timeAgo: lead[row]
? row == 'first_responded_on'
? timeAgo(lead[row])
: formatTime(lead[row])
? row == 'first_response_time'
? formatTime(lead[row])
: timeAgo(lead[row])
: '',
}
}
@ -242,7 +256,7 @@ let newLead = reactive({
last_name: '',
lead_name: '',
organization: '',
status: 'Open',
status: '',
email: '',
mobile_no: '',
lead_owner: '',

View File

@ -6,6 +6,7 @@ import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
export const statusesStore = defineStore('crm-statuses', () => {
let leadStatusesByName = reactive({})
let dealStatusesByName = reactive({})
let communicationStatusesByName = reactive({})
const leadStatuses = createListResource({
doctype: 'CRM Lead Status',
@ -41,6 +42,20 @@ export const statusesStore = defineStore('crm-statuses', () => {
},
})
const communicationStatuses = createListResource({
doctype: 'CRM Communication Status',
fields: ['name'],
cache: 'communication-statuses',
initialData: [],
auto: true,
transform(statuses) {
for (let status of statuses) {
communicationStatusesByName[status.name] = status
}
return statuses
},
})
function colorClasses(color, onlyIcon = false) {
let textColor = `!text-${color}-600`
if (color == 'black') {
@ -55,13 +70,26 @@ export const statusesStore = defineStore('crm-statuses', () => {
}
function getLeadStatus(name) {
if (!name) {
name = leadStatuses.data[0].name
}
return leadStatusesByName[name]
}
function getDealStatus(name) {
if (!name) {
name = dealStatuses.data[0].name
}
return dealStatusesByName[name]
}
function getCommunicationStatus(name) {
if (!name) {
name = communicationStatuses.data[0].name
}
return communicationStatuses[name]
}
function statusOptions(doctype, action) {
let statusesByName =
doctype == 'deal' ? dealStatusesByName : leadStatusesByName
@ -84,8 +112,10 @@ export const statusesStore = defineStore('crm-statuses', () => {
return {
leadStatuses,
dealStatuses,
communicationStatuses,
getLeadStatus,
getDealStatus,
getCommunicationStatus,
statusOptions,
}
})