Merge pull request #148 from frappe/develop
chore: Merge develop to main
This commit is contained in:
commit
d4bf6ecf41
@ -1,4 +1,5 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
def on_update(self, method):
|
||||
@ -15,13 +16,26 @@ def notify_mentions(doc):
|
||||
return
|
||||
mentions = extract_mentions(content)
|
||||
for mention in mentions:
|
||||
owner = frappe.get_cached_value("User", doc.owner, "full_name")
|
||||
doctype = doc.reference_doctype
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ owner }</span>
|
||||
<span>{ _('mentioned you in {0}').format(doctype) }</span>
|
||||
<span class="font-medium text-gray-900">{ doc.reference_name }</span>
|
||||
</div>
|
||||
"""
|
||||
values = frappe._dict(
|
||||
doctype="CRM Notification",
|
||||
from_user=doc.owner,
|
||||
to_user=mention.email,
|
||||
type="Mention",
|
||||
message=doc.content,
|
||||
comment=doc.name,
|
||||
notification_text=notification_text,
|
||||
notification_type_doctype="Comment",
|
||||
notification_type_doc=doc.name,
|
||||
reference_doctype=doc.reference_doctype,
|
||||
reference_name=doc.reference_name,
|
||||
)
|
||||
|
||||
@ -28,13 +28,16 @@ def get_notifications():
|
||||
"to_user": notification.to_user,
|
||||
"read": notification.read,
|
||||
"comment": notification.comment,
|
||||
"reference_doctype": "deal"
|
||||
if notification.reference_doctype == "CRM Deal"
|
||||
else "lead",
|
||||
"notification_text": notification.notification_text,
|
||||
"notification_type_doctype": notification.notification_type_doctype,
|
||||
"notification_type_doc": notification.notification_type_doc,
|
||||
"reference_doctype": (
|
||||
"deal" if notification.reference_doctype == "CRM Deal" else "lead"
|
||||
),
|
||||
"reference_name": notification.reference_name,
|
||||
"route_name": "Deal"
|
||||
if notification.reference_doctype == "CRM Deal"
|
||||
else "Lead",
|
||||
"route_name": (
|
||||
"Deal" if notification.reference_doctype == "CRM Deal" else "Lead"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -42,12 +45,15 @@ def get_notifications():
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def mark_as_read(user=None, comment=None):
|
||||
def mark_as_read(user=None, doc=None):
|
||||
user = user or frappe.session.user
|
||||
filters = {"to_user": user, "read": False}
|
||||
if comment:
|
||||
filters["comment"] = comment
|
||||
for n in frappe.get_all("CRM Notification", filters=filters):
|
||||
if doc:
|
||||
or_filters = [
|
||||
{"comment": doc},
|
||||
{"notification_type_doc": doc},
|
||||
]
|
||||
for n in frappe.get_all("CRM Notification", filters=filters, or_filters=or_filters):
|
||||
d = frappe.get_doc("CRM Notification", n.name)
|
||||
d.read = True
|
||||
d.save()
|
||||
|
||||
314
crm/api/whatsapp.py
Normal file
314
crm/api/whatsapp.py
Normal file
@ -0,0 +1,314 @@
|
||||
import frappe
|
||||
import json
|
||||
from frappe import _
|
||||
from crm.api.doc import get_assigned_users
|
||||
|
||||
|
||||
def validate(doc, method):
|
||||
if doc.type == "Incoming" and doc.get("from"):
|
||||
name, doctype = get_lead_or_deal_from_number(doc.get("from"))
|
||||
doc.reference_doctype = doctype
|
||||
doc.reference_name = name
|
||||
|
||||
|
||||
def on_update(doc, method):
|
||||
frappe.publish_realtime(
|
||||
"whatsapp_message",
|
||||
{
|
||||
"reference_doctype": doc.reference_doctype,
|
||||
"reference_name": doc.reference_name,
|
||||
},
|
||||
)
|
||||
|
||||
notify_agent(doc)
|
||||
|
||||
|
||||
def notify_agent(doc):
|
||||
if doc.type == "Incoming":
|
||||
doctype = doc.reference_doctype
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ _('You') }</span>
|
||||
<span>{ _('received a whatsapp message in {0}').format(doctype) }</span>
|
||||
<span class="font-medium text-gray-900">{ doc.reference_name }</span>
|
||||
</div>
|
||||
"""
|
||||
assigned_users = get_assigned_users(doc.reference_doctype, doc.reference_name)
|
||||
for user in assigned_users:
|
||||
values = frappe._dict(
|
||||
doctype="CRM Notification",
|
||||
from_user=doc.owner,
|
||||
to_user=user,
|
||||
type="WhatsApp",
|
||||
message=doc.message,
|
||||
notification_text=notification_text,
|
||||
notification_type_doctype="WhatsApp Message",
|
||||
notification_type_doc=doc.name,
|
||||
reference_doctype=doc.reference_doctype,
|
||||
reference_name=doc.reference_name,
|
||||
)
|
||||
|
||||
if frappe.db.exists("CRM Notification", values):
|
||||
return
|
||||
frappe.get_doc(values).insert(ignore_permissions=True)
|
||||
|
||||
|
||||
def get_lead_or_deal_from_number(number):
|
||||
"""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"
|
||||
|
||||
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
|
||||
|
||||
|
||||
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 == "+"])
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_whatsapp_enabled():
|
||||
if not frappe.db.exists("DocType", "WhatsApp Settings"):
|
||||
return False
|
||||
return frappe.get_cached_value("WhatsApp Settings", "WhatsApp Settings", "enabled")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_whatsapp_messages(reference_doctype, reference_name):
|
||||
if not frappe.db.exists("DocType", "WhatsApp Message"):
|
||||
return []
|
||||
messages = frappe.get_all(
|
||||
"WhatsApp Message",
|
||||
filters={
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_name,
|
||||
},
|
||||
fields=[
|
||||
"name",
|
||||
"type",
|
||||
"to",
|
||||
"from",
|
||||
"content_type",
|
||||
"message_type",
|
||||
"attach",
|
||||
"template",
|
||||
"use_template",
|
||||
"message_id",
|
||||
"is_reply",
|
||||
"reply_to_message_id",
|
||||
"creation",
|
||||
"message",
|
||||
"status",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"template_parameters",
|
||||
"template_header_parameters",
|
||||
],
|
||||
)
|
||||
|
||||
# Filter messages to get only Template messages
|
||||
template_messages = [
|
||||
message for message in messages if message["message_type"] == "Template"
|
||||
]
|
||||
|
||||
# Iterate through template messages
|
||||
for template_message in template_messages:
|
||||
# Find the template that this message is using
|
||||
template = frappe.get_doc("WhatsApp Templates", template_message["template"])
|
||||
|
||||
# If the template is found, add the template details to the template message
|
||||
if template:
|
||||
template_message["template_name"] = template.template_name
|
||||
if template_message["template_parameters"]:
|
||||
parameters = json.loads(template_message["template_parameters"])
|
||||
template.template = parse_template_parameters(
|
||||
template.template, parameters
|
||||
)
|
||||
|
||||
template_message["template"] = template.template
|
||||
if template_message["template_header_parameters"]:
|
||||
header_parameters = json.loads(
|
||||
template_message["template_header_parameters"]
|
||||
)
|
||||
template.header = parse_template_parameters(
|
||||
template.header, header_parameters
|
||||
)
|
||||
template_message["header"] = template.header
|
||||
template_message["footer"] = template.footer
|
||||
|
||||
# Filter messages to get only reaction messages
|
||||
reaction_messages = [
|
||||
message for message in messages if message["content_type"] == "reaction"
|
||||
]
|
||||
|
||||
# Iterate through reaction messages
|
||||
for reaction_message in reaction_messages:
|
||||
# Find the message that this reaction is reacting to
|
||||
reacted_message = next(
|
||||
(
|
||||
m
|
||||
for m in messages
|
||||
if m["message_id"] == reaction_message["reply_to_message_id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# If the reacted message is found, add the reaction to it
|
||||
if reacted_message:
|
||||
reacted_message["reaction"] = reaction_message["message"]
|
||||
|
||||
for message in messages:
|
||||
from_name = get_from_name(message) if message["from"] else _("You")
|
||||
message["from_name"] = from_name
|
||||
# Filter messages to get only replies
|
||||
reply_messages = [message for message in messages if message["is_reply"]]
|
||||
|
||||
# Iterate through reply messages
|
||||
for reply_message in reply_messages:
|
||||
# Find the message that this message is replying to
|
||||
replied_message = next(
|
||||
(
|
||||
m
|
||||
for m in messages
|
||||
if m["message_id"] == reply_message["reply_to_message_id"]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# If the replied message is found, add the reply details to the reply message
|
||||
from_name = (
|
||||
get_from_name(reply_message) if replied_message["from"] else _("You")
|
||||
)
|
||||
if replied_message:
|
||||
message = replied_message["message"]
|
||||
if replied_message["message_type"] == "Template":
|
||||
message = replied_message["template"]
|
||||
reply_message["reply_message"] = message
|
||||
reply_message["header"] = replied_message.get("header") or ""
|
||||
reply_message["footer"] = replied_message.get("footer") or ""
|
||||
reply_message["reply_to"] = replied_message["name"]
|
||||
reply_message["reply_to_type"] = replied_message["type"]
|
||||
reply_message["reply_to_from"] = from_name
|
||||
|
||||
return [message for message in messages if message["content_type"] != "reaction"]
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_whatsapp_message(
|
||||
reference_doctype,
|
||||
reference_name,
|
||||
message,
|
||||
to,
|
||||
attach,
|
||||
reply_to,
|
||||
content_type="text",
|
||||
):
|
||||
doc = frappe.new_doc("WhatsApp Message")
|
||||
|
||||
if reply_to:
|
||||
reply_doc = frappe.get_doc("WhatsApp Message", reply_to)
|
||||
doc.update(
|
||||
{
|
||||
"is_reply": True,
|
||||
"reply_to_message_id": reply_doc.message_id,
|
||||
}
|
||||
)
|
||||
|
||||
doc.update(
|
||||
{
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_name,
|
||||
"message": message or attach,
|
||||
"to": to,
|
||||
"attach": attach,
|
||||
"content_type": content_type,
|
||||
}
|
||||
)
|
||||
doc.insert(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def send_whatsapp_template(reference_doctype, reference_name, template, to):
|
||||
doc = frappe.new_doc("WhatsApp Message")
|
||||
doc.update(
|
||||
{
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_name,
|
||||
"message_type": "Template",
|
||||
"message": "Template message",
|
||||
"content_type": "text",
|
||||
"use_template": True,
|
||||
"template": template,
|
||||
"to": to,
|
||||
}
|
||||
)
|
||||
doc.insert(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def react_on_whatsapp_message(emoji, reply_to_name):
|
||||
reply_to_doc = frappe.get_doc("WhatsApp Message", reply_to_name)
|
||||
to = reply_to_doc.type == "Incoming" and reply_to_doc.get("from") or reply_to_doc.to
|
||||
doc = frappe.new_doc("WhatsApp Message")
|
||||
doc.update(
|
||||
{
|
||||
"reference_doctype": reply_to_doc.reference_doctype,
|
||||
"reference_name": reply_to_doc.reference_name,
|
||||
"message": emoji,
|
||||
"to": to,
|
||||
"reply_to_message_id": reply_to_doc.message_id,
|
||||
"content_type": "reaction",
|
||||
}
|
||||
)
|
||||
doc.insert(ignore_permissions=True)
|
||||
return doc.name
|
||||
|
||||
|
||||
def parse_template_parameters(string, parameters):
|
||||
for i, parameter in enumerate(parameters, start=1):
|
||||
placeholder = "{{" + str(i) + "}}"
|
||||
string = string.replace(placeholder, parameter)
|
||||
|
||||
return string
|
||||
|
||||
|
||||
def get_from_name(message):
|
||||
doc = frappe.get_doc(message["reference_doctype"], message["reference_name"])
|
||||
from_name = ""
|
||||
if message["reference_doctype"] == "CRM Deal":
|
||||
if doc.get("contacts"):
|
||||
for c in doc.get("contacts"):
|
||||
if c.is_primary:
|
||||
from_name = c.full_name or c.mobile_no
|
||||
break
|
||||
else:
|
||||
from_name = doc.get("lead_name")
|
||||
else:
|
||||
from_name = doc.get("first_name") + " " + doc.get("last_name")
|
||||
return from_name
|
||||
@ -5,14 +5,20 @@
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"notification_text",
|
||||
"section_break_hace",
|
||||
"from_user",
|
||||
"type",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"column_break_dduu",
|
||||
"to_user",
|
||||
"comment",
|
||||
"read",
|
||||
"section_break_pbvx",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"column_break_eant",
|
||||
"notification_type_doctype",
|
||||
"notification_type_doc",
|
||||
"comment",
|
||||
"section_break_vpwa",
|
||||
"message"
|
||||
],
|
||||
@ -28,7 +34,7 @@
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Mention",
|
||||
"options": "Mention\nWhatsApp",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@ -46,8 +52,8 @@
|
||||
{
|
||||
"fieldname": "comment",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Comment",
|
||||
"link_filters": "[[{\"fieldname\":\"comment\",\"field_option\":\"Comment\"},\"comment_type\",\"=\",\"Comment\"]]",
|
||||
"options": "Comment"
|
||||
},
|
||||
{
|
||||
@ -63,6 +69,7 @@
|
||||
{
|
||||
"fieldname": "message",
|
||||
"fieldtype": "HTML Editor",
|
||||
"in_list_view": 1,
|
||||
"label": "Message"
|
||||
},
|
||||
{
|
||||
@ -76,11 +83,40 @@
|
||||
"fieldtype": "Link",
|
||||
"label": "Reference Doctype",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pbvx",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_eant",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_type_doctype",
|
||||
"fieldtype": "Link",
|
||||
"label": "Notification Type Doctype",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_type_doc",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Notification Type Doc",
|
||||
"options": "notification_type_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "notification_text",
|
||||
"fieldtype": "Text",
|
||||
"label": "Notification Text"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_hace",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-01-30 01:04:27.946030",
|
||||
"modified": "2024-04-25 16:26:07.484857",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Notification",
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class CRMNotification(Document):
|
||||
pass
|
||||
def on_update(self):
|
||||
frappe.publish_realtime("crm_notification")
|
||||
|
||||
@ -135,7 +135,11 @@ doc_events = {
|
||||
},
|
||||
"Comment": {
|
||||
"on_update": ["crm.api.comment.on_update"],
|
||||
}
|
||||
},
|
||||
"WhatsApp Message": {
|
||||
"validate": ["crm.api.whatsapp.validate"],
|
||||
"on_update": ["crm.api.whatsapp.on_update"],
|
||||
},
|
||||
}
|
||||
|
||||
# Scheduled Tasks
|
||||
|
||||
@ -8,9 +8,34 @@ from frappe.utils.telemetry import capture
|
||||
no_cache = 1
|
||||
|
||||
|
||||
def get_context(context):
|
||||
def get_context():
|
||||
csrf_token = frappe.sessions.get_csrf_token()
|
||||
frappe.db.commit()
|
||||
context = frappe._dict()
|
||||
context.boot = get_boot()
|
||||
context.boot.csrf_token = csrf_token
|
||||
if frappe.session.user != "Guest":
|
||||
capture("active_site", "crm")
|
||||
context.csrf_token = csrf_token
|
||||
return context
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"], allow_guest=True)
|
||||
def get_context_for_dev():
|
||||
if not frappe.conf.developer_mode:
|
||||
frappe.throw("This method is only meant for developer mode")
|
||||
return get_boot()
|
||||
|
||||
|
||||
def get_boot():
|
||||
return frappe._dict(
|
||||
{
|
||||
"frappe_version": frappe.__version__,
|
||||
"default_route": get_default_route(),
|
||||
"site_name": frappe.local.site,
|
||||
"read_only_mode": frappe.flags.read_only,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_default_route():
|
||||
return "/crm"
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 05a8eca589d23d44f55cfe82ae157fd5de997abb
|
||||
Subproject commit 063cf8d3563776aabe83a93ff5dcea5c435ae1f6
|
||||
@ -16,7 +16,11 @@
|
||||
<div id="modals"></div>
|
||||
<div id="popovers"></div>
|
||||
|
||||
<script> window.csrf_token = '{{ csrf_token }}'; </script>
|
||||
<script>
|
||||
{% for key in boot %}
|
||||
window["{{ key }}"] = {{ boot[key] | tojson }};
|
||||
{% endfor %}
|
||||
</script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.51",
|
||||
"frappe-ui": "^0.1.53",
|
||||
"gemoji": "^8.1.0",
|
||||
"mime": "^4.0.1",
|
||||
"pinia": "^2.0.33",
|
||||
"socket.io-client": "^4.7.2",
|
||||
|
||||
@ -35,32 +35,19 @@
|
||||
</template>
|
||||
<span>{{ __('New Task') }}</span>
|
||||
</Button>
|
||||
<Dropdown
|
||||
v-else
|
||||
:options="[
|
||||
{
|
||||
icon: h(EmailIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Email'),
|
||||
onClick: () => ($refs.emailBox.show = true),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Make a Call'),
|
||||
onClick: () => makeCall(doc.data.mobile_no),
|
||||
},
|
||||
{
|
||||
icon: h(NoteIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Note'),
|
||||
onClick: () => showNote(),
|
||||
},
|
||||
{
|
||||
icon: h(TaskIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Task'),
|
||||
onClick: () => showTask(),
|
||||
},
|
||||
]"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex gap-2" v-else-if="title == 'WhatsApp'">
|
||||
<Button
|
||||
:label="__('Send Template')"
|
||||
@click="showWhatsappTemplates = true"
|
||||
/>
|
||||
<Button variant="solid" @click="$refs.whatsappBox.show()">
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4 w-4" />
|
||||
</template>
|
||||
<span>{{ __('New WhatsApp Message') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown v-else :options="defaultActions" @click.stop>
|
||||
<template v-slot="{ open }">
|
||||
<Button variant="solid" class="flex items-center gap-1">
|
||||
<template #prefix>
|
||||
@ -84,6 +71,17 @@
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="title == 'WhatsApp' && whatsappMessages.data?.length"
|
||||
class="activities flex-1 overflow-y-auto"
|
||||
>
|
||||
<WhatsAppArea
|
||||
class="px-10"
|
||||
v-model="whatsappMessages"
|
||||
v-model:reply="replyMessage"
|
||||
:messages="whatsappMessages.data"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activities?.length" class="activities flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="title == 'Notes'"
|
||||
@ -746,6 +744,15 @@
|
||||
:doctype="doctype"
|
||||
@scroll="scroll"
|
||||
/>
|
||||
<WhatsAppBox
|
||||
ref="whatsappBox"
|
||||
v-if="title == 'WhatsApp'"
|
||||
v-model="doc"
|
||||
v-model:reply="replyMessage"
|
||||
v-model:whatsapp="whatsappMessages"
|
||||
:doctype="doctype"
|
||||
@scroll="scroll"
|
||||
/>
|
||||
<NoteModal
|
||||
v-model="showNoteModal"
|
||||
v-model:reloadNotes="all_activities"
|
||||
@ -760,6 +767,11 @@
|
||||
:doctype="doctype"
|
||||
:doc="doc.data?.name"
|
||||
/>
|
||||
<WhatsappTemplateSelectorModal
|
||||
v-if="whatsappEnabled"
|
||||
v-model="showWhatsappTemplates"
|
||||
@send="(t) => sendTemplate(t)"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
@ -768,6 +780,10 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import WhatsAppArea from '@/components/WhatsAppArea.vue'
|
||||
import WhatsAppBox from '@/components/WhatsAppBox.vue'
|
||||
import RefreshIcon from '@/components/Icons/RefreshIcon.vue'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import DurationIcon from '@/components/Icons/DurationIcon.vue'
|
||||
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
|
||||
@ -787,6 +803,7 @@ import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import CommunicationArea from '@/components/CommunicationArea.vue'
|
||||
import NoteModal from '@/components/Modals/NoteModal.vue'
|
||||
import TaskModal from '@/components/Modals/TaskModal.vue'
|
||||
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
|
||||
import {
|
||||
timeAgo,
|
||||
dateFormat,
|
||||
@ -798,6 +815,7 @@ import {
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { contactsStore } from '@/stores/contacts'
|
||||
import { whatsappEnabled, callEnabled } from '@/stores/settings'
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
@ -808,10 +826,19 @@ import {
|
||||
call,
|
||||
} from 'frappe-ui'
|
||||
import { useElementVisibility } from '@vueuse/core'
|
||||
import { ref, computed, h, markRaw, watch, nextTick } from 'vue'
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
h,
|
||||
markRaw,
|
||||
watch,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
} from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { makeCall } = globalStore()
|
||||
const { makeCall, $socket } = globalStore()
|
||||
const { getUser } = usersStore()
|
||||
const { getContact, getLeadContact } = contactsStore()
|
||||
|
||||
@ -828,6 +855,7 @@ const props = defineProps({
|
||||
|
||||
const doc = defineModel()
|
||||
const reload = defineModel('reload')
|
||||
const tabIndex = defineModel('tabIndex')
|
||||
|
||||
const reload_email = ref(false)
|
||||
|
||||
@ -875,6 +903,86 @@ const all_activities = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const showWhatsappTemplates = ref(false)
|
||||
|
||||
const whatsappMessages = createResource({
|
||||
url: 'crm.api.whatsapp.get_whatsapp_messages',
|
||||
cache: ['whatsapp_messages', doc.value.data.name],
|
||||
params: {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
},
|
||||
auto: true,
|
||||
transform: (data) => sortByCreation(data),
|
||||
onSuccess: () => nextTick(() => scroll()),
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
$socket.off('whatsapp_message')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$socket.on('whatsapp_message', (data) => {
|
||||
if (
|
||||
data.reference_doctype === props.doctype &&
|
||||
data.reference_name === doc.value.data.name
|
||||
) {
|
||||
whatsappMessages.reload()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function sendTemplate(template) {
|
||||
showWhatsappTemplates.value = false
|
||||
createResource({
|
||||
url: 'crm.api.whatsapp.send_whatsapp_template',
|
||||
params: {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
to: doc.value.data.mobile_no,
|
||||
template,
|
||||
},
|
||||
auto: true,
|
||||
})
|
||||
}
|
||||
|
||||
const replyMessage = ref({})
|
||||
|
||||
const defaultActions = computed(() => {
|
||||
let actions = [
|
||||
{
|
||||
icon: h(EmailIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Email'),
|
||||
onClick: () => (emailBox.value.show = true),
|
||||
},
|
||||
{
|
||||
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
|
||||
label: __('Make a Call'),
|
||||
onClick: () => makeCall(doc.value.data.mobile_no),
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
icon: h(NoteIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Note'),
|
||||
onClick: () => showNote(),
|
||||
},
|
||||
{
|
||||
icon: h(TaskIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New Task'),
|
||||
onClick: () => showTask(),
|
||||
},
|
||||
{
|
||||
icon: h(WhatsAppIcon, { class: 'h-4 w-4' }),
|
||||
label: __('New WhatsApp Message'),
|
||||
onClick: () => (tabIndex.value = 5),
|
||||
condition: () => whatsappEnabled.value,
|
||||
},
|
||||
]
|
||||
return actions.filter((action) =>
|
||||
action.condition ? action.condition() : true
|
||||
)
|
||||
})
|
||||
|
||||
function get_activities() {
|
||||
if (!all_activities.data?.versions) return []
|
||||
if (!all_activities.data?.calls.length)
|
||||
@ -901,6 +1009,7 @@ const activities = computed(() => {
|
||||
if (!all_activities.data?.notes) return []
|
||||
return sortByCreation(all_activities.data.notes)
|
||||
}
|
||||
|
||||
activities.forEach((activity) => {
|
||||
activity.icon = timelineIcon(activity.activity_type, activity.is_lead)
|
||||
|
||||
@ -958,6 +1067,8 @@ const emptyText = computed(() => {
|
||||
text = 'No Notes'
|
||||
} else if (props.title == 'Tasks') {
|
||||
text = 'No Tasks'
|
||||
} else if (props.title == 'WhatsApp') {
|
||||
text = 'No WhatsApp Messages'
|
||||
}
|
||||
return text
|
||||
})
|
||||
@ -972,6 +1083,8 @@ const emptyTextIcon = computed(() => {
|
||||
icon = NoteIcon
|
||||
} else if (props.title == 'Tasks') {
|
||||
icon = TaskIcon
|
||||
} else if (props.title == 'WhatsApp') {
|
||||
icon = WhatsAppIcon
|
||||
}
|
||||
return h(icon, { class: 'text-gray-500' })
|
||||
})
|
||||
|
||||
113
frontend/src/components/IconPicker.vue
Normal file
113
frontend/src/components/IconPicker.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<slot v-bind="{ isOpen, togglePopover }">
|
||||
<span class="text-base"> {{ modelValue || '' }} </span>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
v-if="reaction"
|
||||
class="flex items-center justify-center gap-2 rounded-full bg-white px-2 py-1 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="size-5 cursor-pointer rounded-full bg-white text-xl"
|
||||
v-for="r in reactionEmojis"
|
||||
:key="r"
|
||||
@click="() => (emoji = r) && togglePopover()"
|
||||
>
|
||||
<button>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
class="rounded-full"
|
||||
icon="plus"
|
||||
@click.stop="() => (reaction = false)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="my-3 max-w-max transform bg-white px-4 sm:px-0">
|
||||
<div
|
||||
class="relative max-h-96 overflow-y-auto rounded-lg pb-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div class="flex gap-2 px-3 pb-1 pt-3">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
/>
|
||||
</div>
|
||||
<Button @click="setRandom">Random</Button>
|
||||
</div>
|
||||
<div class="w-96"></div>
|
||||
<div class="px-3" v-for="(emojis, group) in emojiGroups" :key="group">
|
||||
<div class="sticky top-0 bg-white pb-2 pt-3 text-sm text-gray-700">
|
||||
{{ group }}
|
||||
</div>
|
||||
<div class="grid w-96 grid-cols-12 place-items-center">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-gray-100 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
v-for="_emoji in emojis"
|
||||
:key="_emoji.description"
|
||||
@click="() => (emoji = _emoji.emoji) && togglePopover()"
|
||||
:title="_emoji.description"
|
||||
>
|
||||
{{ _emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { gemoji } from 'gemoji'
|
||||
import { Popover } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const search = ref('')
|
||||
const emoji = defineModel()
|
||||
const reaction = defineModel('reaction')
|
||||
|
||||
const reactionEmojis = ref(['👍', '❤️', '😂', '😮', '😢', '🙏'])
|
||||
|
||||
const emojiGroups = computed(() => {
|
||||
let groups = {}
|
||||
for (let _emoji of gemoji) {
|
||||
if (search.value) {
|
||||
let keywords = [_emoji.description, ..._emoji.names, ..._emoji.tags]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
if (!keywords.includes(search.value.toLowerCase())) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let group = groups[_emoji.category]
|
||||
if (!group) {
|
||||
groups[_emoji.category] = []
|
||||
group = groups[_emoji.category]
|
||||
}
|
||||
group.push(_emoji)
|
||||
}
|
||||
if (!Object.keys(groups).length) {
|
||||
groups['No results'] = []
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function setRandom() {
|
||||
let total = gemoji.length
|
||||
let index = randomInt(0, total - 1)
|
||||
emoji.value = gemoji[index].emoji
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min)
|
||||
}
|
||||
|
||||
defineExpose({ setRandom })
|
||||
</script>
|
||||
16
frontend/src/components/Icons/CheckIcon.vue
Normal file
16
frontend/src/components/Icons/CheckIcon.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-check"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</template>
|
||||
20
frontend/src/components/Icons/DocumentIcon.vue
Normal file
20
frontend/src/components/Icons/DocumentIcon.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-file-text"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
</template>
|
||||
17
frontend/src/components/Icons/DoubleCheckIcon.vue
Normal file
17
frontend/src/components/Icons/DoubleCheckIcon.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-check-check"
|
||||
>
|
||||
<path d="M18 6 7 17l-5-5" />
|
||||
<path d="m22 10-7.5 7.5L13 16" />
|
||||
</svg>
|
||||
</template>
|
||||
17
frontend/src/components/Icons/ReactIcon.vue
Normal file
17
frontend/src/components/Icons/ReactIcon.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 15 15"
|
||||
width="15"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class=""
|
||||
fill="none"
|
||||
>
|
||||
<title>react</title>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0 7.5C0 11.6305 3.36946 15 7.5 15C11.6527 15 15 11.6305 15 7.5C15 3.36946 11.6305 0 7.5 0C3.36946 0 0 3.36946 0 7.5ZM10.995 8.69333C11.1128 8.67863 11.2219 8.66503 11.3211 8.65309C11.61 8.63028 11.8076 8.91918 11.6784 9.13965C10.8573 10.6374 9.29116 11.793 7.50455 11.793C5.71794 11.793 4.15181 10.6602 3.33072 9.16246C3.18628 8.91918 3.37634 8.63028 3.66524 8.65309C3.79123 8.66749 3.93521 8.68511 4.09426 8.70457C4.94292 8.80842 6.22074 8.96479 7.48174 8.96479C8.81855 8.96479 10.1378 8.80025 10.995 8.69333ZM5.41405 7.37207C6.05761 7.37207 6.60923 6.72851 6.60923 6.02978C6.60923 5.30348 6.05761 4.6875 5.41405 4.6875C4.77048 4.6875 4.21886 5.33106 4.21886 6.02978C4.20967 6.75609 4.77048 7.37207 5.41405 7.37207ZM10.7807 6.05619C10.7807 6.74114 10.24 7.37201 9.60912 7.37201C8.97825 7.37201 8.4375 6.76818 8.4375 6.05619C8.4375 5.37124 8.97825 4.74037 9.60912 4.74037C10.24 4.74037 10.7807 5.34421 10.7807 6.05619Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
19
frontend/src/components/Icons/SmileIcon.vue
Normal file
19
frontend/src/components/Icons/SmileIcon.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
height="24"
|
||||
width="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
class=""
|
||||
version="1.1"
|
||||
x="0px"
|
||||
y="0px"
|
||||
enable-background="new 0 0 24 24"
|
||||
>
|
||||
<title>smiley</title>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M9.153,11.603c0.795,0,1.439-0.879,1.439-1.962S9.948,7.679,9.153,7.679 S7.714,8.558,7.714,9.641S8.358,11.603,9.153,11.603z M5.949,12.965c-0.026-0.307-0.131,5.218,6.063,5.551 c6.066-0.25,6.066-5.551,6.066-5.551C12,14.381,5.949,12.965,5.949,12.965z M17.312,14.073c0,0-0.669,1.959-5.051,1.959 c-3.505,0-5.388-1.164-5.607-1.959C6.654,14.073,12.566,15.128,17.312,14.073z M11.804,1.011c-6.195,0-10.826,5.022-10.826,11.217 s4.826,10.761,11.021,10.761S23.02,18.423,23.02,12.228C23.021,6.033,17.999,1.011,11.804,1.011z M12,21.354 c-5.273,0-9.381-3.886-9.381-9.159s3.942-9.548,9.215-9.548s9.548,4.275,9.548,9.548C21.381,17.467,17.273,21.354,12,21.354z M15.108,11.603c0.795,0,1.439-0.879,1.439-1.962s-0.644-1.962-1.439-1.962s-1.439,0.879-1.439,1.962S14.313,11.603,15.108,11.603z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
28
frontend/src/components/Icons/WhatsAppIcon.vue
Normal file
28
frontend/src/components/Icons/WhatsAppIcon.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 15.5C7.5 16.5 10.3367 17.2101 12.0757 16.7964C13.8147 16.3826 15.3488 15.3614 16.4015 13.9167C17.4541 12.472 17.9562 10.6988 17.8172 8.91668C17.6781 7.13456 16.9072 5.46069 15.6432 4.19671C14.3792 2.93273 12.7053 2.16176 10.9232 2.02273C9.14108 1.8837 7.36789 2.38575 5.92318 3.43842C4.47847 4.49109 3.45724 6.02514 3.04352 7.76414C2.62979 9.50314 3 12 4 13.5L2.5 17L6 15.5Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.3355 12.3844L12 11.5C12.4877 11.0123 12.775 11.209 13.4481 11.4793C13.4824 11.4931 13.515 11.5112 13.5445 11.5333L13.8563 11.7664C14.0434 11.8987 14.0487 12.1733 13.8668 12.3126L13.2106 12.8153C12.9462 13.0178 12.5923 13.0604 12.2947 12.91C11.5548 12.536 10.1264 11.74 9.11332 10.7151C8.13237 9.72267 7.42487 8.40546 7.07965 7.68204C6.92664 7.3614 7.0003 6.98556 7.24735 6.72951L7.85039 6.10451C8.00431 5.94497 8.26796 5.97191 8.38609 6.15923L8.79347 6.72951C9.11332 7.24052 8.65816 7.67639 8.38609 7.85843L7.5 8.49417"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.3355 12.3844L12 11.5C12.4877 11.0123 12.775 11.209 13.4481 11.4793C13.4824 11.4931 13.515 11.5112 13.5445 11.5333L13.8563 11.7664C14.0434 11.8987 14.0487 12.1733 13.8668 12.3126L13.2106 12.8153C12.9462 13.0178 12.5923 13.0604 12.2947 12.91C11.5548 12.536 10.1264 11.74 9.11332 10.7151C8.13237 9.72267 7.42487 8.40546 7.07965 7.68204C6.92664 7.3614 7.0003 6.98556 7.24735 6.72951L7.85039 6.10451C8.00431 5.94497 8.26796 5.97191 8.38609 6.15923L8.79347 6.72951C9.11332 7.24052 8.65816 7.67639 8.38609 7.85843L7.5 8.49417"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ title: __('WhatsApp Templates'), size: '4xl' }"
|
||||
>
|
||||
<template #body-content>
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="__('Welcome Message')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<div
|
||||
v-if="filteredTemplates.length"
|
||||
class="mt-2 grid max-h-[560px] grid-cols-3 gap-2 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100"
|
||||
@click="emit('send', template.name)"
|
||||
>
|
||||
<div class="border-b pb-2 text-base font-semibold">
|
||||
{{ template.name }}
|
||||
</div>
|
||||
<TextEditor
|
||||
v-if="template.template"
|
||||
:content="template.template"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<div class="flex h-56 flex-col items-center justify-center">
|
||||
<div class="text-lg text-gray-500">
|
||||
{{ __('No templates found') }}
|
||||
</div>
|
||||
<Button :label="__('Create New')" class="mt-4" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextEditor, createListResource } from 'frappe-ui'
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
const searchInput = ref('')
|
||||
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
const search = ref('')
|
||||
|
||||
const templates = createListResource({
|
||||
type: 'list',
|
||||
doctype: 'WhatsApp Templates',
|
||||
cache: ['whatsappTemplates'],
|
||||
fields: ['name', 'template', 'footer'],
|
||||
filters: { status: 'APPROVED' },
|
||||
orderBy: 'modified desc',
|
||||
pageLength: 99999,
|
||||
auto: true,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (templates.data == null) {
|
||||
templates.fetch()
|
||||
}
|
||||
})
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return (
|
||||
templates.data?.filter((template) => {
|
||||
return template.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
}) ?? []
|
||||
)
|
||||
})
|
||||
|
||||
watch(show, (value) => value && nextTick(() => searchInput.value?.el?.focus()))
|
||||
</script>
|
||||
@ -48,21 +48,28 @@
|
||||
:key="n.comment"
|
||||
:to="getRoute(n)"
|
||||
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
|
||||
@click="mark_as_read(n.comment)"
|
||||
@click="mark_as_read(n.comment || n.notification_type_doc)"
|
||||
>
|
||||
<div class="mt-1 flex items-center gap-2.5">
|
||||
<div
|
||||
class="h-[5px] w-[5px] rounded-full"
|
||||
:class="[n.read ? 'bg-transparent' : 'bg-gray-900']"
|
||||
/>
|
||||
<UserAvatar :user="n.from_user.name" size="lg" />
|
||||
<WhatsAppIcon
|
||||
v-if="n.type == 'WhatsApp'"
|
||||
class="size-7 rounded-full"
|
||||
/>
|
||||
<UserAvatar v-else :user="n.from_user.name" size="lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 space-x-1 leading-5 text-gray-600">
|
||||
<div v-if="n.notification_text" v-html="n.notification_text" />
|
||||
<div v-else class="mb-2 space-x-1 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ n.from_user.full_name }}
|
||||
</span>
|
||||
<span>{{ __('mentioned you in {0}', [n.reference_doctype]) }}</span>
|
||||
<span>
|
||||
{{ __('mentioned you in {0}', [n.reference_doctype]) }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
{{ n.reference_name }}
|
||||
</span>
|
||||
@ -86,14 +93,18 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import MarkAsDoneIcon from '@/components/Icons/MarkAsDoneIcon.vue'
|
||||
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { notificationsStore } from '@/stores/notifications'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { timeAgo } from '@/utils'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const { $socket } = globalStore()
|
||||
|
||||
const target = ref(null)
|
||||
onClickOutside(
|
||||
@ -112,10 +123,20 @@ function toggleNotificationPanel() {
|
||||
notificationsStore().toggle()
|
||||
}
|
||||
|
||||
function mark_as_read(comment) {
|
||||
notificationsStore().mark_comment_as_read(comment)
|
||||
function mark_as_read(doc) {
|
||||
notificationsStore().mark_doc_as_read(doc)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
$socket.off('crm_notification')
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
$socket.on('crm_notification', () => {
|
||||
notificationsStore().notifications.reload()
|
||||
})
|
||||
})
|
||||
|
||||
function getRoute(notification) {
|
||||
let params = {
|
||||
leadId: notification.reference_name,
|
||||
@ -128,7 +149,9 @@ function getRoute(notification) {
|
||||
return {
|
||||
name: notification.route_name,
|
||||
params: params,
|
||||
hash: '#' + notification.comment,
|
||||
hash: '#' + notification.comment || notification.notification_type_doc,
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {})
|
||||
</script>
|
||||
|
||||
263
frontend/src/components/WhatsAppArea.vue
Normal file
263
frontend/src/components/WhatsAppArea.vue
Normal file
@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="whatsapp in messages"
|
||||
:key="whatsapp.name"
|
||||
class="activity group flex gap-2"
|
||||
:class="[
|
||||
whatsapp.type == 'Outgoing' ? 'flex-row-reverse' : '',
|
||||
whatsapp.reaction ? 'mb-7' : 'mb-3',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:id="whatsapp.name"
|
||||
class="group/message relative max-w-[90%] rounded-md bg-gray-50 p-1.5 pl-2 text-base shadow-sm"
|
||||
>
|
||||
<div
|
||||
v-if="whatsapp.is_reply"
|
||||
@click="() => scrollToMessage(whatsapp.reply_to)"
|
||||
class="mb-1 cursor-pointer rounded border-0 border-l-4 bg-gray-200 p-2 text-gray-600"
|
||||
:class="
|
||||
whatsapp.reply_to_type == 'Incoming'
|
||||
? 'border-green-500'
|
||||
: 'border-blue-400'
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="mb-1 text-sm font-bold"
|
||||
:class="
|
||||
whatsapp.reply_to_type == 'Incoming'
|
||||
? 'text-green-500'
|
||||
: 'text-blue-400'
|
||||
"
|
||||
>
|
||||
{{ whatsapp.reply_to_from || __('You') }}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 max-h-12 overflow-hidden">
|
||||
<div v-if="whatsapp.header" class="text-base font-semibold">
|
||||
{{ whatsapp.header }}
|
||||
</div>
|
||||
<div v-html="formatWhatsAppMessage(whatsapp.reply_message)" />
|
||||
<div v-if="whatsapp.footer" class="text-xs text-gray-600">
|
||||
{{ whatsapp.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex gap-2">
|
||||
<div
|
||||
class="absolute -right-0.5 -top-0.5 flex cursor-pointer gap-1 rounded-full bg-white pb-2 pl-2 pr-1.5 pt-1.5 opacity-0 group-hover/message:opacity-100"
|
||||
:style="{
|
||||
background:
|
||||
'radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 35%, rgba(238, 130, 238, 0) 100%)',
|
||||
}"
|
||||
>
|
||||
<Dropdown :options="messageOptions(whatsapp)">
|
||||
<FeatherIcon name="chevron-down" class="size-4 text-gray-600" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div
|
||||
class="absolute -bottom-5 flex gap-1 rounded-full border bg-white p-1 pb-[3px] shadow-sm"
|
||||
v-if="whatsapp.reaction"
|
||||
>
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
{{ whatsapp.reaction }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
v-if="whatsapp.message_type == 'Template'"
|
||||
>
|
||||
<div v-if="whatsapp.header" class="text-base font-semibold">
|
||||
{{ whatsapp.header }}
|
||||
</div>
|
||||
<div v-html="formatWhatsAppMessage(whatsapp.template)" />
|
||||
<div v-if="whatsapp.footer" class="text-xs text-gray-600">
|
||||
{{ whatsapp.footer }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'text'"
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
<div v-else-if="whatsapp.content_type == 'image'">
|
||||
<img
|
||||
:src="whatsapp.attach"
|
||||
class="h-40 cursor-pointer rounded-md"
|
||||
@click="() => openFileInAnotherTab(whatsapp.attach)"
|
||||
/>
|
||||
<div
|
||||
v-if="!whatsapp.message.startsWith('/files/')"
|
||||
class="mt-1.5"
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'document'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<DocumentIcon
|
||||
class="size-10 cursor-pointer rounded-md text-gray-500"
|
||||
@click="() => openFileInAnotherTab(whatsapp.attach)"
|
||||
/>
|
||||
<div class="text-gray-600">Document</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'audio'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<audio :src="whatsapp.attach" controls class="cursor-pointer" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'video'"
|
||||
class="flex-col items-center gap-2"
|
||||
>
|
||||
<video
|
||||
:src="whatsapp.attach"
|
||||
controls
|
||||
class="h-40 cursor-pointer rounded-md"
|
||||
/>
|
||||
<div
|
||||
v-if="!whatsapp.message.startsWith('/files/')"
|
||||
class="mt-1.5"
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mb-1 flex shrink-0 items-end gap-1 text-gray-600">
|
||||
<Tooltip :text="dateFormat(whatsapp.creation, 'ddd, MMM D, YYYY')">
|
||||
<div class="text-2xs">
|
||||
{{ dateFormat(whatsapp.creation, 'hh:mm a') }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div v-if="whatsapp.type == 'Outgoing'">
|
||||
<CheckIcon
|
||||
v-if="['sent', 'Success'].includes(whatsapp.status)"
|
||||
class="size-4"
|
||||
/>
|
||||
<DoubleCheckIcon
|
||||
v-else-if="['read', 'delivered'].includes(whatsapp.status)"
|
||||
class="size-4"
|
||||
:class="{ 'text-blue-500': whatsapp.status == 'read' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-center opacity-0 transition-all ease-in group-hover:opacity-100"
|
||||
>
|
||||
<IconPicker
|
||||
v-model="emoji"
|
||||
v-model:reaction="reaction"
|
||||
v-slot="{ togglePopover }"
|
||||
@update:modelValue="() => reactOnMessage(whatsapp.name, emoji)"
|
||||
>
|
||||
<Button
|
||||
@click="() => (reaction = true) && togglePopover()"
|
||||
class="rounded-full !size-6 mt-0.5"
|
||||
>
|
||||
<ReactIcon class="text-gray-400" />
|
||||
</Button>
|
||||
</IconPicker>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import CheckIcon from '@/components/Icons/CheckIcon.vue'
|
||||
import DoubleCheckIcon from '@/components/Icons/DoubleCheckIcon.vue'
|
||||
import DocumentIcon from '@/components/Icons/DocumentIcon.vue'
|
||||
import ReactIcon from '@/components/Icons/ReactIcon.vue'
|
||||
import { dateFormat } from '@/utils'
|
||||
import { Tooltip, Dropdown, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
messages: Array,
|
||||
})
|
||||
|
||||
const list = defineModel()
|
||||
|
||||
function openFileInAnotherTab(url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
function formatWhatsAppMessage(message) {
|
||||
// if message contains _text_, make it italic
|
||||
message = message.replace(/_(.*?)_/g, '<i>$1</i>')
|
||||
// if message contains *text*, make it bold
|
||||
message = message.replace(/\*(.*?)\*/g, '<b>$1</b>')
|
||||
// if message contains ~text~, make it strikethrough
|
||||
message = message.replace(/~(.*?)~/g, '<s>$1</s>')
|
||||
// if message contains ```text```, make it monospace
|
||||
message = message.replace(/```(.*?)```/g, '<code>$1</code>')
|
||||
// if message contains `text`, make it inline code
|
||||
message = message.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
// if message contains > text, make it a blockquote
|
||||
message = message.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>')
|
||||
// if contain /n, make it a new line
|
||||
message = message.replace(/\n/g, '<br>')
|
||||
// if contains *<space>text, make it a bullet point
|
||||
message = message.replace(/\* (.*?)(?=\s*\*|$)/g, '<li>$1</li>')
|
||||
message = message.replace(/- (.*?)(?=\s*-|$)/g, '<li>$1</li>')
|
||||
message = message.replace(/(\d+)\. (.*?)(?=\s*(\d+)\.|$)/g, '<li>$2</li>')
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
const emoji = ref('')
|
||||
const reaction = ref(true)
|
||||
|
||||
function reactOnMessage(name, emoji) {
|
||||
createResource({
|
||||
url: 'crm.api.whatsapp.react_on_whatsapp_message',
|
||||
params: {
|
||||
emoji,
|
||||
reply_to_name: name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess() {
|
||||
list.value.reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const reply = defineModel('reply')
|
||||
const replyMode = ref(false)
|
||||
|
||||
function messageOptions(message) {
|
||||
return [
|
||||
{
|
||||
label: 'Reply',
|
||||
onClick: () => {
|
||||
replyMode.value = true
|
||||
reply.value = {
|
||||
...message,
|
||||
message: formatWhatsAppMessage(message.message)
|
||||
}
|
||||
},
|
||||
},
|
||||
// {
|
||||
// label: 'Forward',
|
||||
// onClick: () => console.log('Forward'),
|
||||
// },
|
||||
// {
|
||||
// label: 'Delete',
|
||||
// onClick: () => console.log('Delete'),
|
||||
// },
|
||||
]
|
||||
}
|
||||
|
||||
function scrollToMessage(name) {
|
||||
const element = document.getElementById(name)
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
// Highlight the message
|
||||
element.classList.add('bg-yellow-100')
|
||||
setTimeout(() => {
|
||||
element.classList.remove('bg-yellow-100')
|
||||
}, 1000)
|
||||
}
|
||||
</script>
|
||||
162
frontend/src/components/WhatsAppBox.vue
Normal file
162
frontend/src/components/WhatsAppBox.vue
Normal file
@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="reply?.message"
|
||||
class="flex items-center justify-around gap-2 px-10 pt-2"
|
||||
>
|
||||
<div
|
||||
class="mb-1 ml-13 flex-1 cursor-pointer rounded border-0 border-l-4 border-green-500 bg-gray-100 p-2 text-base text-gray-600"
|
||||
:class="reply.type == 'Incoming' ? 'border-green-500' : 'border-blue-400'"
|
||||
>
|
||||
<div
|
||||
class="mb-1 text-sm font-bold"
|
||||
:class="reply.type == 'Incoming' ? 'text-green-500' : 'text-blue-400'"
|
||||
>
|
||||
{{ reply.from_name || __('You') }}
|
||||
</div>
|
||||
<div class="max-h-12 overflow-hidden" v-html="reply.message" />
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" icon="x" @click="reply = {}" />
|
||||
</div>
|
||||
<div class="flex items-end gap-2 px-10 py-2.5">
|
||||
<div class="flex h-8 items-center gap-2">
|
||||
<FileUploader @success="(file) => uploadFile(file)">
|
||||
<template v-slot="{ openFileSelector }">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Dropdown :options="uploadOptions(openFileSelector)">
|
||||
<FeatherIcon
|
||||
name="plus"
|
||||
class="size-4.5 cursor-pointer text-gray-600"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<IconPicker
|
||||
v-model="emoji"
|
||||
v-slot="{ togglePopover }"
|
||||
@update:modelValue="
|
||||
() => {
|
||||
content += emoji
|
||||
$refs.textarea.$el.focus()
|
||||
}
|
||||
"
|
||||
>
|
||||
<SmileIcon
|
||||
@click="togglePopover"
|
||||
class="flex size-4.5 cursor-pointer rounded-sm text-xl leading-none text-gray-500"
|
||||
/>
|
||||
</IconPicker>
|
||||
</div>
|
||||
<Textarea
|
||||
ref="textarea"
|
||||
type="textarea"
|
||||
class="min-h-8 w-full"
|
||||
:rows="rows"
|
||||
v-model="content"
|
||||
:placeholder="placeholder"
|
||||
@focus="rows = 6"
|
||||
@blur="rows = 1"
|
||||
@keydown.enter="(e) => sendTextMessage(e)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||
import { createResource, Textarea, FileUploader, Dropdown } from 'frappe-ui'
|
||||
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: String,
|
||||
})
|
||||
|
||||
const doc = defineModel()
|
||||
const whatsapp = defineModel('whatsapp')
|
||||
const reply = defineModel('reply')
|
||||
const rows = ref(1)
|
||||
const textarea = ref(null)
|
||||
const emoji = ref('')
|
||||
|
||||
const content = ref('')
|
||||
const placeholder = ref(__('Type your message here...'))
|
||||
const fileType = ref('')
|
||||
|
||||
function show() {
|
||||
nextTick(() => textarea.value.$el.focus())
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
whatsapp.value.attach = file.file_url
|
||||
whatsapp.value.content_type = fileType.value
|
||||
sendWhatsAppMessage()
|
||||
}
|
||||
|
||||
function sendTextMessage(event) {
|
||||
if (event.shiftKey) return
|
||||
sendWhatsAppMessage()
|
||||
textarea.value.$el.blur()
|
||||
content.value = ''
|
||||
}
|
||||
|
||||
async function sendWhatsAppMessage() {
|
||||
let args = {
|
||||
reference_doctype: props.doctype,
|
||||
reference_name: doc.value.data.name,
|
||||
message: content.value,
|
||||
to: doc.value.data.mobile_no,
|
||||
attach: whatsapp.value.attach || '',
|
||||
reply_to: reply.value?.name || '',
|
||||
content_type: whatsapp.value.content_type,
|
||||
}
|
||||
content.value = ''
|
||||
fileType.value = ''
|
||||
whatsapp.value.attach = ''
|
||||
whatsapp.value.content_type = 'text'
|
||||
reply.value = {}
|
||||
createResource({
|
||||
url: 'crm.api.whatsapp.create_whatsapp_message',
|
||||
params: args,
|
||||
auto: true,
|
||||
})
|
||||
}
|
||||
|
||||
function uploadOptions(openFileSelector) {
|
||||
return [
|
||||
{
|
||||
label: __('Upload Document'),
|
||||
icon: 'file',
|
||||
onClick: () => {
|
||||
fileType.value = 'document'
|
||||
openFileSelector()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Upload Image'),
|
||||
icon: 'image',
|
||||
onClick: () => {
|
||||
fileType.value = 'image'
|
||||
openFileSelector('image/*')
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __('Upload Video'),
|
||||
icon: 'video',
|
||||
onClick: () => {
|
||||
fileType.value = 'video'
|
||||
openFileSelector('video/*')
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
watch(reply, (value) => {
|
||||
if (value?.message) {
|
||||
show()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@ -21,9 +21,7 @@ import {
|
||||
} from 'frappe-ui'
|
||||
import translationPlugin from './translation'
|
||||
import { createDialog } from './utils/dialogs'
|
||||
import socket from './socket'
|
||||
import { getCachedListResource } from 'frappe-ui/src/resources/listResource'
|
||||
import { getCachedResource } from 'frappe-ui/src/resources/resources'
|
||||
import { initSocket } from './socket'
|
||||
|
||||
let globalComponents = {
|
||||
Button,
|
||||
@ -53,17 +51,23 @@ for (let key in globalComponents) {
|
||||
|
||||
app.config.globalProperties.$dialog = createDialog
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
socket.on('refetch_resource', (data) => {
|
||||
if (data.cache_key) {
|
||||
let resource =
|
||||
getCachedResource(data.cache_key) || getCachedListResource(data.cache_key)
|
||||
if (resource) {
|
||||
resource.reload()
|
||||
let socket
|
||||
if (import.meta.env.DEV) {
|
||||
frappeRequest({ url: '/api/method/crm.www.crm.get_context_for_dev' }).then(
|
||||
(values) => {
|
||||
for (let key in values) {
|
||||
window[key] = values[key]
|
||||
}
|
||||
socket = initSocket()
|
||||
app.config.globalProperties.$socket = socket
|
||||
app.mount('#app')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
} else {
|
||||
socket = initSocket()
|
||||
app.config.globalProperties.$socket = socket
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
window.$dialog = createDialog
|
||||
|
||||
@ -71,18 +71,20 @@
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<Tooltip
|
||||
<component
|
||||
:is="callEnabled ? Tooltip : 'div'"
|
||||
:text="__('Make Call')"
|
||||
v-if="contact.data.actual_mobile_no"
|
||||
>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-1.5"
|
||||
@click="makeCall(contact.data.actual_mobile_no)"
|
||||
class="flex items-center gap-1.5"
|
||||
:class="callEnabled ? 'cursor-pointer' : ''"
|
||||
@click="callEnabled && makeCall(contact.data.actual_mobile_no)"
|
||||
>
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
<span class="">{{ contact.data.actual_mobile_no }}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</component>
|
||||
<span
|
||||
v-if="contact.data.actual_mobile_no"
|
||||
class="text-3xl leading-[0] text-gray-600"
|
||||
@ -232,6 +234,7 @@ import { globalStore } from '@/stores/global.js'
|
||||
import { usersStore } from '@/stores/users.js'
|
||||
import { organizationsStore } from '@/stores/organizations.js'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { callEnabled } from '@/stores/settings'
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
doctype="CRM Deal"
|
||||
:title="tab.name"
|
||||
v-model:reload="reload"
|
||||
v-model:tabIndex="tabIndex"
|
||||
v-model="deal"
|
||||
/>
|
||||
</Tabs>
|
||||
@ -68,7 +69,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-1.5">
|
||||
<Tooltip :text="__('Make a call')">
|
||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
||||
<Button class="h-7 w-7" @click="triggerCall">
|
||||
<PhoneIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
@ -293,6 +294,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||
@ -318,6 +320,7 @@ import {
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { organizationsStore } from '@/stores/organizations'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { whatsappEnabled, callEnabled } from '@/stores/settings'
|
||||
import {
|
||||
createResource,
|
||||
Dropdown,
|
||||
@ -433,33 +436,43 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Activity',
|
||||
label: __('Activity'),
|
||||
icon: ActivityIcon,
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
label: __('Emails'),
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
label: __('Tasks'),
|
||||
icon: TaskIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notes',
|
||||
label: __('Notes'),
|
||||
icon: NoteIcon,
|
||||
},
|
||||
]
|
||||
const tabs = computed(() => {
|
||||
let tabOptions = [
|
||||
{
|
||||
name: 'Activity',
|
||||
label: __('Activity'),
|
||||
icon: ActivityIcon,
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
label: __('Emails'),
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
label: __('Tasks'),
|
||||
icon: TaskIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notes',
|
||||
label: __('Notes'),
|
||||
icon: NoteIcon,
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
label: __('WhatsApp'),
|
||||
icon: WhatsAppIcon,
|
||||
condition: () => whatsappEnabled.value,
|
||||
},
|
||||
]
|
||||
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
|
||||
})
|
||||
|
||||
const detailSections = computed(() => {
|
||||
let data = deal.data
|
||||
|
||||
@ -46,6 +46,7 @@
|
||||
doctype="CRM Lead"
|
||||
:title="tab.name"
|
||||
v-model:reload="reload"
|
||||
v-model:tabIndex="tabIndex"
|
||||
v-model="lead"
|
||||
/>
|
||||
</Tabs>
|
||||
@ -110,7 +111,7 @@
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="flex gap-1.5">
|
||||
<Tooltip :text="__('Make a call')">
|
||||
<Tooltip v-if="callEnabled" :text="__('Make a call')">
|
||||
<Button
|
||||
class="h-7 w-7"
|
||||
@click="
|
||||
@ -260,6 +261,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
|
||||
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
|
||||
import TaskIcon from '@/components/Icons/TaskIcon.vue'
|
||||
import NoteIcon from '@/components/Icons/NoteIcon.vue'
|
||||
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import CameraIcon from '@/components/Icons/CameraIcon.vue'
|
||||
import LinkIcon from '@/components/Icons/LinkIcon.vue'
|
||||
@ -285,6 +287,7 @@ import { globalStore } from '@/stores/global'
|
||||
import { contactsStore } from '@/stores/contacts'
|
||||
import { organizationsStore } from '@/stores/organizations'
|
||||
import { statusesStore } from '@/stores/statuses'
|
||||
import { whatsappEnabled, callEnabled } from '@/stores/settings'
|
||||
import {
|
||||
createResource,
|
||||
FileUploader,
|
||||
@ -397,33 +400,44 @@ const breadcrumbs = computed(() => {
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabs = [
|
||||
{
|
||||
name: 'Activity',
|
||||
label: __('Activity'),
|
||||
icon: ActivityIcon,
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
label: __('Emails'),
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
label: __('Tasks'),
|
||||
icon: TaskIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notes',
|
||||
label: __('Notes'),
|
||||
icon: NoteIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const tabs = computed(() => {
|
||||
let tabOptions = [
|
||||
{
|
||||
name: 'Activity',
|
||||
label: __('Activity'),
|
||||
icon: ActivityIcon,
|
||||
},
|
||||
{
|
||||
name: 'Emails',
|
||||
label: __('Emails'),
|
||||
icon: EmailIcon,
|
||||
},
|
||||
{
|
||||
name: 'Calls',
|
||||
label: __('Calls'),
|
||||
icon: PhoneIcon,
|
||||
condition: () => callEnabled.value,
|
||||
},
|
||||
{
|
||||
name: 'Tasks',
|
||||
label: __('Tasks'),
|
||||
icon: TaskIcon,
|
||||
},
|
||||
{
|
||||
name: 'Notes',
|
||||
label: __('Notes'),
|
||||
icon: NoteIcon,
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
label: __('WhatsApp'),
|
||||
icon: WhatsAppIcon,
|
||||
condition: () => whatsappEnabled.value,
|
||||
},
|
||||
]
|
||||
return tabOptions.filter((tab) => (tab.condition ? tab.condition() : true))
|
||||
})
|
||||
|
||||
function validateFile(file) {
|
||||
let extn = file.name.split('.').pop().toLowerCase()
|
||||
|
||||
@ -1,14 +1,28 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { socketio_port } from '../../../../sites/common_site_config.json'
|
||||
import { getCachedListResource } from 'frappe-ui/src/resources/listResource'
|
||||
import { getCachedResource } from 'frappe-ui/src/resources/resources'
|
||||
|
||||
function initSocket() {
|
||||
export function initSocket() {
|
||||
let host = window.location.hostname
|
||||
let siteName = window.site_name
|
||||
let port = window.location.port ? `:${socketio_port}` : ''
|
||||
let protocol = port ? 'http' : 'https'
|
||||
let url = `${protocol}://${host}${port}/${host}`
|
||||
let socket = io(url, { withCredentials: true })
|
||||
return socket
|
||||
}
|
||||
let url = `${protocol}://${host}${port}/${siteName}`
|
||||
|
||||
let socket = initSocket()
|
||||
export default socket
|
||||
let socket = io(url, {
|
||||
withCredentials: true,
|
||||
reconnectionAttempts: 5,
|
||||
})
|
||||
socket.on('refetch_resource', (data) => {
|
||||
if (data.cache_key) {
|
||||
let resource =
|
||||
getCachedResource(data.cache_key) ||
|
||||
getCachedListResource(data.cache_key)
|
||||
if (resource) {
|
||||
resource.reload()
|
||||
}
|
||||
}
|
||||
})
|
||||
return socket
|
||||
}
|
||||
@ -3,7 +3,7 @@ import { getCurrentInstance, ref } from 'vue'
|
||||
|
||||
export const globalStore = defineStore('crm-global', () => {
|
||||
const app = getCurrentInstance()
|
||||
const { $dialog } = app.appContext.config.globalProperties
|
||||
const { $dialog, $socket } = app.appContext.config.globalProperties
|
||||
|
||||
let twilioEnabled = ref(false)
|
||||
let callMethod = () => {}
|
||||
@ -22,6 +22,7 @@ export const globalStore = defineStore('crm-global', () => {
|
||||
|
||||
return {
|
||||
$dialog,
|
||||
$socket,
|
||||
twilioEnabled,
|
||||
makeCall,
|
||||
setTwilioEnabled,
|
||||
|
||||
@ -29,18 +29,19 @@ export const notificationsStore = defineStore('crm-notifications', () => {
|
||||
() => notifications.data?.filter((n) => !n.read).length || 0
|
||||
)
|
||||
|
||||
function mark_comment_as_read(comment) {
|
||||
mark_as_read.params = { comment: comment }
|
||||
function mark_doc_as_read(doc) {
|
||||
mark_as_read.params = { doc: doc }
|
||||
mark_as_read.reload()
|
||||
toggle()
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
visible,
|
||||
allNotifications,
|
||||
unreadNotificationsCount,
|
||||
mark_as_read,
|
||||
mark_comment_as_read,
|
||||
mark_doc_as_read,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
21
frontend/src/stores/settings.js
Normal file
21
frontend/src/stores/settings.js
Normal file
@ -0,0 +1,21 @@
|
||||
import { createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const whatsappEnabled = ref(false)
|
||||
createResource({
|
||||
url: 'crm.api.whatsapp.is_whatsapp_enabled',
|
||||
cache: 'Is Whatsapp Enabled',
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
whatsappEnabled.value = Boolean(data)
|
||||
},
|
||||
})
|
||||
export const callEnabled = ref(false)
|
||||
createResource({
|
||||
url: 'crm.integrations.twilio.api.is_enabled',
|
||||
cache: 'Is Twilio Enabled',
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
callEnabled.value = Boolean(data)
|
||||
},
|
||||
})
|
||||
@ -1795,6 +1795,11 @@ function-bind@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
|
||||
integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
|
||||
|
||||
gemoji@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-8.1.0.tgz#3d47a26e569c51efa95198822a6f483d7a7ae600"
|
||||
integrity sha512-HA4Gx59dw2+tn+UAa7XEV4ufUKI4fH1KgcbenVA9YKSj1QJTT0xh5Mwv5HMFNN3l2OtUe3ZIfuRwSyZS5pLIWw==
|
||||
|
||||
get-east-asian-width@^1.0.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz#5e6ebd9baee6fb8b7b6bd505221065f0cd91f64e"
|
||||
@ -3124,6 +3129,7 @@ string-argv@0.3.2:
|
||||
integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@ -3151,6 +3157,7 @@ string-width@^7.0.0:
|
||||
strip-ansi "^7.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
name strip-ansi-cjs
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user