Merge pull request #38 from shariquerik/sla

This commit is contained in:
Shariq Ansari 2023-12-11 15:45:04 +05:30 committed by GitHub
commit bd51426e12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 966 additions and 17 deletions

View File

@ -24,7 +24,17 @@
"deal_owner",
"section_break_eepu",
"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": [
{
@ -134,11 +144,62 @@
"fieldname": "organization_tab",
"fieldtype": "Tab Break",
"label": "Organization"
},
{
"fieldname": "sla_tab",
"fieldtype": "Tab Break",
"label": "SLA",
"read_only": 1
},
{
"fieldname": "sla",
"fieldtype": "Link",
"label": "SLA",
"options": "CRM Service Level Agreement"
},
{
"fieldname": "response_by",
"fieldtype": "Datetime",
"label": "Response By"
},
{
"fieldname": "column_break_pfvq",
"fieldtype": "Column Break"
},
{
"fieldname": "sla_status",
"fieldtype": "Select",
"label": "SLA Status",
"options": "\nFirst Response Due\nFailed\nFulfilled"
},
{
"fieldname": "sla_creation",
"fieldtype": "Datetime",
"label": "SLA Creation"
},
{
"fieldname": "response_details_section",
"fieldtype": "Section Break",
"label": "Response Details"
},
{
"fieldname": "column_break_hpvj",
"fieldtype": "Column Break"
},
{
"fieldname": "first_response_time",
"fieldtype": "Duration",
"label": "First Response Time"
},
{
"fieldname": "first_responded_on",
"fieldtype": "Datetime",
"label": "First Responded On"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-29 11:31:46.968519",
"modified": "2023-12-11 12:37:51.198228",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Deal",

View File

@ -7,10 +7,16 @@ from frappe.model.document import Document
class CRMDeal(Document):
def before_validate(self):
self.set_sla()
def validate(self):
self.set_primary_contact()
self.set_primary_email_mobile_no()
def before_save(self):
self.apply_sla()
def set_primary_contact(self, contact=None):
if not self.contacts:
return
@ -45,6 +51,22 @@ class CRMDeal(Document):
self.email = ""
self.mobile_no = ""
def set_sla(self):
"""
Find an SLA to apply to the deal.
"""
if sla := get_sla("CRM Deal"):
if not sla:
return
self.sla = sla.name
def apply_sla(self):
"""
Apply SLA if set.
"""
if sla := frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}):
sla.apply(self)
@staticmethod
def sort_options():
return [
@ -113,6 +135,9 @@ class CRMDeal(Document):
"email",
"mobile_no",
"deal_owner",
"sla_status",
"first_response_time",
"first_responded_on",
"modified",
]
return {'columns': columns, 'rows': rows}
@ -145,4 +170,10 @@ def set_primary_contact(deal, contact):
deal = frappe.get_cached_doc("CRM Deal", deal)
deal.set_primary_contact(contact)
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)

View File

