Merge pull request #530 from shariquerik/exotel
This commit is contained in:
commit
82ed3e36cc
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
0
crm/fcrm/doctype/crm_exotel_agent/__init__.py
Normal file
0
crm/fcrm/doctype/crm_exotel_agent/__init__.py
Normal file
8
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js
Normal file
8
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
80
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json
Normal file
80
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.json
Normal 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"
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py
Normal file
9
crm/fcrm/doctype/crm_exotel_agent/crm_exotel_agent.py
Normal 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
|
||||
30
crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py
Normal file
30
crm/fcrm/doctype/crm_exotel_agent/test_crm_exotel_agent.py
Normal 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
|
||||
0
crm/fcrm/doctype/crm_exotel_settings/__init__.py
Normal file
0
crm/fcrm/doctype/crm_exotel_settings/__init__.py
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
115
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
Normal file
115
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json
Normal 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": []
|
||||
}
|
||||
24
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
Normal file
24
crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py
Normal 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"),
|
||||
)
|
||||
@ -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
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
0
crm/integrations/__init__.py
Normal file
0
crm/integrations/__init__.py
Normal file
144
crm/integrations/api.py
Normal file
144
crm/integrations/api.py
Normal 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}
|
||||
257
crm/integrations/exotel/handler.py
Normal file
257
crm/integrations/exotel/handler.py
Normal 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()
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
95
crm/utils/__init__.py
Normal 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
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
17
frontend/src/components/Icons/AvatarIcon.vue
Normal file
17
frontend/src/components/Icons/AvatarIcon.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -8,5 +8,5 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CallUI from '@/components/CallUI.vue'
|
||||
import CallUI from '@/components/Telephony/CallUI.vue'
|
||||
</script>
|
||||
|
||||
@ -198,6 +198,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'showCallLog',
|
||||
'loadMore',
|
||||
'updatePageCount',
|
||||
'columnWidthUpdated',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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') {
|
||||
|
||||
311
frontend/src/components/Settings/TelephonySettings.vue
Normal file
311
frontend/src/components/Settings/TelephonySettings.vue
Normal 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>
|
||||
@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<SettingsPage doctype="Twilio Settings" class="p-8" />
|
||||
</template>
|
||||
<script setup>
|
||||
import SettingsPage from '@/components/Settings/SettingsPage.vue'
|
||||
</script>
|
||||
140
frontend/src/components/Telephony/CallUI.vue
Normal file
140
frontend/src/components/Telephony/CallUI.vue
Normal 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>
|
||||
573
frontend/src/components/Telephony/ExotelCallUI.vue
Normal file
573
frontend/src/components/Telephony/ExotelCallUI.vue
Normal 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>
|
||||
144
frontend/src/components/Telephony/TaskPanel.vue
Normal file
144
frontend/src/components/Telephony/TaskPanel.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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}%`]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@ -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==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user