1
0
forked from test/crm

Merge pull request #139 from shariquerik/whatsapp

feat: WhatsApp Integration
This commit is contained in:
Shariq Ansari 2024-04-27 14:32:25 +05:30 committed by GitHub
commit 1013d9b2fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1507 additions and 145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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' })
})

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

@ -71,18 +71,20 @@
>
&middot;
</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'

View File

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

View File

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

View File

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

View File

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

View File

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

View 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)
},
})

View File

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