@ -34,7 +34,17 @@
"no_of_employees",
"annual_revenue",
"image",
"converted"
"converted",
"sla_tab",
"sla",
"sla_creation",
"column_break_ffnp",
"sla_status",
"response_details_section",
"response_by",
"column_break_pweh",
"first_response_time",
"first_responded_on"
],
"fields": [
{
@ -196,12 +206,63 @@
"fieldname": "details",
"fieldtype": "Tab Break",
"label": "Details"
},
{
"fieldname": "sla_tab",
"fieldtype": "Tab Break",
"label": "SLA",
"read_only": 1
},
{
"fieldname": "sla",
"fieldtype": "Link",
"label": "SLA",
"options": "CRM Service Level Agreement"
},
{
"fieldname": "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",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2023-11-29 11:31:08.555096",
"modified": "2023-12-10 13:54:53.630114",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Lead",

View File

@ -9,12 +9,18 @@ from frappe.utils import has_gravatar, validate_email_address
class CRMLead(Document):
def before_validate(self):
self.set_sla()
def validate(self):
self.set_full_name()
self.set_lead_name()
self.set_title()
self.validate_email()
def before_save(self):
self.apply_sla()
def set_full_name(self):
if self.first_name:
self.lead_name = " ".join(
@ -121,6 +127,22 @@ class CRMLead(Document):
deal.insert(ignore_permissions=True)
return deal.name
def set_sla(self):
"""
Find an SLA to apply to the lead.
"""
if sla := get_sla("CRM Lead"):
if not sla:
return
self.sla = sla.name
def apply_sla(self):
"""
Apply SLA if set.
"""
if sla := frappe.get_last_doc("CRM Service Level Agreement", {"name": self.sla}):
sla.apply(self)
@staticmethod
def sort_options():
return [
@ -193,6 +215,9 @@ class CRMLead(Document):
"mobile_no",
"lead_owner",
"first_name",
"sla_status",
"first_response_time",
"first_responded_on",
"modified",
"image",
]
@ -209,4 +234,10 @@ def convert_to_deal(lead):
contact = lead.create_contact(False)
deal = lead.create_deal(contact)
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)

View 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": []
}

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

View File

@ -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"
);
}
},
});

View File

@ -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": []
}

View File

@ -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

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 TestCRMServiceLevelAgreement(FrappeTestCase):
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 Service Level Priority", {
// refresh(frm) {
// },
// });

View File

@ -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": []
}

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 CRMServiceLevelPriority(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 TestCRMServiceLevelPriority(FrappeTestCase):
pass

View File

@ -1,19 +1,19 @@
{
"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",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "crm",
"icon": "filter",
"idx": 0,
"indicator_color": "green",
"indicator_color": "",
"is_hidden": 0,
"label": "Frappe CRM",
"links": [],
"modified": "2023-11-29 13:37:56.731645",
"modified": "2023-12-11 13:06:13.532693",
"modified_by": "Administrator",
"module": "FCRM",
"name": "Frappe CRM",
@ -40,6 +40,14 @@
"type": "URL",
"url": "/crm"
},
{
"color": "Grey",
"doc_view": "List",
"label": "SLA",
"link_to": "CRM Service Level Agreement",
"stats_filter": "[]",
"type": "DocType"
},
{
"color": "Grey",
"doc_view": "List",

View File

@ -44,9 +44,31 @@
<PhoneIcon class="h-4 w-4" />
</div>
</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 }}
</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'">
<FormControl
type="checkbox"
@ -74,6 +96,7 @@ import {
ListRowItem,
ListSelectBanner,
FormControl,
Badge,
} from 'frappe-ui'
const props = defineProps({

View File

@ -53,9 +53,31 @@
<PhoneIcon class="h-4 w-4" />
</div>
</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 }}
</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'">
<FormControl
type="checkbox"
@ -83,6 +105,7 @@ import {
ListSelectBanner,
ListRowItem,
FormControl,
Badge,
} from 'frappe-ui'
const props = defineProps({

View File

@ -153,8 +153,8 @@ const allFields = [
},
{
label: 'Deal Owner',
name: 'lead_owner',
type: 'link',
name: 'deal_owner',
type: 'user',
placeholder: 'Deal Owner',
},
],

View File

@ -94,6 +94,72 @@
</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-col overflow-y-auto">
<div
@ -275,7 +341,14 @@ 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, activeAgents } from '@/utils'
import {
openWebsite,
createToast,
activeAgents,
dateFormat,
timeAgo,
formatTime,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
@ -313,6 +386,15 @@ 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)

View File

