Merge pull request #63 from shariquerik/notification
feat: Notification Panel
This commit is contained in:
commit
2bceb5404a
@ -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
43
crm/api/comment.py
Normal 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
59
crm/api/notifications.py
Normal 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()
|
||||
0
crm/fcrm/doctype/crm_notification/__init__.py
Normal file
0
crm/fcrm/doctype/crm_notification/__init__.py
Normal file
8
crm/fcrm/doctype/crm_notification/crm_notification.js
Normal file
8
crm/fcrm/doctype/crm_notification/crm_notification.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
129
crm/fcrm/doctype/crm_notification/crm_notification.json
Normal file
129
crm/fcrm/doctype/crm_notification/crm_notification.json
Normal 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": []
|
||||
}
|
||||
9
crm/fcrm/doctype/crm_notification/crm_notification.py
Normal file
9
crm/fcrm/doctype/crm_notification/crm_notification.py
Normal 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
|
||||
@ -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
|
||||
@ -133,6 +133,9 @@ doc_events = {
|
||||
"ToDo": {
|
||||
"after_insert": ["crm.api.todo.after_insert"],
|
||||
},
|
||||
"Comment": {
|
||||
"on_update": ["crm.api.comment.on_update"],
|
||||
}
|
||||
}
|
||||
|
||||
# Scheduled Tasks
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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>
|
||||
|
||||
16
frontend/src/components/Icons/MarkAsDoneIcon.vue
Normal file
16
frontend/src/components/Icons/MarkAsDoneIcon.vue
Normal 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>
|
||||
26
frontend/src/components/Icons/NotificationsIcon.vue
Normal file
26
frontend/src/components/Icons/NotificationsIcon.vue
Normal 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>
|
||||
@ -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 = [
|
||||
{
|
||||
|
||||
131
frontend/src/components/Notifications.vue
Normal file
131
frontend/src/components/Notifications.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
46
frontend/src/stores/notifications.js
Normal file
46
frontend/src/stores/notifications.js
Normal 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,
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user