1
0
forked from test/crm

Merge pull request #530 from shariquerik/exotel

This commit is contained in:
Shariq Ansari 2025-01-18 21:26:13 +05:30 committed by GitHub
commit 82ed3e36cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2755 additions and 603 deletions

View File

@ -1,10 +1,13 @@
import json
from bs4 import BeautifulSoup
import frappe
from bs4 import BeautifulSoup
from frappe import _
from frappe.utils.caching import redis_cache
from frappe.desk.form.load import get_docinfo
from frappe.query_builder import JoinType
from crm.fcrm.doctype.crm_call_log.crm_call_log import parse_call_log
@frappe.whitelist()
def get_activities(name):
@ -15,11 +18,14 @@ def get_activities(name):
else:
frappe.throw(_("Document not found"), frappe.DoesNotExistError)
def get_deal_activities(name):
get_docinfo('', "CRM Deal", name)
get_docinfo("", "CRM Deal", name)
docinfo = frappe.response["docinfo"]
deal_meta = frappe.get_meta("CRM Deal")
deal_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields}
deal_fields = {
field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields
}
avoid_fields = [
"lead",
"response_by",
@ -43,13 +49,15 @@ def get_deal_activities(name):
activities, calls, notes, tasks, attachments = get_lead_activities(lead)
creation_text = "converted the lead to this deal"
activities.append({
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": creation_text,
"is_lead": False,
})
activities.append(
{
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": creation_text,
"is_lead": False,
}
)
docinfo.versions.reverse()
@ -107,7 +115,7 @@ def get_deal_activities(name):
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
"attachments": get_attachments('Comment', comment.name),
"attachments": get_attachments("Comment", comment.name),
"is_lead": False,
}
activities.append(activity)
@ -125,7 +133,7 @@ def get_deal_activities(name):
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"attachments": get_attachments('Communication', communication.name),
"attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
@ -144,21 +152,24 @@ def get_deal_activities(name):
}
activities.append(activity)
calls = calls + get_linked_calls(name)
notes = notes + get_linked_notes(name)
tasks = tasks + get_linked_tasks(name)
attachments = attachments + get_attachments('CRM Deal', name)
calls = calls + get_linked_calls(name).get("calls", [])
notes = notes + get_linked_notes(name) + get_linked_calls(name).get("notes", [])
tasks = tasks + get_linked_tasks(name) + get_linked_calls(name).get("tasks", [])
attachments = attachments + get_attachments("CRM Deal", name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
return activities, calls, notes, tasks, attachments
def get_lead_activities(name):
get_docinfo('', "CRM Lead", name)
get_docinfo("", "CRM Lead", name)
docinfo = frappe.response["docinfo"]
lead_meta = frappe.get_meta("CRM Lead")
lead_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields}
lead_fields = {
field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields
}
avoid_fields = [
"converted",
"response_by",
@ -169,13 +180,15 @@ def get_lead_activities(name):
]
doc = frappe.db.get_values("CRM Lead", name, ["creation", "owner"])[0]
activities = [{
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": "created this lead",
"is_lead": True,
}]
activities = [
{
"activity_type": "creation",
"creation": doc[0],
"owner": doc[1],
"data": "created this lead",
"is_lead": True,
}
]
docinfo.versions.reverse()
@ -233,7 +246,7 @@ def get_lead_activities(name):
"creation": comment.creation,
"owner": comment.owner,
"content": comment.content,
"attachments": get_attachments('Comment', comment.name),
"attachments": get_attachments("Comment", comment.name),
"is_lead": True,
}
activities.append(activity)
@ -251,7 +264,7 @@ def get_lead_activities(name):
"recipients": communication.recipients,
"cc": communication.cc,
"bcc": communication.bcc,
"attachments": get_attachments('Communication', communication.name),
"attachments": get_attachments("Communication", communication.name),
"read_by_recipient": communication.read_by_recipient,
"delivery_status": communication.delivery_status,
},
@ -270,10 +283,10 @@ def get_lead_activities(name):
}
activities.append(activity)
calls = get_linked_calls(name)
notes = get_linked_notes(name)
tasks = get_linked_tasks(name)
attachments = get_attachments('CRM Lead', name)
calls = get_linked_calls(name).get("calls", [])
notes = get_linked_notes(name) + get_linked_calls(name).get("notes", [])
tasks = get_linked_tasks(name) + get_linked_calls(name).get("tasks", [])
attachments = get_attachments("CRM Lead", name)
activities.sort(key=lambda x: x["creation"], reverse=True)
activities = handle_multiple_versions(activities)
@ -282,11 +295,25 @@ def get_lead_activities(name):
def get_attachments(doctype, name):
return frappe.db.get_all(
"File",
filters={"attached_to_doctype": doctype, "attached_to_name": name},
fields=["name", "file_name", "file_type", "file_url", "file_size", "is_private", "creation", "owner"],
) or []
return (
frappe.db.get_all(
"File",
filters={"attached_to_doctype": doctype, "attached_to_name": name},
fields=[
"name",
"file_name",
"file_type",
"file_url",
"file_size",
"is_private",
"modified",
"creation",
"owner",
],
)
or []
)
def handle_multiple_versions(versions):
activities = []
@ -298,7 +325,8 @@ def handle_multiple_versions(versions):
activities.append(version)
if not old_version:
old_version = version
if is_version: grouped_versions.append(version)
if is_version:
grouped_versions.append(version)
continue
if is_version and old_version.get("owner") and version["owner"] == old_version["owner"]:
grouped_versions.append(version)
@ -306,13 +334,15 @@ def handle_multiple_versions(versions):
if grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
grouped_versions = []
if is_version: grouped_versions.append(version)
if is_version:
grouped_versions.append(version)
old_version = version
if version == versions[-1] and grouped_versions:
activities.append(parse_grouped_versions(grouped_versions))
return activities
def parse_grouped_versions(versions):
version = versions[0]
if len(versions) == 1:
@ -321,6 +351,7 @@ def parse_grouped_versions(versions):
version["other_versions"] = other_versions
return version
def get_linked_calls(name):
calls = frappe.db.get_all(
"CRM Call Log",
@ -341,16 +372,89 @@ def get_linked_calls(name):
"note",
],
)
return calls or []
linked_calls = frappe.db.get_all(
"Dynamic Link", filters={"link_name": name, "parenttype": "CRM Call Log"}, pluck="parent"
)
notes = []
tasks = []
if linked_calls:
CallLog = frappe.qb.DocType("CRM Call Log")
Link = frappe.qb.DocType("Dynamic Link")
query = (
frappe.qb.from_(CallLog)
.select(
CallLog.name,
CallLog.caller,
CallLog.receiver,
CallLog["from"],
CallLog.to,
CallLog.duration,
CallLog.start_time,
CallLog.end_time,
CallLog.status,
CallLog.type,
CallLog.recording_url,
CallLog.creation,
CallLog.note,
Link.link_doctype,
Link.link_name,
)
.join(Link, JoinType.inner)
.on(Link.parent == CallLog.name)
.where(CallLog.name.isin(linked_calls))
)
_calls = query.run(as_dict=True)
for call in _calls:
if call.get("link_doctype") == "FCRM Note":
notes.append(call.link_name)
elif call.get("link_doctype") == "CRM Task":
tasks.append(call.link_name)
_calls = [call for call in _calls if call.get("link_doctype") not in ["FCRM Note", "CRM Task"]]
if _calls:
calls = calls + _calls
if notes:
notes = frappe.db.get_all(
"FCRM Note",
filters={"name": ("in", notes)},
fields=["name", "title", "content", "owner", "modified"],
)
if tasks:
tasks = frappe.db.get_all(
"CRM Task",
filters={"name": ("in", tasks)},
fields=[
"name",
"title",
"description",
"assigned_to",
"due_date",
"priority",
"status",
"modified",
],
)
calls = [parse_call_log(call) for call in calls] if calls else []
return {"calls": calls, "notes": notes, "tasks": tasks}
def get_linked_notes(name):
notes = frappe.db.get_all(
"FCRM Note",
filters={"reference_docname": name},
fields=['name', 'title', 'content', 'owner', 'modified'],
fields=["name", "title", "content", "owner", "modified"],
)
return notes or []
def get_linked_tasks(name):
tasks = frappe.db.get_all(
"CRM Task",
@ -360,7 +464,6 @@ def get_linked_tasks(name):
"title",
"description",
"assigned_to",
"assigned_to",
"due_date",
"priority",
"status",
@ -369,6 +472,7 @@ def get_linked_tasks(name):
)
return tasks or []
def parse_attachment_log(html, type):
soup = BeautifulSoup(html, "html.parser")
a_tag = soup.find("a")
@ -390,4 +494,4 @@ def parse_attachment_log(html, type):
"file_name": a_tag.text,
"file_url": a_tag["href"],
"is_private": is_private,
}
}

View File

@ -185,12 +185,13 @@ def get_quick_filters(doctype: str):
if field.fieldtype == "Select" and options and isinstance(options, str):
options = options.split("\n")
options = [{"label": option, "value": option} for option in options]
options.insert(0, {"label": "", "value": ""})
if not any([not option.get("value") for option in options]):
options.insert(0, {"label": "", "value": ""})
quick_filters.append(
{
"label": _(field.label),
"name": field.fieldname,
"type": field.fieldtype,
"fieldname": field.fieldname,
"fieldtype": field.fieldtype,
"options": options,
}
)
@ -306,6 +307,7 @@ def get_data(
)
or []
)
data = parse_list_data(data, doctype)
if view_type == "kanban":
if not rows:
@ -479,6 +481,13 @@ def get_data(
}
def parse_list_data(data, doctype):
_list = get_controller(doctype)
if hasattr(_list, "parse_list_data"):
data = _list.parse_list_data(data)
return data
def convert_filter_to_tuple(doctype, filters):
if isinstance(filters, dict):
filters_items = filters.items()

View File