@ -71,6 +71,7 @@ import {
dateTooltipFormat,
timeAgo,
formatNumberIntoCurrency,
formatTime,
} from '@/utils'
import {
FeatherIcon,
@ -154,6 +155,16 @@ const rows = computed(() => {
label: deal.status,
color: getDealStatus(deal.status)?.iconColorClass,
}
} else if (row == 'sla_status') {
_rows[row] = {
label: deal.sla_status,
color:
deal.sla_status === 'Failed'
? 'red'
: deal.sla_status === 'Fulfilled'
? 'green'
: 'gray',
}
} else if (row == 'deal_owner') {
_rows[row] = {
label: deal.deal_owner && getUser(deal.deal_owner).full_name,
@ -164,6 +175,17 @@ const rows = computed(() => {
label: dateFormat(deal[row], dateTooltipFormat),
timeAgo: timeAgo(deal[row]),
}
} else if (['first_response_time', 'first_responded_on'].includes(row)) {
_rows[row] = {
label: deal.first_responded_on
? dateFormat(deal.first_responded_on, dateTooltipFormat)
: '',
timeAgo: deal[row]
? row == 'first_responded_on'
? timeAgo(deal[row])
: formatTime(deal[row])
: '',
}
}
})
return _rows

View File

@ -134,6 +134,72 @@
</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">
<div class="flex flex-col overflow-y-auto">
<div
@ -181,7 +247,14 @@ 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 { openWebsite, createToast, activeAgents } from '@/utils'
import {
openWebsite,
createToast,
activeAgents,
dateFormat,
timeAgo,
formatTime,
} from '@/utils'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
import { organizationsStore } from '@/stores/organizations'
@ -197,6 +270,7 @@ import {
Avatar,
Tabs,
Breadcrumbs,
Badge,
call,
} from 'frappe-ui'
import { ref, computed } from 'vue'
@ -220,6 +294,15 @@ 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)

View File

@ -65,7 +65,7 @@ import { statusesStore } from '@/stores/statuses'
import { useOrderBy } from '@/composables/orderby'
import { useFilter } from '@/composables/filter'
import { useDebounceFn } from '@vueuse/core'
import { dateFormat, dateTooltipFormat, timeAgo } from '@/utils'
import { dateFormat, dateTooltipFormat, timeAgo, formatTime } from '@/utils'
import {
FeatherIcon,
Dialog,
@ -154,6 +154,16 @@ const rows = computed(() => {
label: lead.status,
color: getLeadStatus(lead.status)?.iconColorClass,
}
} else if (row == 'sla_status') {
_rows[row] = {
label: lead.sla_status,
color:
lead.sla_status === 'Failed'
? 'red'
: lead.sla_status === 'Fulfilled'
? 'green'
: 'gray',
}
} else if (row == 'lead_owner') {
_rows[row] = {
label: lead.lead_owner && getUser(lead.lead_owner).full_name,
@ -164,6 +174,17 @@ const rows = computed(() => {
label: dateFormat(lead[row], dateTooltipFormat),
timeAgo: timeAgo(lead[row]),
}
} else if (['first_response_time', 'first_responded_on'].includes(row)) {
_rows[row] = {
label: lead.first_responded_on
? dateFormat(lead.first_responded_on, dateTooltipFormat)
: '',
timeAgo: lead[row]
? row == 'first_responded_on'
? timeAgo(lead[row])
: formatTime(lead[row])
: '',
}
}
})
return _rows

View File

@ -12,6 +12,31 @@ export function createToast(options) {
})
}
export function formatTime(seconds) {
const days = Math.floor(seconds / (3600 * 24))
const hours = Math.floor((seconds % (3600 * 24)) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
let formattedTime = ''
if (days > 0) {
formattedTime += `${days}d `
}
if (hours > 0 || days > 0) {
formattedTime += `${hours}h `
}
if (minutes > 0 || hours > 0 || days > 0) {
formattedTime += `${minutes}m `
}
formattedTime += `${remainingSeconds}s`
return formattedTime.trim()
}
export function dateFormat(date, format) {
const _format = format || 'DD-MM-YYYY HH:mm:ss'
return useDateFormat(date, _format).value