Merge pull request #317 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-08-21 10:25:24 +05:30 committed by GitHub
commit bc83749552
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 730 additions and 574 deletions

View File

@ -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)
@ -45,3 +46,12 @@ def get_user_signature():
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(),
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@ -1 +1 @@
Subproject commit b5cc6c0cd36ee25e8e945e8c91d8b5c035fb5e44 Subproject commit cf4e7d347237c23ebde5f5c890abdf7e81284961

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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