Merge pull request #63 from shariquerik/notification

feat: Notification Panel
This commit is contained in:
Shariq Ansari 2024-01-30 11:12:47 +05:30 committed by GitHub
commit 2bceb5404a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 555 additions and 25 deletions

View File

@ -97,6 +97,7 @@ def get_deal_activities(name):
for comment in docinfo.comments:
activity = {
"name": comment.name,
"activity_type": "comment",
"creation": comment.creation,
"owner": comment.owner,
@ -203,6 +204,7 @@ def get_lead_activities(name):
for comment in docinfo.comments:
activity = {
"name": comment.name,
"activity_type": "comment",
"creation": comment.creation,
"owner": comment.owner,

43
crm/api/comment.py Normal file
View File

@ -0,0 +1,43 @@
import frappe
from bs4 import BeautifulSoup
def on_update(self, method):
notify_mentions(self)
def notify_mentions(doc):
"""
Extract mentions from `content`, and notify.
`content` must have `HTML` content.
"""
content = getattr(doc, "content", None)
if not content:
return
mentions = extract_mentions(content)
for mention in mentions:
values = frappe._dict(
doctype="CRM Notification",
from_user=doc.owner,
to_user=mention.email,
type="Mention",
message=doc.content,
comment=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()
def extract_mentions(html):
if not html:
return []
soup = BeautifulSoup(html, "html.parser")
mentions = []
for d in soup.find_all("span", attrs={"data-type": "mention"}):
mentions.append(
frappe._dict(full_name=d.get("data-label"), email=d.get("data-id"))
)
return mentions

59
crm/api/notifications.py Normal file
View File

@ -0,0 +1,59 @@
import frappe
from frappe.query_builder import Order
@frappe.whitelist()
def get_notifications():
if frappe.session.user == "Guest":
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
Notification = frappe.qb.DocType("CRM Notification")
query = (
frappe.qb.from_(Notification)
.select("*")
.where(Notification.to_user == frappe.session.user)
.orderby("creation", order=Order.desc)
)
notifications = query.run(as_dict=True)
_notifications = []
for notification in notifications:
_notifications.append(
{
"creation": notification.creation,
"from_user": {
"name": notification.from_user,
"full_name": frappe.get_value(
"User", notification.from_user, "full_name"
),
},
"type": notification.type,
"to_user": notification.to_user,
"read": notification.read,
"comment": notification.comment,
"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",
}
)
return _notifications
@frappe.whitelist()
def mark_as_read(user=None, comment=None):
if frappe.session.user == "Guest":
frappe.throw("Authentication failed", exc=frappe.AuthenticationError)
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):
d = frappe.get_doc("CRM Notification", n.name)
d.read = True
d.save()

View File

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

View File

@ -0,0 +1,129 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-01-29 19:31:13.613929",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"from_user",
"type",
"reference_doctype",
"reference_name",
"column_break_dduu",
"to_user",
"comment",
"read",
"section_break_vpwa",
"message"
],
"fields": [
{
"fieldname": "from_user",
"fieldtype": "Link",
"label": "From User",
"options": "User"
},
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Mention",
"reqd": 1
},
{
"fieldname": "column_break_dduu",
"fieldtype": "Column Break"
},
{
"fieldname": "to_user",
"fieldtype": "Link",
"in_list_view": 1,
"label": "To User",
"options": "User",
"reqd": 1
},
{
"fieldname": "comment",
"fieldtype": "Link",
"label": "Comment",
"link_filters": "[[{\"fieldname\":\"comment\",\"field_option\":\"Comment\"},\"comment_type\",\"=\",\"Comment\"]]",
"options": "Comment"
},
{
"default": "0",
"fieldname": "read",
"fieldtype": "Check",
"label": "Read"
},
{
"fieldname": "section_break_vpwa",
"fieldtype": "Section Break"
},
{
"fieldname": "message",
"fieldtype": "HTML Editor",
"label": "Message"
},
{
"fieldname": "reference_name",
"fieldtype": "Dynamic Link",
"label": "Reference Doc",
"options": "reference_doctype"
},
{
"fieldname": "reference_doctype",
"fieldtype": "Link",
"label": "Reference Doctype",
"options": "DocType"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-01-30 01:04:27.946030",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Notification",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Sales User",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

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

View File

@ -0,0 +1,9 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests.utils import FrappeTestCase
class TestCRMNotification(FrappeTestCase):
pass

View File

@ -133,6 +133,9 @@ doc_events = {
"ToDo": {
"after_insert": ["crm.api.todo.after_insert"],
},
"Comment": {
"on_update": ["crm.api.comment.on_update"],
}
}
# Scheduled Tasks

View File

@ -7,7 +7,9 @@ import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def before_install():
pass
frappe.reload_doc("fcrm", "doctype", "crm_lead_status")
frappe.reload_doc("fcrm", "doctype", "crm_deal_status")
frappe.reload_doc("fcrm", "doctype", "crm_communication_status")
def after_install():
add_default_lead_statuses()

View File

@ -417,7 +417,11 @@
</div>
</div>
</div>
<div class="mb-4" v-else-if="activity.activity_type == 'comment'">
<div
class="mb-4"
:id="activity.name"
v-else-if="activity.activity_type == 'comment'"
>
<div
class="mb-0.5 flex items-start justify-stretch gap-2 py-1.5 text-base"
>
@ -765,6 +769,7 @@ import {
} from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
import { ref, computed, h, defineModel, markRaw, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
const { makeCall } = globalStore()
const { getUser } = usersStore()
@ -1106,11 +1111,14 @@ watch([reload, reload_email], ([reload_value, reload_email_value]) => {
}
})
function scroll(el) {
function scroll(hash) {
setTimeout(() => {
if (!el) {
let el
if (!hash) {
let e = document.getElementsByClassName('activity')
el = e[e.length - 1]
} else {
el = document.getElementById(hash)
}
if (el && !useElementVisibility(el).value) {
el.scrollIntoView({ behavior: 'smooth' })
@ -1121,7 +1129,12 @@ function scroll(el) {
defineExpose({ emailBox })
nextTick(() => scroll())
const route = useRoute()
nextTick(() => {
const hash = route.hash.slice(1) || null
scroll(hash)
})
</script>
<style scoped>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.1443 4.56673C15.3259 4.35875 15.3046 4.04289 15.0966 3.86123C14.8886 3.67957 14.5728 3.7009 14.3911 3.90887L8.18815 11.0103L7.78736 10.57C7.60147 10.3657 7.28524 10.3509 7.08103 10.5368C6.87682 10.7227 6.86196 11.0389 7.04785 11.2431L7.82609 12.0981C7.92205 12.2035 8.05843 12.2629 8.20097 12.2615C8.34351 12.26 8.47864 12.1978 8.57242 12.0904L15.1443 4.56673ZM11.2939 4.56674C11.4756 4.35876 11.4543 4.0429 11.2463 3.86124C11.0383 3.67957 10.7225 3.7009 10.5408 3.90888L4.33783 11.0103L1.6023 8.00507C1.41642 7.80086 1.10018 7.786 0.895974 7.97189C0.691764 8.15777 0.676909 8.47401 0.862793 8.67822L3.97577 12.0981C4.07172 12.2035 4.20811 12.2629 4.35065 12.2615C4.49318 12.26 4.62832 12.1978 4.7221 12.0904L11.2939 4.56674Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,26 @@
<template>
<svg
width="18"
height="19"
viewBox="0 0 18 19"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_3181_20053)">
<path
d="M10.6097 2.9398L10.1121 2.98864L10.1445 3.31827L10.4606 3.41705L10.6097 2.9398ZM11.1567 3.14404L11.3569 2.68587L11.1567 3.14404ZM6.573 3.27134L6.79841 3.71765L6.573 3.27134ZM7.3908 2.93968L7.53988 3.41694L7.85603 3.31818L7.88841 2.98855L7.3908 2.93968ZM10.4606 3.41705C10.63 3.46998 10.7955 3.53188 10.9564 3.60221L11.3569 2.68587C11.1628 2.60107 10.9632 2.5264 10.7589 2.46255L10.4606 3.41705ZM10.9564 3.60221C12.6848 4.35744 13.891 6.08158 13.891 8.08634H14.891C14.891 5.66998 13.4363 3.59451 11.3569 2.68587L10.9564 3.60221ZM13.891 8.08634V12.1891H14.891V8.08634H13.891ZM13.891 12.1891C13.891 12.5068 14.1486 12.7643 14.4663 12.7643V11.7643C14.7009 11.7643 14.891 11.9545 14.891 12.1891H13.891ZM14.4663 12.7643C14.744 12.7643 14.9692 12.9895 14.9692 13.2673H15.9692C15.9692 12.4372 15.2963 11.7643 14.4663 11.7643V12.7643ZM14.9692 13.2673V13.3425H15.9692V13.2673H14.9692ZM14.9692 13.3425C14.9692 13.6618 14.7104 13.9207 14.391 13.9207V14.9207C15.2626 14.9207 15.9692 14.2141 15.9692 13.3425H14.9692ZM14.391 13.9207H3.60932V14.9207H14.391V13.9207ZM3.60932 13.9207C3.28999 13.9207 3.03113 13.6618 3.03113 13.3425H2.03113C2.03113 14.2141 2.73771 14.9207 3.60932 14.9207V13.9207ZM3.03113 13.3425V13.2671H2.03113V13.3425H3.03113ZM3.03113 13.2671C3.03113 12.9894 3.25624 12.7643 3.53392 12.7643V11.7643C2.70395 11.7643 2.03113 12.4371 2.03113 13.2671H3.03113ZM3.53392 12.7643C3.85159 12.7643 4.10913 12.5068 4.10913 12.1891H3.10913C3.10913 11.9545 3.29932 11.7643 3.53392 11.7643V12.7643ZM4.10913 12.1891V8.08634H3.10913V12.1891H4.10913ZM4.10913 8.08634C4.10913 6.17794 5.20204 4.52391 6.79841 3.71765L6.34758 2.82504C4.42753 3.79478 3.10913 5.78615 3.10913 8.08634H4.10913ZM6.79841 3.71765C7.03522 3.59804 7.28302 3.49717 7.53988 3.41694L7.24173 2.46242C6.93186 2.55921 6.63302 2.68087 6.34758 2.82504L6.79841 3.71765ZM7.88841 2.98855C7.94387 2.42393 8.42082 1.98242 9.00027 1.98242V0.982422C7.90133 0.982422 6.99845 1.81927 6.8932 2.8908L7.88841 2.98855ZM9.00027 1.98242C9.57975 1.98242 10.0567 2.42397 10.1121 2.98864L11.1074 2.89096C11.0022 1.81935 10.0993 0.982422 9.00027 0.982422V1.98242ZM10.6567 14.4207C10.6567 15.3355 9.91506 16.0771 9.00027 16.0771V17.0771C10.4673 17.0771 11.6567 15.8878 11.6567 14.4207H10.6567ZM9.00027 16.0771C8.08548 16.0771 7.34389 15.3355 7.34389 14.4207H6.34389C6.34389 15.8878 7.53319 17.0771 9.00027 17.0771V16.0771Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3181_20053">
<rect
width="18"
height="18"
fill="white"
transform="translate(0 0.0292969)"
/>
</clipPath>
</defs>
</svg>
</template>

View File

@ -1,12 +1,37 @@
<template>
<div
class="flex h-full flex-col justify-between transition-all duration-300 ease-in-out"
class="relative flex h-full flex-col justify-between transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'w-12' : 'w-56'"
>
<div>
<UserDropdown class="p-2" :isCollapsed="isSidebarCollapsed" />
</div>
<div class="flex-1 overflow-y-auto">
<div class="mb-3 flex flex-col">
<SidebarLink
id="notifications-btn"
label="Notifications"
:icon="NotificationsIcon"
:isCollapsed="isSidebarCollapsed"
@click="() => toggleNotificationPanel()"
class="relative mx-2 my-0.5"
>
<template #right>
<Badge
v-if="
!isSidebarCollapsed &&
notificationsStore().unreadNotificationsCount
"
:label="notificationsStore().unreadNotificationsCount"
variant="subtle"
/>
<div
v-else-if="notificationsStore().unreadNotificationsCount"
class="absolute z-20 top-0 left-0 h-1.5 w-1.5 translate-x-6 translate-y-1 rounded-full bg-gray-800"
/>
</template>
</SidebarLink>
</div>
<div v-for="view in allViews" :key="view.label">
<div
v-if="!view.hideLabel && isSidebarCollapsed && view.views?.length"
@ -66,6 +91,7 @@
</span>
</template>
</SidebarLink>
<Notifications />
</div>
</template>
@ -81,13 +107,18 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Notifications from '@/components/Notifications.vue'
import { viewsStore } from '@/stores/views'
import { notificationsStore } from '@/stores/notifications'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
const { getPinnedViews, getPublicViews } = viewsStore()
const isSidebarCollapsed = useStorage('sidebar_is_collapsed', false)
const { toggle: toggleNotificationPanel } = notificationsStore()
const isSidebarCollapsed = useStorage('isSidebarCollapsed', false)
const links = [
{

View File

@ -0,0 +1,131 @@
<template>
<div
v-if="notificationsStore().visible"
ref="target"
class="absolute z-20 h-screen bg-white transition-all duration-300 ease-in-out"
:style="{
'box-shadow': '8px 0px 8px rgba(0, 0, 0, 0.1)',
'max-width': '350px',
'min-width': '350px',
'left': 'calc(100% + 1px)'
}"
>
<div class="flex h-screen flex-col">
<div
class="z-20 flex items-center justify-between border-b bg-white px-5 py-2.5"
>
<div class="text-base font-medium">Notifications</div>
<div class="flex gap-1">
<Tooltip text="Mark all as read">
<Button
variant="ghost"
@click="() => notificationsStore().mark_as_read.reload()"
>
<template #icon>
<MarkAsDoneIcon class="h-4 w-4" />
</template>
</Button>
</Tooltip>
<Tooltip text="Close">
<Button variant="ghost" @click="() => toggleNotificationPanel()">
<template #icon>
<FeatherIcon name="x" class="h-4 w-4" />
</template>
</Button>
</Tooltip>
</div>
</div>
<div
v-if="notificationsStore().allNotifications?.length"
class="divide-y overflow-auto text-base"
>
<RouterLink
v-for="n in notificationsStore().allNotifications"
: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)"
>
<div class="mt-1 flex items-center gap-2.5">
<div
class="h-1.5 w-1.5 rounded-full"
:class="[n.read ? 'bg-transparent' : 'bg-gray-900']"
/>
<UserAvatar :user="n.from_user.name" size="lg" />
</div>
<div>
<div class="mb-2 space-x-1 leading-5 text-gray-700">
<span class="font-medium text-gray-900">
{{ n.from_user.full_name }}
</span>
<span>mentioned you in {{ n.reference_doctype }}</span>
<span class="font-medium text-gray-900">
{{ n.reference_name }}
</span>
</div>
<div class="text-sm text-gray-600">
{{ timeAgo(n.creation) }}
</div>
</div>
</RouterLink>
</div>
<div
v-else
class="flex flex-1 flex-col items-center justify-center gap-2"
>
<NotificationsIcon class="h-20 w-20 text-gray-300" />
<div class="text-lg font-medium text-gray-500">
No new notifications
</div>
</div>
</div>
</div>
</template>
<script setup>
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'
const target = ref(null)
onClickOutside(
target,
() => {
if (notificationsStore().visible) {
toggleNotificationPanel()
}
},
{
ignore: ['#notifications-btn'],
}
)
function toggleNotificationPanel() {
notificationsStore().toggle()
}
function mark_as_read(comment) {
notificationsStore().mark_comment_as_read(comment)
}
function getRoute(notification) {
let params = {
leadId: notification.reference_name,
}
if (notification.route_name === 'Deal') {
params = {
dealId: notification.reference_name,
}
}
return {
name: notification.route_name,
params: params,
hash: '#' + notification.comment,
}
}
</script>

View File

@ -5,26 +5,29 @@
@click="handleClick"
>
<div
class="flex items-center duration-300 ease-in-out"
class="flex w-full justify-between items-center duration-300 ease-in-out"
:class="isCollapsed ? 'p-1' : 'px-2 py-1'"
>
<Tooltip :text="label" placement="right">
<slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component :is="icon" class="h-4 w-4 text-gray-700" />
</span>
</slot>
</Tooltip>
<span
class="flex-shrink-0 text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ label }}
</span>
<div class="flex items-center">
<Tooltip :text="label" placement="right">
<slot name="icon">
<span class="grid h-5 w-6 flex-shrink-0 place-items-center">
<component :is="icon" class="h-4 w-4 text-gray-700" />
</span>
</slot>
</Tooltip>
<span
class="flex-1 flex-shrink-0 text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ label }}
</span>
</div>
<slot name="right" />
</div>
</button>
</template>

View File

@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { createResource } from 'frappe-ui'
import { computed, ref } from 'vue'
export const notificationsStore = defineStore('crm-notifications', () => {
let visible = ref(false)
const notifications = createResource({
url: 'crm.api.notifications.get_notifications',
initialData: [],
auto: true,
})
const mark_as_read = createResource({
url: 'crm.api.notifications.mark_as_read',
auto: false,
onSuccess: () => {
mark_as_read.params = {}
notifications.reload()
},
})
function toggle() {
visible.value = !visible.value
}
const allNotifications = computed(() => notifications.data || [])
const unreadNotificationsCount = computed(
() => notifications.data?.filter((n) => !n.read).length || 0
)
function mark_comment_as_read(comment) {
mark_as_read.params = { comment: comment }
mark_as_read.reload()
toggle()
}
return {
visible,
allNotifications,
unreadNotificationsCount,
mark_as_read,
mark_comment_as_read,
toggle,
}
})