@ -9,6 +9,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"telephony_medium",
"section_break_gyqe",
"id",
"from",
"status",
@ -24,7 +26,9 @@
"caller",
"recording_url",
"end_time",
"note"
"note",
"section_break_kebz",
"links"
],
"fields": [
{
@ -75,6 +79,7 @@
"label": "To"
},
{
"description": "Call duration in seconds",
"fieldname": "duration",
"fieldtype": "Duration",
"in_list_view": 1,
@ -123,11 +128,33 @@
"fieldtype": "Dynamic Link",
"label": "Reference Name",
"options": "reference_doctype"
},
{
"fieldname": "section_break_kebz",
"fieldtype": "Section Break"
},
{
"fieldname": "links",
"fieldtype": "Table",
"label": "Links",
"options": "Dynamic Link"
},
{
"fieldname": "telephony_medium",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Telephony Medium",
"options": "\nManual\nTwilio\nExotel"
},
{
"fieldname": "section_break_gyqe",
"fieldtype": "Section Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-16 13:23:09.201843",
"modified": "2025-01-17 21:46:01.558377",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Call Log",

View File

@ -4,78 +4,190 @@
import frappe
from frappe.model.document import Document
from crm.integrations.api import get_contact_by_phone_number
from crm.utils import seconds_to_duration
class CRMCallLog(Document):
@staticmethod
def default_list_data():
columns = [
{
'label': 'From',
'type': 'Link',
'key': 'caller',
'options': 'User',
'width': '9rem',
},
{
'label': 'To',
'type': 'Link',
'key': 'receiver',
'options': 'User',
'width': '9rem',
},
{
'label': 'Type',
'type': 'Select',
'key': 'type',
'width': '9rem',
},
{
'label': 'Status',
'type': 'Select',
'key': 'status',
'width': '9rem',
},
{
'label': 'Duration',
'type': 'Duration',
'key': 'duration',
'width': '6rem',
},
{
'label': 'From (number)',
'type': 'Data',
'key': 'from',
'width': '9rem',
},
{
'label': 'To (number)',
'type': 'Data',
'key': 'to',
'width': '9rem',
},
{
'label': 'Created On',
'type': 'Datetime',
'key': 'creation',
'width': '8rem',
},
]
rows = [
"name",
"caller",
"receiver",
"type",
"status",
"duration",
"from",
"to",
"note",
"recording_url",
"reference_doctype",
"reference_docname",
"creation",
]
return {'columns': columns, 'rows': rows}
@staticmethod
def default_list_data():
columns = [
{
"label": "From",
"type": "Link",
"key": "caller",
"options": "User",
"width": "9rem",
},
{
"label": "To",
"type": "Link",
"key": "receiver",
"options": "User",
"width": "9rem",
},
{
"label": "Type",
"type": "Select",
"key": "type",
"width": "9rem",
},
{
"label": "Status",
"type": "Select",
"key": "status",
"width": "9rem",
},
{
"label": "Duration",
"type": "Duration",
"key": "duration",
"width": "6rem",
},
{
"label": "From (number)",
"type": "Data",
"key": "from",
"width": "9rem",
},
{
"label": "To (number)",
"type": "Data",
"key": "to",
"width": "9rem",
},
{
"label": "Created On",
"type": "Datetime",
"key": "creation",
"width": "8rem",
},
]
rows = [
"name",
"caller",
"receiver",
"type",
"status",
"duration",
"from",
"to",
"note",
"recording_url",
"reference_doctype",
"reference_docname",
"creation",
]
return {"columns": columns, "rows": rows}
def parse_list_data(calls):
return [parse_call_log(call) for call in calls] if calls else []
def has_link(self, doctype, name):
for link in self.links:
if link.link_doctype == doctype and link.link_name == name:
return True
def link_with_reference_doc(self, reference_doctype, reference_name):
if self.has_link(reference_doctype, reference_name):
return
self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name})
def parse_call_log(call):
call["show_recording"] = False
call["duration"] = seconds_to_duration(call.get("duration"))
if call.get("type") == "Incoming":
call["activity_type"] = "incoming_call"
contact = get_contact_by_phone_number(call.get("from"))
receiver = (
frappe.db.get_values("User", call.get("receiver"), ["full_name", "user_image"])[0]
if call.get("receiver")
else [None, None]
)
call["caller"] = {
"label": contact.get("full_name", "Unknown"),
"image": contact.get("image"),
}
call["receiver"] = {
"label": receiver[0],
"image": receiver[1],
}
elif call.get("type") == "Outgoing":
call["activity_type"] = "outgoing_call"
contact = get_contact_by_phone_number(call.get("to"))
caller = (
frappe.db.get_values("User", call.get("caller"), ["full_name", "user_image"])[0]
if call.get("caller")
else [None, None]
)
call["caller"] = {
"label": caller[0],
"image": caller[1],
}
call["receiver"] = {
"label": contact.get("full_name", "Unknown"),
"image": contact.get("image"),
}
return call
@frappe.whitelist()
def get_call_log(name):
call = frappe.get_cached_doc(
"CRM Call Log",
name,
fields=[
"name",
"caller",
"receiver",
"duration",
"type",
"status",
"from",
"to",
"note",
"recording_url",
"reference_doctype",
"reference_docname",
"creation",
],
).as_dict()
call = parse_call_log(call)
notes = []
tasks = []
if call.get("note"):
note = frappe.get_cached_doc("FCRM Note", call.get("note")).as_dict()
notes.append(note)
if call.get("reference_doctype") and call.get("reference_docname"):
if call.get("reference_doctype") == "CRM Lead":
call["_lead"] = call.get("reference_docname")
elif call.get("reference_doctype") == "CRM Deal":
call["_deal"] = call.get("reference_docname")
if call.get("links"):
for link in call.get("links"):
if link.get("link_doctype") == "CRM Task":
task = frappe.get_cached_doc("CRM Task", link.get("link_name")).as_dict()
tasks.append(task)
elif link.get("link_doctype") == "FCRM Note":
note = frappe.get_cached_doc("FCRM Note", link.get("link_name")).as_dict()
notes.append(note)
elif link.get("link_doctype") == "CRM Lead":
call["_lead"] = link.get("link_name")
elif link.get("link_doctype") == "CRM Deal":
call["_deal"] = link.get("link_name")
call["_tasks"] = tasks
call["_notes"] = notes
return call
@frappe.whitelist()
def create_lead_from_call_log(call_log):
@ -85,15 +197,17 @@ def create_lead_from_call_log(call_log):
lead.lead_owner = frappe.session.user
lead.save(ignore_permissions=True)
frappe.db.set_value("CRM Call Log", call_log.get("name"), {
"reference_doctype": "CRM Lead",
"reference_docname": lead.name
})
frappe.db.set_value(
"CRM Call Log",
call_log.get("name"),
{"reference_doctype": "CRM Lead", "reference_docname": lead.name},
)
if call_log.get("note"):
frappe.db.set_value("FCRM Note", call_log.get("note"), {
"reference_doctype": "CRM Lead",
"reference_docname": lead.name
})
frappe.db.set_value(
"FCRM Note",
call_log.get("note"),
{"reference_doctype": "CRM Lead", "reference_docname": lead.name},
)
return lead.name
return lead.name

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Exotel Agent", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,80 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:mobile_no",
"creation": "2025-01-11 16:12:46.602782",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user",
"user_name",
"column_break_hdec",
"mobile_no",
"exotel_number"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User",
"options": "User",
"reqd": 1
},
{
"fieldname": "column_break_hdec",
"fieldtype": "Column Break"
},
{
"fieldname": "mobile_no",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Mobile No.",
"reqd": 1,
"unique": 1
},
{
"fetch_from": "user.full_name",
"fieldname": "user_name",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "User Name"
},
{
"fieldname": "exotel_number",
"fieldtype": "Data",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Exotel Number"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-15 20:03:31.162162",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Exotel Agent",
"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": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "user_name"
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CRMExotelAgent(Document):
pass

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMExotelAgent(UnitTestCase):
"""
Unit tests for CRMExotelAgent.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMExotelAgent(IntegrationTestCase):
"""
Integration tests for CRMExotelAgent.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Exotel Settings", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,115 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-01-08 15:55:50.710356",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"enabled",
"column_break_uxtz",
"record_call",
"section_break_kfez",
"account_sid",
"section_break_iuct",
"api_key",
"column_break_hyen",
"api_token"
],
"fields": [
{
"default": "0",
"fieldname": "enabled",
"fieldtype": "Check",
"label": "Enabled"
},
{
"fieldname": "section_break_kfez",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"depends_on": "enabled",
"fieldname": "account_sid",
"fieldtype": "Data",
"label": "Account SID",
"mandatory_depends_on": "enabled"
},
{
"depends_on": "enabled",
"fieldname": "section_break_iuct",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "column_break_hyen",
"fieldtype": "Column Break"
},
{
"depends_on": "enabled",
"fieldname": "api_key",
"fieldtype": "Data",
"in_list_view": 1,
"label": "API Key",
"mandatory_depends_on": "enabled"
},
{
"fieldname": "column_break_uxtz",
"fieldtype": "Column Break"
},
{
"depends_on": "enabled",
"fieldname": "api_token",
"fieldtype": "Password",
"in_list_view": 1,
"label": "API Token",
"mandatory_depends_on": "enabled"
},
{
"default": "0",
"depends_on": "enabled",
"fieldname": "record_call",
"fieldtype": "Check",
"label": "Record Call"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-15 19:31:00.310049",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Exotel Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"email": 1,
"print": 1,
"read": 1,
"role": "All",
"share": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,24 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
import requests
from frappe import _
from frappe.model.document import Document
class CRMExotelSettings(Document):
def validate(self):
self.verify_credentials()
def verify_credentials(self):
if self.enabled:
response = requests.get(
"https://api.exotel.com/v1/Accounts/{sid}".format(sid=self.account_sid),
auth=(self.api_key, self.get_password("api_token")),
)
if response.status_code != 200:
frappe.throw(
_(f"Please enter valid exotel Account SID, API key & API token: {response.reason}"),
title=_("Invalid credentials"),
)

View File

@ -0,0 +1,29 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMExotelSettings(UnitTestCase):
"""
Unit tests for CRMExotelSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMExotelSettings(IntegrationTestCase):
"""
Integration tests for CRMExotelSettings.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -12,7 +12,9 @@
"brand_logo",
"favicon",
"dropdown_items_tab",
"dropdown_items"
"dropdown_items",
"calling_tab",
"default_calling_medium"
],
"fields": [
{
@ -56,12 +58,23 @@
"fieldname": "favicon",
"fieldtype": "Attach",
"label": "Favicon"
},
{
"fieldname": "calling_tab",
"fieldtype": "Tab Break",
"label": "Calling"
},
{
"fieldname": "default_calling_medium",
"fieldtype": "Select",
"label": "Default calling medium",
"options": "\nTwilio\nExotel"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-12-30 19:21:30.847343",
"modified": "2025-01-15 17:40:32.784762",
"modified_by": "Administrator",
"module": "FCRM",
"name": "FCRM Settings",

View File

@ -23,6 +23,7 @@
],
"fields": [
{
"depends_on": "enabled",
"fieldname": "account_sid",
"fieldtype": "Data",
"in_list_view": 1,
@ -30,6 +31,7 @@
"mandatory_depends_on": "eval: doc.enabled"
},
{
"depends_on": "enabled",
"fieldname": "api_key",
"fieldtype": "Data",
"label": "API Key",
@ -37,6 +39,7 @@
"read_only": 1
},
{
"depends_on": "enabled",
"fieldname": "api_secret",
"fieldtype": "Password",
"label": "API Secret",
@ -48,6 +51,7 @@
"fieldtype": "Column Break"
},
{
"depends_on": "enabled",
"fieldname": "auth_token",
"fieldtype": "Password",
"in_list_view": 1,
@ -55,6 +59,7 @@
"mandatory_depends_on": "eval: doc.enabled"
},
{
"depends_on": "enabled",
"fieldname": "twiml_sid",
"fieldtype": "Data",
"label": "TwiML SID",
@ -67,6 +72,7 @@
},
{
"default": "0",
"depends_on": "enabled",
"fieldname": "record_calls",
"fieldtype": "Check",
"label": "Record Calls"
@ -77,7 +83,8 @@
},
{
"fieldname": "section_break_malx",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"hide_border": 1
},
{
"default": "0",
@ -87,7 +94,8 @@
},
{
"fieldname": "section_break_eklq",
"fieldtype": "Section Break"
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "column_break_yqvr",
@ -97,7 +105,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-12-30 19:23:04.401439",
"modified": "2025-01-15 19:35:13.406254",
"modified_by": "Administrator",
"module": "FCRM",
"name": "Twilio Settings",

View File

144
crm/integrations/api.py Normal file
View File

@ -0,0 +1,144 @@
import frappe
from frappe.query_builder import Order
from pypika.functions import Replace
from crm.utils import are_same_phone_number, parse_phone_number
@frappe.whitelist()
def is_call_integration_enabled():
twilio_enabled = frappe.db.get_single_value("Twilio Settings", "enabled")
exotel_enabled = frappe.db.get_single_value("CRM Exotel Settings", "enabled")
default_calling_medium = frappe.db.get_single_value("FCRM Settings", "default_calling_medium")
return {
"twilio_enabled": twilio_enabled,
"exotel_enabled": exotel_enabled,
"default_calling_medium": default_calling_medium,
}
@frappe.whitelist()
def set_default_calling_medium(medium):
return frappe.db.set_value("FCRM Settings", "FCRM Settings", "default_calling_medium", medium)
@frappe.whitelist()
def add_note_to_call_log(call_sid, note):
"""Add/Update note to call log based on call sid."""
_note = None
if not note.get("name"):
_note = frappe.get_doc(
{
"doctype": "FCRM Note",
"content": note.get("content"),
}
).insert(ignore_permissions=True)
call_log = frappe.get_cached_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("FCRM Note", _note.name)
call_log.save(ignore_permissions=True)
else:
_note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content"))
return _note
@frappe.whitelist()
def add_task_to_call_log(call_sid, task):
"""Add/Update task to call log based on call sid."""
_task = None
if not task.get("name"):
_task = frappe.get_doc(
{
"doctype": "CRM Task",
"title": task.get("title"),
"description": task.get("description"),
}
).insert(ignore_permissions=True)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.link_with_reference_doc("CRM Task", _task.name)
call_log.save(ignore_permissions=True)
else:
_task = frappe.get_doc("CRM Task", task.get("name"))
_task.update(
{
"title": task.get("title"),
"description": task.get("description"),
}
)
_task.save(ignore_permissions=True)
return _task
@frappe.whitelist()
def get_contact_by_phone_number(phone_number):
"""Get contact by phone number."""
number = parse_phone_number(phone_number)
if number.get("is_valid"):
return get_contact(number.get("national_number"))
else:
return get_contact(phone_number, exact_match=True)
def get_contact(phone_number, exact_match=False):
cleaned_number = (
phone_number.strip()
.replace(" ", "")
.replace("-", "")
.replace("(", "")
.replace(")", "")
.replace("+", "")
)
# Check if the number is associated with a contact
Contact = frappe.qb.DocType("Contact")
normalized_phone = Replace(
Replace(Replace(Replace(Replace(Contact.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", ""
)
query = (
frappe.qb.from_(Contact)
.select(Contact.name, Contact.full_name, Contact.image, Contact.mobile_no)
.where(normalized_phone.like(f"%{cleaned_number}%"))
.orderby("modified", order=Order.desc)
)
contacts = query.run(as_dict=True)
if len(contacts):
# Check if the contact is associated with a deal
for contact in contacts:
if frappe.db.exists("CRM Contacts", {"contact": contact.name, "is_primary": 1}):
deal = frappe.db.get_value(
"CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent"
)
if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match):
contact["deal"] = deal
return contact
# Else, return the first contact
if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match):
return contacts[0]
# Else, Check if the number is associated with a lead
Lead = frappe.qb.DocType("CRM Lead")
normalized_phone = Replace(
Replace(Replace(Replace(Replace(Lead.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", ""
)
query = (
frappe.qb.from_(Lead)
.select(Lead.name, Lead.lead_name, Lead.image, Lead.mobile_no)
.where(Lead.converted == 0)
.where(normalized_phone.like(f"%{cleaned_number}%"))
.orderby("modified", order=Order.desc)
)
leads = query.run(as_dict=True)
if len(leads):
for lead in leads:
if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match):
lead["lead"] = lead.name
return lead
return {"mobile_no": phone_number}

View File

@ -0,0 +1,257 @@
import json
import bleach
import frappe
import requests
from frappe import _
from frappe.integrations.utils import create_request_log
from crm.integrations.api import get_contact_by_phone_number
# Incoming Call
@frappe.whitelist(allow_guest=True)
def handle_request(**kwargs):
if not is_integration_enabled():
return
request_log = create_request_log(
kwargs,
request_description="Exotel Call",
service_name="Exotel",
request_headers=frappe.request.headers,
is_remote_request=1,
)
try:
request_log.status = "Completed"
exotel_settings = get_exotel_settings()
if not exotel_settings.enabled:
return
call_payload = kwargs
frappe.publish_realtime("exotel_call", call_payload)
status = call_payload.get("Status")
if status == "free":
return
if call_log := get_call_log(call_payload):
update_call_log(call_payload, call_log=call_log)
else:
create_call_log(
call_id=call_payload.get("CallSid"),
from_number=call_payload.get("CallFrom"),
to_number=call_payload.get("DialWhomNumber"),
medium=call_payload.get("To"),
status=get_call_log_status(call_payload),
agent=call_payload.get("AgentEmail"),
)
except Exception:
request_log.status = "Failed"
request_log.error = frappe.get_traceback()
frappe.db.rollback()
frappe.log_error(title="Error while creating/updating call record")
frappe.db.commit()
finally:
request_log.save(ignore_permissions=True)
frappe.db.commit()
# Outgoing Call
@frappe.whitelist()
def make_a_call(to_number, from_number=None, caller_id=None):
if not is_integration_enabled():
frappe.throw(_("Please setup Exotel intergration"), title=_("Integration Not Enabled"))
endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
if not from_number:
from_number = frappe.get_value("CRM Exotel Agent", {"user": frappe.session.user}, "mobile_no")
if not caller_id:
caller_id = frappe.get_value("CRM Exotel Agent", {"user": frappe.session.user}, "exotel_number")
if caller_id and caller_id not in get_all_exophones():
frappe.throw(_("Exotel Number {0} is not valid").format(caller_id), title=_("Invalid Exotel Number"))
if not from_number:
frappe.throw(
_("You do not have mobile number set in your Exotel Agent"), title=_("Mobile Number Missing")
)
record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call")
try:
response = requests.post(
endpoint,
data={
"From": from_number,
"To": to_number,
"CallerId": caller_id,
"Record": "true" if record_call else "false",
"StatusCallback": get_status_updater_url(),
"StatusCallbackEvents[0]": "terminal",
"StatusCallbackEvents[1]": "answered",
},
)
response.raise_for_status()
except requests.exceptions.HTTPError:
if exc := response.json().get("RestException"):
frappe.throw(bleach.linkify(exc.get("Message")), title=_("Exotel Exception"))
else:
res = response.json()
call_payload = res.get("Call", {})
create_call_log(
call_id=call_payload.get("Sid"),
from_number=call_payload.get("From"),
to_number=call_payload.get("To"),
medium=call_payload.get("PhoneNumberSid"),
call_type="Outgoing",
agent=frappe.session.user,
)
return response.json()
def get_exotel_endpoint(action=None):
settings = get_exotel_settings()
return "https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/{action}".format(
api_key=settings.api_key,
api_token=settings.get_password("api_token"),
sid=settings.account_sid,
action=action,
)
def get_all_exophones():
endpoint = get_exotel_endpoint("IncomingPhoneNumbers.json")
response = requests.get(endpoint)
return [
phone.get("IncomingPhoneNumber", {}).get("PhoneNumber")
for phone in response.json().get("IncomingPhoneNumbers", [])
]
def get_status_updater_url():
from frappe.utils.data import get_url
return get_url("api/method/crm.integrations.exotel.handler.handle_request")
def get_exotel_settings():
return frappe.get_single("CRM Exotel Settings")
@frappe.whitelist()
def is_integration_enabled():
return frappe.db.get_single_value("CRM Exotel Settings", "enabled", True)
# Call Log Functions
def create_call_log(
call_id,
from_number,
to_number,
medium,
agent,
status="Ringing",
call_type="Incoming",
):
call_log = frappe.new_doc("CRM Call Log")
call_log.id = call_id
call_log.to = to_number
call_log.medium = medium
call_log.type = call_type
call_log.status = status
call_log.telephony_medium = "Exotel"
setattr(call_log, "from", from_number)
if call_type == "Incoming":
call_log.receiver = agent
else:
call_log.caller = agent
# link call log with lead/deal
contact_number = from_number if call_type == "Incoming" else to_number
link(contact_number, call_log)
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
def link(contact_number, call_log):
contact = get_contact_by_phone_number(contact_number)
if contact.get("name"):
doctype = "Contact"
docname = contact.get("name")
if contact.get("lead"):
doctype = "CRM Lead"
docname = contact.get("lead")
elif contact.get("deal"):
doctype = "CRM Deal"
docname = contact.get("deal")
call_log.link_with_reference_doc(doctype, docname)
def get_call_log(call_payload):
call_log_id = call_payload.get("CallSid")
if frappe.db.exists("CRM Call Log", call_log_id):
return frappe.get_doc("CRM Call Log", call_log_id)
def get_call_log_status(call_payload, direction="inbound"):
if direction == "outbound-api" or direction == "outbound-dial":
status = call_payload.get("Status")
if status == "completed":
return "Completed"
elif status == "in-progress":
return "In Progress"
elif status == "busy":
return "Ringing"
elif status == "no-answer":
return "No Answer"
elif status == "failed":
return "Failed"
status = call_payload.get("DialCallStatus")
call_type = call_payload.get("CallType")
dial_call_status = call_payload.get("DialCallStatus")
if call_type == "incomplete" and dial_call_status == "no-answer":
status = "No Answer"
elif call_type == "client-hangup" and dial_call_status == "canceled":
status = "Canceled"
elif call_type == "incomplete" and dial_call_status == "failed":
status = "Failed"
elif call_type == "completed":
status = "Completed"
elif dial_call_status == "busy":
status = "Ringing"
return status
def update_call_log(call_payload, status="Ringing", call_log=None):
direction = call_payload.get("Direction")
call_log = call_log or get_call_log(call_payload)
status = get_call_log_status(call_payload, direction)
try:
if call_log:
call_log.status = status
# resetting this because call might be redirected to other number
call_log.to = call_payload.get("DialWhomNumber") or call_payload.get("To")
call_log.duration = (
call_payload.get("DialCallDuration") or call_payload.get("ConversationDuration") or 0
)
call_log.recording_url = call_payload.get("RecordingUrl")
call_log.start_time = call_payload.get("StartTime")
call_log.end_time = call_payload.get("EndTime")
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
except Exception:
frappe.log_error(title="Error while updating call record")
frappe.db.commit()

View File

@ -1,44 +1,46 @@
from werkzeug.wrappers import Response
import json
import frappe
from frappe import _
from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails
from .utils import parse_mobile_no
from werkzeug.wrappers import Response
from crm.integrations.api import get_contact_by_phone_number
from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails
@frappe.whitelist()
def is_enabled():
return frappe.db.get_single_value("Twilio Settings", "enabled")
@frappe.whitelist()
def generate_access_token():
"""Returns access token that is required to authenticate Twilio Client SDK.
"""
"""Returns access token that is required to authenticate Twilio Client SDK."""
twilio = Twilio.connect()
if not twilio:
return {}
from_number = frappe.db.get_value('Twilio Agents', frappe.session.user, 'twilio_number')
from_number = frappe.db.get_value("Twilio Agents", frappe.session.user, "twilio_number")
if not from_number:
return {
"ok": False,
"error": "caller_phone_identity_missing",
"detail": "Phone number is not mapped to the caller"
"detail": "Phone number is not mapped to the caller",
}
token=twilio.generate_voice_access_token(identity=frappe.session.user)
return {
'token': frappe.safe_decode(token)
}
token = twilio.generate_voice_access_token(identity=frappe.session.user)
return {"token": frappe.safe_decode(token)}
@frappe.whitelist(allow_guest=True)
def voice(**kwargs):
"""This is a webhook called by twilio to get instructions when the voice call request comes to twilio server.
"""
"""This is a webhook called by twilio to get instructions when the voice call request comes to twilio server."""
def _get_caller_number(caller):
identity = caller.replace('client:', '').strip()
identity = caller.replace("client:", "").strip()
user = Twilio.emailid_from_identity(identity)
return frappe.db.get_value('Twilio Agents', user, 'twilio_number')
return frappe.db.get_value("Twilio Agents", user, "twilio_number")
args = frappe._dict(kwargs)
twilio = Twilio.connect()
@ -54,7 +56,8 @@ def voice(**kwargs):
call_details = TwilioCallDetails(args, call_from=from_number)
create_call_log(call_details)
return Response(resp.to_xml(), mimetype='text/xml')
return Response(resp.to_xml(), mimetype="text/xml")
@frappe.whitelist(allow_guest=True)
def twilio_incoming_call_handler(**kwargs):
@ -63,36 +66,57 @@ def twilio_incoming_call_handler(**kwargs):
create_call_log(call_details)
resp = IncomingCall(args.From, args.To).process()
return Response(resp.to_xml(), mimetype='text/xml')
return Response(resp.to_xml(), mimetype="text/xml")
def create_call_log(call_details: TwilioCallDetails):
call_log = frappe.get_doc({**call_details.to_dict(),
'doctype': 'CRM Call Log',
'medium': 'Twilio'
})
call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log)
call_log.flags.ignore_permissions = True
call_log.save()
details = call_details.to_dict()
call_log = frappe.get_doc({**details, "doctype": "CRM Call Log", "telephony_medium": "Twilio"})
# link call log with lead/deal
contact_number = details.get("from") if details.get("type") == "Incoming" else details.get("to")
link(contact_number, call_log)
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
def link(contact_number, call_log):
contact = get_contact_by_phone_number(contact_number)
if contact.get("name"):
doctype = "Contact"
docname = contact.get("name")
if contact.get("lead"):
doctype = "CRM Lead"
docname = contact.get("lead")
elif contact.get("deal"):
doctype = "CRM Deal"
docname = contact.get("deal")
call_log.link_with_reference_doc(doctype, docname)
def update_call_log(call_sid, status=None):
"""Update call log status.
"""
"""Update call log status."""
twilio = Twilio.connect()
if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): return
if not (twilio and frappe.db.exists("CRM Call Log", call_sid)):
return
try:
call_details = twilio.get_call_info(call_sid)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
call_log.duration = call_details.duration
call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
except Exception:
frappe.log_error(title="Error while updating call record")
frappe.db.commit()
call_details = twilio.get_call_info(call_sid)
call_log = frappe.get_doc("CRM Call Log", call_sid)
call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
call_log.duration = call_details.duration
call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
if call_log.note and call_log.reference_docname:
frappe.db.set_value("FCRM Note", call_log.note, "reference_doctype", call_log.reference_doctype)
frappe.db.set_value("FCRM Note", call_log.note, "reference_docname", call_log.reference_docname)
call_log.flags.ignore_permissions = True
call_log.save()
frappe.db.commit()
@frappe.whitelist(allow_guest=True)
def update_recording_info(**kwargs):
@ -102,9 +126,10 @@ def update_recording_info(**kwargs):
call_sid = args.CallSid
update_call_log(call_sid)
frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url)
except:
except Exception:
frappe.log_error(title=_("Failed to capture Twilio recording"))
@frappe.whitelist(allow_guest=True)
def update_call_status_info(**kwargs):
try:
@ -113,70 +138,29 @@ def update_call_status_info(**kwargs):
update_call_log(parent_call_sid, status=args.CallStatus)
call_info = {
'ParentCallSid': args.ParentCallSid,
'CallSid': args.CallSid,
'CallStatus': args.CallStatus,
'CallDuration': args.CallDuration,
'From': args.From,
'To': args.To,
"ParentCallSid": args.ParentCallSid,
"CallSid": args.CallSid,
"CallStatus": args.CallStatus,
"CallDuration": args.CallDuration,
"From": args.From,
"To": args.To,
}
client = Twilio.get_twilio_client()
client.calls(args.ParentCallSid).user_defined_messages.create(
content=json.dumps(call_info)
)
except:
client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
except Exception:
frappe.log_error(title=_("Failed to update Twilio call status"))
def get_datetime_from_timestamp(timestamp):
from datetime import datetime
from zoneinfo import ZoneInfo
if not timestamp: return None
if not timestamp:
return None
datetime_utc_tz_str = timestamp.strftime('%Y-%m-%d %H:%M:%S%z')
datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, '%Y-%m-%d %H:%M:%S%z')
datetime_utc_tz_str = timestamp.strftime("%Y-%m-%d %H:%M:%S%z")
datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, "%Y-%m-%d %H:%M:%S%z")
system_timezone = frappe.utils.get_system_timezone()
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone))
return frappe.utils.format_datetime(converted_datetime, 'yyyy-MM-dd HH:mm:ss')
@frappe.whitelist()
def add_note_to_call_log(call_sid, note):
"""Add note to call log. based on child call sid.
"""
twilio = Twilio.connect()
if not twilio: return
call_details = twilio.get_call_info(call_sid)
sid = call_sid if call_details.direction == 'inbound' else call_details.parent_call_sid
frappe.db.set_value("CRM Call Log", sid, "note", note)
frappe.db.commit()
def get_lead_or_deal_from_number(call):
"""Get lead/deal from the given number.
"""
def find_record(doctype, mobile_no, where=''):
mobile_no = parse_mobile_no(mobile_no)
query = f"""
SELECT name, mobile_no
FROM `tab{doctype}`
WHERE CONCAT('+', REGEXP_REPLACE(mobile_no, '[^0-9]', '')) = {mobile_no}
"""
data = frappe.db.sql(query + where, as_dict=True)
return data[0].name if data else None
doctype = "CRM Deal"
number = call.get('to') if call.type == 'Outgoing' else call.get('from')
doc = find_record(doctype, number) or None
if not doc:
doctype = "CRM Lead"
doc = find_record(doctype, number, 'AND converted is not True')
if not doc:
doc = find_record(doctype, number)
return doc, doctype
return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss")

View File

@ -1,16 +1,17 @@
from twilio.rest import Client as TwilioClient
from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VoiceGrant
from twilio.twiml.voice_response import VoiceResponse, Dial
from .utils import get_public_url, merge_dicts
import frappe
from frappe import _
from frappe.utils.password import get_decrypted_password
from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VoiceGrant
from twilio.rest import Client as TwilioClient
from twilio.twiml.voice_response import Dial, VoiceResponse
from .utils import get_public_url, merge_dicts
class Twilio:
"""Twilio connector over TwilioClient.
"""
"""Twilio connector over TwilioClient."""
def __init__(self, settings):
"""
:param settings: `Twilio Settings` doctype
@ -24,22 +25,19 @@ class Twilio:
@classmethod
def connect(self):
"""Make a twilio connection.
"""
"""Make a twilio connection."""
settings = frappe.get_doc("Twilio Settings")
if not (settings and settings.enabled):
return
return Twilio(settings=settings)
def get_phone_numbers(self):
"""Get account's twilio phone numbers.
"""
"""Get account's twilio phone numbers."""
numbers = self.twilio_client.incoming_phone_numbers.list()
return [n.phone_number for n in numbers]
def generate_voice_access_token(self, identity: str, ttl=60*60):
"""Generates a token required to make voice calls from the browser.
"""
def generate_voice_access_token(self, identity: str, ttl=60 * 60):
"""Generates a token required to make voice calls from the browser."""
# identity is used by twilio to identify the user uniqueness at browser(or any endpoints).
identity = self.safe_identity(identity)
@ -49,7 +47,7 @@ class Twilio:
# Create a Voice grant and add to token
voice_grant = VoiceGrant(
outgoing_application_sid=self.application_sid,
incoming_allow=True, # Allow incoming calls
incoming_allow=True, # Allow incoming calls
)
token.add_grant(voice_grant)
return token.to_jwt()
@ -60,14 +58,13 @@ class Twilio:
Twilio Client JS fails to make a call connection if identity has special characters like @, [, / etc)
https://www.twilio.com/docs/voice/client/errors (#31105)
"""
return identity.replace('@', '(at)')
return identity.replace("@", "(at)")
@classmethod
def emailid_from_identity(cls, identity: str):
"""Convert safe identity string into emailID.
"""
return identity.replace('(at)', '@')
"""Convert safe identity string into emailID."""
return identity.replace("(at)", "@")
def get_recording_status_callback_url(self):
url_path = "/api/method/crm.integrations.twilio.api.update_recording_info"
return get_public_url(url_path)
@ -77,20 +74,19 @@ class Twilio:
return get_public_url(url_path)
def generate_twilio_dial_response(self, from_number: str, to_number: str):
"""Generates voice call instructions to forward the call to agents Phone.
"""
"""Generates voice call instructions to forward the call to agents Phone."""
resp = VoiceResponse()
dial = Dial(
caller_id=from_number,
record=self.settings.record_calls,
recording_status_callback=self.get_recording_status_callback_url(),
recording_status_callback_event='completed'
recording_status_callback_event="completed",
)
dial.number(
to_number,
status_callback_event='initiated ringing answered completed',
status_callback_event="initiated ringing answered completed",
status_callback=self.get_update_call_status_callback_url(),
status_callback_method='POST'
status_callback_method="POST",
)
resp.append(dial)
return resp
@ -98,21 +94,20 @@ class Twilio:
def get_call_info(self, call_sid):
return self.twilio_client.calls(call_sid).fetch()
def generate_twilio_client_response(self, client, ring_tone='at'):
"""Generates voice call instructions to forward the call to agents computer.
"""
def generate_twilio_client_response(self, client, ring_tone="at"):
"""Generates voice call instructions to forward the call to agents computer."""
resp = VoiceResponse()
dial = Dial(
ring_tone=ring_tone,
record=self.settings.record_calls,
recording_status_callback=self.get_recording_status_callback_url(),
recording_status_callback_event='completed'
recording_status_callback_event="completed",
)
dial.client(
client,
status_callback_event='initiated ringing answered completed',
status_callback_event="initiated ringing answered completed",
status_callback=self.get_update_call_status_callback_url(),
status_callback_method='POST'
status_callback_method="POST",
)
resp.append(dial)
return resp
@ -122,12 +117,13 @@ class Twilio:
twilio_settings = frappe.get_doc("Twilio Settings")
if not twilio_settings.enabled:
frappe.throw(_("Please enable twilio settings before making a call."))
auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", "auth_token")
client = TwilioClient(twilio_settings.account_sid, auth_token)
return client
class IncomingCall:
def __init__(self, from_number, to_number, meta=None):
self.from_number = from_number
@ -145,17 +141,18 @@ class IncomingCall:
if not attender:
resp = VoiceResponse()
resp.say(_('Agent is unavailable to take the call, please call after some time.'))
resp.say(_("Agent is unavailable to take the call, please call after some time."))
return resp
if attender['call_receiving_device'] == 'Phone':
return twilio.generate_twilio_dial_response(self.from_number, attender['mobile_no'])
if attender["call_receiving_device"] == "Phone":
return twilio.generate_twilio_dial_response(self.from_number, attender["mobile_no"])
else:
return twilio.generate_twilio_client_response(twilio.safe_identity(attender['name']))
return twilio.generate_twilio_client_response(twilio.safe_identity(attender["name"]))
def get_twilio_number_owners(phone_number):
"""Get list of users who is using the phone_number.
>>> get_twilio_number_owners('+11234567890')
>>> get_twilio_number_owners("+11234567890")
{
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
'owner2': {....}
@ -163,105 +160,106 @@ def get_twilio_number_owners(phone_number):
"""
# remove special characters from phone number and get only digits also remove white spaces
# keep + sign in the number at start of the number
phone_number = ''.join([c for c in phone_number if c.isdigit() or c == '+'])
phone_number = "".join([c for c in phone_number if c.isdigit() or c == "+"])
user_voice_settings = frappe.get_all(
'Twilio Agents',
filters={'twilio_number': phone_number},
fields=["name", "call_receiving_device"]
"Twilio Agents", filters={"twilio_number": phone_number}, fields=["name", "call_receiving_device"]
)
user_wise_voice_settings = {user['name']: user for user in user_voice_settings}
user_wise_voice_settings = {user["name"]: user for user in user_voice_settings}
user_general_settings = frappe.get_all(
'User',
filters = [['name', 'IN', user_wise_voice_settings.keys()]],
fields = ['name', 'mobile_no']
"User", filters=[["name", "IN", user_wise_voice_settings.keys()]], fields=["name", "mobile_no"]
)
user_wise_general_settings = {user['name']: user for user in user_general_settings}
user_wise_general_settings = {user["name"]: user for user in user_general_settings}
return merge_dicts(user_wise_general_settings, user_wise_voice_settings)
def get_active_loggedin_users(users):
"""Filter the current loggedin users from the given users list
"""
rows = frappe.db.sql("""
"""Filter the current loggedin users from the given users list"""
rows = frappe.db.sql(
"""
SELECT `user`
FROM `tabSessions`
WHERE `user` IN %(users)s
""", {'users': users})
""",
{"users": users},
)
return [row[0] for row in set(rows)]
def get_the_call_attender(owners, caller=None):
"""Get attender details from list of owners
"""
if not owners: return
"""Get attender details from list of owners"""
if not owners:
return
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
if len(current_loggedin_users) > 1 and caller:
deal_owner = frappe.db.get_value('CRM Deal', {'mobile_no': caller}, 'deal_owner')
deal_owner = frappe.db.get_value("CRM Deal", {"mobile_no": caller}, "deal_owner")
if not deal_owner:
deal_owner = frappe.db.get_value('CRM Lead', {'mobile_no': caller, 'converted': False}, 'lead_owner')
deal_owner = frappe.db.get_value(
"CRM Lead", {"mobile_no": caller, "converted": False}, "lead_owner"
)
for user in current_loggedin_users:
if user == deal_owner:
current_loggedin_users = [user]
for name, details in owners.items():
if ((details['call_receiving_device'] == 'Phone' and details['mobile_no']) or
(details['call_receiving_device'] == 'Computer' and name in current_loggedin_users)):
if (details["call_receiving_device"] == "Phone" and details["mobile_no"]) or (
details["call_receiving_device"] == "Computer" and name in current_loggedin_users
):
return details
class TwilioCallDetails:
def __init__(self, call_info, call_from = None, call_to = None):
def __init__(self, call_info, call_from=None, call_to=None):
self.call_info = call_info
self.account_sid = call_info.get('AccountSid')
self.application_sid = call_info.get('ApplicationSid')
self.call_sid = call_info.get('CallSid')
self.call_status = self.get_call_status(call_info.get('CallStatus'))
self._call_from = call_from or call_info.get('From')
self._call_to = call_to or call_info.get('To')
self.account_sid = call_info.get("AccountSid")
self.application_sid = call_info.get("ApplicationSid")
self.call_sid = call_info.get("CallSid")
self.call_status = self.get_call_status(call_info.get("CallStatus"))
self._call_from = call_from or call_info.get("From")
self._call_to = call_to or call_info.get("To")
def get_direction(self):
if self.call_info.get('Caller').lower().startswith('client'):
return 'Outgoing'
return 'Incoming'
if self.call_info.get("Caller").lower().startswith("client"):
return "Outgoing"
return "Incoming"
def get_from_number(self):
return self._call_from or self.call_info.get('From')
return self._call_from or self.call_info.get("From")
def get_to_number(self):
return self._call_to or self.call_info.get('To')
return self._call_to or self.call_info.get("To")
@classmethod
def get_call_status(cls, twilio_status):
"""Convert Twilio given status into system status.
"""
twilio_status = twilio_status or ''
return ' '.join(twilio_status.split('-')).title()
"""Convert Twilio given status into system status."""
twilio_status = twilio_status or ""
return " ".join(twilio_status.split("-")).title()
def to_dict(self):
"""Convert call details into dict.
"""
"""Convert call details into dict."""
direction = self.get_direction()
from_number = self.get_from_number()
to_number = self.get_to_number()
caller = ''
receiver = ''
caller = ""
receiver = ""
if direction == 'Outgoing':
caller = self.call_info.get('Caller')
identity = caller.replace('client:', '').strip()
caller = Twilio.emailid_from_identity(identity) if identity else ''
if direction == "Outgoing":
caller = self.call_info.get("Caller")
identity = caller.replace("client:", "").strip()
caller = Twilio.emailid_from_identity(identity) if identity else ""
else:
owners = get_twilio_number_owners(to_number)
attender = get_the_call_attender(owners, from_number)
receiver = attender['name'] if attender else ''
receiver = attender["name"] if attender else ""
return {
'type': direction,
'status': self.call_status,
'id': self.call_sid,
'from': from_number,
'to': to_number,
'receiver': receiver,
'caller': caller,
}
"type": direction,
"status": self.call_status,
"id": self.call_sid,
"from": from_number,
"to": to_number,
"receiver": receiver,
"caller": caller,
}

View File

@ -1,7 +1,7 @@
from frappe.utils import get_url
def get_public_url(path: str=None):
def get_public_url(path: str | None = None):
return get_url().split(":8", 1)[0] + path
@ -13,11 +13,5 @@ def merge_dicts(d1: dict, d2: dict):
)
... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}}
"""
return {k:{**v, **d2.get(k, {})} for k, v in d1.items()}
return {k: {**v, **d2.get(k, {})} for k, v in d1.items()}
def parse_mobile_no(mobile_no: str):
"""Parse mobile number to remove spaces, brackets, etc.
>>> parse_mobile_no('+91 (766) 667 6666')
... '+917666676666'
"""
return ''.join([c for c in mobile_no if c.isdigit() or c == '+'])

95
crm/utils/__init__.py Normal file
View File

@ -0,0 +1,95 @@
import phonenumbers
from frappe.utils import floor
from phonenumbers import NumberParseException
from phonenumbers import PhoneNumberFormat as PNF
def parse_phone_number(phone_number, default_country="IN"):
try:
# Parse the number
number = phonenumbers.parse(phone_number, default_country)
# Get various information about the number
result = {
"is_valid": phonenumbers.is_valid_number(number),
"country_code": number.country_code,
"national_number": str(number.national_number),
"formats": {
"international": phonenumbers.format_number(number, PNF.INTERNATIONAL),
"national": phonenumbers.format_number(number, PNF.NATIONAL),
"E164": phonenumbers.format_number(number, PNF.E164),
"RFC3966": phonenumbers.format_number(number, PNF.RFC3966),
},
"type": phonenumbers.number_type(number),
"country": phonenumbers.region_code_for_number(number),
"is_possible": phonenumbers.is_possible_number(number),
}
return {"success": True, **result}
except NumberParseException as e:
return {"success": False, "error": str(e)}
def are_same_phone_number(number1, number2, default_region="IN", validate=True):
"""
Check if two phone numbers are the same, regardless of their format.
Args:
number1 (str): First phone number
number2 (str): Second phone number
default_region (str): Default region code for parsing ambiguous numbers
Returns:
bool: True if numbers are same, False otherwise
"""
try:
# Parse both numbers
parsed1 = phonenumbers.parse(number1, default_region)
parsed2 = phonenumbers.parse(number2, default_region)
# Check if both numbers are valid
if validate and not (phonenumbers.is_valid_number(parsed1) and phonenumbers.is_valid_number(parsed2)):
return False
# Convert both to E164 format and compare
formatted1 = phonenumbers.format_number(parsed1, phonenumbers.PhoneNumberFormat.E164)
formatted2 = phonenumbers.format_number(parsed2, phonenumbers.PhoneNumberFormat.E164)
return formatted1 == formatted2
except phonenumbers.NumberParseException:
return False
def seconds_to_duration(seconds):
if not seconds:
return "0s"
hours = floor(seconds // 3600)
minutes = floor((seconds % 3600) // 60)
seconds = floor((seconds % 3600) % 60)
# 1h 0m 0s -> 1h
# 0h 1m 0s -> 1m
# 0h 0m 1s -> 1s
# 1h 1m 0s -> 1h 1m
# 1h 0m 1s -> 1h 1s
# 0h 1m 1s -> 1m 1s
# 1h 1m 1s -> 1h 1m 1s
if hours and minutes and seconds:
return f"{hours}h {minutes}m {seconds}s"
elif hours and minutes:
return f"{hours}h {minutes}m"
elif hours and seconds:
return f"{hours}h {seconds}s"
elif minutes and seconds:
return f"{minutes}m {seconds}s"
elif hours:
return f"{hours}h"
elif minutes:
return f"{minutes}m"
elif seconds:
return f"{seconds}s"
else:
return "0s"

@ -1 +1 @@
Subproject commit d82b3a12eeb6cb9e83375550508b462ce5cfdaf2
Subproject commit 863eaae9ada2edb287fc09fb21d05212bb5eebe9

View File

@ -14,7 +14,7 @@
"@vueuse/core": "^10.3.0",
"@vueuse/integrations": "^10.3.0",
"feather-icons": "^4.28.0",
"frappe-ui": "^0.1.104",
"frappe-ui": "^0.1.105",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",
@ -22,7 +22,7 @@
"socket.io-client": "^4.7.2",
"sortablejs": "^1.15.0",
"tailwindcss": "^3.3.3",
"vue": "^3.4.12",
"vue": "^3.5.13",
"vue-router": "^4.2.2",
"vuedraggable": "^4.1.0"
},

View File

@ -484,7 +484,7 @@ import CommunicationArea from '@/components/CommunicationArea.vue'
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
import AllModals from '@/components/Activities/AllModals.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import { timeAgo, formatDate, secondsToDuration, startCase } from '@/utils'
import { timeAgo, formatDate, startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
@ -544,40 +544,6 @@ const all_activities = createResource({
cache: ['activity', doc.value.data.name],
auto: true,
transform: ([versions, calls, notes, tasks, attachments]) => {
if (calls?.length) {
calls.forEach((doc) => {
doc.show_recording = false
doc.activity_type =
doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call'
doc.duration = secondsToDuration(doc.duration)
if (doc.type === 'Incoming') {
doc.caller = {
label:
getContact(doc.from)?.full_name ||
getLeadContact(doc.from)?.full_name ||
'Unknown',
image:
getContact(doc.from)?.image || getLeadContact(doc.from)?.image,
}
doc.receiver = {
label: getUser(doc.receiver).full_name,
image: getUser(doc.receiver).user_image,
}
} else {
doc.caller = {
label: getUser(doc.caller).full_name,
image: getUser(doc.caller).user_image,
}
doc.receiver = {
label:
getContact(doc.to)?.full_name ||
getLeadContact(doc.to)?.full_name ||
'Unknown',
image: getContact(doc.to)?.image || getLeadContact(doc.to)?.image,
}
}
})
}
return { versions, calls, notes, tasks, attachments }
},
})
@ -662,13 +628,13 @@ const activities = computed(() => {
return sortByCreation(all_activities.data.calls)
} else if (title.value == 'Tasks') {
if (!all_activities.data?.tasks) return []
return sortByCreation(all_activities.data.tasks)
return sortByModified(all_activities.data.tasks)
} else if (title.value == 'Notes') {
if (!all_activities.data?.notes) return []
return sortByCreation(all_activities.data.notes)
return sortByModified(all_activities.data.notes)
} else if (title.value == 'Attachments') {
if (!all_activities.data?.attachments) return []
return sortByCreation(all_activities.data.attachments)
return sortByModified(all_activities.data.attachments)
}
_activities.forEach((activity) => {
@ -696,6 +662,9 @@ const activities = computed(() => {
function sortByCreation(list) {
return list.sort((a, b) => new Date(a.creation) - new Date(b.creation))
}
function sortByModified(list) {
return list.sort((b, a) => new Date(a.modified) - new Date(b.modified))
}
function update_activities_details(activity) {
activity.owner_name = getUser(activity.owner).full_name

View File

@ -1,5 +1,5 @@
<template>
<div>
<div @click="showCallLogModal = true" class="cursor-pointer">
<div class="mb-1 flex items-center justify-stretch gap-2 py-1 text-base">
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
<Avatar
@ -70,7 +70,7 @@
v-if="activity.recording_url"
:label="activity.show_recording ? __('Hide Recording') : __('Listen')"
class="cursor-pointer"
@click="activity.show_recording = !activity.show_recording"
@click.stop="activity.show_recording = !activity.show_recording"
>
<template #prefix>
<PlayIcon class="size-3" />
@ -84,10 +84,12 @@
<div
v-if="activity.show_recording && activity.recording_url"
class="flex flex-col items-center justify-between"
@click.stop
>
<AudioPlayer :src="activity.recording_url" />
</div>
</div>
<CallLogModal v-model="showCallLogModal" :name="callLogName" />
</div>
</template>
<script setup>
@ -96,11 +98,16 @@ import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import AudioPlayer from '@/components/Activities/AudioPlayer.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue'
import { statusLabelMap, statusColorMap } from '@/utils/callLog.js'
import { formatDate, timeAgo } from '@/utils'
import { Avatar, Badge, Tooltip } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
activity: Object,
})
const callLogName = ref(props.activity.name)
const showCallLogModal = ref(false)
</script>

View File

@ -12,6 +12,45 @@ const timer = ref(null)
const updatedTime = ref('0:00')
function startCounter() {
updatedTime.value = getTime()
}
function start() {
timer.value = setInterval(() => startCounter(), 1000)
}
function stop() {
clearInterval(timer.value)
let output = updatedTime.value
hours.value = 0
minutes.value = 0
seconds.value = 0
updatedTime.value = '0:00'
return output
}
function getTime(_seconds = 0) {
if (_seconds) {
if (typeof _seconds === 'string') {
_seconds = parseInt(_seconds)
}
seconds.value = _seconds
if (seconds.value >= 60) {
minutes.value = Math.floor(seconds.value / 60)
seconds.value = seconds.value % 60
} else {
minutes.value = 0
}
if (minutes.value >= 60) {
hours.value = Math.floor(minutes.value / 60)
minutes.value = minutes.value % 60
} else {
hours.value = 0
}
}
if (seconds.value === 59) {
seconds.value = 0
minutes.value = minutes.value + 1
@ -36,22 +75,8 @@ function startCounter() {
}
}
updatedTime.value = hoursCount + minutesCount + ':' + secondsCount
return hoursCount + minutesCount + ':' + secondsCount
}
function start() {
timer.value = setInterval(() => startCounter(), 1000)
}
function stop() {
clearInterval(timer.value)
let output = updatedTime.value
hours.value = 0
minutes.value = 0
seconds.value = 0
updatedTime.value = '0:00'
return output
}
defineExpose({ start, stop, updatedTime })
defineExpose({ start, stop, getTime, updatedTime })
</script>

View File

@ -0,0 +1,17 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 7C9.65685 7 11 5.65685 11 4C11 2.34315 9.65685 1 8 1C6.34315 1 5 2.34315 5 4C5 5.65685 6.34315 7 8 7ZM7.5969 8C4.50582 8 2 10.5058 2 13.5969C2 14.118 2.37677 14.5628 2.89081 14.6485L4.52397 14.9207C6.82545 15.3042 9.17455 15.3042 11.476 14.9207L13.1092 14.6485C13.6232 14.5628 14 14.118 14 13.5969C14 10.5058 11.4942 8 8.4031 8H7.5969Z"
fill="currentColor"
fill-opacity="0.54"
/>
</svg>
</template>

View File

@ -9,8 +9,9 @@
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.99707 14H11.9971C12.8255 14 13.4971 13.3284 13.4971 12.5V6.49988L12 6.49989C10.6193 6.4999 9.5 5.38061 9.5 3.99989V2H3.99707C3.16864 2 2.49707 2.67157 2.49707 3.5V12.5C2.49707 13.3284 3.16864 14 3.99707 14ZM13.8291 4.2806C14.1476 4.62366 14.3612 5.04668 14.4502 5.49987L14.4968 5.49987L14.4968 5.94777L14.4971 5.98173V12.5C14.4971 13.8807 13.3778 15 11.9971 15H3.99707C2.61636 15 1.49707 13.8807 1.49707 12.5V3.5C1.49707 2.11929 2.61636 1 3.99707 1H9.69261C10.3878 1 11.0516 1.28945 11.5246 1.79887L13.8291 4.2806ZM12 5.49989L13.4176 5.49988C13.3502 5.30132 13.2414 5.11735 13.0963 4.96105L10.7918 2.47932C10.7044 2.38525 10.6063 2.30368 10.5 2.23582V3.99989C10.5 4.82832 11.1716 5.4999 12 5.49989ZM5 11C4.72386 11 4.5 11.2239 4.5 11.5C4.5 11.7761 4.72386 12 5 12H8C8.27614 12 8.5 11.7761 8.5 11.5C8.5 11.2239 8.27614 11 8 11H5ZM10 9.5H5H4.75C4.47386 9.5 4.25 9.27614 4.25 9C4.25 8.72386 4.47386 8.5 4.75 8.5H5H10H11.25C11.5261 8.5 11.75 8.72386 11.75 9C11.75 9.27614 11.5261 9.5 11.25 9.5H10Z"
d="M3.99707 14H11.9971C12.8255 14 13.4971 13.3284 13.4971 12.5V6.49988L11 6.4999C10.1716 6.49991 9.5 5.82833 9.5 4.9999V2H3.99707C3.16864 2 2.49707 2.67157 2.49707 3.5V12.5C2.49707 13.3284 3.16864 14 3.99707 14ZM13.8291 4.2806C14.1476 4.62366 14.3612 5.04668 14.4502 5.49987L14.4968 5.49987L14.4968 5.94777L14.4971 5.98173V12.5C14.4971 13.8807 13.3778 15 11.9971 15H3.99707C2.61636 15 1.49707 13.8807 1.49707 12.5V3.5C1.49707 2.11929 2.61636 1 3.99707 1H9.69261C10.3878 1 11.0516 1.28945 11.5246 1.79887L13.8291 4.2806ZM11 5.4999L13.4176 5.49988C13.3502 5.30132 13.2414 5.11735 13.0963 4.96105L10.7918 2.47932C10.7044 2.38525 10.6063 2.30368 10.5 2.23582V4.9999C10.5 5.27604 10.7239 5.4999 11 5.4999ZM5 11C4.72386 11 4.5 11.2239 4.5 11.5C4.5 11.7761 4.72386 12 5 12H11C11.2761 12 11.5 11.7761 11.5 11.5C11.5 11.2239 11.2761 11 11 11H5ZM4.5 9C4.5 8.72386 4.72386 8.5 5 8.5H10H11C11.2761 8.5 11.5 8.72386 11.5 9C11.5 9.27614 11.2761 9.5 11 9.5H10H5C4.72386 9.5 4.5 9.27614 4.5 9Z"
fill="currentColor"
fill-opacity="0.72"
/>
</svg>
</template>

View File

@ -8,5 +8,5 @@
</template>
<script setup>
import CallUI from '@/components/CallUI.vue'
import CallUI from '@/components/Telephony/CallUI.vue'
</script>

View File

@ -198,6 +198,7 @@ const props = defineProps({
})
const emit = defineEmits([
'showCallLog',
'loadMore',
'updatePageCount',
'columnWidthUpdated',

View File

@ -16,6 +16,6 @@
<script setup>
import MenuIcon from '@/components/Icons/MenuIcon.vue'
import CallUI from '@/components/CallUI.vue'
import CallUI from '@/components/Telephony/CallUI.vue'
import { mobileSidebarOpened as sidebarOpened } from '@/composables/settings'
</script>

View File

@ -83,12 +83,7 @@
</div>
</div>
</template>
<template
v-if="
callLog.doc?.type.label == 'Incoming' && !callLog.doc?.reference_docname
"
#actions
>
<template v-if="!callLog.data?._lead && !callLog.data?._deal" #actions>
<Button
class="w-full"
variant="solid"
@ -97,7 +92,7 @@
/>
</template>
</Dialog>
<NoteModal v-model="showNoteModal" :note="callNoteDoc?.doc" />
<NoteModal v-model="showNoteModal" :note="callNoteDoc" />
</template>
<script setup>
@ -111,13 +106,7 @@ import NoteIcon from '@/components/Icons/NoteIcon.vue'
import CheckCircleIcon from '@/components/Icons/CheckCircleIcon.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import {
FeatherIcon,
Avatar,
Tooltip,
createDocumentResource,
call,
} from 'frappe-ui'
import { FeatherIcon, Avatar, Tooltip, call, createResource } from 'frappe-ui'
import { getCallLogDetail } from '@/utils/callLog'
import { ref, computed, h, watch } from 'vue'
import { useRouter } from 'vue-router'
@ -136,63 +125,59 @@ const callNoteDoc = ref(null)
const callLog = ref({})
const detailFields = computed(() => {
if (!callLog.value.doc) return []
if (!callLog.value.data) return []
let details = [
{
icon: h(FeatherIcon, {
name: callLog.value.doc.type.icon,
name: callLog.value.data.type.icon,
class: 'h-3.5 w-3.5',
}),
name: 'type',
value: callLog.value.doc.type.label + ' Call',
value: callLog.value.data.type.label + ' Call',
},
{
icon: ContactsIcon,
name: 'receiver',
value: {
receiver: callLog.value.doc.receiver,
caller: callLog.value.doc.caller,
receiver: callLog.value.data.receiver,
caller: callLog.value.data.caller,
},
},
{
icon:
callLog.value.doc.reference_doctype == 'CRM Lead'
? LeadsIcon
: Dealsicon,
name: 'reference_doctype',
value:
callLog.value.doc.reference_doctype == 'CRM Lead' ? 'Lead' : 'Deal',
icon: callLog.value.data._lead ? LeadsIcon : Dealsicon,
name: 'reference_doc',
value: callLog.value.data._lead ? 'Lead' : 'Deal',
link: () => {
if (callLog.value.doc.reference_doctype == 'CRM Lead') {
if (callLog.value.data._lead) {
router.push({
name: 'Lead',
params: { leadId: callLog.value.doc.reference_docname },
params: { leadId: callLog.value.data._lead },
})
} else {
router.push({
name: 'Deal',
params: { dealId: callLog.value.doc.reference_docname },
params: { dealId: callLog.value.data._deal },
})
}
},
condition: () => callLog.value.doc.reference_docname,
condition: () => callLog.value.data._lead || callLog.value.data._deal,
},
{
icon: CalendarIcon,
name: 'creation',
value: callLog.value.doc.creation.label,
tooltip: callLog.value.doc.creation.label,
value: callLog.value.data.creation.label,
tooltip: callLog.value.data.creation.label,
},
{
icon: DurationIcon,
name: 'duration',
value: callLog.value.doc.duration.label,
value: callLog.value.data.duration.label,
},
{
icon: CheckCircleIcon,
name: 'status',
value: callLog.value.doc.status.label,
color: callLog.value.doc.status.color,
value: callLog.value.data.status.label,
color: callLog.value.data.status.color,
},
{
icon: h(FeatherIcon, {
@ -200,12 +185,12 @@ const detailFields = computed(() => {
class: 'h-4 w-4 mt-2',
}),
name: 'recording_url',
value: callLog.value.doc.recording_url,
value: callLog.value.data.recording_url,
},
{
icon: NoteIcon,
name: 'note',
value: callNoteDoc.value?.doc,
value: callNoteDoc.value,
},
]
@ -216,7 +201,7 @@ const detailFields = computed(() => {
function createLead() {
call('crm.fcrm.doctype.crm_call_log.crm_call_log.create_lead_from_call_log', {
call_log: callLog.value.doc,
call_log: callLog.value.data,
}).then((d) => {
if (d) {
router.push({ name: 'Lead', params: { leadId: d } })
@ -226,24 +211,9 @@ function createLead() {
watch(show, (val) => {
if (val) {
callLog.value = createDocumentResource({
doctype: 'CRM Call Log',
name: props.name,
fields: [
'name',
'caller',
'receiver',
'duration',
'type',
'status',
'from',
'to',
'note',
'recording_url',
'reference_doctype',
'reference_docname',
'creation',
],
callLog.value = createResource({
url: 'crm.fcrm.doctype.crm_call_log.crm_call_log.get_call_log',
params: { name: props.name },
cache: ['call_log', props.name],
auto: true,
transform: (doc) => {
@ -253,17 +223,7 @@ watch(show, (val) => {
return doc
},
onSuccess: (doc) => {
if (!doc.note) {
callNoteDoc.value = null
return
}
callNoteDoc.value = createDocumentResource({
doctype: 'FCRM Note',
name: doc.note,
fields: ['title', 'content'],
cache: ['note', doc.note],
auto: true,
})
callNoteDoc.value = doc._notes?.[0] ?? null
},
})
}

View File

@ -1,13 +1,13 @@
<template>
<FormControl
v-if="filter.type == 'Check'"
v-if="filter.fieldtype == 'Check'"
:label="filter.label"
type="checkbox"
v-model="filter.value"
@change.stop="updateFilter(filter, $event.target.checked)"
/>
<FormControl
v-else-if="filter.type === 'Select'"
v-else-if="filter.fieldtype === 'Select'"
class="form-control cursor-pointer [&_select]:cursor-pointer"
type="select"
v-model="filter.value"
@ -16,16 +16,16 @@
@change.stop="updateFilter(filter, $event.target.value)"
/>
<Link
v-else-if="filter.type === 'Link'"
v-else-if="filter.fieldtype === 'Link'"
:value="filter.value"
:doctype="filter.options"
:placeholder="filter.label"
@change="(data) => updateFilter(filter, data)"
/>
<component
v-else-if="['Date', 'Datetime'].includes(filter.type)"
v-else-if="['Date', 'Datetime'].includes(filter.fieldtype)"
class="border-none"
:is="filter.type === 'Date' ? DatePicker : DateTimePicker"
:is="filter.fieldtype === 'Date' ? DatePicker : DateTimePicker"
:value="filter.value"
@change="(v) => updateFilter(filter, v)"
:placeholder="filter.label"

View File

@ -56,7 +56,7 @@ import InviteMemberPage from '@/components/Settings/InviteMemberPage.vue'
import ProfileSettings from '@/components/Settings/ProfileSettings.vue'
import WhatsAppSettings from '@/components/Settings/WhatsAppSettings.vue'
import ERPNextSettings from '@/components/Settings/ERPNextSettings.vue'
import TwilioSettings from '@/components/Settings/TwilioSettings.vue'
import TelephonySettings from '@/components/Settings/TelephonySettings.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import { usersStore } from '@/stores/users'
import {
@ -105,9 +105,9 @@ const tabs = computed(() => {
label: __('Integrations'),
items: [
{
label: __('Twilio'),
label: __('Telephony'),
icon: PhoneIcon,
component: markRaw(TwilioSettings),
component: markRaw(TelephonySettings),
},
{
label: __('WhatsApp'),

View File

@ -131,6 +131,7 @@ const tabs = computed(() => {
_sections.push({
label: field.label,
name: field.fieldname,
hideBorder: field.hide_border,
columns: [{ name: 'column_' + getRandom(), fields: [] }],
})
} else if (field.fieldtype === 'Column Break') {

View File

@ -0,0 +1,311 @@
<template>
<div class="flex h-full flex-col gap-8 p-8">
<h2
class="flex gap-2 text-xl font-semibold leading-none h-5 text-ink-gray-9"
>
<div>{{ __('Telephony Settings') }}</div>
<Badge
v-if="twilio.isDirty || exotel.isDirty || mediumChanged"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h2>
<div
v-if="!twilio.get.loading || !exotel.get.loading"
class="flex-1 flex flex-col gap-8 overflow-y-auto"
>
<!-- General -->
<FormControl
type="select"
v-model="defaultCallingMedium"
:label="__('Default calling medium')"
:options="[
{ label: __(''), value: '' },
{ label: __('Twilio'), value: 'Twilio' },
{ label: __('Exotel'), value: 'Exotel' },
]"
class="w-1/2"
/>
<!-- Twilio -->
<div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-9">
{{ __('Twilio') }}
</span>
<FieldLayout
v-if="twilio?.doc && twilioTabs"
:tabs="twilioTabs"
:data="twilio.doc"
doctype="Twilio Settings"
/>
</div>
<!-- Exotel -->
<div class="flex flex-col justify-between gap-4">
<span class="text-base font-semibold text-ink-gray-9">
{{ __('Exotel') }}
</span>
<FieldLayout
v-if="exotel?.doc && exotelTabs"
:tabs="exotelTabs"
:data="exotel.doc"
doctype="CRM Exotel Settings"
/>
</div>
</div>
<div v-else class="flex flex-1 items-center justify-center">
<Spinner class="size-8" />
</div>
<div class="flex justify-between gap-2">
<div>
<ErrorMessage
class="mt-2"
:message="twilio.save.error || exotel.save.error || error"
/>
</div>
<Button
:loading="twilio.save.loading || exotel.save.loading"
:label="__('Update')"
variant="solid"
@click="update"
/>
</div>
</div>
</template>
<script setup>
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import {
createDocumentResource,
createResource,
FormControl,
Spinner,
Badge,
ErrorMessage,
call,
} from 'frappe-ui'
import { defaultCallingMedium } from '@/composables/settings'
import { createToast, getRandom } from '@/utils'
import { ref, computed, watch } from 'vue'
const twilioFields = createResource({
url: 'crm.api.doc.get_fields',
cache: ['fields', 'Twilio Settings'],
params: {
doctype: 'Twilio Settings',
allow_all_fieldtypes: true,
},
auto: true,
})
const exotelFields = createResource({
url: 'crm.api.doc.get_fields',
cache: ['fields', 'CRM Exotel Settings'],
params: {
doctype: 'CRM Exotel Settings',
allow_all_fieldtypes: true,
},
auto: true,
})
const twilio = createDocumentResource({
doctype: 'Twilio Settings',
name: 'Twilio Settings',
fields: ['*'],
auto: true,
setValue: {
onSuccess: () => {
createToast({
title: __('Success'),
text: __('Twilio settings updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: __('Error'),
text: err.message + ': ' + err.messages[0],
icon: 'x',
iconClasses: 'text-ink-red-4',
})
},
},
})
const exotel = createDocumentResource({
doctype: 'CRM Exotel Settings',
name: 'CRM Exotel Settings',
fields: ['*'],
auto: true,
setValue: {
onSuccess: () => {
createToast({
title: __('Success'),
text: __('Exotel settings updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
onError: (err) => {
createToast({
title: __('Error'),
text: err.message + ': ' + err.messages[0],
icon: 'x',
iconClasses: 'text-ink-red-4',
})
},
},
})
const twilioTabs = computed(() => {
if (!twilioFields.data) return []
let _tabs = []
let fieldsData = twilioFields.data
if (fieldsData[0].type != 'Tab Break') {
let _sections = []
if (fieldsData[0].type != 'Section Break') {
_sections.push({
name: 'first_section',
columns: [{ name: 'first_column', fields: [] }],
})
}
_tabs.push({ name: 'first_tab', sections: _sections })
}
fieldsData.forEach((field) => {
let last_tab = _tabs[_tabs.length - 1]
let _sections = _tabs.length ? last_tab.sections : []
if (field.fieldtype === 'Tab Break') {
_tabs.push({
label: field.label,
name: field.fieldname,
sections: [
{
name: 'section_' + getRandom(),
columns: [{ name: 'column_' + getRandom(), fields: [] }],
},
],
})
} else if (field.fieldtype === 'Section Break') {
_sections.push({
label: field.label,
name: field.fieldname,
hideBorder: field.hide_border,
columns: [{ name: 'column_' + getRandom(), fields: [] }],
})
} else if (field.fieldtype === 'Column Break') {
_sections[_sections.length - 1].columns.push({
name: field.fieldname,
fields: [],
})
} else {
let last_section = _sections[_sections.length - 1]
let last_column = last_section.columns[last_section.columns.length - 1]
last_column.fields.push(field)
}
})
return _tabs
})
const exotelTabs = computed(() => {
if (!exotelFields.data) return []
let _tabs = []
let fieldsData = exotelFields.data
if (fieldsData[0].type != 'Tab Break') {
let _sections = []
if (fieldsData[0].type != 'Section Break') {
_sections.push({
name: 'first_section',
columns: [{ name: 'first_column', fields: [] }],
})
}
_tabs.push({ name: 'first_tab', sections: _sections })
}
fieldsData.forEach((field) => {
let last_tab = _tabs[_tabs.length - 1]
let _sections = _tabs.length ? last_tab.sections : []
if (field.fieldtype === 'Tab Break') {
_tabs.push({
label: field.label,
name: field.fieldname,
sections: [
{
name: 'section_' + getRandom(),
columns: [{ name: 'column_' + getRandom(), fields: [] }],
},
],
})
} else if (field.fieldtype === 'Section Break') {
_sections.push({
label: field.label,
name: field.fieldname,
hideBorder: field.hide_border,
columns: [{ name: 'column_' + getRandom(), fields: [] }],
})
} else if (field.fieldtype === 'Column Break') {
_sections[_sections.length - 1].columns.push({
name: field.fieldname,
fields: [],
})
} else {
let last_section = _sections[_sections.length - 1]
let last_column = last_section.columns[last_section.columns.length - 1]
last_column.fields.push(field)
}
})
return _tabs
})
const mediumChanged = ref(false)
watch(defaultCallingMedium, () => {
mediumChanged.value = true
})
function update() {
if (!validateIfDefaultMediumIsEnabled()) return
if (mediumChanged.value) {
updateMedium()
}
if (twilio.isDirty) {
twilio.save.submit()
}
if (exotel.isDirty) {
exotel.save.submit()
}
}
async function updateMedium() {
await call('crm.integrations.api.set_default_calling_medium', {
medium: defaultCallingMedium.value,
})
mediumChanged.value = false
error.value = ''
createToast({
title: __('Success'),
text: __('Default calling medium updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
}
const error = ref('')
function validateIfDefaultMediumIsEnabled() {
if (defaultCallingMedium.value === 'Twilio' && !twilio.doc.enabled) {
error.value = __('Twilio is not enabled')
return false
}
if (defaultCallingMedium.value === 'Exotel' && !exotel.doc.enabled) {
error.value = __('Exotel is not enabled')
return false
}
return true
}
</script>

View File

@ -1,6 +0,0 @@
<template>
<SettingsPage doctype="Twilio Settings" class="p-8" />
</template>
<script setup>
import SettingsPage from '@/components/Settings/SettingsPage.vue'
</script>

View File

@ -0,0 +1,140 @@
<template>
<TwilioCallUI ref="twilio" />
<ExotelCallUI ref="exotel" />
<Dialog
v-model="show"
:options="{
title: __('Make call'),
actions: [
{
label: __('Call using {0}', [callMedium]),
variant: 'solid',
onClick: makeCallUsing,
},
],
}"
>
<template #body-content>
<div class="flex flex-col gap-4">
<FormControl
type="text"
v-model="mobileNumber"
:label="__('Mobile Number')"
/>
<FormControl
type="select"
v-model="callMedium"
:label="__('Call Medium')"
:options="['Twilio', 'Exotel']"
/>
<div class="flex flex-col gap-1">
<FormControl
type="checkbox"
v-model="isDefaultMedium"
:label="__('Make {0} as default call medium', [callMedium])"
/>
<div v-if="isDefaultMedium" class="text-sm text-ink-gray-4">
{{ __('You can change the default call medium from the settings') }}
</div>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import TwilioCallUI from '@/components/Telephony/TwilioCallUI.vue'
import ExotelCallUI from '@/components/Telephony/ExotelCallUI.vue'
import {
twilioEnabled,
exotelEnabled,
defaultCallingMedium,
} from '@/composables/settings'
import { globalStore } from '@/stores/global'
import { createToast } from '@/utils'
import { FormControl, call } from 'frappe-ui'
import { nextTick, ref, watch } from 'vue'
const { setMakeCall } = globalStore()
const twilio = ref(null)
const exotel = ref(null)
const callMedium = ref('Twilio')
const isDefaultMedium = ref(false)
const show = ref(false)
const mobileNumber = ref('')
function makeCall(number) {
if (
twilioEnabled.value &&
exotelEnabled.value &&
!defaultCallingMedium.value
) {
mobileNumber.value = number
show.value = true
return
}
callMedium.value = twilioEnabled.value ? 'Twilio' : 'Exotel'
if (defaultCallingMedium.value) {
callMedium.value = defaultCallingMedium.value
}
mobileNumber.value = number
makeCallUsing()
}
function makeCallUsing() {
if (isDefaultMedium.value && callMedium.value) {
setDefaultCallingMedium()
}
if (callMedium.value === 'Twilio') {
twilio.value.makeOutgoingCall(mobileNumber.value)
}
if (callMedium.value === 'Exotel') {
exotel.value.makeOutgoingCall(mobileNumber.value)
}
show.value = false
}
async function setDefaultCallingMedium() {
await call('crm.integrations.api.set_default_calling_medium', {
medium: callMedium.value,
})
defaultCallingMedium.value = callMedium.value
createToast({
title: __('Default calling medium set successfully to {0}', [
callMedium.value,
]),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
}
watch(
[twilioEnabled, exotelEnabled],
([twilioValue, exotelValue]) =>
nextTick(() => {
if (twilioValue) {
twilio.value.setup()
callMedium.value = 'Twilio'
}
if (exotelValue) {
exotel.value.setup()
callMedium.value = 'Exotel'
}
if (twilioValue || exotelValue) {
callMedium.value = 'Twilio'
setMakeCall(makeCall)
}
}),
{ immediate: true },
)
</script>

View File

@ -0,0 +1,573 @@
<template>
<div>
<div
v-show="showSmallCallPopup"
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-1 rounded-full bg-surface-gray-7 px-2 py-[7px] text-base text-ink-gray-2"
@click="toggleCallPopup"
>
<div
class="flex justify-center items-center size-5 rounded-full bg-surface-gray-6 shrink-0 mr-1"
>
<Avatar
v-if="contact?.image"
:image="contact.image"
:label="contact.full_name"
class="!size-5"
/>
<AvatarIcon v-else class="size-3" />
</div>
<span>{{ contact?.full_name ?? phoneNumber }}</span>
<span>·</span>
<div v-if="callStatus == 'In progress'">
{{ counterUp?.updatedTime }}
</div>
<div
v-else-if="callStatus == 'Call ended' || callStatus == 'No answer'"
class="blink"
:class="{
'text-red-700':
callStatus == 'Call ended' || callStatus == 'No answer',
}"
>
<span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'">
<span> · </span>
<span>{{ callDuration }}</span>
</span>
</div>
<div v-else>{{ __(callStatus) }}</div>
</div>
<div
v-show="showCallPopup"
class="fixed z-20 w-[280px] min-h-44 flex gap-2 flex-col rounded-lg bg-surface-gray-7 p-4 pt-2.5 text-ink-gray-2 shadow-2xl"
:style="style"
@click.stop
>
<div
ref="callPopupHeader"
class="header flex items-center justify-between gap-1 text-base cursor-move select-none"
>
<div class="flex gap-2 items-center truncate">
<div
v-if="showNote || showTask"
class="flex items-center gap-3 truncate"
>
<Avatar
v-if="contact?.image"
:image="contact.image"
:label="contact.full_name"
class="!size-7 shrink-0"
/>
<div
v-else
class="flex justify-center items-center size-7 rounded-full bg-surface-gray-6 shrink-0"
>
<AvatarIcon class="size-3" />
</div>
<div
class="flex flex-col gap-1 text-base leading-4 overflow-hidden"
>
<div class="font-medium truncate">
{{ contact?.full_name ?? phoneNumber }}
</div>
<div class="text-ink-gray-6">
<div v-if="callStatus == 'In progress'">
<span>{{ phoneNumber }}</span>
<span> · </span>
<span>{{ counterUp?.updatedTime }}</span>
</div>
<div
v-else-if="
callStatus == 'Call ended' || callStatus == 'No answer'
"
class="blink"
:class="{
'text-red-700':
callStatus == 'Call ended' || callStatus == 'No answer',
}"
>
<span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'">
<span> · </span>
<span>{{ callDuration }}</span>
</span>
</div>
<div v-else>{{ __(callStatus) }}</div>
</div>
</div>
</div>
<div v-else>
<div v-if="callStatus == 'In progress'">
{{ counterUp?.updatedTime }}
</div>
<div
v-else-if="
callStatus == 'Call ended' || callStatus == 'No answer'
"
class="blink"
:class="{
'text-red-700':
callStatus == 'Call ended' || callStatus == 'No answer',
}"
>
<span>{{ __(callStatus) }}</span>
<span v-if="callStatus == 'Call ended'">
<span> · </span>
<span>{{ callDuration }}</span>
</span>
</div>
<div v-else>{{ __(callStatus) }}</div>
</div>
</div>
<div class="flex">
<Button
@click="toggleCallPopup"
class="bg-surface-gray-7 text-ink-white hover:bg-surface-gray-6 shrink-0"
size="md"
>
<template #icon>
<MinimizeIcon class="h-4 w-4 cursor-pointer" />
</template>
</Button>
<Button
v-if="callStatus == 'Call ended' || callStatus == 'No answer'"
@click="closeCallPopup"
class="bg-surface-gray-7 text-ink-white hover:bg-surface-gray-6 shrink-0"
icon="x"
size="md"
/>
</div>
</div>
<div class="body flex-1">
<div v-if="showNote">
<TextEditor
variant="ghost"
ref="content"
editor-class="prose-sm h-[290px] text-ink-white overflow-auto mt-1"
:bubbleMenu="true"
:content="note.content"
@change="(val) => (note.content = val)"
:placeholder="__('Take a note...')"
/>
</div>
<TaskPanel ref="taskRef" v-else-if="showTask" :task="task" />
<div v-else class="flex items-center gap-3">
<Avatar
v-if="contact?.image"
:image="contact.image"
:label="contact.full_name"
class="!size-8"
/>
<div
v-else
class="flex justify-center items-center size-8 rounded-full bg-surface-gray-6"
>
<AvatarIcon class="size-4" />
</div>
<div v-if="contact?.full_name" class="flex flex-col gap-1">
<div class="text-lg font-medium leading-5">
{{ contact.full_name }}
</div>
<div class="text-base text-ink-gray-6 leading-4">
{{ phoneNumber }}
</div>
</div>
<div v-else class="text-lg font-medium leading-5">
{{ phoneNumber }}
</div>
</div>
</div>
<div class="footer flex justify-between gap-2">
<div class="flex gap-2">
<Button
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
size="md"
@click="showNoteWindow"
>
<template #icon>
<NoteIcon class="w-4 h-4" />
</template>
</Button>
<Button
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
size="md"
@click="showTaskWindow"
>
<template #icon>
<TaskIcon class="w-4 h-4" />
</template>
</Button>
<Button
v-if="contact.deal || contact.lead"
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
size="md"
:label="contact.deal ? __('Deal') : __('Lead')"
@click="openDealOrLead"
>
<template #suffix>
<ArrowUpRightIcon class="w-4 h-4" />
</template>
</Button>
</div>
<Button
v-if="(note.name || task.name) && dirty"
@click="update"
class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3"
variant="solid"
:label="__('Update')"
size="md"
/>
<Button
v-else-if="
((note?.content && note.content != '<p></p>') || task.title) &&
!note.name &&
!task.name
"
@click="save"
class="bg-surface-white !text-ink-gray-9 hover:!bg-surface-gray-3"
variant="solid"
:label="__('Save')"
size="md"
/>
</div>
</div>
<CountUpTimer ref="counterUp" />
</div>
</template>
<script setup>
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import AvatarIcon from '@/components/Icons/AvatarIcon.vue'
import MinimizeIcon from '@/components/Icons/MinimizeIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import TaskPanel from '@/components/Telephony/TaskPanel.vue'
import CountUpTimer from '@/components/CountUpTimer.vue'
import { createToast } from '@/utils'
import { globalStore } from '@/stores/global'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { TextEditor, Avatar, Button, createResource } from 'frappe-ui'
import { ref, onBeforeUnmount, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
const { $socket } = globalStore()
const callPopupHeader = ref(null)
const showCallPopup = ref(false)
const showSmallCallPopup = ref(false)
function toggleCallPopup() {
showCallPopup.value = !showCallPopup.value
if (showSmallCallPopup.value == undefined) {
showSmallCallPopup = !showSmallCallPopup
} else {
showSmallCallPopup.value = !showSmallCallPopup.value
}
}
const { width, height } = useWindowSize()
let { style } = useDraggable(callPopupHeader, {
initialValue: { x: width.value - 350, y: height.value - 250 },
preventDefault: true,
})
const callStatus = ref('')
const phoneNumber = ref('')
const callData = ref(null)
const counterUp = ref(null)
const contact = ref({
full_name: '',
image: '',
mobile_no: '',
})
watch(phoneNumber, (value) => {
if (!value) return
getContact.fetch()
})
const getContact = createResource({
url: 'crm.integrations.api.get_contact_by_phone_number',
makeParams() {
return {
phone_number: phoneNumber.value,
}
},
cache: ['contact', phoneNumber.value],
onSuccess(data) {
contact.value = data
},
})
const dirty = ref(false)
const note = ref({
name: '',
content: '',
})
const showNote = ref(false)
function showNoteWindow() {
showNote.value = !showNote.value
if (!showTask.value) {
updateWindowHeight(showNote.value)
}
if (showNote.value) {
showTask.value = false
}
}
function createUpdateNote() {
createResource({
url: 'crm.integrations.api.add_note_to_call_log',
params: {
call_sid: callData.value.CallSid,
note: note.value,
},
auto: true,
onSuccess(_note) {
note.value['name'] = _note.name
nextTick(() => {
dirty.value = false
})
},
})
}
const task = ref({
name: '',
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
})
const showTask = ref(false)
function showTaskWindow() {
showTask.value = !showTask.value
if (!showNote.value) {
updateWindowHeight(showTask.value)
}
if (showTask.value) {
showNote.value = false
}
}
function createUpdateTask() {
createResource({
url: 'crm.integrations.api.add_task_to_call_log',
params: {
call_sid: callData.value.CallSid,
task: task.value,
},
auto: true,
onSuccess(_task) {
task.value['name'] = _task.name
nextTick(() => {
dirty.value = false
})
},
})
}
watch([note, task], () => (dirty.value = true), { deep: true })
function updateWindowHeight(condition) {
let callPopup = callPopupHeader.value.parentElement
let top = parseInt(callPopup.style.top)
let updatedTop = 0
updatedTop = condition ? top - 224 : top + 224
if (updatedTop < 0) {
updatedTop = 10
}
callPopup.style.top = updatedTop + 'px'
}
function makeOutgoingCall(number) {
phoneNumber.value = number
createResource({
url: 'crm.integrations.exotel.handler.make_a_call',
params: { to_number: phoneNumber.value },
auto: true,
onSuccess() {
callStatus.value = 'Calling...'
showCallPopup.value = true
showSmallCallPopup.value = false
},
onError(err) {
createToast({
title: 'Error',
text: err.messages[0],
icon: 'x',
iconClasses: 'text-red-600',
})
},
})
}
function setup() {
$socket.on('exotel_call', (data) => {
callData.value = data
console.log(data)
callStatus.value = updateStatus(data)
if (!showCallPopup.value && !showSmallCallPopup.value) {
showCallPopup.value = true
}
})
}
onBeforeUnmount(() => {
$socket.off('exotel_call')
})
const router = useRouter()
function openDealOrLead() {
if (contact.value.deal) {
router.push({
name: 'Deal',
params: { dealId: contact.value.deal },
})
} else if (contact.value.lead) {
router.push({
name: 'Lead',
params: { leadId: contact.value.lead },
})
}
}
function closeCallPopup() {
showCallPopup.value = false
showSmallCallPopup.value = false
note.value = {
name: '',
content: '',
}
task.value = {
name: '',
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}
}
function save() {
if (note.value.content) createUpdateNote()
if (task.value.title) createUpdateTask()
}
function update() {
if (note.value.content) createUpdateNote()
if (task.value.title) createUpdateTask()
}
const callDuration = ref('00:00')
function updateStatus(data) {
// outgoing call
if (
data.EventType == 'answered' &&
data.Direction == 'outbound-api' &&
data.Status == 'in-progress' &&
data['Legs[0][Status]'] == 'in-progress' &&
data['Legs[1][Status]'] == ''
) {
return 'Ringing...'
} else if (
data.EventType == 'answered' &&
data.Direction == 'outbound-api' &&
data.Status == 'in-progress' &&
data['Legs[1][Status]'] == 'in-progress'
) {
counterUp.value.start()
return 'In progress'
} else if (
data.EventType == 'terminal' &&
data.Direction == 'outbound-api' &&
(data.Status == 'no-answer' || data.Status == 'busy') &&
(data['Legs[1][Status]'] == 'no-answer' ||
data['Legs[0][Status]'] == 'no-answer' ||
data['Legs[1][Status]'] == 'busy' ||
data['Legs[0][Status]'] == 'busy')
) {
counterUp.value.stop()
return 'No answer'
} else if (
data.EventType == 'terminal' &&
data.Direction == 'outbound-api' &&
data.Status == 'completed'
) {
counterUp.value.stop()
callDuration.value = getTime(
parseInt(data['Legs[0][OnCallDuration]']) ||
parseInt(data.DialCallDuration),
)
return 'Call ended'
}
// incoming call
if (
data.EventType == 'Dial' &&
data.Direction == 'incoming' &&
data.Status == 'busy'
) {
phoneNumber.value = data.From || data.CallFrom
return 'Incoming call'
} else if (
data.Direction == 'incoming' &&
data.CallType == 'incomplete' &&
data.DialCallStatus == 'no-answer'
) {
return 'No answer'
} else if (
data.Direction == 'incoming' &&
(data.CallType == 'completed' || data.CallType == 'client-hangup') &&
(data.DialCallStatus == 'completed' || data.DialCallStatus == 'canceled')
) {
callDuration.value = counterUp.value.getTime(
parseInt(data['Legs[0][OnCallDuration]']) ||
parseInt(data.DialCallDuration),
)
return 'Call ended'
}
}
defineExpose({ makeOutgoingCall, setup })
</script>
<style scoped>
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.blink {
animation: blink 1s ease-in-out 6;
}
:deep(.ProseMirror) {
caret-color: var(--ink-white);
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<div class="h-[294px] text-base">
<FormControl
type="text"
variant="ghost"
class="mb-2 title"
v-model="task.title"
:placeholder="__('Schedule a task...')"
/>
<TextEditor
variant="ghost"
ref="content"
editor-class="prose-sm h-[150px] text-ink-white overflow-auto"
:bubbleMenu="true"
:content="task.description"
@change="(val) => (task.description = val)"
:placeholder="__('Add description...')"
/>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Dropdown :options="taskStatusOptions(updateTaskStatus)">
<Button
:label="task.status"
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
>
<template #prefix>
<TaskStatusIcon :status="task.status" />
</template>
</Button>
</Dropdown>
<Dropdown :options="taskPriorityOptions(updateTaskPriority)">
<Button
:label="task.priority"
class="bg-surface-gray-6 text-ink-white hover:bg-surface-gray-5"
>
<template #prefix>
<TaskPriorityIcon :priority="task.priority" />
</template>
</Button>
</Dropdown>
</div>
<Link
class="user"
:value="getUser(task.assigned_to).full_name"
doctype="User"
@change="(option) => (task.assigned_to = option)"
:placeholder="__('John Doe')"
:hideMe="true"
>
<template #prefix>
<UserAvatar class="mr-2 !h-4 !w-4" :user="task.assigned_to" />
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<DateTimePicker
class="datepicker w-36"
v-model="task.due_date"
:placeholder="__('01/04/2024 11:30 PM')"
:formatter="(date) => getFormat(date, '', true, true)"
input-class="border-none"
/>
</div>
</div>
</template>
<script setup>
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { taskStatusOptions, taskPriorityOptions, getFormat } from '@/utils'
import { TextEditor, Dropdown, Tooltip, DateTimePicker } from 'frappe-ui'
const props = defineProps({
task: {
type: Object,
default: () => ({
title: '',
description: '',
assigned_to: '',
due_date: '',
status: 'Backlog',
priority: 'Low',
}),
},
})
const { getUser } = usersStore()
function updateTaskStatus(status) {
props.task.status = status
}
function updateTaskPriority(priority) {
props.task.priority = priority
}
</script>
<style scoped>
:deep(.title input) {
background-color: var(--surface-gray-7);
caret-color: var(--ink-white);
color: var(--ink-white);
outline: none;
border: none;
padding: 0;
}
:deep(.datepicker input) {
background-color: var(--surface-gray-6);
caret-color: var(--ink-white);
color: var(--ink-white);
outline: none;
border: none;
}
:deep(.title input:focus),
:deep(.datepicker input:focus) {
border: none;
outline: none;
box-shadow: none;
}
:deep(.user button) {
background-color: var(--surface-gray-6);
border: none;
color: var(--ink-white);
}
:deep(.user button:hover) {
background-color: var(--surface-gray-5);
border: none;
}
:deep(.user button:focus) {
box-shadow: none;
outline: none;
}
</style>

View File

@ -13,6 +13,7 @@
</div>
<div class="flex flex-col items-center justify-center gap-3">
<Avatar
v-if="contact?.image"
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-24 !w-24 items-center justify-center [&>div]:text-[30px]"
@ -20,9 +21,9 @@
/>
<div class="flex flex-col items-center justify-center gap-1">
<div class="text-xl font-medium">
{{ contact.full_name }}
{{ contact?.full_name ?? __('Unknown') }}
</div>
<div class="text-sm text-ink-gray-5">{{ contact.mobile_no }}</div>
<div class="text-sm text-ink-gray-5">{{ contact?.mobile_no }}</div>
</div>
<CountUpTimer ref="counterUp">
<div v-if="onCall" class="my-1 text-base">
@ -34,10 +35,10 @@
callStatus == 'initiating'
? __('Initiating call...')
: callStatus == 'ringing'
? __('Ringing...')
: calling
? __('Calling...')
: __('Incoming call...')
? __('Ringing...')
: calling
? __('Calling...')
: __('Incoming call...')
}}
</div>
<div v-if="onCall" class="flex gap-2">
@ -120,12 +121,13 @@
>
<div class="flex items-center gap-2">
<Avatar
v-if="contact?.image"
:image="contact.image"
:label="contact.full_name"
class="relative flex !h-5 !w-5 items-center justify-center"
/>
<div class="max-w-[120px] truncate">
{{ contact.full_name }}
{{ contact?.full_name ?? __('Unknown') }}
</div>
</div>
<div v-if="onCall" class="flex items-center gap-2">
@ -195,22 +197,13 @@ import CountUpTimer from '@/components/CountUpTimer.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import { Device } from '@twilio/voice-sdk'
import { useDraggable, useWindowSize } from '@vueuse/core'
import { globalStore } from '@/stores/global'
import { contactsStore } from '@/stores/contacts'
import { capture } from '@/telemetry'
import { Avatar, call } from 'frappe-ui'
import { onMounted, ref, watch } from 'vue'
const { getContact, getLeadContact } = contactsStore()
const { setMakeCall, setTwilioEnabled } = globalStore()
import { Avatar, call, createResource } from 'frappe-ui'
import { ref, watch } from 'vue'
let device = ''
let log = ref('Connecting...')
let _call = null
const contact = ref({
full_name: '',
mobile_no: '',
})
let showCallPopup = ref(false)
let showSmallCallWindow = ref(false)
@ -220,8 +213,36 @@ let muted = ref(false)
let callPopup = ref(null)
let counterUp = ref(null)
let callStatus = ref('')
const phoneNumber = ref('')
const contact = ref({
full_name: '',
image: '',
mobile_no: '',
})
watch(phoneNumber, (value) => {
if (!value) return
getContact.fetch()
})
const getContact = createResource({
url: 'crm.integrations.api.get_contact_by_phone_number',
makeParams() {
return {
phone_number: phoneNumber.value,
}
},
cache: ['contact', phoneNumber.value],
onSuccess(data) {
contact.value = data
},
})
const showNoteModal = ref(false)
const note = ref({
name: '',
title: '',
content: '',
})
@ -229,9 +250,9 @@ const note = ref({
async function updateNote(_note, insert_mode = false) {
note.value = _note
if (insert_mode && _note.name) {
await call('crm.integrations.twilio.api.add_note_to_call_log', {
await call('crm.integrations.api.add_note_to_call_log', {
call_sid: _call.parameters.CallSid,
note: _note.name,
note: _note,
})
}
}
@ -243,10 +264,6 @@ let { style } = useDraggable(callPopup, {
preventDefault: true,
})
async function is_twilio_enabled() {
return await call('crm.integrations.twilio.api.is_enabled')
}
async function startupClient() {
log.value = 'Requesting Access Token...'
@ -304,19 +321,7 @@ function toggleMute() {
function handleIncomingCall(call) {
log.value = `Incoming call from ${call.parameters.From}`
// get name of the caller from the phone number
contact.value = getContact(call.parameters.From)
if (!contact.value) {
contact.value = getLeadContact(call.parameters.From)
}
if (!contact.value) {
contact.value = {
full_name: __('Unknown'),
mobile_no: call.parameters.From,
}
}
phoneNumber.value = call.parameters.From
showCallPopup.value = true
_call = call
@ -358,6 +363,7 @@ function hangUpCall() {
callStatus.value = ''
muted.value = false
note.value = {
name: '',
title: '',
content: '',
}
@ -379,19 +385,7 @@ function handleDisconnectedIncomingCall() {
}
async function makeOutgoingCall(number) {
// check if number has a country code
// if (number?.replace(/[^0-9+]/g, '').length == 10) {
// $dialog({
// title: 'Invalid Mobile Number',
// message: `${number} is not a valid mobile number. Either add a country code or check the number again.`,
// })
// return
// }
contact.value = getContact(number)
if (!contact.value) {
contact.value = getLeadContact(number)
}
phoneNumber.value = number
if (device) {
log.value = `Attempting to call ${number} ...`
@ -437,6 +431,7 @@ async function makeOutgoingCall(number) {
muted.value = false
counterUp.value.stop()
note.value = {
name: '',
title: '',
content: '',
}
@ -451,6 +446,7 @@ async function makeOutgoingCall(number) {
callStatus.value = ''
muted.value = false
note.value = {
name: '',
title: '',
content: '',
}
@ -477,6 +473,7 @@ function cancelCall() {
callStatus.value = ''
muted.value = false
note.value = {
name: '',
title: '',
content: '',
}
@ -491,21 +488,15 @@ function toggleCallWindow() {
}
}
onMounted(async () => {
let enabled = await is_twilio_enabled()
setTwilioEnabled(enabled)
enabled && startupClient()
setMakeCall(makeOutgoingCall)
})
watch(
() => log.value,
(value) => {
console.log(value)
},
{ immediate: true }
{ immediate: true },
)
defineExpose({ makeOutgoingCall, setup: startupClient })
</script>
<style scoped>

View File

@ -65,7 +65,7 @@
>
<div
v-for="filter in quickFilterList"
:key="filter.name"
:key="filter.fieldname"
class="m-1 min-w-36"
>
<QuickFilterField
@ -595,13 +595,13 @@ const quickFilterList = computed(() => {
}
filters.forEach((filter) => {
filter['value'] = filter.type == 'Check' ? false : ''
if (list.value.params?.filters[filter.name]) {
let value = list.value.params.filters[filter.name]
filter['value'] = filter.fieldtype == 'Check' ? false : ''
if (list.value.params?.filters[filter.fieldname]) {
let value = list.value.params.filters[filter.fieldname]
if (Array.isArray(value)) {
if (
(['Check', 'Select', 'Link', 'Date', 'Datetime'].includes(
filter.type,
filter.fieldtype,
) &&
value[0]?.toLowerCase() == 'like') ||
value[0]?.toLowerCase() != 'like'
@ -626,9 +626,11 @@ const quickFilters = createResource({
function applyQuickFilter(filter, value) {
let filters = { ...list.value.params.filters }
let field = filter.name
let field = filter.fieldname
if (value) {
if (['Check', 'Select', 'Link', 'Date', 'Datetime'].includes(filter.type)) {
if (
['Check', 'Select', 'Link', 'Date', 'Datetime'].includes(filter.fieldtype)
) {
filters[field] = value
} else {
filters[field] = ['LIKE', `%${value}%`]

View File

@ -14,22 +14,28 @@
>
<div class="w-full">
<button
class="flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
class="relative flex h-7 w-full items-center justify-between gap-2 rounded bg-surface-gray-2 px-2 py-1 transition-colors hover:bg-surface-gray-3 border border-transparent focus:border-outline-gray-4 focus:outline-none focus:ring-2 focus:ring-outline-gray-3"
:class="inputClasses"
@click="() => togglePopover()"
>
<div class="flex text-base leading-5 items-center truncate">
<div
v-if="selectedValue"
class="flex text-base leading-5 items-center truncate"
>
<slot name="prefix" />
<span v-if="selectedValue" class="truncate">
<span class="truncate">
{{ displayValue(selectedValue) }}
</span>
<span v-else class="text-ink-gray-4 truncate">
{{ placeholder || '' }}
</span>
</div>
<div
v-else
class="absolute text-ink-gray-4 text-left truncate w-full pr-7"
>
{{ placeholder || '' }}
</div>
<FeatherIcon
name="chevron-down"
class="h-4 w-4 text-ink-gray-5"
class="absolute h-4 w-4 text-ink-gray-5 right-2"
aria-hidden="true"
/>
</button>

View File

@ -21,12 +21,18 @@ createResource({
})
export const callEnabled = ref(false)
export const twilioEnabled = ref(false)
export const exotelEnabled = ref(false)
export const defaultCallingMedium = ref('')
createResource({
url: 'crm.integrations.twilio.api.is_enabled',
cache: 'Is Twilio Enabled',
url: 'crm.integrations.api.is_call_integration_enabled',
cache: 'Is Call Integration Enabled',
auto: true,
onSuccess: (data) => {
callEnabled.value = Boolean(data)
twilioEnabled.value = Boolean(data.twilio_enabled)
exotelEnabled.value = Boolean(data.exotel_enabled)
defaultCallingMedium.value = data.default_calling_medium
callEnabled.value = twilioEnabled.value || exotelEnabled.value
},
})

View File

@ -55,7 +55,7 @@
v-if="note.content"
:content="note.content"
:editable="false"
editor-class="!prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
editor-class="prose-sm text-sm max-w-none text-ink-gray-5 focus:outline-none"
class="flex-1 overflow-hidden"
/>
<div class="mt-2 flex items-center justify-between gap-2">

View File

@ -5,13 +5,8 @@ export const globalStore = defineStore('crm-global', () => {
const app = getCurrentInstance()
const { $dialog, $socket } = app.appContext.config.globalProperties
let twilioEnabled = ref(false)
let callMethod = () => {}
function setTwilioEnabled(value) {
twilioEnabled.value = value
}
function setMakeCall(value) {
callMethod = value
}
@ -23,9 +18,7 @@ export const globalStore = defineStore('crm-global', () => {
return {
$dialog,
$socket,
twilioEnabled,
makeCall,
setTwilioEnabled,
setMakeCall,
}
})

View File

@ -1,41 +1,15 @@
import { secondsToDuration, formatDate, timeAgo } from '@/utils'
import { formatDate, timeAgo } from '@/utils'
import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { contactsStore } from '@/stores/contacts'
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta('CRM Call Log')
const { getUser } = usersStore()
const { getContact, getLeadContact } = contactsStore()
export function getCallLogDetail(row, log, columns = []) {
let incoming = log.type === 'Incoming'
if (row === 'caller') {
if (row === 'duration') {
return {
label: incoming
? getContact(log.from)?.full_name ||
getLeadContact(log.from)?.full_name ||
'Unknown'
: getUser(log.caller).full_name,
image: incoming
? getContact(log.from)?.image || getLeadContact(log.from)?.image
: getUser(log.caller).user_image,
}
} else if (row === 'receiver') {
return {
label: incoming
? getUser(log.receiver).full_name
: getContact(log.to)?.full_name ||
getLeadContact(log.to)?.full_name ||
'Unknown',
image: incoming
? getUser(log.receiver).user_image
: getContact(log.to)?.image || getLeadContact(log.to)?.image,
}
} else if (row === 'duration') {
return {
label: secondsToDuration(log.duration),
label: log.duration,
icon: 'clock',
}
} else if (row === 'type') {
@ -82,7 +56,7 @@ export const statusLabelMap = {
Busy: 'Declined',
Failed: 'Failed',
Queued: 'Queued',
Cancelled: 'Cancelled',
Canceled: 'Canceled',
Ringing: 'Ringing',
'No Answer': 'Missed Call',
'In Progress': 'In Progress',
@ -94,7 +68,7 @@ export const statusColorMap = {
Failed: 'red',
Initiated: 'gray',
Queued: 'gray',
Cancelled: 'gray',
Canceled: 'gray',
Ringing: 'gray',
'No Answer': 'red',
'In Progress': 'blue',

View File

@ -113,19 +113,6 @@ export function htmlToText(html) {
return div.textContent || div.innerText || ''
}
export function secondsToDuration(seconds) {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const _seconds = Math.floor((seconds % 3600) % 60)
if (hours == 0 && minutes == 0) {
return `${_seconds}s`
} else if (hours == 0) {
return `${minutes}m ${_seconds}s`
}
return `${hours}h ${minutes}m ${_seconds}s`
}
export function startCase(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}

View File

@ -2388,10 +2388,10 @@ fraction.js@^4.3.7:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
frappe-ui@^0.1.104:
version "0.1.104"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.104.tgz#1ca8b303817d45cdccee9af9ef3597524e4eb0f2"
integrity sha512-rLgYwGKPChJHYBH6AIgsdN3ZPpT+N1K2UthRvKrPva0xLX9YAQS7xpiw5xVxwZXnpc1//EiXqOQcB7bb575wAg==
frappe-ui@^0.1.105:
version "0.1.105"
resolved "https://registry.yarnpkg.com/frappe-ui/-/frappe-ui-0.1.105.tgz#3bdf3c458ba27f27ff2f2a28cf7eb6f9ed872367"
integrity sha512-9bZ/hj/HhQ9vp7DxE8aOKS8HqwETZrKT3IhSzjpYOk21efK8QwdbQ9sp0t4m3UII+HaUTSOTHnFzF7y9EhRZxg==
dependencies:
"@headlessui/vue" "^1.7.14"
"@popperjs/core" "^2.11.2"
@ -4332,7 +4332,7 @@ vue-router@^4.2.2:
dependencies:
"@vue/devtools-api" "^6.6.4"
vue@^3.4.12:
vue@^3.5.13:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a"
integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==