Merge pull request #317 from frappe/develop
chore: Merge develop to main
This commit is contained in:
commit
bc83749552
@ -2,6 +2,7 @@ from bs4 import BeautifulSoup
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe.translate import get_all_translations
|
from frappe.translate import get_all_translations
|
||||||
from frappe.utils import cstr
|
from frappe.utils import cstr
|
||||||
|
from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
@ -44,4 +45,13 @@ def get_user_signature():
|
|||||||
content = ""
|
content = ""
|
||||||
if (cstr(_signature) or signature):
|
if (cstr(_signature) or signature):
|
||||||
content = f'<br><p class="signature">{signature}</p>'
|
content = f'<br><p class="signature">{signature}</p>'
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_posthog_settings():
|
||||||
|
return {
|
||||||
|
"posthog_project_id": frappe.conf.get(POSTHOG_PROJECT_FIELD),
|
||||||
|
"posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD),
|
||||||
|
"enable_telemetry": frappe.get_system_settings("enable_telemetry"),
|
||||||
|
"telemetry_site_age": frappe.utils.telemetry.site_age(),
|
||||||
|
}
|
||||||
@ -246,8 +246,9 @@ def get_data(
|
|||||||
is_default = True
|
is_default = True
|
||||||
data = []
|
data = []
|
||||||
_list = get_controller(doctype)
|
_list = get_controller(doctype)
|
||||||
|
default_rows = []
|
||||||
if hasattr(_list, "default_list_data"):
|
if hasattr(_list, "default_list_data"):
|
||||||
rows = _list.default_list_data().get("rows")
|
default_rows = _list.default_list_data().get("rows")
|
||||||
|
|
||||||
if view_type != "kanban":
|
if view_type != "kanban":
|
||||||
if columns or rows:
|
if columns or rows:
|
||||||
@ -278,6 +279,7 @@ def get_data(
|
|||||||
rows = frappe.parse_json(list_view_settings.rows)
|
rows = frappe.parse_json(list_view_settings.rows)
|
||||||
is_default = False
|
is_default = False
|
||||||
elif not custom_view or is_default and hasattr(_list, "default_list_data"):
|
elif not custom_view or is_default and hasattr(_list, "default_list_data"):
|
||||||
|
rows = default_rows
|
||||||
columns = _list.default_list_data().get("columns")
|
columns = _list.default_list_data().get("columns")
|
||||||
|
|
||||||
# check if rows has all keys from columns if not add them
|
# check if rows has all keys from columns if not add them
|
||||||
@ -302,6 +304,9 @@ def get_data(
|
|||||||
) or []
|
) or []
|
||||||
|
|
||||||
if view_type == "kanban":
|
if view_type == "kanban":
|
||||||
|
if not rows:
|
||||||
|
rows = default_rows
|
||||||
|
|
||||||
if not kanban_columns and column_field:
|
if not kanban_columns and column_field:
|
||||||
field_meta = frappe.get_meta(doctype).get_field(column_field)
|
field_meta = frappe.get_meta(doctype).get_field(column_field)
|
||||||
if field_meta.fieldtype == "Link":
|
if field_meta.fieldtype == "Link":
|
||||||
|
|||||||
@ -149,6 +149,7 @@ class CRMLead(Document):
|
|||||||
"organization_name": self.organization,
|
"organization_name": self.organization,
|
||||||
"website": self.website,
|
"website": self.website,
|
||||||
"territory": self.territory,
|
"territory": self.territory,
|
||||||
|
"industry": self.industry,
|
||||||
"annual_revenue": self.annual_revenue,
|
"annual_revenue": self.annual_revenue,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -53,11 +53,11 @@ def create(view):
|
|||||||
def update(view):
|
def update(view):
|
||||||
view = frappe._dict(view)
|
view = frappe._dict(view)
|
||||||
|
|
||||||
filters = parse_json(view.filters) or {}
|
filters = parse_json(view.filters or {})
|
||||||
columns = parse_json(view.columns) or []
|
columns = parse_json(view.columns or [])
|
||||||
rows = parse_json(view.rows) or []
|
rows = parse_json(view.rows or [])
|
||||||
kanban_columns = parse_json(view.kanban_columns) or []
|
kanban_columns = parse_json(view.kanban_columns or [])
|
||||||
kanban_fields = parse_json(view.kanban_fields) or []
|
kanban_fields = parse_json(view.kanban_fields or [])
|
||||||
|
|
||||||
default_rows = sync_default_rows(view.doctype)
|
default_rows = sync_default_rows(view.doctype)
|
||||||
rows = rows + default_rows if default_rows else rows
|
rows = rows + default_rows if default_rows else rows
|
||||||
@ -92,6 +92,8 @@ def public(name, value):
|
|||||||
frappe.throw("Not permitted", frappe.PermissionError)
|
frappe.throw("Not permitted", frappe.PermissionError)
|
||||||
|
|
||||||
doc = frappe.get_doc("CRM View Settings", name)
|
doc = frappe.get_doc("CRM View Settings", name)
|
||||||
|
if doc.pinned:
|
||||||
|
doc.pinned = False
|
||||||
doc.public = value
|
doc.public = value
|
||||||
doc.user = "" if value else frappe.session.user
|
doc.user = "" if value else frappe.session.user
|
||||||
doc.save()
|
doc.save()
|
||||||
|
|||||||
14
crm/hooks.py
14
crm/hooks.py
@ -4,11 +4,23 @@ app_publisher = "Frappe Technologies Pvt. Ltd."
|
|||||||
app_description = "Kick-ass Open Source CRM"
|
app_description = "Kick-ass Open Source CRM"
|
||||||
app_email = "shariq@frappe.io"
|
app_email = "shariq@frappe.io"
|
||||||
app_license = "AGPLv3"
|
app_license = "AGPLv3"
|
||||||
app_icon_url = ""
|
app_icon_url = "/assets/crm/manifest/apple-icon-180.png"
|
||||||
app_icon_title = "CRM"
|
app_icon_title = "CRM"
|
||||||
app_icon_route = "/crm"
|
app_icon_route = "/crm"
|
||||||
|
|
||||||
|
# Apps
|
||||||
|
# ------------------
|
||||||
|
|
||||||
# required_apps = []
|
# required_apps = []
|
||||||
|
add_to_apps_screen = [
|
||||||
|
{
|
||||||
|
"name": "crm",
|
||||||
|
"logo": "/assets/crm/manifest/apple-icon-180.png",
|
||||||
|
"title": "CRM",
|
||||||
|
"route": "/crm",
|
||||||
|
# "has_permission": "crm.api.permission.has_app_permission"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
# Includes in <head>
|
# Includes in <head>
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|||||||
BIN
crm/public/images/desk.png
Normal file
BIN
crm/public/images/desk.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@ -1 +1 @@
|
|||||||
Subproject commit b5cc6c0cd36ee25e8e945e8c91d8b5c035fb5e44
|
Subproject commit cf4e7d347237c23ebde5f5c890abdf7e81284961
|
||||||
@ -14,7 +14,7 @@
|
|||||||
"@vueuse/core": "^10.3.0",
|
"@vueuse/core": "^10.3.0",
|
||||||
"@vueuse/integrations": "^10.3.0",
|
"@vueuse/integrations": "^10.3.0",
|
||||||
"feather-icons": "^4.28.0",
|
"feather-icons": "^4.28.0",
|
||||||
"frappe-ui": "^0.1.63",
|
"frappe-ui": "^0.1.66",
|
||||||
"gemoji": "^8.1.0",
|
"gemoji": "^8.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime": "^4.0.1",
|
"mime": "^4.0.1",
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view v-if="$route.name == 'Login'" />
|
<Layout v-if="session().isLoggedIn">
|
||||||
<Layout v-else-if="session().isLoggedIn">
|
|
||||||
<router-view />
|
<router-view />
|
||||||
</Layout>
|
</Layout>
|
||||||
<Dialogs />
|
<Dialogs />
|
||||||
|
|||||||
@ -437,6 +437,7 @@ import { globalStore } from '@/stores/global'
|
|||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
import { whatsappEnabled } from '@/composables/settings'
|
import { whatsappEnabled } from '@/composables/settings'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Button, Tooltip, createResource } from 'frappe-ui'
|
import { Button, Tooltip, createResource } from 'frappe-ui'
|
||||||
import { useElementVisibility } from '@vueuse/core'
|
import { useElementVisibility } from '@vueuse/core'
|
||||||
import {
|
import {
|
||||||
@ -552,6 +553,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
function sendTemplate(template) {
|
function sendTemplate(template) {
|
||||||
showWhatsappTemplates.value = false
|
showWhatsappTemplates.value = false
|
||||||
|
capture('send_whatsapp_template', { doctype: props.doctype })
|
||||||
createResource({
|
createResource({
|
||||||
url: 'crm.api.whatsapp.send_whatsapp_template',
|
url: 'crm.api.whatsapp.send_whatsapp_template',
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@ -170,9 +170,9 @@ import DoubleCheckIcon from '@/components/Icons/DoubleCheckIcon.vue'
|
|||||||
import DocumentIcon from '@/components/Icons/DocumentIcon.vue'
|
import DocumentIcon from '@/components/Icons/DocumentIcon.vue'
|
||||||
import ReactIcon from '@/components/Icons/ReactIcon.vue'
|
import ReactIcon from '@/components/Icons/ReactIcon.vue'
|
||||||
import { dateFormat } from '@/utils'
|
import { dateFormat } from '@/utils'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Tooltip, Dropdown, createResource } from 'frappe-ui'
|
import { Tooltip, Dropdown, createResource } from 'frappe-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messages: Array,
|
messages: Array,
|
||||||
@ -219,6 +219,7 @@ function reactOnMessage(name, emoji) {
|
|||||||
},
|
},
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
capture('whatsapp_react_on_message')
|
||||||
list.value.reload()
|
list.value.reload()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
() => {
|
() => {
|
||||||
content += emoji
|
content += emoji
|
||||||
$refs.textarea.$el.focus()
|
$refs.textarea.$el.focus()
|
||||||
|
capture('whatsapp_emoji_added')
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@ -65,8 +66,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import IconPicker from '@/components/IconPicker.vue'
|
import IconPicker from '@/components/IconPicker.vue'
|
||||||
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
import SmileIcon from '@/components/Icons/SmileIcon.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { createResource, Textarea, FileUploader, Dropdown } from 'frappe-ui'
|
import { createResource, Textarea, FileUploader, Dropdown } from 'frappe-ui'
|
||||||
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
|
|
||||||
import { ref, nextTick, watch } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -92,6 +93,7 @@ function uploadFile(file) {
|
|||||||
whatsapp.value.attach = file.file_url
|
whatsapp.value.attach = file.file_url
|
||||||
whatsapp.value.content_type = fileType.value
|
whatsapp.value.content_type = fileType.value
|
||||||
sendWhatsAppMessage()
|
sendWhatsAppMessage()
|
||||||
|
capture('whatsapp_upload_file')
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendTextMessage(event) {
|
function sendTextMessage(event) {
|
||||||
@ -99,6 +101,7 @@ function sendTextMessage(event) {
|
|||||||
sendWhatsAppMessage()
|
sendWhatsAppMessage()
|
||||||
textarea.value.$el.blur()
|
textarea.value.$el.blur()
|
||||||
content.value = ''
|
content.value = ''
|
||||||
|
capture('whatsapp_send_message')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendWhatsAppMessage() {
|
async function sendWhatsAppMessage() {
|
||||||
|
|||||||
79
frontend/src/components/Apps.vue
Normal file
79
frontend/src/components/Apps.vue
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="right-start" class="flex w-full">
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
active ? 'bg-gray-100' : 'text-gray-800',
|
||||||
|
'group w-full flex h-7 items-center justify-between rounded px-2 text-base hover:bg-gray-100',
|
||||||
|
]"
|
||||||
|
@click.prevent="togglePopover()"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<AppsIcon class="size-4" />
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{{ __('Apps') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FeatherIcon name="chevron-right" class="size-4 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||||
|
>
|
||||||
|
<div v-for="app in apps.data" :key="app.name">
|
||||||
|
<a
|
||||||
|
:href="app.route"
|
||||||
|
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<img class="size-8" :src="app.logo" />
|
||||||
|
<div class="text-sm text-gray-700" @click="app.onClick">
|
||||||
|
{{ app.title }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import AppsIcon from '@/components/Icons/AppsIcon.vue'
|
||||||
|
import { Popover, createResource } from 'frappe-ui'
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
import { stopRecording } from '@/telemetry';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
active: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
const apps = createResource({
|
||||||
|
url: 'frappe.apps.get_apps',
|
||||||
|
cache: 'apps',
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
let _apps = [
|
||||||
|
{
|
||||||
|
name: 'frappe',
|
||||||
|
logo: '/assets/frappe/images/framework.png',
|
||||||
|
title: __('Desk'),
|
||||||
|
route: '/app',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
data.map((app) => {
|
||||||
|
if (app.name === 'crm') return
|
||||||
|
_apps.push({
|
||||||
|
name: app.name,
|
||||||
|
logo: app.logo,
|
||||||
|
title: __(app.title),
|
||||||
|
route: app.route,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return _apps
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopRecording()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -197,6 +197,7 @@ import { Device } from '@twilio/voice-sdk'
|
|||||||
import { useDraggable, useWindowSize } from '@vueuse/core'
|
import { useDraggable, useWindowSize } from '@vueuse/core'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { contactsStore } from '@/stores/contacts'
|
import { contactsStore } from '@/stores/contacts'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Avatar, call } from 'frappe-ui'
|
import { Avatar, call } from 'frappe-ui'
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
@ -403,6 +404,8 @@ async function makeOutgoingCall(number) {
|
|||||||
showCallPopup.value = true
|
showCallPopup.value = true
|
||||||
callStatus.value = 'initiating'
|
callStatus.value = 'initiating'
|
||||||
|
|
||||||
|
capture('make_outgoing_call')
|
||||||
|
|
||||||
_call.on('messageReceived', (message) => {
|
_call.on('messageReceived', (message) => {
|
||||||
let info = message.content
|
let info = message.content
|
||||||
callStatus.value = info.CallStatus
|
callStatus.value = info.CallStatus
|
||||||
|
|||||||
@ -92,6 +92,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
|
|||||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
|
import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
import { ref, computed, defineModel } from 'vue'
|
import { ref, computed, defineModel } from 'vue'
|
||||||
|
|
||||||
@ -139,6 +140,7 @@ function appendEmoji() {
|
|||||||
editor.value.commands.insertContent(emoji.value)
|
editor.value.commands.insertContent(emoji.value)
|
||||||
editor.value.commands.focus()
|
editor.value.commands.focus()
|
||||||
emoji.value = ''
|
emoji.value = ''
|
||||||
|
capture('emoji_inserted_in_comment', { emoji: emoji.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAttachment(attachment) {
|
function removeAttachment(attachment) {
|
||||||
|
|||||||
@ -88,6 +88,7 @@ import EmailEditor from '@/components/EmailEditor.vue'
|
|||||||
import CommentBox from '@/components/CommentBox.vue'
|
import CommentBox from '@/components/CommentBox.vue'
|
||||||
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
import CommentIcon from '@/components/Icons/CommentIcon.vue'
|
||||||
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
import Email2Icon from '@/components/Icons/Email2Icon.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { call, createResource } from 'frappe-ui'
|
import { call, createResource } from 'frappe-ui'
|
||||||
@ -176,6 +177,10 @@ async function sendMail() {
|
|||||||
let subject = newEmailEditor.value.subject
|
let subject = newEmailEditor.value.subject
|
||||||
let cc = newEmailEditor.value.ccEmails || []
|
let cc = newEmailEditor.value.ccEmails || []
|
||||||
let bcc = newEmailEditor.value.bccEmails || []
|
let bcc = newEmailEditor.value.bccEmails || []
|
||||||
|
|
||||||
|
if (attachments.value.length) {
|
||||||
|
capture('email_attachments_added')
|
||||||
|
}
|
||||||
await call('frappe.core.doctype.communication.email.make', {
|
await call('frappe.core.doctype.communication.email.make', {
|
||||||
recipients: recipients.join(', '),
|
recipients: recipients.join(', '),
|
||||||
attachments: attachments.value.map((x) => x.name),
|
attachments: attachments.value.map((x) => x.name),
|
||||||
@ -200,6 +205,7 @@ async function sendComment() {
|
|||||||
comment_by: getUser()?.full_name || undefined,
|
comment_by: getUser()?.full_name || undefined,
|
||||||
})
|
})
|
||||||
if (comment && attachments.value.length) {
|
if (comment && attachments.value.length) {
|
||||||
|
capture('comment_attachments_added')
|
||||||
await call('crm.api.comment.add_attachments', {
|
await call('crm.api.comment.add_attachments', {
|
||||||
name: comment.name,
|
name: comment.name,
|
||||||
attachments: attachments.value.map((x) => x.name),
|
attachments: attachments.value.map((x) => x.name),
|
||||||
@ -214,6 +220,7 @@ async function submitEmail() {
|
|||||||
newEmail.value = ''
|
newEmail.value = ''
|
||||||
reload.value = true
|
reload.value = true
|
||||||
emit('scroll')
|
emit('scroll')
|
||||||
|
capture('email_sent', { doctype: props.doctype })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitComment() {
|
async function submitComment() {
|
||||||
@ -223,6 +230,7 @@ async function submitComment() {
|
|||||||
newComment.value = ''
|
newComment.value = ''
|
||||||
reload.value = true
|
reload.value = true
|
||||||
emit('scroll')
|
emit('scroll')
|
||||||
|
capture('comment_sent', { doctype: props.doctype })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleEmailBox() {
|
function toggleEmailBox() {
|
||||||
|
|||||||
@ -175,6 +175,7 @@ import AttachmentItem from '@/components/AttachmentItem.vue'
|
|||||||
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
|
import MultiselectInput from '@/components/Controls/MultiselectInput.vue'
|
||||||
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
|
||||||
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { validateEmail } from '@/utils'
|
import { validateEmail } from '@/utils'
|
||||||
import Paragraph from '@tiptap/extension-paragraph'
|
import Paragraph from '@tiptap/extension-paragraph'
|
||||||
import { EditorContent } from '@tiptap/vue-3'
|
import { EditorContent } from '@tiptap/vue-3'
|
||||||
@ -273,12 +274,14 @@ async function applyEmailTemplate(template) {
|
|||||||
editor.value.commands.setContent(data.message)
|
editor.value.commands.setContent(data.message)
|
||||||
}
|
}
|
||||||
showEmailTemplateSelectorModal.value = false
|
showEmailTemplateSelectorModal.value = false
|
||||||
|
capture('email_template_applied', { doctype: props.doctype })
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendEmoji() {
|
function appendEmoji() {
|
||||||
editor.value.commands.insertContent(emoji.value)
|
editor.value.commands.insertContent(emoji.value)
|
||||||
editor.value.commands.focus()
|
editor.value.commands.focus()
|
||||||
emoji.value = ''
|
emoji.value = ''
|
||||||
|
capture('emoji_inserted_in_email', { emoji: emoji.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCC() {
|
function toggleCC() {
|
||||||
|
|||||||
17
frontend/src/components/Icons/AppsIcon.vue
Normal file
17
frontend/src/components/Icons/AppsIcon.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
width="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
class=""
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M13.8496 4.69692L12.0062 6.54029C11.8109 6.73555 11.4944 6.73555 11.2991 6.54028L9.45572 4.69692C9.26046 4.50166 9.26046 4.18508 9.45572 3.98981L11.2991 2.14645C11.4944 1.95118 11.8109 1.95118 12.0062 2.14645L13.8496 3.98981C14.0448 4.18507 14.0448 4.50166 13.8496 4.69692ZM14.5567 3.28271C15.1425 3.86849 15.1425 4.81824 14.5567 5.40403L12.7133 7.24739C12.1275 7.83318 11.1778 7.83318 10.592 7.24739L8.74862 5.40402C8.16283 4.81824 8.16283 3.86849 8.74862 3.28271L10.592 1.43934C11.1778 0.853553 12.1275 0.853554 12.7133 1.43934L14.5567 3.28271ZM5.60691 4.34338C5.60691 5.3394 4.79948 6.14683 3.80346 6.14683C2.80743 6.14683 2 5.3394 2 4.34338C2 3.34736 2.80743 2.53992 3.80346 2.53992C4.79948 2.53992 5.60691 3.34736 5.60691 4.34338ZM6.60691 4.34338C6.60691 5.89168 5.35176 7.14683 3.80346 7.14683C2.25515 7.14683 1 5.89168 1 4.34338C1 2.79507 2.25515 1.53992 3.80346 1.53992C5.35176 1.53992 6.60691 2.79507 6.60691 4.34338ZM12.9565 10.3897H10.3495C10.0734 10.3897 9.84954 10.6136 9.84954 10.8897V13.4966C9.84954 13.7728 10.0734 13.9966 10.3495 13.9966H12.9565C13.2326 13.9966 13.4565 13.7728 13.4565 13.4966V10.8897C13.4565 10.6136 13.2326 10.3897 12.9565 10.3897ZM10.3495 9.38971C9.52112 9.38971 8.84954 10.0613 8.84954 10.8897V13.4966C8.84954 14.325 9.52111 14.9966 10.3495 14.9966H12.9565C13.7849 14.9966 14.4565 14.325 14.4565 13.4966V10.8897C14.4565 10.0613 13.7849 9.38971 12.9565 9.38971H10.3495ZM2.5 10.3897H5.10691C5.38305 10.3897 5.60691 10.6136 5.60691 10.8897V13.4966C5.60691 13.7728 5.38306 13.9966 5.10691 13.9966H2.5C2.22386 13.9966 2 13.7728 2 13.4966V10.8897C2 10.6136 2.22386 10.3897 2.5 10.3897ZM1 10.8897C1 10.0613 1.67157 9.38971 2.5 9.38971H5.10691C5.93534 9.38971 6.60691 10.0613 6.60691 10.8897V13.4966C6.60691 14.325 5.93534 14.9966 5.10691 14.9966H2.5C1.67157 14.9966 1 14.325 1 13.4966V10.8897Z"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -21,6 +21,7 @@ import EditValueModal from '@/components/Modals/EditValueModal.vue'
|
|||||||
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
import AssignmentModal from '@/components/Modals/AssignmentModal.vue'
|
||||||
import { setupListActions, createToast } from '@/utils'
|
import { setupListActions, createToast } from '@/utils'
|
||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { call } from 'frappe-ui'
|
import { call } from 'frappe-ui'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -56,6 +57,40 @@ function editValues(selections, unselectAll) {
|
|||||||
unselectAllAction.value = unselectAll
|
unselectAllAction.value = unselectAll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertToDeal(selections, unselectAll) {
|
||||||
|
$dialog({
|
||||||
|
title: __('Convert to Deal'),
|
||||||
|
message: __('Are you sure you want to convert {0} Lead(s) to Deal(s)?', [
|
||||||
|
selections.size,
|
||||||
|
]),
|
||||||
|
variant: 'solid',
|
||||||
|
theme: 'blue',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: __('Convert'),
|
||||||
|
variant: 'solid',
|
||||||
|
onClick: (close) => {
|
||||||
|
capture('bulk_convert_to_deal')
|
||||||
|
Array.from(selections).forEach((name) => {
|
||||||
|
call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', {
|
||||||
|
lead: name,
|
||||||
|
}).then(() => {
|
||||||
|
createToast({
|
||||||
|
title: __('Converted successfully'),
|
||||||
|
icon: 'check',
|
||||||
|
iconClasses: 'text-green-600',
|
||||||
|
})
|
||||||
|
list.value.reload()
|
||||||
|
unselectAll()
|
||||||
|
close()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function deleteValues(selections, unselectAll) {
|
function deleteValues(selections, unselectAll) {
|
||||||
$dialog({
|
$dialog({
|
||||||
title: __('Delete'),
|
title: __('Delete'),
|
||||||
@ -70,6 +105,7 @@ function deleteValues(selections, unselectAll) {
|
|||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
theme: 'red',
|
theme: 'red',
|
||||||
onClick: (close) => {
|
onClick: (close) => {
|
||||||
|
capture('bulk_delete')
|
||||||
call('frappe.desk.reportview.delete_items', {
|
call('frappe.desk.reportview.delete_items', {
|
||||||
items: JSON.stringify(Array.from(selections)),
|
items: JSON.stringify(Array.from(selections)),
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
@ -112,6 +148,7 @@ function clearAssignemnts(selections, unselectAll) {
|
|||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
theme: 'red',
|
theme: 'red',
|
||||||
onClick: (close) => {
|
onClick: (close) => {
|
||||||
|
capture('bulk_clear_assignment')
|
||||||
call('frappe.desk.form.assign_to.remove_multiple', {
|
call('frappe.desk.form.assign_to.remove_multiple', {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
names: JSON.stringify(Array.from(selections)),
|
names: JSON.stringify(Array.from(selections)),
|
||||||
@ -162,6 +199,13 @@ function bulkActions(selections, unselectAll) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.doctype === 'CRM Lead') {
|
||||||
|
actions.push({
|
||||||
|
label: __('Convert to Deal'),
|
||||||
|
onClick: () => convertToDeal(selections, unselectAll),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
customBulkActions.value.forEach((action) => {
|
customBulkActions.value.forEach((action) => {
|
||||||
actions.push({
|
actions.push({
|
||||||
label: __(action.label),
|
label: __(action.label),
|
||||||
|
|||||||
@ -85,6 +85,11 @@
|
|||||||
<div>{{ item.timeAgo }}</div>
|
<div>{{ item.timeAgo }}</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="column.type === 'Text Editor'"
|
||||||
|
v-html="item"
|
||||||
|
class="truncate text-base h-4 [&>p]:truncate"
|
||||||
|
/>
|
||||||
<div v-else-if="column.type === 'Check'">
|
<div v-else-if="column.type === 'Check'">
|
||||||
<FormControl
|
<FormControl
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -233,7 +238,7 @@ const listBulkActionsRef = ref(null)
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
customListActions: computed(
|
customListActions: computed(
|
||||||
() => listBulkActionsRef.value?.customListActions
|
() => listBulkActionsRef.value?.customListActions,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
label: __('Cancel'),
|
label: __('Cancel'),
|
||||||
variant: 'subtle',
|
variant: 'subtle',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
assignees = oldAssignees
|
assignees = [...oldAssignees]
|
||||||
show = false
|
show = false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -20,6 +20,11 @@
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}"
|
}"
|
||||||
|
@close="
|
||||||
|
() => {
|
||||||
|
assignees = [...oldAssignees]
|
||||||
|
}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<Link
|
<Link
|
||||||
@ -75,9 +80,9 @@
|
|||||||
import UserAvatar from '@/components/UserAvatar.vue'
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Tooltip, call } from 'frappe-ui'
|
import { Tooltip, call } from 'frappe-ui'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { watchOnce } from '@vueuse/core'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
doc: {
|
doc: {
|
||||||
@ -106,7 +111,7 @@ const { getUser } = usersStore()
|
|||||||
|
|
||||||
const removeValue = (value) => {
|
const removeValue = (value) => {
|
||||||
assignees.value = assignees.value.filter(
|
assignees.value = assignees.value.filter(
|
||||||
(assignee) => assignee.name !== value
|
(assignee) => assignee.name !== value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,13 +140,13 @@ function updateAssignees() {
|
|||||||
}
|
}
|
||||||
const removedAssignees = oldAssignees.value
|
const removedAssignees = oldAssignees.value
|
||||||
.filter(
|
.filter(
|
||||||
(assignee) => !assignees.value.find((a) => a.name === assignee.name)
|
(assignee) => !assignees.value.find((a) => a.name === assignee.name),
|
||||||
)
|
)
|
||||||
.map((assignee) => assignee.name)
|
.map((assignee) => assignee.name)
|
||||||
|
|
||||||
const addedAssignees = assignees.value
|
const addedAssignees = assignees.value
|
||||||
.filter(
|
.filter(
|
||||||
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name)
|
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name),
|
||||||
)
|
)
|
||||||
.map((assignee) => assignee.name)
|
.map((assignee) => assignee.name)
|
||||||
|
|
||||||
@ -157,6 +162,7 @@ function updateAssignees() {
|
|||||||
|
|
||||||
if (addedAssignees.length) {
|
if (addedAssignees.length) {
|
||||||
if (props.docs.size) {
|
if (props.docs.size) {
|
||||||
|
capture('bulk_assign_to', { doctype: props.doctype })
|
||||||
call('frappe.desk.form.assign_to.add_multiple', {
|
call('frappe.desk.form.assign_to.add_multiple', {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
name: JSON.stringify(Array.from(props.docs)),
|
name: JSON.stringify(Array.from(props.docs)),
|
||||||
@ -167,6 +173,7 @@ function updateAssignees() {
|
|||||||
emit('reload')
|
emit('reload')
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
capture('assign_to', { doctype: props.doctype })
|
||||||
call('frappe.desk.form.assign_to.add', {
|
call('frappe.desk.form.assign_to.add', {
|
||||||
doctype: props.doctype,
|
doctype: props.doctype,
|
||||||
name: props.doc.name,
|
name: props.doc.name,
|
||||||
@ -177,7 +184,7 @@ function updateAssignees() {
|
|||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watchOnce(assignees, (value) => {
|
onMounted(() => {
|
||||||
oldAssignees.value = [...value]
|
oldAssignees.value = [...assignees.value]
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -92,6 +92,7 @@ import CertificateIcon from '@/components/Icons/CertificateIcon.vue'
|
|||||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { call, createResource } from 'frappe-ui'
|
import { call, createResource } from 'frappe-ui'
|
||||||
import { ref, nextTick, watch, computed } from 'vue'
|
import { ref, nextTick, watch, computed } from 'vue'
|
||||||
import { createToast } from '@/utils'
|
import { createToast } from '@/utils'
|
||||||
@ -160,7 +161,10 @@ async function callInsertDoc() {
|
|||||||
..._contact.value,
|
..._contact.value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
doc.name && handleContactUpdate(doc)
|
if (doc.name) {
|
||||||
|
capture('contact_created')
|
||||||
|
handleContactUpdate(doc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContactUpdate(doc) {
|
function handleContactUpdate(doc) {
|
||||||
|
|||||||
@ -61,6 +61,7 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
|||||||
import Fields from '@/components/Fields.vue'
|
import Fields from '@/components/Fields.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Switch, createResource } from 'frappe-ui'
|
import { Switch, createResource } from 'frappe-ui'
|
||||||
import { computed, ref, reactive, onMounted, nextTick } from 'vue'
|
import { computed, ref, reactive, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -201,6 +202,7 @@ function createDeal() {
|
|||||||
isDealCreating.value = true
|
isDealCreating.value = true
|
||||||
},
|
},
|
||||||
onSuccess(name) {
|
onSuccess(name) {
|
||||||
|
capture('deal_created')
|
||||||
isDealCreating.value = false
|
isDealCreating.value = false
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({ name: 'Deal', params: { dealId: name } })
|
router.push({ name: 'Deal', params: { dealId: name } })
|
||||||
@ -230,5 +232,8 @@ onMounted(() => {
|
|||||||
if (!deal.deal_owner) {
|
if (!deal.deal_owner) {
|
||||||
deal.deal_owner = getUser().name
|
deal.deal_owner = getUser().name
|
||||||
}
|
}
|
||||||
|
if (!deal.status && dealStatuses.value[0].value) {
|
||||||
|
deal.status = dealStatuses.value[0].value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { FormControl, call, createResource, TextEditor, DatePicker } from 'frappe-ui'
|
import { FormControl, call, createResource, TextEditor, DatePicker } from 'frappe-ui'
|
||||||
import { ref, computed, onMounted, h } from 'vue'
|
import { ref, computed, onMounted, h } from 'vue'
|
||||||
|
|
||||||
@ -115,6 +116,7 @@ function updateValues() {
|
|||||||
newValue.value = ''
|
newValue.value = ''
|
||||||
loading.value = false
|
loading.value = false
|
||||||
show.value = false
|
show.value = false
|
||||||
|
capture('bulk_update', { doctype: props.doctype })
|
||||||
emit('reload')
|
emit('reload')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -96,6 +96,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Checkbox, Select, TextEditor, call } from 'frappe-ui'
|
import { Checkbox, Select, TextEditor, call } from 'frappe-ui'
|
||||||
import { ref, nextTick, watch } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
|
||||||
@ -171,7 +172,10 @@ async function callInsertDoc() {
|
|||||||
..._emailTemplate.value,
|
..._emailTemplate.value,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
doc.name && handleEmailTemplateUpdate(doc)
|
if (doc.name) {
|
||||||
|
capture('email_template_created', { doctype: doc.reference_doctype })
|
||||||
|
handleEmailTemplateUpdate(doc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEmailTemplateUpdate(doc) {
|
function handleEmailTemplateUpdate(doc) {
|
||||||
|
|||||||
@ -46,6 +46,7 @@ import EditIcon from '@/components/Icons/EditIcon.vue'
|
|||||||
import Fields from '@/components/Fields.vue'
|
import Fields from '@/components/Fields.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
import { computed, onMounted, ref, reactive, nextTick } from 'vue'
|
import { computed, onMounted, ref, reactive, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -153,6 +154,7 @@ function createNewLead() {
|
|||||||
isLeadCreating.value = true
|
isLeadCreating.value = true
|
||||||
},
|
},
|
||||||
onSuccess(data) {
|
onSuccess(data) {
|
||||||
|
capture('lead_created')
|
||||||
isLeadCreating.value = false
|
isLeadCreating.value = false
|
||||||
show.value = false
|
show.value = false
|
||||||
router.push({ name: 'Lead', params: { leadId: data.name } })
|
router.push({ name: 'Lead', params: { leadId: data.name } })
|
||||||
@ -182,5 +184,8 @@ onMounted(() => {
|
|||||||
if (!lead.lead_owner) {
|
if (!lead.lead_owner) {
|
||||||
lead.lead_owner = getUser().name
|
lead.lead_owner = getUser().name
|
||||||
}
|
}
|
||||||
|
if (!lead.status && leadStatuses.value[0].value) {
|
||||||
|
lead.status = leadStatuses.value[0].value
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -66,6 +66,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { TextEditor, call } from 'frappe-ui'
|
import { TextEditor, call } from 'frappe-ui'
|
||||||
import { ref, nextTick, watch } from 'vue'
|
import { ref, nextTick, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -124,6 +125,7 @@ async function updateNote() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (d.name) {
|
if (d.name) {
|
||||||
|
capture('note_created')
|
||||||
notes.value?.reload()
|
notes.value?.reload()
|
||||||
emit('after', d, true)
|
emit('after', d, true)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,7 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
|
|||||||
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
|
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { formatNumberIntoCurrency } from '@/utils'
|
import { formatNumberIntoCurrency } from '@/utils'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { call, FeatherIcon, createResource } from 'frappe-ui'
|
import { call, FeatherIcon, createResource } from 'frappe-ui'
|
||||||
import { ref, nextTick, watch, computed, h } from 'vue'
|
import { ref, nextTick, watch, computed, h } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -157,7 +158,10 @@ async function callInsertDoc() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
loading.value = false
|
loading.value = false
|
||||||
doc.name && handleOrganizationUpdate(doc)
|
if (doc.name) {
|
||||||
|
capture('organization_created')
|
||||||
|
handleOrganizationUpdate(doc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOrganizationUpdate(doc, renamed = false) {
|
function handleOrganizationUpdate(doc, renamed = false) {
|
||||||
|
|||||||
@ -118,6 +118,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
|
|||||||
import Link from '@/components/Controls/Link.vue'
|
import Link from '@/components/Controls/Link.vue'
|
||||||
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
|
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { TextEditor, Dropdown, Tooltip, call, DateTimePicker } from 'frappe-ui'
|
import { TextEditor, Dropdown, Tooltip, call, DateTimePicker } from 'frappe-ui'
|
||||||
import { ref, watch, nextTick, onMounted } from 'vue'
|
import { ref, watch, nextTick, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -199,6 +200,7 @@ async function updateTask() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (d.name) {
|
if (d.name) {
|
||||||
|
capture('task_created')
|
||||||
tasks.value.reload()
|
tasks.value.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,7 +75,6 @@ const templates = createListResource({
|
|||||||
filters: { status: 'APPROVED', for_doctype: ['in', [props.doctype, '']] },
|
filters: { status: 'APPROVED', for_doctype: ['in', [props.doctype, '']] },
|
||||||
orderBy: 'modified desc',
|
orderBy: 'modified desc',
|
||||||
pageLength: 99999,
|
pageLength: 99999,
|
||||||
auto: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -18,10 +18,7 @@
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<Tooltip :text="__('Mark all as read')">
|
<Tooltip :text="__('Mark all as read')">
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button variant="ghost" @click="() => markAllAsRead()">
|
||||||
variant="ghost"
|
|
||||||
@click="() => notificationsStore().mark_as_read.reload()"
|
|
||||||
>
|
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<MarkAsDoneIcon class="h-4 w-4" />
|
<MarkAsDoneIcon class="h-4 w-4" />
|
||||||
</template>
|
</template>
|
||||||
@ -48,7 +45,7 @@
|
|||||||
:key="n.comment"
|
:key="n.comment"
|
||||||
:to="getRoute(n)"
|
:to="getRoute(n)"
|
||||||
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
|
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
|
||||||
@click="mark_as_read(n.comment || n.notification_type_doc)"
|
@click="markAsRead(n.comment || n.notification_type_doc)"
|
||||||
>
|
>
|
||||||
<div class="mt-1 flex items-center gap-2.5">
|
<div class="mt-1 flex items-center gap-2.5">
|
||||||
<div
|
<div
|
||||||
@ -98,6 +95,7 @@ import { notificationsStore } from '@/stores/notifications'
|
|||||||
import { globalStore } from '@/stores/global'
|
import { globalStore } from '@/stores/global'
|
||||||
import { timeAgo } from '@/utils'
|
import { timeAgo } from '@/utils'
|
||||||
import { onClickOutside } from '@vueuse/core'
|
import { onClickOutside } from '@vueuse/core'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { Tooltip } from 'frappe-ui'
|
import { Tooltip } from 'frappe-ui'
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
@ -113,17 +111,23 @@ onClickOutside(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignore: ['#notifications-btn'],
|
ignore: ['#notifications-btn'],
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleNotificationPanel() {
|
function toggleNotificationPanel() {
|
||||||
notificationsStore().toggle()
|
notificationsStore().toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
function mark_as_read(doc) {
|
function markAsRead(doc) {
|
||||||
|
capture('notification_mark_as_read')
|
||||||
notificationsStore().mark_doc_as_read(doc)
|
notificationsStore().mark_doc_as_read(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markAllAsRead() {
|
||||||
|
capture('notification_mark_all_as_read')
|
||||||
|
notificationsStore().mark_as_read.reload()
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
$socket.off('crm_notification')
|
$socket.off('crm_notification')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
:key="s.label"
|
:key="s.label"
|
||||||
class="flex items-center gap-2 text-base leading-5"
|
class="flex items-center gap-2 text-base leading-5"
|
||||||
>
|
>
|
||||||
<div class="sm:w-[106px] w-36 text-sm text-gray-600">{{ __(s.label) }}</div>
|
<div class="sm:w-[106px] w-36 text-sm text-gray-600">
|
||||||
|
{{ __(s.label) }}
|
||||||
|
</div>
|
||||||
<div class="grid min-h-[28px] items-center">
|
<div class="grid min-h-[28px] items-center">
|
||||||
<Tooltip v-if="s.tooltipText" :text="__(s.tooltipText)">
|
<Tooltip v-if="s.tooltipText" :text="__(s.tooltipText)">
|
||||||
<div class="ml-2 cursor-pointer">
|
<div class="ml-2 cursor-pointer">
|
||||||
@ -43,6 +45,7 @@
|
|||||||
import { Dropdown, Tooltip } from 'frappe-ui'
|
import { Dropdown, Tooltip } from 'frappe-ui'
|
||||||
import { timeAgo, dateFormat, formatTime, dateTooltipFormat } from '@/utils'
|
import { timeAgo, dateFormat, formatTime, dateTooltipFormat } from '@/utils'
|
||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { computed, defineModel } from 'vue'
|
import { computed, defineModel } from 'vue'
|
||||||
|
|
||||||
const data = defineModel()
|
const data = defineModel()
|
||||||
@ -58,8 +61,8 @@ let slaSection = computed(() => {
|
|||||||
data.value.sla_status == 'Failed'
|
data.value.sla_status == 'Failed'
|
||||||
? 'red'
|
? 'red'
|
||||||
: data.value.sla_status == 'Fulfilled'
|
: data.value.sla_status == 'Fulfilled'
|
||||||
? 'green'
|
? 'green'
|
||||||
: 'orange'
|
: 'orange'
|
||||||
|
|
||||||
if (status == 'First Response Due') {
|
if (status == 'First Response Due') {
|
||||||
status = timeAgo(data.value.response_by)
|
status = timeAgo(data.value.response_by)
|
||||||
@ -94,11 +97,13 @@ let slaSection = computed(() => {
|
|||||||
options: communicationStatuses.data?.map((status) => ({
|
options: communicationStatuses.data?.map((status) => ({
|
||||||
label: status.name,
|
label: status.name,
|
||||||
value: status.name,
|
value: status.name,
|
||||||
onClick: () =>
|
onClick: () => {
|
||||||
emit('updateField', 'communication_status', status.name),
|
capture('sla_status_change')
|
||||||
|
emit('updateField', 'communication_status', status.name)
|
||||||
|
},
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
return sections
|
return sections
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Draggable :list="sections" item-key="label" class="flex flex-col">
|
<Draggable :list="sections" item-key="label" class="flex flex-col gap-5.5">
|
||||||
<template #item="{ element: section }">
|
<template #item="{ element: section }">
|
||||||
<div
|
<div class="flex flex-col gap-1.5 p-2.5 bg-gray-50 rounded">
|
||||||
class="py-2 first:pt-0"
|
<div class="flex items-center justify-between">
|
||||||
:class="section.hideBorder ? '' : 'border-t first:border-t-0'"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between pb-2">
|
|
||||||
<div
|
<div
|
||||||
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 text-base font-semibold leading-5"
|
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 text-base font-medium leading-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="!section.editingLabel"
|
v-if="!section.editingLabel"
|
||||||
@ -39,81 +36,71 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Draggable
|
||||||
<Draggable
|
:list="section.fields"
|
||||||
:list="section.fields"
|
group="fields"
|
||||||
group="fields"
|
item-key="label"
|
||||||
item-key="label"
|
class="grid gap-1.5"
|
||||||
class="grid gap-2"
|
:class="
|
||||||
:class="
|
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
|
||||||
section.columns ? 'grid-cols-' + section.columns : 'grid-cols-3'
|
"
|
||||||
"
|
handle=".cursor-grab"
|
||||||
handle=".cursor-grab"
|
>
|
||||||
>
|
<template #item="{ element: field }">
|
||||||
<template #item="{ element: field }">
|
<div
|
||||||
<div
|
class="px-2.5 py-2 border rounded text-base bg-white text-gray-800 flex items-center leading-4 justify-between gap-2"
|
||||||
class="px-1.5 py-1 border rounded text-base text-gray-800 flex items-center justify-between gap-2"
|
>
|
||||||
>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
<div>{{ field.label }}</div>
|
||||||
<div>{{ field.label }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon="x"
|
|
||||||
@click="
|
|
||||||
section.fields.splice(section.fields.indexOf(field), 1)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
</Draggable>
|
variant="ghost"
|
||||||
<Autocomplete
|
class="!size-4 rounded-sm"
|
||||||
v-if="fields.data"
|
icon="x"
|
||||||
value=""
|
@click="
|
||||||
:options="fields.data"
|
section.fields.splice(section.fields.indexOf(field), 1)
|
||||||
@change="(e) => addField(section, e)"
|
|
||||||
>
|
|
||||||
<template #target="{ togglePopover }">
|
|
||||||
<div
|
|
||||||
class="grid gap-2 w-full"
|
|
||||||
:class="
|
|
||||||
section.columns
|
|
||||||
? 'grid-cols-' + section.columns
|
|
||||||
: 'grid-cols-3'
|
|
||||||
"
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
<Autocomplete
|
||||||
|
v-if="fields.data"
|
||||||
|
value=""
|
||||||
|
:options="fields.data"
|
||||||
|
@change="(e) => addField(section, e)"
|
||||||
|
>
|
||||||
|
<template #target="{ togglePopover }">
|
||||||
|
<div class="gap-2 w-full">
|
||||||
|
<Button
|
||||||
|
class="w-full !h-8 !border-gray-200 hover:!border-gray-300"
|
||||||
|
variant="outline"
|
||||||
|
@click="togglePopover()"
|
||||||
|
:label="__('Add Field')"
|
||||||
>
|
>
|
||||||
<Button
|
<template #prefix>
|
||||||
class="mt-2 w-full !h-[38px] !border-gray-200"
|
<FeatherIcon name="plus" class="h-4" />
|
||||||
variant="outline"
|
</template>
|
||||||
@click="togglePopover()"
|
</Button>
|
||||||
:label="__('Add Field')"
|
</div>
|
||||||
>
|
</template>
|
||||||
<template #prefix>
|
<template #item-label="{ option }">
|
||||||
<FeatherIcon name="plus" class="h-4" />
|
<div class="flex flex-col gap-1">
|
||||||
</template>
|
<div>{{ option.label }}</div>
|
||||||
</Button>
|
<div class="text-gray-500 text-sm">
|
||||||
|
{{ `${option.fieldname} - ${option.fieldtype}` }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<template #item-label="{ option }">
|
</template>
|
||||||
<div class="flex flex-col gap-1">
|
</Autocomplete>
|
||||||
<div>{{ option.label }}</div>
|
|
||||||
<div class="text-gray-500 text-sm">
|
|
||||||
{{ `${option.fieldname} - ${option.fieldtype}` }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Autocomplete>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
<div class="py-2 border-t">
|
<div class="mt-5.5">
|
||||||
<Button
|
<Button
|
||||||
class="w-full !h-[38px] !border-gray-200"
|
class="w-full h-8"
|
||||||
variant="outline"
|
variant="subtle"
|
||||||
:label="__('Add Section')"
|
:label="__('Add Section')"
|
||||||
@click="
|
@click="
|
||||||
sections.push({
|
sections.push({
|
||||||
|
|||||||
@ -1,62 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||||
<template #body>
|
<template #body-title>
|
||||||
<div class="flex flex-col overflow-hidden h-[calc(100vh_-_8rem)]">
|
<h3
|
||||||
<div class="flex flex-col gap-2 p-8 pb-5">
|
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-gray-900"
|
||||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
|
>
|
||||||
<div>{{ __('Edit Quick Entry Layout') }}</div>
|
<div>{{ __('Edit Quick Entry Layout') }}</div>
|
||||||
<Badge
|
<Badge
|
||||||
v-if="dirty"
|
v-if="dirty"
|
||||||
:label="__('Not Saved')"
|
:label="__('Not Saved')"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
theme="orange"
|
theme="orange"
|
||||||
/>
|
/>
|
||||||
</h2>
|
</h3>
|
||||||
<div class="flex gap-6 items-end">
|
</template>
|
||||||
<FormControl
|
<template #body-content>
|
||||||
class="flex-1"
|
<div class="flex flex-col gap-3">
|
||||||
type="select"
|
<div class="flex justify-between gap-2">
|
||||||
v-model="_doctype"
|
<FormControl
|
||||||
:label="__('DocType')"
|
type="select"
|
||||||
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
|
class="w-1/4"
|
||||||
@change="reload"
|
v-model="_doctype"
|
||||||
/>
|
:options="['CRM Lead', 'CRM Deal', 'Contact', 'CRM Organization']"
|
||||||
<div class="flex flex-row-reverse gap-2">
|
@change="reload"
|
||||||
<Button
|
/>
|
||||||
:loading="loading"
|
<Switch
|
||||||
:label="__('Save')"
|
v-model="preview"
|
||||||
variant="solid"
|
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||||
@click="saveChanges"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Button :label="__('Reset')" @click="reload" />
|
|
||||||
<Button
|
|
||||||
:label="preview ? __('Hide Preview') : __('Show Preview')"
|
|
||||||
@click="preview = !preview"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="sections?.data" class="overflow-y-auto p-8 pt-3">
|
<div v-if="sections?.data">
|
||||||
<div
|
<QuickEntryLayoutBuilder
|
||||||
class="rounded-xl h-full inline-block w-full px-4 pb-6 pt-5 sm:px-6 transform overflow-y-auto bg-white text-left align-middle shadow-xl transition-all"
|
v-if="!preview"
|
||||||
>
|
:sections="sections.data"
|
||||||
<QuickEntryLayoutBuilder
|
:doctype="_doctype"
|
||||||
v-if="!preview"
|
/>
|
||||||
:sections="sections.data"
|
<Fields v-else :sections="sections.data" :data="{}" />
|
||||||
:doctype="_doctype"
|
|
||||||
/>
|
|
||||||
<Fields v-else :sections="sections.data" :data="{}" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex flex-row-reverse gap-2">
|
||||||
|
<Button
|
||||||
|
:loading="loading"
|
||||||
|
:label="__('Save')"
|
||||||
|
variant="solid"
|
||||||
|
@click="saveChanges"
|
||||||
|
/>
|
||||||
|
<Button :label="__('Reset')" @click="reload" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Fields from '@/components/Fields.vue'
|
import Fields from '@/components/Fields.vue'
|
||||||
import QuickEntryLayoutBuilder from '@/components/Settings/QuickEntryLayoutBuilder.vue'
|
import QuickEntryLayoutBuilder from '@/components/Settings/QuickEntryLayoutBuilder.vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
import { capture } from '@/telemetry'
|
||||||
|
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
|
||||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -121,7 +122,8 @@ function saveChanges() {
|
|||||||
},
|
},
|
||||||
).then(() => {
|
).then(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
reload()
|
show.value = false
|
||||||
|
capture('quick_entry_layout_builder', { doctype: _doctype.value })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Draggable :list="sections" item-key="label" class="flex flex-col">
|
<Draggable :list="sections" item-key="label" class="flex flex-col gap-5.5">
|
||||||
<template #item="{ element: section }">
|
<template #item="{ element: section }">
|
||||||
<div class="border-b">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center justify-between p-2">
|
<div
|
||||||
|
class="flex items-center justify-between rounded px-2.5 py-2 bg-gray-50"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="flex h-7 max-w-fit cursor-pointer items-center gap-2 pl-2 pr-3 text-base font-semibold leading-5"
|
class="flex max-w-fit cursor-pointer items-center gap-2 text-base leading-4"
|
||||||
@click="section.opened = !section.opened"
|
@click="section.opened = !section.opened"
|
||||||
>
|
>
|
||||||
<FeatherIcon
|
<FeatherIcon
|
||||||
@ -26,51 +28,54 @@
|
|||||||
<Button
|
<Button
|
||||||
v-if="section.editingLabel"
|
v-if="section.editingLabel"
|
||||||
icon="check"
|
icon="check"
|
||||||
|
class="!size-4 rounded-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click.stop="section.editingLabel = false"
|
@click.stop="section.editingLabel = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex gap-1 items-center">
|
||||||
<Button
|
<Button
|
||||||
v-if="!section.editingLabel"
|
v-if="!section.editingLabel"
|
||||||
icon="edit"
|
class="!size-4 rounded-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="section.editingLabel = true"
|
@click="section.editingLabel = true"
|
||||||
/>
|
>
|
||||||
|
<EditIcon class="h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="section.editable !== false"
|
v-if="section.editable !== false"
|
||||||
|
class="!size-4 rounded-sm"
|
||||||
icon="x"
|
icon="x"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@click="sections.splice(sections.indexOf(section), 1)"
|
@click="sections.splice(sections.indexOf(section), 1)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="section.opened" class="p-4 pt-0 pb-2">
|
<div v-show="section.opened">
|
||||||
<Draggable
|
<Draggable
|
||||||
:list="section.fields"
|
:list="section.fields"
|
||||||
group="fields"
|
group="fields"
|
||||||
item-key="label"
|
item-key="label"
|
||||||
class="flex flex-col gap-1"
|
class="flex flex-col gap-1.5"
|
||||||
handle=".cursor-grab"
|
handle=".cursor-grab"
|
||||||
>
|
>
|
||||||
<template #item="{ element: field }">
|
<template #item="{ element: field }">
|
||||||
<div
|
<div
|
||||||
class="px-1.5 py-1 border rounded text-base text-gray-800 flex items-center justify-between gap-2"
|
class="px-2.5 py-2 border rounded text-base leading-4 text-gray-800 flex items-center justify-between gap-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||||
<div>{{ field.label }}</div>
|
<div>{{ field.label }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
icon="x"
|
||||||
icon="x"
|
class="!size-4 rounded-sm"
|
||||||
@click="
|
@click="
|
||||||
section.fields.splice(section.fields.indexOf(field), 1)
|
section.fields.splice(section.fields.indexOf(field), 1)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
@ -82,7 +87,7 @@
|
|||||||
>
|
>
|
||||||
<template #target="{ togglePopover }">
|
<template #target="{ togglePopover }">
|
||||||
<Button
|
<Button
|
||||||
class="w-full mt-2"
|
class="w-full h-8 mt-1.5 !border-gray-200 hover:!border-gray-300"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="togglePopover()"
|
@click="togglePopover()"
|
||||||
:label="__('Add Field')"
|
:label="__('Add Field')"
|
||||||
@ -113,10 +118,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
</Draggable>
|
||||||
<div class="p-2">
|
<div class="mt-5.5">
|
||||||
<Button
|
<Button
|
||||||
class="w-full"
|
class="w-full h-8"
|
||||||
variant="outline"
|
variant="subtle"
|
||||||
:label="__('Add Section')"
|
:label="__('Add Section')"
|
||||||
@click="
|
@click="
|
||||||
sections.push({ label: __('New Section'), opened: true, fields: [] })
|
sections.push({ label: __('New Section'), opened: true, fields: [] })
|
||||||
@ -130,6 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||||
import Draggable from 'vuedraggable'
|
import Draggable from 'vuedraggable'
|
||||||
|
|||||||
@ -1,67 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model="show" :options="{ size: '3xl' }">
|
<Dialog v-model="show" :options="{ size: '3xl' }">
|
||||||
<template #body>
|
<template #body-title>
|
||||||
<div ref="parentRef" class="flex h-[calc(100vh_-_8rem)]">
|
<h3
|
||||||
<div class="flex-1 flex flex-col justify-between gap-2 p-8">
|
class="flex items-center gap-2 text-2xl font-semibold leading-6 text-gray-900"
|
||||||
<div class="flex flex-col gap-2">
|
>
|
||||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
|
<div>{{ __('Edit Field Layout') }}</div>
|
||||||
<div>{{ __('Edit Fields Layout') }}</div>
|
<Badge
|
||||||
<Badge
|
v-if="dirty"
|
||||||
v-if="dirty"
|
:label="__('Not Saved')"
|
||||||
:label="__('Not Saved')"
|
variant="subtle"
|
||||||
variant="subtle"
|
theme="orange"
|
||||||
theme="orange"
|
/>
|
||||||
/>
|
</h3>
|
||||||
</h2>
|
</template>
|
||||||
<FormControl
|
<template #body-content>
|
||||||
type="select"
|
<div class="flex flex-col gap-5.5">
|
||||||
v-model="_doctype"
|
<div class="flex justify-between gap-2">
|
||||||
:label="__('DocType')"
|
<FormControl
|
||||||
:options="['CRM Lead', 'CRM Deal']"
|
type="select"
|
||||||
@change="reload"
|
class="w-1/4"
|
||||||
/>
|
v-model="_doctype"
|
||||||
</div>
|
:options="['CRM Lead', 'CRM Deal']"
|
||||||
<div class="flex flex-row-reverse gap-2">
|
@change="reload"
|
||||||
<Button
|
/>
|
||||||
:loading="loading"
|
<Switch
|
||||||
:label="__('Save')"
|
v-model="preview"
|
||||||
variant="solid"
|
:label="preview ? __('Hide preview') : __('Show preview')"
|
||||||
@click="saveChanges"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Button :label="__('Reset')" @click="reload" />
|
|
||||||
<Button
|
|
||||||
:label="preview ? __('Hide Preview') : __('Show Preview')"
|
|
||||||
@click="preview = !preview"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Resizer
|
<div v-if="sections.data" class="flex gap-4">
|
||||||
v-if="sections.data"
|
<SidePanelLayoutBuilder
|
||||||
class="flex flex-col justify-between border-l"
|
class="flex flex-1 flex-col pr-2"
|
||||||
:parent="parentRef"
|
:sections="sections.data"
|
||||||
side="right"
|
:doctype="_doctype"
|
||||||
>
|
/>
|
||||||
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
<div v-if="preview" class="flex flex-1 flex-col border rounded">
|
||||||
<div class="flex flex-col overflow-y-auto">
|
<div
|
||||||
<SidePanelLayoutBuilder
|
v-for="(section, i) in sections.data"
|
||||||
v-if="!preview"
|
:key="section.label"
|
||||||
:sections="sections.data"
|
class="flex flex-col py-1.5 px-1"
|
||||||
:doctype="_doctype"
|
:class="{ 'border-b': i !== sections.data.length - 1 }"
|
||||||
/>
|
>
|
||||||
<div
|
<Section :is-opened="section.opened" :label="section.label">
|
||||||
v-else
|
<SectionFields :fields="section.fields" v-model="data" />
|
||||||
v-for="(section, i) in sections.data"
|
</Section>
|
||||||
:key="section.label"
|
|
||||||
class="flex flex-col p-3"
|
|
||||||
:class="{ 'border-b': i !== sections.data.length - 1 }"
|
|
||||||
>
|
|
||||||
<Section :is-opened="section.opened" :label="section.label">
|
|
||||||
<SectionFields :fields="section.fields" v-model="data" />
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Resizer>
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-1 justify-center items-center text-gray-600 bg-gray-50 rounded border border-gray-50"
|
||||||
|
>
|
||||||
|
{{ __('Toggle on for preview') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex flex-row-reverse gap-2">
|
||||||
|
<Button
|
||||||
|
:loading="loading"
|
||||||
|
:label="__('Save')"
|
||||||
|
variant="solid"
|
||||||
|
@click="saveChanges"
|
||||||
|
/>
|
||||||
|
<Button :label="__('Reset')" @click="reload" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@ -69,10 +72,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Section from '@/components/Section.vue'
|
import Section from '@/components/Section.vue'
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
import SectionFields from '@/components/SectionFields.vue'
|
||||||
import Resizer from '@/components/Resizer.vue'
|
|
||||||
import SidePanelLayoutBuilder from '@/components/Settings/SidePanelLayoutBuilder.vue'
|
import SidePanelLayoutBuilder from '@/components/Settings/SidePanelLayoutBuilder.vue'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
import { Dialog, Badge, call, createResource } from 'frappe-ui'
|
import { capture } from '@/telemetry'
|
||||||
|
import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui'
|
||||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -82,9 +85,10 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['reload'])
|
||||||
|
|
||||||
const show = defineModel()
|
const show = defineModel()
|
||||||
const _doctype = ref(props.doctype)
|
const _doctype = ref(props.doctype)
|
||||||
const parentRef = ref(null)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const preview = ref(false)
|
const preview = ref(false)
|
||||||
@ -139,7 +143,9 @@ function saveChanges() {
|
|||||||
},
|
},
|
||||||
).then(() => {
|
).then(() => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
reload()
|
show.value = false
|
||||||
|
capture('side_panel_layout_builder', { doctype: _doctype.value })
|
||||||
|
emit('reload')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,131 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="parentRef" class="flex h-full">
|
|
||||||
<div class="flex-1 flex flex-col justify-between gap-2 p-8">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h2 class="flex gap-2 text-xl font-semibold leading-none h-5 mb-4">
|
|
||||||
<div>{{ __('Sidebar Fields Layout') }}</div>
|
|
||||||
<Badge
|
|
||||||
v-if="dirty"
|
|
||||||
:label="__('Not Saved')"
|
|
||||||
variant="subtle"
|
|
||||||
theme="orange"
|
|
||||||
/>
|
|
||||||
</h2>
|
|
||||||
<FormControl
|
|
||||||
type="select"
|
|
||||||
v-model="doctype"
|
|
||||||
:label="__('DocType')"
|
|
||||||
:options="['CRM Lead', 'CRM Deal']"
|
|
||||||
@change="reload"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row-reverse gap-2">
|
|
||||||
<Button
|
|
||||||
:loading="loading"
|
|
||||||
:label="__('Save')"
|
|
||||||
variant="solid"
|
|
||||||
@click="saveChanges"
|
|
||||||
/>
|
|
||||||
<Button :label="__('Reset')" @click="reload" />
|
|
||||||
<Button
|
|
||||||
:label="preview ? __('Hide Preview') : __('Show Preview')"
|
|
||||||
@click="preview = !preview"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Resizer
|
|
||||||
v-if="sections.data"
|
|
||||||
class="flex flex-col justify-between border-l"
|
|
||||||
:parent="parentRef"
|
|
||||||
side="right"
|
|
||||||
>
|
|
||||||
<div class="flex flex-1 flex-col justify-between overflow-hidden">
|
|
||||||
<div class="flex flex-col overflow-y-auto">
|
|
||||||
<SidebarLayoutBuilder
|
|
||||||
v-if="!preview"
|
|
||||||
:sections="sections.data"
|
|
||||||
:doctype="doctype"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
v-for="(section, i) in sections.data"
|
|
||||||
:key="section.label"
|
|
||||||
class="flex flex-col p-3"
|
|
||||||
:class="{ 'border-b': i !== sections.data.length - 1 }"
|
|
||||||
>
|
|
||||||
<Section :is-opened="section.opened" :label="section.label">
|
|
||||||
<SectionFields :fields="section.fields" v-model="data" />
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Resizer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import Section from '@/components/Section.vue'
|
|
||||||
import SectionFields from '@/components/SectionFields.vue'
|
|
||||||
import Resizer from '@/components/Resizer.vue'
|
|
||||||
import SidebarLayoutBuilder from '@/components/Settings/SidebarLayoutBuilder.vue'
|
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
|
||||||
import { Badge, call, createResource } from 'frappe-ui'
|
|
||||||
import { ref, watch, onMounted } from 'vue'
|
|
||||||
|
|
||||||
const parentRef = ref(null)
|
|
||||||
const doctype = ref('CRM Lead')
|
|
||||||
const loading = ref(false)
|
|
||||||
const dirty = ref(false)
|
|
||||||
const preview = ref(false)
|
|
||||||
const data = ref({})
|
|
||||||
|
|
||||||
function getParams() {
|
|
||||||
return { doctype: doctype.value, type: 'Side Panel' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sections = createResource({
|
|
||||||
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
|
|
||||||
cache: ['sidebar-sections', doctype.value],
|
|
||||||
params: getParams(),
|
|
||||||
onSuccess(data) {
|
|
||||||
sections.originalData = JSON.parse(JSON.stringify(data))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => sections?.data,
|
|
||||||
() => {
|
|
||||||
dirty.value =
|
|
||||||
JSON.stringify(sections?.data) !== JSON.stringify(sections?.originalData)
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(() => useDebounceFn(reload, 100)())
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
sections.params = getParams()
|
|
||||||
sections.reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveChanges() {
|
|
||||||
let _sections = JSON.parse(JSON.stringify(sections.data))
|
|
||||||
_sections.forEach((section) => {
|
|
||||||
if (!section.fields) return
|
|
||||||
section.fields = section.fields.map(
|
|
||||||
(field) => field.fieldname || field.name,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
loading.value = true
|
|
||||||
call(
|
|
||||||
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
|
|
||||||
{
|
|
||||||
doctype: doctype.value,
|
|
||||||
type: 'Side Panel',
|
|
||||||
layout: JSON.stringify(_sections),
|
|
||||||
},
|
|
||||||
).then(() => {
|
|
||||||
loading.value = false
|
|
||||||
reload()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -15,15 +15,15 @@
|
|||||||
<FeatherIcon
|
<FeatherIcon
|
||||||
v-if="typeof icon == 'string'"
|
v-if="typeof icon == 'string'"
|
||||||
:name="icon"
|
:name="icon"
|
||||||
class="size-4.5 text-gray-700"
|
class="size-4 text-gray-700"
|
||||||
/>
|
/>
|
||||||
<component v-else :is="icon" class="size-4.5 text-gray-700" />
|
<component v-else :is="icon" class="size-4 text-gray-700" />
|
||||||
</span>
|
</span>
|
||||||
</slot>
|
</slot>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
|
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
|
||||||
<span
|
<span
|
||||||
class="flex-1 flex-shrink-0 truncate text-base duration-300 ease-in-out"
|
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
isCollapsed
|
isCollapsed
|
||||||
? 'ml-0 w-0 overflow-hidden opacity-0'
|
? 'ml-0 w-0 overflow-hidden opacity-0'
|
||||||
|
|||||||
@ -50,10 +50,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import SettingsModal from '@/components/Settings/SettingsModal.vue'
|
import SettingsModal from '@/components/Settings/SettingsModal.vue'
|
||||||
import CRMLogo from '@/components/Icons/CRMLogo.vue'
|
import CRMLogo from '@/components/Icons/CRMLogo.vue'
|
||||||
|
import Apps from '@/components/Apps.vue'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { Dropdown } from 'frappe-ui'
|
import { Dropdown } from 'frappe-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, markRaw} from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isCollapsed: {
|
isCollapsed: {
|
||||||
@ -75,9 +76,7 @@ let dropdownOptions = ref([
|
|||||||
hideLabel: true,
|
hideLabel: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
icon: 'corner-up-left',
|
component: markRaw(Apps),
|
||||||
label: computed(() => __('Switch to Desk')),
|
|
||||||
onClick: () => window.location.replace('/app'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: 'life-buoy',
|
icon: 'life-buoy',
|
||||||
|
|||||||
@ -287,7 +287,13 @@ import { globalStore } from '@/stores/global'
|
|||||||
import { viewsStore } from '@/stores/views'
|
import { viewsStore } from '@/stores/views'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { isEmoji } from '@/utils'
|
import { isEmoji } from '@/utils'
|
||||||
import { createResource, Dropdown, call, FeatherIcon } from 'frappe-ui'
|
import {
|
||||||
|
createResource,
|
||||||
|
Dropdown,
|
||||||
|
call,
|
||||||
|
FeatherIcon,
|
||||||
|
usePageMeta,
|
||||||
|
} from 'frappe-ui'
|
||||||
import { computed, ref, onMounted, watch, h, markRaw } from 'vue'
|
import { computed, ref, onMounted, watch, h, markRaw } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
@ -356,6 +362,19 @@ const currentView = computed(() => {
|
|||||||
label:
|
label:
|
||||||
_view?.label || props.options?.defaultViewName || getViewType().label,
|
_view?.label || props.options?.defaultViewName || getViewType().label,
|
||||||
icon: _view?.icon || getViewType().icon,
|
icon: _view?.icon || getViewType().icon,
|
||||||
|
is_default: !_view || _view.is_default,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
let label = currentView.value.label
|
||||||
|
if (currentView.value.is_default) {
|
||||||
|
let routeName = route.name
|
||||||
|
label = `${routeName} - ${label}`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: label,
|
||||||
|
emoji: isEmoji(currentView.value.icon) ? currentView.value.icon : '',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -799,7 +818,7 @@ async function updateKanbanSettings(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadMoreKanban(columnName) {
|
function loadMoreKanban(columnName) {
|
||||||
let columns = list.value.data.kanban_columns || "[]"
|
let columns = list.value.data.kanban_columns || '[]'
|
||||||
|
|
||||||
if (typeof columns === 'string') {
|
if (typeof columns === 'string') {
|
||||||
columns = JSON.parse(columns)
|
columns = JSON.parse(columns)
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import router from './router'
|
|
||||||
import App from './App.vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FrappeUI,
|
FrappeUI,
|
||||||
Button,
|
Button,
|
||||||
@ -19,9 +14,14 @@ import {
|
|||||||
frappeRequest,
|
frappeRequest,
|
||||||
FeatherIcon,
|
FeatherIcon,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import translationPlugin from './translation'
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import { createDialog } from './utils/dialogs'
|
import { createDialog } from './utils/dialogs'
|
||||||
import { initSocket } from './socket'
|
import { initSocket } from './socket'
|
||||||
|
import router from './router'
|
||||||
|
import translationPlugin from './translation'
|
||||||
|
import { posthogPlugin } from './telemetry'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
let globalComponents = {
|
let globalComponents = {
|
||||||
Button,
|
Button,
|
||||||
@ -45,6 +45,7 @@ app.use(FrappeUI)
|
|||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(translationPlugin)
|
app.use(translationPlugin)
|
||||||
|
app.use(posthogPlugin)
|
||||||
for (let key in globalComponents) {
|
for (let key in globalComponents) {
|
||||||
app.component(key, globalComponents[key])
|
app.component(key, globalComponents[key])
|
||||||
}
|
}
|
||||||
@ -61,7 +62,7 @@ if (import.meta.env.DEV) {
|
|||||||
socket = initSocket()
|
socket = initSocket()
|
||||||
app.config.globalProperties.$socket = socket
|
app.config.globalProperties.$socket = socket
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
socket = initSocket()
|
socket = initSocket()
|
||||||
|
|||||||
@ -224,6 +224,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
call,
|
call,
|
||||||
createResource,
|
createResource,
|
||||||
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
@ -268,6 +269,22 @@ const showContactModal = ref(false)
|
|||||||
const showQuickEntryModal = ref(false)
|
const showQuickEntryModal = ref(false)
|
||||||
const detailMode = ref(false)
|
const detailMode = ref(false)
|
||||||
|
|
||||||
|
const contact = createResource({
|
||||||
|
url: 'crm.api.contact.get_contact',
|
||||||
|
cache: ['contact', props.contactId],
|
||||||
|
params: {
|
||||||
|
name: props.contactId,
|
||||||
|
},
|
||||||
|
auto: true,
|
||||||
|
transform: (data) => {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
actual_mobile_no: data.mobile_no,
|
||||||
|
mobile_no: data.mobile_no,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const breadcrumbs = computed(() => {
|
const breadcrumbs = computed(() => {
|
||||||
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
|
let items = [{ label: __('Contacts'), route: { name: 'Contacts' } }]
|
||||||
items.push({
|
items.push({
|
||||||
@ -277,6 +294,13 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: contact.data?.full_name || contact.data?.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
function validateFile(file) {
|
function validateFile(file) {
|
||||||
let extn = file.name.split('.').pop().toLowerCase()
|
let extn = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
||||||
@ -325,22 +349,6 @@ const tabs = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const contact = createResource({
|
|
||||||
url: 'crm.api.contact.get_contact',
|
|
||||||
cache: ['contact', props.contactId],
|
|
||||||
params: {
|
|
||||||
name: props.contactId,
|
|
||||||
},
|
|
||||||
auto: true,
|
|
||||||
transform: (data) => {
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
actual_mobile_no: data.mobile_no,
|
|
||||||
mobile_no: data.mobile_no,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deals = createResource({
|
const deals = createResource({
|
||||||
url: 'crm.api.contact.get_linked_deals',
|
url: 'crm.api.contact.get_linked_deals',
|
||||||
cache: ['deals', props.contactId],
|
cache: ['deals', props.contactId],
|
||||||
|
|||||||
@ -174,8 +174,8 @@
|
|||||||
<span>{{ __('Loading...') }}</span>
|
<span>{{ __('Loading...') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="section.contacts.length"
|
v-else-if="deal_contacts?.data?.length"
|
||||||
v-for="(contact, i) in section.contacts"
|
v-for="(contact, i) in deal_contacts.data"
|
||||||
:key="contact.name"
|
:key="contact.name"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -251,7 +251,7 @@
|
|||||||
</Section>
|
</Section>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="i != section.contacts.length - 1"
|
v-if="i != deal_contacts.data.length - 1"
|
||||||
class="mx-2 h-px border-t border-gray-200"
|
class="mx-2 h-px border-t border-gray-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -298,6 +298,7 @@
|
|||||||
v-if="showSidePanelModal"
|
v-if="showSidePanelModal"
|
||||||
v-model="showSidePanelModal"
|
v-model="showSidePanelModal"
|
||||||
doctype="CRM Deal"
|
doctype="CRM Deal"
|
||||||
|
@reload="() => fieldsLayout.reload()"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@ -349,6 +350,7 @@ import {
|
|||||||
Tabs,
|
Tabs,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
call,
|
call,
|
||||||
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, computed, h, onMounted } from 'vue'
|
import { ref, computed, h, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
@ -457,6 +459,12 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: organization.value?.name || deal.data?.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
let tabOptions = [
|
let tabOptions = [
|
||||||
@ -603,22 +611,11 @@ const deal_contacts = createResource({
|
|||||||
params: { name: props.dealId },
|
params: { name: props.dealId },
|
||||||
cache: ['deal_contacts', props.dealId],
|
cache: ['deal_contacts', props.dealId],
|
||||||
auto: true,
|
auto: true,
|
||||||
onSuccess: (data) => {
|
transform: (data) => {
|
||||||
let contactSection = fieldsLayout.data?.find(
|
data.forEach((contact) => {
|
||||||
(section) => section.name == 'contacts_section',
|
contact.opened = false
|
||||||
)
|
|
||||||
if (!contactSection) return
|
|
||||||
contactSection.contacts = data.map((contact) => {
|
|
||||||
return {
|
|
||||||
name: contact.name,
|
|
||||||
full_name: contact.full_name,
|
|
||||||
email: contact.email,
|
|
||||||
mobile_no: contact.mobile_no,
|
|
||||||
image: contact.image,
|
|
||||||
is_primary: contact.is_primary,
|
|
||||||
opened: false,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
return data
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
:label="__('Create')"
|
:label="__('Create')"
|
||||||
@click="showEmailTemplateModal = true"
|
@click="() => showEmailTemplate()"
|
||||||
>
|
>
|
||||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||||
</Button>
|
</Button>
|
||||||
@ -55,7 +55,7 @@
|
|||||||
>
|
>
|
||||||
<Email2Icon class="h-10 w-10" />
|
<Email2Icon class="h-10 w-10" />
|
||||||
<span>{{ __('No {0} Found', [__('Email Templates')]) }}</span>
|
<span>{{ __('No {0} Found', [__('Email Templates')]) }}</span>
|
||||||
<Button :label="__('Create')" @click="showEmailTemplateModal = true">
|
<Button :label="__('Create')" @click="() => showEmailTemplate()">
|
||||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -115,28 +115,32 @@ const rows = computed(() => {
|
|||||||
|
|
||||||
const showEmailTemplateModal = ref(false)
|
const showEmailTemplateModal = ref(false)
|
||||||
|
|
||||||
const emailTemplate = ref({
|
const emailTemplate = ref({})
|
||||||
subject: '',
|
|
||||||
response: '',
|
|
||||||
response_html: '',
|
|
||||||
name: '',
|
|
||||||
enabled: 1,
|
|
||||||
use_html: 0,
|
|
||||||
owner: '',
|
|
||||||
reference_doctype: 'CRM Deal',
|
|
||||||
})
|
|
||||||
|
|
||||||
function showEmailTemplate(name) {
|
function showEmailTemplate(name) {
|
||||||
let et = rows.value?.find((row) => row.name === name)
|
if (!name) {
|
||||||
emailTemplate.value = {
|
emailTemplate.value = {
|
||||||
subject: et.subject,
|
subject: '',
|
||||||
response: et.response,
|
response: '',
|
||||||
response_html: et.response_html,
|
response_html: '',
|
||||||
name: et.name,
|
name: '',
|
||||||
enabled: et.enabled,
|
enabled: 1,
|
||||||
use_html: et.use_html,
|
use_html: 0,
|
||||||
owner: et.owner,
|
owner: '',
|
||||||
reference_doctype: et.reference_doctype,
|
reference_doctype: 'CRM Deal',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let et = rows.value?.find((row) => row.name === name)
|
||||||
|
emailTemplate.value = {
|
||||||
|
subject: et.subject,
|
||||||
|
response: et.response,
|
||||||
|
response_html: et.response_html,
|
||||||
|
name: et.name,
|
||||||
|
enabled: et.enabled,
|
||||||
|
use_html: et.use_html,
|
||||||
|
owner: et.owner,
|
||||||
|
reference_doctype: et.reference_doctype,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
showEmailTemplateModal.value = true
|
showEmailTemplateModal.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@ -266,7 +266,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<SidePanelModal v-if="showSidePanelModal" v-model="showSidePanelModal" />
|
<SidePanelModal
|
||||||
|
v-if="showSidePanelModal"
|
||||||
|
v-model="showSidePanelModal"
|
||||||
|
@reload="() => fieldsLayout.reload()"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Resizer from '@/components/Resizer.vue'
|
import Resizer from '@/components/Resizer.vue'
|
||||||
@ -308,6 +312,7 @@ import { organizationsStore } from '@/stores/organizations'
|
|||||||
import { statusesStore } from '@/stores/statuses'
|
import { statusesStore } from '@/stores/statuses'
|
||||||
import { usersStore } from '@/stores/users'
|
import { usersStore } from '@/stores/users'
|
||||||
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
import { whatsappEnabled, callEnabled } from '@/composables/settings'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import {
|
import {
|
||||||
createResource,
|
createResource,
|
||||||
FileUploader,
|
FileUploader,
|
||||||
@ -318,6 +323,7 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
call,
|
call,
|
||||||
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
@ -422,6 +428,12 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: lead.data?.lead_name || lead.data?.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const tabIndex = ref(0)
|
const tabIndex = ref(0)
|
||||||
|
|
||||||
const tabs = computed(() => {
|
const tabs = computed(() => {
|
||||||
@ -576,6 +588,7 @@ async function convertToDeal(updated) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (deal) {
|
if (deal) {
|
||||||
|
capture('convert_lead_to_deal')
|
||||||
if (updated) {
|
if (updated) {
|
||||||
await organizations.reload()
|
await organizations.reload()
|
||||||
await contacts.reload()
|
await contacts.reload()
|
||||||
|
|||||||
@ -1,104 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-screen w-screen justify-center bg-gray-100">
|
|
||||||
<div class="mt-32 w-full px-4">
|
|
||||||
<CRMLogo class="mx-auto h-10" />
|
|
||||||
<div class="mt-6 flex items-center justify-center space-x-1.5">
|
|
||||||
<span class="text-3xl font-semibold text-gray-900">Login to CRM</span>
|
|
||||||
</div>
|
|
||||||
<div class="mx-auto mt-6 w-full px-4 sm:w-96">
|
|
||||||
<form
|
|
||||||
v-if="showEmailLogin"
|
|
||||||
method="POST"
|
|
||||||
action="/api/method/login"
|
|
||||||
@submit.prevent="submit"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<FormControl
|
|
||||||
variant="outline"
|
|
||||||
size="md"
|
|
||||||
:type="
|
|
||||||
(email || '').toLowerCase() === 'administrator'
|
|
||||||
? 'text'
|
|
||||||
: 'email'
|
|
||||||
"
|
|
||||||
label="Email"
|
|
||||||
v-model="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
:disabled="session.login.loading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4">
|
|
||||||
<FormControl
|
|
||||||
variant="outline"
|
|
||||||
size="md"
|
|
||||||
label="Password"
|
|
||||||
v-model="password"
|
|
||||||
placeholder="••••••"
|
|
||||||
:disabled="session.login.loading"
|
|
||||||
type="password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ErrorMessage class="mt-2" :message="session.login.error" />
|
|
||||||
<Button
|
|
||||||
variant="solid"
|
|
||||||
class="mt-6 w-full"
|
|
||||||
:loading="session.login.loading"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
<button
|
|
||||||
v-if="authProviders.data.length"
|
|
||||||
class="mt-2 w-full py-2 text-base text-gray-600"
|
|
||||||
@click="showEmailLogin = false"
|
|
||||||
>
|
|
||||||
Login using other methods
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div
|
|
||||||
class="mx-auto space-y-2"
|
|
||||||
v-if="authProviders.data && !showEmailLogin"
|
|
||||||
>
|
|
||||||
<Button @click="showEmailLogin = true" variant="solid" class="w-full">
|
|
||||||
Login via email
|
|
||||||
</Button>
|
|
||||||
<a
|
|
||||||
class="flex justify-center items-center gap-2 w-full rounded border bg-gray-900 px-3 py-1 text-center text-base h-7 focus:outline-none focus:ring-2 focus:ring-gray-400 text-white transition-colors hover:bg-gray-700"
|
|
||||||
v-for="provider in authProviders.data"
|
|
||||||
:key="provider.name"
|
|
||||||
:href="provider.auth_url"
|
|
||||||
>
|
|
||||||
<div v-if="provider.icon" v-html="provider.icon" />
|
|
||||||
Login via {{ provider.provider_name }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup>
|
|
||||||
import CRMLogo from '@/components/Icons/CRMLogo.vue';
|
|
||||||
import { sessionStore } from '@/stores/session'
|
|
||||||
import { createResource } from 'frappe-ui'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const session = sessionStore()
|
|
||||||
let showEmailLogin = ref(false)
|
|
||||||
let email = ref('')
|
|
||||||
let password = ref('')
|
|
||||||
|
|
||||||
let authProviders = createResource({
|
|
||||||
url: 'crm.api.auth.oauth_providers',
|
|
||||||
auto: true,
|
|
||||||
onSuccess(data) {
|
|
||||||
showEmailLogin.value = data.length === 0
|
|
||||||
},
|
|
||||||
})
|
|
||||||
authProviders.fetch()
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
session.login.submit({
|
|
||||||
usr: email.value,
|
|
||||||
pwd: password.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@ -235,6 +235,7 @@ import {
|
|||||||
call,
|
call,
|
||||||
createListResource,
|
createListResource,
|
||||||
createDocumentResource,
|
createDocumentResource,
|
||||||
|
usePageMeta,
|
||||||
} from 'frappe-ui'
|
} from 'frappe-ui'
|
||||||
import LayoutHeader from '@/components/LayoutHeader.vue'
|
import LayoutHeader from '@/components/LayoutHeader.vue'
|
||||||
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
import OrganizationModal from '@/components/Modals/OrganizationModal.vue'
|
||||||
@ -295,6 +296,12 @@ const breadcrumbs = computed(() => {
|
|||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
usePageMeta(() => {
|
||||||
|
return {
|
||||||
|
title: props.organizationId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function validateFile(file) {
|
function validateFile(file) {
|
||||||
let extn = file.name.split('.').pop().toLowerCase()
|
let extn = file.name.split('.').pop().toLowerCase()
|
||||||
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
if (!['png', 'jpg', 'jpeg'].includes(extn)) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { usersStore } from '@/stores/users'
|
import { userResource } from '@/stores/user'
|
||||||
import { sessionStore } from '@/stores/session'
|
import { sessionStore } from '@/stores/session'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@ -102,11 +102,6 @@ const routes = [
|
|||||||
name: 'Invalid Page',
|
name: 'Invalid Page',
|
||||||
component: () => import('@/pages/InvalidPage.vue'),
|
component: () => import('@/pages/InvalidPage.vue'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'Login',
|
|
||||||
component: () => import('@/pages/Login.vue'),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleMobileView = (componentName) => {
|
const handleMobileView = (componentName) => {
|
||||||
@ -139,19 +134,18 @@ let router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const { users } = usersStore()
|
|
||||||
const { isLoggedIn } = sessionStore()
|
const { isLoggedIn } = sessionStore()
|
||||||
|
|
||||||
isLoggedIn && (await users.promise)
|
isLoggedIn && (await userResource.promise)
|
||||||
|
|
||||||
if (from.meta?.scrollPos) {
|
if (from.meta?.scrollPos) {
|
||||||
from.meta.scrollPos.top = document.querySelector('#list-rows')?.scrollTop
|
from.meta.scrollPos.top = document.querySelector('#list-rows')?.scrollTop
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.name === 'Login' && isLoggedIn) {
|
if (to.name === 'Home' && isLoggedIn) {
|
||||||
next({ name: 'Leads' })
|
next({ name: 'Leads' })
|
||||||
} else if (to.name !== 'Login' && !isLoggedIn) {
|
} else if (!isLoggedIn) {
|
||||||
next({ name: 'Login' })
|
window.location.href = "/login?redirect-to=/crm";
|
||||||
} else if (to.matched.length === 0) {
|
} else if (to.matched.length === 0) {
|
||||||
next({ name: 'Invalid Page' })
|
next({ name: 'Invalid Page' })
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { createResource } from 'frappe-ui'
|
import { createResource } from 'frappe-ui'
|
||||||
import { usersStore } from './users'
|
import { userResource } from './user'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
export const sessionStore = defineStore('crm-session', () => {
|
export const sessionStore = defineStore('crm-session', () => {
|
||||||
const { users } = usersStore()
|
|
||||||
|
|
||||||
function sessionUser() {
|
function sessionUser() {
|
||||||
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
let cookies = new URLSearchParams(document.cookie.split('; ').join('&'))
|
||||||
let _sessionUser = cookies.get('user_id')
|
let _sessionUser = cookies.get('user_id')
|
||||||
@ -25,7 +23,7 @@ export const sessionStore = defineStore('crm-session', () => {
|
|||||||
throw new Error('Invalid email or password')
|
throw new Error('Invalid email or password')
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
users.reload()
|
userResource.reload()
|
||||||
user.value = sessionUser()
|
user.value = sessionUser()
|
||||||
login.reset()
|
login.reset()
|
||||||
router.replace({ path: '/' })
|
router.replace({ path: '/' })
|
||||||
@ -35,9 +33,9 @@ export const sessionStore = defineStore('crm-session', () => {
|
|||||||
const logout = createResource({
|
const logout = createResource({
|
||||||
url: 'logout',
|
url: 'logout',
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
users.reset()
|
userResource.reset()
|
||||||
user.value = null
|
user.value = null
|
||||||
router.replace({ name: 'Login' })
|
router.replace({ name: 'Home' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
|
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||||
|
import { capture } from '@/telemetry'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { createListResource } from 'frappe-ui'
|
import { createListResource } from 'frappe-ui'
|
||||||
import { reactive, h } from 'vue'
|
import { reactive, h } from 'vue'
|
||||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
|
||||||
|
|
||||||
export const statusesStore = defineStore('crm-statuses', () => {
|
export const statusesStore = defineStore('crm-statuses', () => {
|
||||||
let leadStatusesByName = reactive({})
|
let leadStatusesByName = reactive({})
|
||||||
@ -103,6 +104,7 @@ export const statusesStore = defineStore('crm-statuses', () => {
|
|||||||
class: statusesByName[status].iconColorClass,
|
class: statusesByName[status].iconColorClass,
|
||||||
}),
|
}),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
capture('status_changed', { doctype, status })
|
||||||
action && action('status', statusesByName[status].name)
|
action && action('status', statusesByName[status].name)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
12
frontend/src/stores/user.js
Normal file
12
frontend/src/stores/user.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import router from '@/router'
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
|
||||||
|
export const userResource = createResource({
|
||||||
|
url: 'frappe.auth.get_logged_user',
|
||||||
|
cache: 'User',
|
||||||
|
onError(error) {
|
||||||
|
if (error && error.exc_type === 'AuthenticationError') {
|
||||||
|
router.push({ name: 'Home' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
97
frontend/src/telemetry.ts
Normal file
97
frontend/src/telemetry.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import '../../../frappe/frappe/public/js/lib/posthog.js'
|
||||||
|
import { createResource } from 'frappe-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
posthog: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type PosthogSettings = {
|
||||||
|
posthog_project_id: string
|
||||||
|
posthog_host: string
|
||||||
|
enable_telemetry: boolean
|
||||||
|
telemetry_site_age: number
|
||||||
|
}
|
||||||
|
interface CaptureOptions {
|
||||||
|
data: {
|
||||||
|
user: string
|
||||||
|
[key: string]: string | number | boolean | object
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let posthog: typeof window.posthog = window.posthog
|
||||||
|
|
||||||
|
// Posthog Settings
|
||||||
|
let posthogSettings = createResource({
|
||||||
|
url: 'crm.api.get_posthog_settings',
|
||||||
|
cache: 'posthog_settings',
|
||||||
|
onSuccess: (ps: PosthogSettings) => initPosthog(ps),
|
||||||
|
})
|
||||||
|
|
||||||
|
let isTelemetryEnabled = () => {
|
||||||
|
if (!posthogSettings.data) return false
|
||||||
|
|
||||||
|
return (
|
||||||
|
posthogSettings.data.enable_telemetry &&
|
||||||
|
posthogSettings.data.posthog_project_id &&
|
||||||
|
posthogSettings.data.posthog_host
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posthog Initialization
|
||||||
|
function initPosthog(ps: PosthogSettings) {
|
||||||
|
if (!isTelemetryEnabled()) return
|
||||||
|
|
||||||
|
posthog.init(ps.posthog_project_id, {
|
||||||
|
api_host: ps.posthog_host,
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
autocapture: false,
|
||||||
|
capture_pageview: true,
|
||||||
|
capture_pageleave: true,
|
||||||
|
enable_heatmaps: false,
|
||||||
|
disable_session_recording: false,
|
||||||
|
loaded: (ph: typeof posthog) => {
|
||||||
|
window.posthog = ph
|
||||||
|
ph.identify(window.location.hostname)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posthog Functions
|
||||||
|
function capture(
|
||||||
|
event: string,
|
||||||
|
options: CaptureOptions = { data: { user: '' } },
|
||||||
|
) {
|
||||||
|
if (!isTelemetryEnabled()) return
|
||||||
|
window.posthog.capture(`crm_${event}`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording() {
|
||||||
|
if (!isTelemetryEnabled()) return
|
||||||
|
if (window.posthog?.__loaded) {
|
||||||
|
window.posthog.startSessionRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (!isTelemetryEnabled()) return
|
||||||
|
if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) {
|
||||||
|
window.posthog.stopSessionRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posthog Plugin
|
||||||
|
function posthogPlugin(app: any) {
|
||||||
|
app.config.globalProperties.posthog = posthog
|
||||||
|
if (!window.posthog?.length) posthogSettings.fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
posthog,
|
||||||
|
posthogSettings,
|
||||||
|
posthogPlugin,
|
||||||
|
capture,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user